diff --git a/.env.example b/.env.example index 7b8b8cee..ab6f7986 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,10 @@ SENDLIB_API_URL=https://sendlib.samueltuoyo.com/api/send # SendLib. Prefer a Workspace address on your own domain (e.g. no-reply@deenbridge.app). EMAIL_FROM=no-reply@deenbridge.com -# Stellar blockchain network (testnet or mainnet) +# Stellar blockchain network (testnet or mainnet; "public" is accepted as an +# alias for mainnet). Validated fail-fast at boot — a mainnet flag paired +# with a testnet Horizon URL or USDC issuer aborts startup with the exact +# problem named. See docs/MAINNET.md for the full mainnet switch checklist. STELLAR_NETWORK=testnet # Resilient Horizon Client Configuration (Optional) @@ -53,6 +56,27 @@ DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX PLATFORM_FEE_PERCENT=0 PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# ── Fee-bump sponsorship (#30) ──────────────────────────────────────────────── +# The platform can pay a user's Stellar network fee by wrapping their signed +# transaction in a fee-bump. A user holding USDC but ~no XLM can then transact. +# All of these are OPTIONAL: with FEE_SPONSOR_ENABLED unset/false (the default) +# the feature is completely inert and the payment/donation flow is unchanged. +# +# Master switch. Leave false to disable sponsorship entirely. +FEE_SPONSOR_ENABLED=false +# DEDICATED fee-source secret (S…) for an account holding a small XLM float used +# ONLY to pay network fees. It MUST NOT be the donation or platform receiving +# wallet, and can never move user funds — it only signs the fee-bump wrapper. +# Required (and validated at boot) when FEE_SPONSOR_ENABLED=true. Never commit a +# real secret; keep it out of source control like the donation secret. +FEE_SPONSOR_SECRET= +# Per-transaction fee ceiling in stroops (default 1000000 = 0.1 XLM). +FEE_SPONSOR_MAX_FEE_STROOPS=1000000 +# Total XLM-fee spend allowed per UTC day, in stroops (default 100000000 = 10 XLM). +FEE_SPONSOR_DAILY_CAP_STROOPS=100000000 +# Max sponsored transactions per user per UTC day (default 10). +FEE_SPONSOR_PER_USER_DAILY_LIMIT=10 + # SEP-1 stellar.toml (/.well-known/stellar.toml) — optional, omitted when blank STELLAR_PLATFORM_PUBLIC_KEY= ORG_NAME= @@ -84,6 +108,31 @@ GIVING_ESCROW_CONTRACT_ID= ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=30d +# ── Authentication abuse hardening (issue #89) ────────────────────────────── +# Progressive per-account login lockout. After LOGIN_MAX_ATTEMPTS consecutive +# failures the account is locked for an escalating backoff (base * 2^(excess)), +# capped at LOGIN_LOCKOUT_MAX_MS. Clears automatically on success/backoff. +LOGIN_MAX_ATTEMPTS=5 +LOGIN_LOCKOUT_BASE_MS=60000 +LOGIN_LOCKOUT_MAX_MS=86400000 + +# Breached-password check (HaveIBeenPwned range API). Only the 5-char SHA-1 +# prefix is sent; fails OPEN on outage so signups/resets never break. +# HIBP_RANGE_URL=https://api.pwnedpasswords.com/range/ +# HIBP_TIMEOUT_MS=2000 + +# Per-email signup / verification-resend throttle (survives IP rotation). +RATE_LIMIT_EMAIL_AUTH_MAX=20 +RATE_LIMIT_EMAIL_AUTH_WINDOW_MS=900000 +# RATE_LIMIT_EMAIL_AUTH_DISABLE=true + +# Optional captcha gate for /register and /resend-verification. No-op when +# unset; fails OPEN on provider outage. Supports hCaptcha (default) and +# Google reCAPTCHA v2/v3 via CAPTCHA_VERIFY_URL override. +# CAPTCHA_SECRET_KEY= +# CAPTCHA_VERIFY_URL=https://hcaptcha.com/siteverify +# CAPTCHA_TIMEOUT_MS=5000 + # Redis Configuration (optional - app works without Redis but with reduced performance) # Option 1: Use REDIS_URL for full connection string (recommended for cloud services) # REDIS_URL=redis://username:password@host:port @@ -106,3 +155,31 @@ JOBS_ENABLED=true QUEUE_DRIVER=mongo JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token +# Service-to-service auth for the AI service (dnb-ai). A JSON array of signed, +# scoped, rotatable keys keyed by `kid`. REQUIRED in production (boot fails +# fast if missing); optional in dev/test. Keep >=1 entry active; to rotate, +# add a new active kid, deploy, switch dnb-ai over, then set the old kid +# "active": false. See docs/service-to-service-auth.md. +# AI_SERVICE_KEYS=[{"kid":"k1","secret":"replace_with_a_long_random_secret","scopes":["ai:read-content"],"active":true}] + + +# ── Outbound webhooks (issue #45) ──────────────────────────────────────────── +# Webhook signing secrets are stored ENCRYPTED at rest (AES-256-GCM). This key +# derives the encryption key (SHA-256). REQUIRED in production (boot fails fast +# if missing); a fixed dev fallback is used in development/test. Rotating this +# invalidates all stored secrets — rotate per-endpoint secrets via the API. +# WEBHOOK_SECRET_ENCRYPTION_KEY=replace_with_a_long_random_value +# Enable the interval delivery worker on this process (like INGESTION_WORKER_ENABLED). +WEBHOOK_WORKER_ENABLED=false +# Delivery loop poll interval (ms). +WEBHOOK_POLL_INTERVAL_MS=5000 +# Total attempts before a delivery is dead-lettered. +WEBHOOK_MAX_ATTEMPTS=6 +# Consecutive dead deliveries that auto-disable an endpoint. +WEBHOOK_AUTO_DISABLE_THRESHOLD=5 +# Max random jitter (ms) added to each backoff delay. +WEBHOOK_BACKOFF_JITTER_MS=30000 +# Per-request HTTP timeout for delivery POSTs (ms). +WEBHOOK_HTTP_TIMEOUT_MS=10000 +# Payload envelope version advertised to consumers. +WEBHOOK_API_VERSION=2025-01-01 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b7f9b0..292a8948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: services: mongodb: - image: mongo:7 + image: mongo:6.0 ports: - 27017:27017 @@ -24,12 +24,23 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci + - name: Wait for MongoDB + run: | + for i in $(seq 1 30); do + if nc -z localhost 27017 2>/dev/null || (exec 6<>/dev/tcp/localhost/27017) 2>/dev/null; then + echo "MongoDB is listening on port 27017" + break + fi + echo "Waiting for MongoDB..." + sleep 1 + done + - name: Run tests run: npm test env: @@ -41,16 +52,19 @@ jobs: CLOUDINARY_CLOUD_NAME: ci_test_cloud CLOUDINARY_API_KEY: ci_test_key CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak - build: name: Syntax and Boot Check runs-on: ubuntu-latest services: mongodb: - image: mongo:7 + image: mongo:6.0 ports: - 27017:27017 + redis: + image: redis:7 + ports: + - 6379:6379 steps: - name: Checkout code @@ -59,12 +73,34 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci + - name: Wait for MongoDB + run: | + for i in $(seq 1 30); do + if nc -z localhost 27017 2>/dev/null || (exec 6<>/dev/tcp/localhost/27017) 2>/dev/null; then + echo "MongoDB is listening on port 27017" + break + fi + echo "Waiting for MongoDB..." + sleep 1 + done + + - name: Wait for Redis + run: | + for i in $(seq 1 30); do + if nc -z localhost 6379 2>/dev/null || (exec 6<>/dev/tcp/localhost/6379) 2>/dev/null; then + echo "Redis is listening on port 6379" + break + fi + echo "Waiting for Redis..." + sleep 1 + done + - name: Check syntax of all source files run: | find . -name "*.js" -not -path "./node_modules/*" -print0 \ @@ -94,3 +130,4 @@ jobs: CLOUDINARY_CLOUD_NAME: ci_test_cloud CLOUDINARY_API_KEY: ci_test_key CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak + REDIS_URL: redis://localhost:6379 diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000..602b7001 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,37 @@ +name: Contracts CI + +on: + pull_request: + branches: [main, dev] + paths: + - "contracts/**" + - ".github/workflows/contracts.yml" + push: + branches: [main, dev] + paths: + - "contracts/**" + - ".github/workflows/contracts.yml" + +jobs: + rust: + name: Rust contract checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --manifest-path contracts/Cargo.toml -- --check + + - name: Run clippy + run: cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings + + - name: Run contract tests + run: cargo test --manifest-path contracts/Cargo.toml diff --git a/README.md b/README.md index 4800763d..af0bb777 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The platform is composed of three services: - 🎓 **Course Management** — create, enroll, review, and track courses - 📚 **Digital Library** — upload, purchase, and read Islamic books - ⭐ **Stellar Payments** — USDC payment initialize → sign → submit → on-chain verify flow +- ⛽ **Fee Sponsorship** — optional platform-paid network fees via fee-bump, with a structural whitelist and spend caps ([docs](docs/fee-sponsorship.md)) - 👛 **Wallet Management** — connect Freighter, xBull, or Albedo; balance and trustline checks - 💬 **Real-time** — Socket.io messaging and notifications - ☁️ **Media** — Cloudinary uploads for avatars, covers, books, and reels @@ -76,12 +77,14 @@ The API runs at `http://localhost:5000`. | `PORT` | Server port (default `5000`) | | `MONGO_URI` | MongoDB connection string | | `JWT_SECRET` | Secret for signing tokens (32+ chars) | -| `STELLAR_NETWORK` | `testnet` or `mainnet` | +| `STELLAR_NETWORK` | `testnet` or `mainnet` (`public` accepted; validated at boot) | +| — | **Switching to mainnet? See [docs/MAINNET.md](docs/MAINNET.md)** — env changes, creator trustlines, smoke-test checklist | | `CLOUDINARY_*` | Cloudinary credentials for media uploads | | `QUEUE_DRIVER` | `mongo` (durable production default) or `inline` (tests/CI) | | `JOBS_ENABLED` | Start background workers; defaults to `true` | | `JOBS_DASHBOARD_TOKEN` | Bearer token protecting `/admin/jobs` | | `STELLAR_PLATFORM_PUBLIC_KEY` | Public key published in `stellar.toml` `ACCOUNTS[]` | +| `FEE_SPONSOR_ENABLED` | Turn on platform-paid network fees (fee-bump). Off by default; when on, `FEE_SPONSOR_SECRET` is validated at boot ([docs](docs/fee-sponsorship.md)) | See `.env.example` for the full list. @@ -142,3 +145,6 @@ Read **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full workflow, coding standa - 🌐 Website: [dnb-frontend.vercel.app](https://dnb-frontend.vercel.app) - 🐦 X/Twitter: [@deen_bridge](https://x.com/deen_bridge) - 🏢 Organization: [github.com/Deen-Bridge](https://github.com/Deen-Bridge) +# Course categories + +Seed the curated Islamic-discipline taxonomy with `npm run seed:categories`. Existing free-text course and book categories can be linked without removing their legacy string values by running `npm run migrate:categories`. Both commands are idempotent and require `MONGO_URI`. diff --git a/app.js b/app.js index 316d1e2c..c3fb2491 100644 --- a/app.js +++ b/app.js @@ -2,27 +2,18 @@ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import compression from "compression"; -import dotenv from "dotenv"; import crypto from "crypto"; import "./src/jobs/handlers.js"; -// Load env vars, except in tests where test/jest.setup.js has already loaded -// (and stripped) secrets — re-loading .env here would leak SMTP/REDIS creds -// back into the test process and cause real network calls. -if (process.env.NODE_ENV !== "test") { - dotenv.config(); -} - -import connectDB from "./src/config/db.js"; -import validateEnv from "./src/config/validateEnv.js"; import logger from "./src/config/logger.js"; -import { registry, metricsMiddleware, observeHttpDuration } from "./src/config/metrics.js"; +import { metricsMiddleware, observeHttpDuration } from "./src/config/metrics.js"; import { helmetMiddleware, standardLimiter, generousLimiter, authLimiter, + paymentLimiter, mongoSanitizeMiddleware, hppMiddleware, customSecurityHeaders, @@ -31,8 +22,6 @@ import { sanitizeInput } from "./src/middlewares/validate.js"; import { errorHandler, notFound, - handleUnhandledRejection, - handleUncaughtException, } from "./src/middlewares/errorHandler.js"; import authRoutes from "./src/routes/authRoutes.js"; @@ -49,21 +38,21 @@ import callRoutes from "./src/routes/callRoutes.js"; import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; +import stellarPledgeRoutes from "./src/routes/stellar/pledgeRoutes.js"; +import stellarGiftRoutes from "./src/routes/stellar/giftRoutes.js"; import payoutRoutes from "./src/routes/payoutRoutes.js"; import uploadRoutes from "./src/routes/uploadRoutes.js"; import notificationRoutes from "./src/routes/notificationRoutes.js"; import jobsRoutes from "./src/routes/jobsRoutes.js"; +import internalAiRoutes from "./src/routes/internal/aiRoutes.js"; import wellKnownRoutes from "./src/routes/wellKnownRoutes.js"; import auditRoutes from "./src/routes/admin/auditRoutes.js"; import educatorRoutes from "./src/routes/educatorRoutes.js"; - -handleUncaughtException(); -validateEnv(); - -// Connect to MongoDB (skip during tests as tests handle their own connections) -if (process.env.NODE_ENV !== "test") { - connectDB(); -} +import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js"; +import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js"; +import webhookRoutes from "./src/routes/webhookRoutes.js"; +import categoryRoutes from "./src/routes/categoryRoutes.js"; +import { healthCheck, ping } from "./src/controllers/healthController.js"; const app = express(); @@ -143,7 +132,17 @@ const corsOptions = { app.use(cors(corsOptions)); -app.use(express.json({ limit: "10mb" })); +// Capture the raw request bytes so the service-to-service auth middleware can +// verify HMAC signatures over the exact body (see middlewares/serviceAuth.js). +// This only stashes a Buffer reference and does not alter parsing behaviour. +app.use( + express.json({ + limit: "10mb", + verify: (req, _res, buf) => { + req.rawBody = buf; + }, + }) +); app.use(express.urlencoded({ extended: true, limit: "10mb" })); app.use(cookieParser()); app.use(compression()); @@ -164,13 +163,8 @@ app.get("/", (req, res) => { }); }); -app.get("/health", (req, res) => { - res.json({ - success: true, - message: "pong", - timestamp: new Date().toISOString(), - }); -}); +app.get("/ping", ping); +app.get("/health", healthCheck); // SEP-1 stellar.toml — must be outside /api rate limiter app.use("/.well-known", wellKnownRoutes); @@ -186,6 +180,7 @@ app.use("/api/payouts", standardLimiter, payoutRoutes); // Read-heavy & content routes — generous limiter app.use("/api/courses", generousLimiter, courseRoutes); +app.use("/api/categories", generousLimiter, categoryRoutes); app.use("/api/reels", generousLimiter, reelsRoute); app.use("/api/books", generousLimiter, bookRoutes); app.use("/api/books", generousLimiter, recommendedBooksRoutes); @@ -194,14 +189,25 @@ app.use("/api/users", generousLimiter, userRoutes); app.use("/api/search", generousLimiter, searchRoutes); app.use("/api/calls", generousLimiter, callRoutes); app.use("/api/educators", generousLimiter, educatorRoutes); +app.use("/api/educator-verification", standardLimiter, educatorVerificationRoutes); app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes); -app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes); +// Payment routes mutate money state — stricter per-user limiter (issue #4). +app.use("/api/stellar/payment", paymentLimiter, stellarPaymentRoutes); app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes); +app.use("/api/stellar/pledges", generousLimiter, stellarPledgeRoutes); +app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes); app.use("/api/notifications", generousLimiter, notificationRoutes); +// Outbound webhook management API (admin-gated) +app.use("/api/webhooks", standardLimiter, webhookRoutes); + +// Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs +app.use("/api/internal/ai", internalAiRoutes); + // Admin — no rate limit app.use("/admin/jobs", jobsRoutes); app.use("/api/admin/audit", auditRoutes); +app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes); // ====================== // ERROR HANDLING @@ -209,7 +215,6 @@ app.use("/api/admin/audit", auditRoutes); app.use(notFound); app.use(errorHandler); -handleUnhandledRejection(); logger.info("DeenBridge API initialized"); logger.info(`Logging enabled - Level: ${logger.level}`); diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 00000000..2d67a90c --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,2 @@ +target/ +test_snapshots/ diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock new file mode 100644 index 00000000..a30dd318 --- /dev/null +++ b/contracts/Cargo.lock @@ -0,0 +1,2121 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes-lit" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b04f2b1d34cb428043f14aa4c853d14294532e8bbde3b6a3bc2faaaae31a1dd" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54851b5b3f24621804b1cded2820975623c205e3055d2d44031cdb1237339ac8" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scholarship-escrow" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77bc93d930032c487cb1506b6ed166b2af49db76d52678ec4887ac621ecce01" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b22e9981cdd444f3aa6734bc58d76195bf7eca3ccf1dd432b875af5d02da068" +dependencies = [ + "arbitrary", + "crate-git-revision 0.0.6", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6072f99ca6bf8e8d5b04e05d083dac785e5357d9c0f36a6658f819c2fd7d67" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c06afd7c75ce150ce53e4d77a77645b18e3fb61856a0ddc42bfcecdc39fa3b9" +dependencies = [ + "ark-bls12-381", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek 5.0.0", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey 0.0.13", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "647811bdd28a3ec40296987f6635781e5e1141c8f5affbbd53ba12b6295b7bb6" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b59883d8bd0d1aed8d57579a9974ab88eaf787dd0a1af104f6881b5707450558" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-sdk" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3f21971c84fcfb08957e3e8f5a9a70f134cb07ad9ee053ac7e6d7a887a82af" +dependencies = [ + "arbitrary", + "bytes-lit", + "crate-git-revision 0.0.9", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey 0.0.16", + "visibility", +] + +[[package]] +name = "soroban-sdk-macros" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd4a847273d749807fe2eb52e2b9c1917ee482cd6a39465cad5c389548996ad" +dependencies = [ + "darling 0.20.11", + "heck", + "itertools", + "macro-string", + "proc-macro2", + "quote", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473404322827b285cbcd87517f365986bd63af7842c78b2a86ee061715fda61e" +dependencies = [ + "base64", + "sha2", + "stellar-xdr", + "thiserror 1.0.69", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f25698b6ce2125850a9ef075cf9ba1e8d25b4cfa0c46aca42dadd80cc29d881" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", + "heapless", +] + +[[package]] +name = "stellar-xdr" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" +dependencies = [ + "arbitrary", + "base64", + "cfg_eval", + "crate-git-revision 0.0.6", + "escape-bytes", + "ethnum", + "hex", + "serde", + "serde_with", + "sha2", + "stellar-strkey 0.0.13", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml new file mode 100644 index 00000000..7067e861 --- /dev/null +++ b/contracts/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["scholarship_escrow"] diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..1d7aaad4 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,60 @@ +# Scholarship Escrow Contract + +This directory contains the Stage 1 Soroban scholarship escrow contract for +issue 34. The contract stores an immutable scholarship schedule, accepts +non-custodial donor funding in a Stellar Asset Contract token, releases exact +milestone amounts after arbiter authorization, and permits pro-rata refunds +after expiry. + +## Local Setup + +Install Rust with `rustup`, then install the target used by current Soroban +tooling: + +```bash +rustup target add wasm32v1-none +``` + +Install the Stellar CLI using the official Stellar CLI instructions. Check the +local toolchain before building: + +```bash +rustc --version +cargo --version +stellar --version +``` + +## Test and Format + +Run these commands from the repository root: + +```bash +cargo fmt --manifest-path contracts/Cargo.toml -- --check +cargo test --manifest-path contracts/Cargo.toml +cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings +``` + +## Build + +Build the contract through Cargo: + +```bash +cargo build --manifest-path contracts/Cargo.toml \ + --package scholarship-escrow \ + --target wasm32v1-none \ + --release +``` + +The resulting WASM is written to +`contracts/target/wasm32v1-none/release/scholarship_escrow.wasm`. + +The Stellar CLI can also build the workspace once it is installed: + +```bash +stellar contract build --package scholarship-escrow +``` + +Testnet deployment, SAC wrapping for the configured USDC issuer, contract ID +configuration, and the unsigned JavaScript invocation flow are Stage 2 work. +Do not put secret keys in this repository or in application environment +variables. diff --git a/contracts/scholarship_escrow/Cargo.toml b/contracts/scholarship_escrow/Cargo.toml new file mode 100644 index 00000000..065b96e6 --- /dev/null +++ b/contracts/scholarship_escrow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "scholarship-escrow" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "=27.0.6" + +[dev-dependencies] +soroban-sdk = { version = "=27.0.6", features = ["testutils"] } diff --git a/contracts/scholarship_escrow/src/lib.rs b/contracts/scholarship_escrow/src/lib.rs new file mode 100644 index 00000000..c5a9db4f --- /dev/null +++ b/contracts/scholarship_escrow/src/lib.rs @@ -0,0 +1,829 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, Vec, +}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + EmptyMilestones = 2, + InvalidExpiry = 3, + InvalidMilestoneAmount = 4, + InvalidMilestoneState = 5, + MilestoneTotalOverflow = 6, + InvalidAmount = 7, + FundingCapExceeded = 8, + EscrowExpired = 9, + InvalidMilestoneIndex = 10, + MilestoneAlreadyReleased = 11, + InsufficientFunds = 12, + DonorNotFound = 13, + AlreadyRefunded = 14, + NoRefundAvailable = 15, + ArithmeticOverflow = 16, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { + pub amount: i128, + pub released: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowState { + pub arbiter: Address, + pub beneficiary: Address, + pub token: Address, + pub expiry: u32, + pub milestone_total: i128, + pub funded_total: i128, + pub released_total: i128, + pub refunded_total: i128, + pub refund_pool: Option, +} + +#[contracttype] +enum DataKey { + State, + Milestones, + Donors, + DonorContribution(Address), + DonorRefund(Address), + RefundClaimed(Address), +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Initialized { + #[topic] + pub arbiter: Address, + #[topic] + pub beneficiary: Address, + pub token: Address, + pub expiry: u32, + pub milestone_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Funded { + #[topic] + pub donor: Address, + pub amount: i128, + pub donor_total: i128, + pub funded_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneApproved { + #[topic] + pub index: u32, + pub amount: i128, + pub released_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Refunded { + #[topic] + pub donor: Address, + pub amount: i128, + pub refunded_total: i128, +} + +#[contract] +pub struct ScholarshipEscrow; + +#[contractimpl] +impl ScholarshipEscrow { + pub fn init( + env: Env, + arbiter: Address, + beneficiary: Address, + token: Address, + milestones: Vec, + expiry: u32, + ) -> Result<(), Error> { + if env.storage().persistent().has(&DataKey::State) { + return Err(Error::AlreadyInitialized); + } + + arbiter.require_auth(); + + if milestones.is_empty() { + return Err(Error::EmptyMilestones); + } + if expiry <= env.ledger().sequence() { + return Err(Error::InvalidExpiry); + } + + let mut milestone_total = 0_i128; + for milestone in milestones.iter() { + if milestone.amount <= 0 { + return Err(Error::InvalidMilestoneAmount); + } + if milestone.released { + return Err(Error::InvalidMilestoneState); + } + milestone_total = milestone_total + .checked_add(milestone.amount) + .ok_or(Error::MilestoneTotalOverflow)?; + } + + let state = EscrowState { + arbiter: arbiter.clone(), + beneficiary: beneficiary.clone(), + token: token.clone(), + expiry, + milestone_total, + funded_total: 0, + released_total: 0, + refunded_total: 0, + refund_pool: None, + }; + + env.storage().persistent().set(&DataKey::State, &state); + env.storage() + .persistent() + .set(&DataKey::Milestones, &milestones); + env.storage() + .persistent() + .set(&DataKey::Donors, &Vec::
::new(&env)); + + Initialized { + arbiter, + beneficiary, + token, + expiry, + milestone_total, + } + .publish(&env); + + Ok(()) + } + + pub fn fund(env: Env, donor: Address, amount: i128) -> Result<(), Error> { + let mut state = Self::load_state(&env); + Self::ensure_active(&env, &state)?; + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let funded_total = state + .funded_total + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + if funded_total > state.milestone_total { + return Err(Error::FundingCapExceeded); + } + + donor.require_auth(); + + let donor_key = DataKey::DonorContribution(donor.clone()); + let donor_total = env + .storage() + .persistent() + .get(&donor_key) + .unwrap_or(0_i128) + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + let mut donors: Vec
= env + .storage() + .persistent() + .get(&DataKey::Donors) + .unwrap_or_else(|| Vec::new(&env)); + if !donors.contains(&donor) { + donors.push_back(donor.clone()); + env.storage().persistent().set(&DataKey::Donors, &donors); + } + + let token_client = token::Client::new(&env, &state.token); + token_client.transfer(&donor, env.current_contract_address(), &amount); + + state.funded_total = funded_total; + env.storage().persistent().set(&DataKey::State, &state); + env.storage().persistent().set(&donor_key, &donor_total); + + Funded { + donor, + amount, + donor_total, + funded_total, + } + .publish(&env); + + Ok(()) + } + + pub fn approve_milestone(env: Env, index: u32) -> Result<(), Error> { + let mut state = Self::load_state(&env); + Self::ensure_active(&env, &state)?; + state.arbiter.require_auth(); + + let mut milestones: Vec = env + .storage() + .persistent() + .get(&DataKey::Milestones) + .unwrap(); + let mut milestone = milestones.get(index).ok_or(Error::InvalidMilestoneIndex)?; + + if milestone.released { + return Err(Error::MilestoneAlreadyReleased); + } + + let available = state + .funded_total + .checked_sub(state.released_total) + .and_then(|value| value.checked_sub(state.refunded_total)) + .ok_or(Error::ArithmeticOverflow)?; + if available < milestone.amount { + return Err(Error::InsufficientFunds); + } + + let released_total = state + .released_total + .checked_add(milestone.amount) + .ok_or(Error::ArithmeticOverflow)?; + + let token_client = token::Client::new(&env, &state.token); + token_client.transfer( + &env.current_contract_address(), + &state.beneficiary, + &milestone.amount, + ); + + milestone.released = true; + milestones.set(index, milestone.clone()); + state.released_total = released_total; + env.storage() + .persistent() + .set(&DataKey::Milestones, &milestones); + env.storage().persistent().set(&DataKey::State, &state); + + MilestoneApproved { + index, + amount: milestone.amount, + released_total, + } + .publish(&env); + + Ok(()) + } + + pub fn refund(env: Env, donor: Address) -> Result { + let mut state = Self::load_state(&env); + if env.ledger().sequence() < state.expiry { + return Err(Error::InvalidExpiry); + } + + donor.require_auth(); + + let donor_key = DataKey::DonorContribution(donor.clone()); + let contribution: i128 = env + .storage() + .persistent() + .get(&donor_key) + .ok_or(Error::DonorNotFound)?; + let claimed_key = DataKey::RefundClaimed(donor.clone()); + if env + .storage() + .persistent() + .get(&claimed_key) + .unwrap_or(false) + { + return Err(Error::AlreadyRefunded); + } + + let refund_pool = match state.refund_pool { + Some(pool) => pool, + None => { + let pool = state + .funded_total + .checked_sub(state.released_total) + .and_then(|value| value.checked_sub(state.refunded_total)) + .ok_or(Error::ArithmeticOverflow)?; + if pool <= 0 { + return Err(Error::NoRefundAvailable); + } + state.refund_pool = Some(pool); + pool + } + }; + + if refund_pool <= 0 { + return Err(Error::NoRefundAvailable); + } + + let donors: Vec
= env + .storage() + .persistent() + .get(&DataKey::Donors) + .unwrap_or_else(|| Vec::new(&env)); + let unclaimed_donors = donors + .iter() + .filter(|address| { + !env.storage() + .persistent() + .get(&DataKey::RefundClaimed(address.clone())) + .unwrap_or(false) + }) + .count(); + + let amount = if unclaimed_donors == 1 { + refund_pool + .checked_sub(state.refunded_total) + .ok_or(Error::ArithmeticOverflow)? + } else { + contribution + .checked_mul(refund_pool) + .ok_or(Error::ArithmeticOverflow)? + / state.funded_total + }; + + let refunded_total = state + .refunded_total + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + if amount > 0 { + let token_client = token::Client::new(&env, &state.token); + token_client.transfer(&env.current_contract_address(), &donor, &amount); + } + + env.storage().persistent().set(&claimed_key, &true); + env.storage() + .persistent() + .set(&DataKey::DonorRefund(donor.clone()), &amount); + state.refunded_total = refunded_total; + env.storage().persistent().set(&DataKey::State, &state); + + Refunded { + donor, + amount, + refunded_total, + } + .publish(&env); + + Ok(amount) + } + + pub fn state(env: Env) -> EscrowState { + Self::load_state(&env) + } + + pub fn funded_total(env: Env) -> i128 { + Self::load_state(&env).funded_total + } + + pub fn milestone(env: Env, index: u32) -> Milestone { + let milestones: Vec = env + .storage() + .persistent() + .get(&DataKey::Milestones) + .unwrap(); + milestones.get(index).unwrap() + } + + pub fn donor_contribution(env: Env, donor: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::DonorContribution(donor)) + .unwrap_or(0) + } + + fn load_state(env: &Env) -> EscrowState { + env.storage().persistent().get(&DataKey::State).unwrap() + } + + fn ensure_active(env: &Env, state: &EscrowState) -> Result<(), Error> { + if env.ledger().sequence() >= state.expiry { + Err(Error::EscrowExpired) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use super::*; + use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, Events as _, Ledger as _}, + token::StellarAssetClient, + Env, Event, IntoVal, Symbol, + }; + + const NOW: u32 = 1_000; + const EXPIRY: u32 = 2_000; + + struct Context { + env: Env, + contract_id: Address, + token: Address, + arbiter: Address, + beneficiary: Address, + donor_a: Address, + donor_b: Address, + } + + impl Context { + fn client(&self) -> ScholarshipEscrowClient<'_> { + ScholarshipEscrowClient::new(&self.env, &self.contract_id) + } + + fn token_client(&self) -> StellarAssetClient<'_> { + StellarAssetClient::new(&self.env, &self.token) + } + } + + fn context() -> Context { + let env = Env::default(); + env.ledger().set_sequence_number(NOW); + + let arbiter = Address::generate(&env); + let beneficiary = Address::generate(&env); + let donor_a = Address::generate(&env); + let donor_b = Address::generate(&env); + + let token_contract = env.register_stellar_asset_contract_v2(arbiter.clone()); + let token = token_contract.address(); + let token_client = StellarAssetClient::new(&env, &token); + + let contract_id = env.register(ScholarshipEscrow, ()); + let client = ScholarshipEscrowClient::new(&env, &contract_id); + + env.mock_all_auths(); + token_client.mint(&donor_a, &1_000); + token_client.mint(&donor_b, &1_000); + client.init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 600, + released: false, + }, + Milestone { + amount: 400, + released: false, + }, + ], + &EXPIRY, + ); + + Context { + env, + contract_id, + token, + arbiter, + beneficiary, + donor_a, + donor_b, + } + } + + #[test] + fn init_stores_fixed_state_and_emits_event() { + let ctx = context(); + let events = ctx.env.events().all().filter_by_contract(&ctx.contract_id); + assert_eq!( + events.events(), + &[Initialized { + arbiter: ctx.arbiter.clone(), + beneficiary: ctx.beneficiary.clone(), + token: ctx.token.clone(), + expiry: EXPIRY, + milestone_total: 1_000, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + let state = ctx.client().state(); + assert_eq!(state.arbiter, ctx.arbiter); + assert_eq!(state.beneficiary, ctx.beneficiary); + assert_eq!(state.token, ctx.token); + assert_eq!(state.expiry, EXPIRY); + assert_eq!(state.milestone_total, 1_000); + assert_eq!(ctx.client().milestone(&0).amount, 600); + assert!(!ctx.client().milestone(&0).released); + } + + #[test] + fn funding_is_tracked_per_donor_and_capped_at_milestone_total() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().fund(&ctx.donor_b, &400); + + assert_eq!(ctx.client().funded_total(), 1_000); + assert_eq!(ctx.client().donor_contribution(&ctx.donor_a), 600); + assert_eq!(ctx.client().donor_contribution(&ctx.donor_b), 400); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 1_000); + assert_eq!( + ctx.client().try_fund(&ctx.donor_a, &1), + Err(Ok(Error::FundingCapExceeded)) + ); + } + + #[test] + fn arbiter_releases_exact_milestone_amount_once() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().approve_milestone(&0); + + assert_eq!(ctx.token_client().balance(&ctx.beneficiary), 600); + assert_eq!(ctx.client().state().released_total, 600); + assert!(ctx.client().milestone(&0).released); + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::MilestoneAlreadyReleased)) + ); + } + + #[test] + fn cannot_release_more_than_funded() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &500); + + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::InsufficientFunds)) + ); + } + + #[test] + fn refunds_are_pro_rata_after_expiry_and_return_the_rounding_remainder() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().fund(&ctx.donor_b, &400); + ctx.client().approve_milestone(&0); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!(ctx.client().refund(&ctx.donor_b), 160); + assert_eq!(ctx.client().refund(&ctx.donor_a), 240); + assert_eq!(ctx.token_client().balance(&ctx.donor_a), 640); + assert_eq!(ctx.token_client().balance(&ctx.donor_b), 760); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0); + assert_eq!(ctx.client().state().refunded_total, 400); + assert_eq!( + ctx.client().try_refund(&ctx.donor_a), + Err(Ok(Error::AlreadyRefunded)) + ); + } + + #[test] + fn refund_rounding_dust_is_paid_to_the_final_claimant() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &333); + ctx.client().fund(&ctx.donor_b, &667); + ctx.client().approve_milestone(&0); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!(ctx.client().refund(&ctx.donor_a), 133); + assert_eq!(ctx.client().refund(&ctx.donor_b), 267); + assert_eq!(ctx.client().state().refunded_total, 400); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0); + } + + #[test] + fn refund_before_expiry_is_rejected() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + + assert_eq!( + ctx.client().try_refund(&ctx.donor_a), + Err(Ok(Error::InvalidExpiry)) + ); + } + + #[test] + fn funding_and_approval_stop_at_expiry() { + let ctx = context(); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!( + ctx.client().try_fund(&ctx.donor_a, &1), + Err(Ok(Error::EscrowExpired)) + ); + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::EscrowExpired)) + ); + } + + #[test] + fn funding_requires_donor_authorization() { + let ctx = context(); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().fund(&ctx.donor_a, &1); + })); + assert!(result.is_err()); + } + + #[test] + fn approval_requires_arbiter_authorization() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().approve_milestone(&0); + })); + assert!(result.is_err()); + } + + #[test] + fn refund_requires_donor_authorization() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.env.ledger().set_sequence_number(EXPIRY); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().refund(&ctx.donor_a); + })); + assert!(result.is_err()); + } + + #[test] + fn state_changes_emit_indexable_events() { + let ctx = context(); + + ctx.client().fund(&ctx.donor_a, &600); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[Funded { + donor: ctx.donor_a.clone(), + amount: 600, + donor_total: 600, + funded_total: 600, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.client().fund(&ctx.donor_b, &400); + ctx.client().approve_milestone(&0); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[MilestoneApproved { + index: 0, + amount: 600, + released_total: 600, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.env.ledger().set_sequence_number(EXPIRY); + ctx.client().refund(&ctx.donor_b); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[Refunded { + donor: ctx.donor_b.clone(), + amount: 160, + refunded_total: 160, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + } + + #[test] + fn auth_tree_contains_donor_and_arbiter_authorizations() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + let fund_auths = ctx.env.auths(); + assert!(fund_auths + .iter() + .any(|(address, _)| address == &ctx.donor_a)); + + ctx.client().approve_milestone(&0); + let approval_auths = ctx.env.auths(); + assert!(approval_auths.iter().any(|(address, invocation)| { + address == &ctx.arbiter + && invocation.function + == AuthorizedFunction::Contract(( + ctx.contract_id.clone(), + Symbol::new(&ctx.env, "approve_milestone"), + (&0_u32,).into_val(&ctx.env), + )) + })); + } + + #[test] + fn invalid_initialization_constraints_are_rejected() { + let env = Env::default(); + env.ledger().set_sequence_number(NOW); + let arbiter = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token = Address::generate(&env); + let contract_id = env.register(ScholarshipEscrow, ()); + let client = ScholarshipEscrowClient::new(&env, &contract_id); + + env.mock_all_auths(); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::Vec::new(&env), + &EXPIRY, + ), + Err(Ok(Error::EmptyMilestones)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 0, + released: false, + } + ], + &EXPIRY, + ), + Err(Ok(Error::InvalidMilestoneAmount)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 1, + released: true, + } + ], + &EXPIRY, + ), + Err(Ok(Error::InvalidMilestoneState)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 1, + released: false, + } + ], + &NOW, + ), + Err(Ok(Error::InvalidExpiry)) + ); + } + + #[test] + fn initialization_cannot_run_twice() { + let ctx = context(); + let milestones = soroban_sdk::vec![ + &ctx.env, + Milestone { + amount: 1_000, + released: false, + } + ]; + + assert_eq!( + ctx.client().try_init( + &ctx.arbiter, + &ctx.beneficiary, + &ctx.token, + &milestones, + &(EXPIRY + 1), + ), + Err(Ok(Error::AlreadyInitialized)) + ); + } +} diff --git a/docs/MAINNET.md b/docs/MAINNET.md new file mode 100644 index 00000000..dd48da04 --- /dev/null +++ b/docs/MAINNET.md @@ -0,0 +1,187 @@ +# Switching DeenBridge to Stellar Mainnet (USDC) + +DeenBridge runs on **Stellar testnet** by default (`STELLAR_NETWORK=testnet`). +This document is the complete checklist for moving the payment stack to +**mainnet** (the Stellar public network, `STELLAR_NETWORK=public` or +`mainnet`). Following it end-to-end means the switch requires **no code +reading** — only environment changes, wallet/trustline setup, and a smoke +test. + +> ⚠️ **Config is validated at boot.** The backend validates the Stellar +> configuration at startup (see `src/config/stellar.js`). A wrong or +> incomplete configuration **fails fast** with an error naming the exact +> problem instead of failing later on the first Horizon call. If you see +> `❌ Stellar configuration error` in the logs at boot, fix the named +> variable and restart — do not ship it. + +--- + +## 1. Understand what "network" controls + +Everything network-dependent resolves from a single source of truth +(`src/config/stellar.js`): + +| Setting | testnet | mainnet (`mainnet` / `public`) | +|---------|---------|-------------------------------| +| Network passphrase | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | +| Default Horizon URL | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | +| USDC issuer (Circle) | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| EURC issuer | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2` | + +`STELLAR_NETWORK` accepts `testnet`, `mainnet`, or `public` (`public` is the +SDF name for the production network and is treated as `mainnet`). The USDC +issuer and default Horizon URL are derived from the network — you cannot +accidentally combine a mainnet flag with a testnet issuer or Horizon URL; the +boot-time validation rejects it. + +--- + +## 2. Backend environment variables + +Change these in the backend deployment (`.env` / Render / Vercel env): + +```dotenv +# The one switch that matters +STELLAR_NETWORK=mainnet # or "public" — both mean mainnet + +# Optional: explicit Horizon endpoints (comma-separated, for redundancy). +# Leave UNSET to use the network default (https://horizon.stellar.org). +# Never point a mainnet deployment at the testnet Horizon URL — boot will fail. +# HORIZON_URLS=https://horizon.stellar.org,https://horizon-fr.stellar.org + +# Horizon client tuning (optional, same defaults as testnet) +# HORIZON_TIMEOUT_MS=10000 +# HORIZON_MAX_RETRIES=3 +# HORIZON_CB_THRESHOLD=5 +# HORIZON_CB_COOLDOWN_MS=30000 +``` + +Also confirm the platform-level keys are set for mainnet (they are +network-agnostic, but they move real money now): + +```dotenv +# Platform fee wallet (receives the platform share of a fee-split purchase) +PLATFORM_WALLET_PUBLIC_KEY=G... +# Donation fund destination +DONATION_WALLET_PUBLIC_KEY=G... +# SEP-10 auth keypair public key (published in stellar.toml) +SIGNING_KEY=G... +``` + +### What NOT to change + +- `PLATFORM_FEE_PERCENT`, `PLATFORM_COLLECT_ENABLED` — unchanged. +- The **secret keys** of user wallets are never stored on the backend + (non-custodial). Users hold their own funds. + +--- + +## 3. Frontend environment variables + +The frontend must run on the **same network** or signatures will be rejected +(wrong network passphrase). In the `dnb-frontend` deployment set: + +```dotenv +NEXT_PUBLIC_STELLAR_NETWORK=mainnet +``` + +This must match the backend's `STELLAR_NETWORK` **exactly**. A mismatch +(backend on mainnet, frontend on testnet) produces signatures that fail with +`op_bad_auth` / bad network passphrase on submit. + +--- + +## 4. Creator trustlines (critical) + +Creators receive USDC **directly to their own wallets** (direct settlement) +or the platform wallet receives it (platform-collect mode). For a creator to +be able to receive USDC on mainnet, their wallet **must have a USDC +trustline to the mainnet Circle issuer**: + +``` +USDC issuer (mainnet): GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +``` + +Notes: + +- A **trustline added on testnet does not carry over** to mainnet — trustlines + are per-account and per-network. Every creator must add the mainnet USDC + trustline even if they already had one on testnet. +- Freighter / xBull / Albedo have a "manage assets" / "add asset" flow; pasting + the issuer above adds the trustline. This costs a small one-time XLM reserve + (the account must also hold a bit of XLM for fees and the reserve). +- A purchase to a creator **without** the USDC trustline fails on-chain + (`op_no_trust`). The preflight check (`POST /api/stellar/payment/preflight`) + surfaces this before the wallet is asked to sign, and the initialize + endpoint can return `{ fallback: "claimable_balance" }` so the buyer can + still complete via a claimable balance instead of dead-ending. +- If the platform is in **platform-collect** mode, the platform wallet itself + must have the mainnet USDC trustline. + +### How to verify a trustline + +```bash +# Replace G... with the creator's public key +curl "https://horizon.stellar.org/accounts/G..." | jq '.balances[] | select(.asset_code=="USDC")' +``` + +An entry with `asset_code: "USDC"` and `asset_issuer` equal to the mainnet +Circle issuer confirms the trustline exists. If the array is empty of USDC, +the creator needs to add it. + +--- + +## 5. Smoke-test checklist (first mainnet transaction) + +Run these in order, on the **mainnet deployment**, after the env changes are +live. Do not proceed past a failed step. + +1. **Boot check** — start the backend. Confirm it logs + `✅ Environment variables validated successfully` and **no** + `❌ Stellar configuration error`. A bad `STELLAR_NETWORK` value or a + mainnet/testnet Horizon or issuer mismatch aborts startup with the exact + variable named. + ```bash + curl -s http://localhost:5000/health # -> {"success":true,"message":"pong"} + ``` +2. **Network sanity** — confirm the app resolves mainnet: + ```bash + curl -s http://localhost:5000/.well-known/stellar.toml | grep -i network + ``` + and, from the code, `NETWORK`/`networkPassphrase` resolve to + `Public Global Stellar Network ; September 2015`. +3. **Creator trustline** — pick a test creator, confirm their mainnet USDC + trustline via the Horizon query above. If missing, have them add it. +4. **Buyer setup** — a test buyer connects a **mainnet** wallet with a small + amount of USDC (≥ item price) and enough XLM for fees/reserve. +5. **Preflight** — call `POST /api/stellar/payment/preflight` for a paid + course. Expect `success: true` with no `destination_no_trustline` reason. +6. **Initialize** — `POST /api/stellar/payment/initialize` returns unsigned + XDR + `expectedHash`. Confirm the returned `networkPassphrase` is the + **public** passphrase. +7. **Sign & submit** — the wallet signs the XDR on mainnet; + `POST /api/stellar/payment/submit` returns `"Payment successful!"` with a + mainnet `stellar.expert` explorer URL. +8. **On-chain verify** — open the explorer link. Confirm a USDC payment to the + creator (or platform) and that the buyer now owns the item + (`GET /api/stellar/payment/transactions` shows it `confirmed`). +9. **Creator received USDC** — confirm the creator's mainnet USDC balance + increased by the expected amount (minus platform fee if enabled). + +### Rollback + +To go back to testnet, revert `STELLAR_NETWORK=testnet` (backend) and +`NEXT_PUBLIC_STELLAR_NETWORK=testnet` (frontend) and redeploy. Both sides must +change together. Testnet and mainnet data (transactions, balances) are +completely separate — records created on mainnet are not visible on testnet +and vice versa. + +--- + +## 6. Reference + +- Stellar docs: [Networks](https://developers.stellar.org/docs/learn/encyclopedia/network-configuration), + [Claimable balances](https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/claimable-balances) +- Circle USDC: [USDC on Stellar](https://www.circle.com/en/usdc/stellar) +- Config source of truth: `src/config/stellar.js`, asset registry: + `src/config/assets.js` diff --git a/docs/authorization-matrix.md b/docs/authorization-matrix.md new file mode 100644 index 00000000..e6e6c572 --- /dev/null +++ b/docs/authorization-matrix.md @@ -0,0 +1,62 @@ +# Authorization Matrix — Resource Ownership + +This document describes the centralized resource-ownership authorization layer +(`src/middlewares/authorize.js`) that guards every mutating endpoint for books, +courses, spaces, and reviews. + +## How it works + +- `protect` authenticates the request and sets `req.user` (full User doc). +- `authorizeOwnership({ model, ownerField, resourceType })` loads the target + resource, then allows the request only if the caller is the **owner** or an + **admin**. On success it attaches the loaded doc as `req.resource`. +- `authorizeReviewOwnership({ model })` loads the parent item (Book/Course) and + the target review subdocument, applying the same owner-or-admin rule to + `review.user`. It supports both the id-scoped route (`/:id/reviews/:reviewId`) + and the self-scoped route (`/:id/reviews`, which targets the caller's own + review). +- Every denial writes an audit row (`authz.ownership.denied`, + `status: "failure"`) via the fire-and-forget audit service, then returns + `403` through the global error handler. + +## Owner fields + +| Resource | Model | Owner field | +| -------- | -------- | ------------------ | +| Book | `Book` | `author` | +| Course | `Course` | `createdBy` | +| Space | `Space` | `host` | +| Review | subdoc | `reviews[].user` | + +## Expected status codes + +Legend: **owner** = the resource owner; **non-owner educator/mentor** = an +authenticated mentor who does not own the resource; **student** = an +authenticated non-owner student; **admin** = any admin. + +| Resource / Action | Owner | Non-owner educator/mentor | Student (non-owner) | Admin | Non-existent id | +| ------------------------------------- | ----- | ------------------------- | ------------------- | ----- | --------------- | +| Book — `DELETE /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Course — `PUT /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Space — `PUT /update/:id` | 2xx | 403 | 403 | 2xx | 404 | +| Space — `DELETE /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Book review — `PUT/PATCH /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Book review — `DELETE /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Course review — `PUT/PATCH /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Course review — `DELETE /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | + +Notes on review status codes: + +- On the **self-scoped** review routes (no `:reviewId`), a non-owner has no + review of their own to act on, so the guard returns **404** ("Review not + found") rather than 403 — there is no target subdocument to deny. +- On the **id-scoped** review routes (`/:reviewId`), acting on another user's + review returns **403**; an unknown `:reviewId` returns **404**. + +## Out of scope / notes + +- **Review CREATE** (`POST /:id/reviews`) is **purchase-gated** (entitlement / + verified-purchase check in `verifyItemPurchase`), not ownership-gated. It is + intentionally NOT wrapped by the ownership layer. +- There is **no book-update route** and **no course-delete route** in the + current API surface, so those cells do not exist yet. diff --git a/docs/fee-sponsorship.md b/docs/fee-sponsorship.md new file mode 100644 index 00000000..4a202f3f --- /dev/null +++ b/docs/fee-sponsorship.md @@ -0,0 +1,133 @@ +# Fee-Bump Sponsorship — Platform-Paid Network Fees + +This document describes the optional **fee-bump sponsorship** flow (issue #30): +the platform can pay a user's Stellar network fee so a user who holds USDC but +almost no XLM can still buy a book, buy a course, or donate. + +## The problem it solves + +Stellar network fees are paid in XLM. A newly onboarded user typically holds +USDC but little or no XLM, so their otherwise-valid payment fails at submission +for lack of XLM. Fee sponsorship removes that onboarding wall **without touching +custody**: the platform wraps the user-signed transaction in a +[fee-bump transaction](https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/fee-bump-transactions) +signed by a dedicated *fee-source* account. The user still signs — and only ever +signs — their own payment operations; the sponsor key signs **only** the +fee-bump wrapper and can never move user funds. + +## Trust model & guard rails + +Because the server signs on behalf of the platform, the flow is reject-by-default: + +1. **Structural whitelist** (`validateInnerTransaction`). The user's inner + transaction must match, operation-for-operation, the pending `Transaction` + row the server already built at initialize: + - **source** equals `buyerWallet`; + - **exact operation set** — the operation count equals the expected count + (1 for a direct payment/donation, 2 for a fee split) and every operation is + a `payment` in the settlement asset. This is enforced by **allow-list**: + only `payment` is permitted, so `changeTrust`, `setOptions`, `manageData`, + `accountMerge`, `createAccount`, `pathPaymentStrict*`, a second unexpected + `payment`, or any operation type not yet invented all fail; + - **destinations, amounts, asset** match the row exactly (creator + platform + split from `platformFee`; donations against `DONATION_WALLET_PUBLIC_KEY`), + compared in stroops; + - **memo** equals the row's memo. +2. **Spend caps** (`SponsorshipSpend` + `checkSpendCaps`), enforced *before* + wrapping: + - per-transaction fee ceiling (`FEE_SPONSOR_MAX_FEE_STROOPS`); + - per-UTC-day total spend (`FEE_SPONSOR_DAILY_CAP_STROOPS`); + - per-user per-UTC-day sponsored-transaction count + (`FEE_SPONSOR_PER_USER_DAILY_LIMIT`). +3. **Sponsor float pre-check** — refuses (non-fatally) if the sponsor account + cannot cover the declared max fee, so an underfunded float never causes a + Stellar submit failure that would mark the user's transaction `failed`. + +## Fee-bump fee semantics + +The fee-bump fee is priced per operation **including** the wrapper — the total +fee is `baseFeePerOp × (innerOps + 1)` (verified against the installed +`@stellar/stellar-sdk` and asserted in tests). The service declares the highest +per-op fee the per-transaction ceiling allows, so the sponsor tolerates fee +surges up to the cap while Horizon still only charges the true network fee. The +declared total is always clamped to `FEE_SPONSOR_MAX_FEE_STROOPS`. + +Two hashes are recorded for a sponsored row: Horizon returns the **fee-bump +(outer) hash** (`feeBumpTxHash`), while the **inner-transaction hash** +(`stellarTxHash`) is what matches the `expectedHash` stored at initialize and +what the on-chain payment verification (`verifyPaymentOperations`) runs against. + +## API + +Sponsorship is opt-in per submit — there is **no new endpoint**. Add +`requestSponsorship: true` to an existing submit request: + +- `POST /api/stellar/payment/submit` — `{ transactionId, signedXdr, requestSponsorship: true }` +- `POST /api/stellar/donation/submit` — `{ donationId, signedXdr, requestSponsorship: true }` + +When sponsorship is applied, the confirmed response carries `sponsored: true`, +`feeBumpTxHash`, and `sponsorFeeCharged` (the real `fee_charged` from Horizon). + +### Failure semantics + +Sponsorship-specific failures **never** mark the user's `Transaction` `failed`. +They return a distinct non-fatal status with `retryUnsponsored: true`, leaving +the row `pending` so the client can retry without sponsorship (the user pays +their own fee): + +| Reason (`sponsorship.reason`) | Status | Meaning | +|-------------------------------|:------:|---------| +| `whitelist_rejected` | 422 | Inner transaction did not match the row | +| `daily_cap_exceeded` | 429 | Per-day total spend cap would be exceeded | +| `per_user_daily_limit` | 429 | Per-user daily sponsored count reached | +| `fee_ceiling_too_low` | 503 | Per-tx fee ceiling too low to fee-bump | +| `sponsor_underfunded` | 503 | Sponsor float cannot cover the fee | +| `sponsor_misconfigured` | 503 | Secret missing/invalid at request time | + +Only a genuine on-network submission failure follows the existing failed-path. + +With `FEE_SPONSOR_ENABLED=false` (the default), the flag is ignored entirely and +both submit paths are byte-for-byte the original unsponsored flow — sending +`requestSponsorship: true` behaves exactly as if the flag were absent. + +### Ops status endpoint + +`GET /api/stellar/payment/sponsorship/status` (admin-only) returns whether +sponsorship is enabled, the sponsor account's **public key** (never the secret) +and live XLM float, the configured caps, and today's spend, so the float can be +topped up before it runs dry. + +## Configuration + +All variables are optional; with the master switch off, a boot with none of them +set is unchanged. When `FEE_SPONSOR_ENABLED=true`, a missing or invalid +`FEE_SPONSOR_SECRET` is a **hard boot failure** (fail fast with a clear message). + +| Variable | Default | Description | +|----------|---------|-------------| +| `FEE_SPONSOR_ENABLED` | `false` | Master switch | +| `FEE_SPONSOR_SECRET` | — | Dedicated `S…` fee-source secret. **MUST NOT** be the donation or platform receiving wallet. Never logged, never returned over HTTP. | +| `FEE_SPONSOR_MAX_FEE_STROOPS` | `1000000` | Per-transaction fee ceiling (0.1 XLM) | +| `FEE_SPONSOR_DAILY_CAP_STROOPS` | `100000000` | Total fee spend per UTC day (10 XLM) | +| `FEE_SPONSOR_PER_USER_DAILY_LIMIT` | `10` | Max sponsored transactions per user per UTC day | + +The sponsor account should be a dedicated, low-balance account topped up only +with the XLM float it needs for fees — never the donation or platform receiving +wallet. + +## Observability + +Every sponsorship decision is logged (approved / rejected + reason; the secret is +never logged) and counted in Prometheus via `fee_sponsorships_approved_total` +and `fee_sponsorships_rejected_total{reason}`. + +## Tests + +- `test/feeSponsorService.test.js` — structural whitelist adversarial matrix, + fee-bump fee correctness (asserted against the SDK), inner-transaction-untouched + proof, spend caps, secret handling, and boot-config validation. +- `test/feeSponsorSubmit.test.js` — controller wiring for both the payment and + donation submit paths: flag-off regression (byte-for-byte unchanged), flag-on + sponsorship, and cap/whitelist rejections that never mark the row `failed`. + +Run: `node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --forceExit test/feeSponsorService.test.js test/feeSponsorSubmit.test.js` diff --git a/docs/idempotency.md b/docs/idempotency.md new file mode 100644 index 00000000..e2529e12 --- /dev/null +++ b/docs/idempotency.md @@ -0,0 +1,56 @@ +# Request-Level Idempotency + +DeenBridge Backend provides header-driven request idempotency on all mutating payment, donation, refund, and payout endpoints. This prevents retried HTTP requests (due to flaky mobile connectivity, proxy retries, or double-tapped UI buttons) from creating duplicate transactions, submitting duplicate Stellar on-chain payments, or double-crediting educators. + +## Header + +Clients pass the `Idempotency-Key` HTTP header with a unique identifier (e.g. UUID v4): + +```http +POST /api/stellar/payment/initialize HTTP/1.1 +Host: api.deenbridge.app +Authorization: Bearer +Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d +Content-Type: application/json + +{ + "itemType": "book", + "itemId": "65b82e80cbf0776967aab8fe", + "buyerWallet": "GBKENR..." +} +``` + +## Behavior & Lifecycle + +1. **First Use (`in_progress`)**: + An `IdempotencyKey` record is atomically inserted with `status: "in_progress"` scoped to `{ key, userId, endpoint }`. A Mongo unique compound index guarantees atomicity under concurrent requests. +2. **Captured Response (`completed`)**: + Upon handler completion, the HTTP status code and JSON response body are captured and persisted with `status: "completed"`. +3. **Replay**: + Subsequent requests with the same `Idempotency-Key`, matching body, and same user immediately receive the stored status code and response body without re-executing controller logic or database/on-chain mutations. +4. **Expiration (TTL)**: + Idempotency keys automatically expire after 24 hours via MongoDB TTL index. + +## Error Responses & Concurrency + +| Scenario | HTTP Status | Response Description | +|----------|-------------|----------------------| +| **In-Flight Concurrency** | `409 Conflict` | A second request with the same idempotency key is already `in_progress`. Clients should back off and retry. | +| **Payload Mismatch** | `422 Unprocessable Entity` | The idempotency key was reused with a different request body fingerprint. | +| **Server Failure (5xx)** | — | Idempotency keys are purged on 5xx errors so clients can safely retry after a backend failure. | + +## Supported Endpoints & Policy + +Idempotency key protection is enabled on all mutating payment endpoints (`required: false` policy for backward compatibility): + +- `POST /api/stellar/payment/initialize` +- `POST /api/stellar/payment/submit` +- `POST /api/stellar/payment/transactions/:id/refund-request` +- `POST /api/stellar/payment/refunds/:refundId/build` +- `POST /api/stellar/payment/refunds/:refundId/submit` +- `POST /api/stellar/payment/refunds/:refundId/reject` +- `POST /api/stellar/payment/refunds/:refundId/dispute` +- `POST /api/stellar/donation/initialize` +- `POST /api/stellar/donation/submit` +- `POST /api/payouts/build` +- `POST /api/payouts/:batchId/submit` diff --git a/docs/service-to-service-auth.md b/docs/service-to-service-auth.md new file mode 100644 index 00000000..7770a74f --- /dev/null +++ b/docs/service-to-service-auth.md @@ -0,0 +1,149 @@ +# Service-to-Service (S2S) Authentication + +The backend authenticates the AI service (**dnb-ai**) with **signed, scoped, +rotatable keys** — not a single shared static secret. Each request is signed +with an HMAC-SHA256 signature over a canonical string, using a key selected by +its `kid`. This gives replay protection, constant-time verification, per-key +scopes, and **zero-downtime key rotation** via overlapping active `kid`s. + +- Middleware: `src/middlewares/serviceAuth.js` (`requireServiceAuth({ scope })`) +- Key store: `src/config/serviceKeys.js` (parses `AI_SERVICE_KEYS`) +- Guarded route (reference): `GET /api/internal/ai/whoami` + (scope `ai:read-content`) — reflects the authenticated service identity. + +Denied attempts are recorded to the audit log as `service_auth.denied` +(`status: "failure"`). + +## Headers + +Every S2S request MUST send all four headers: + +| Header | Meaning | +| ------------------ | ------------------------------------------------------------- | +| `X-Service-Id` | Logical caller id, e.g. `dnb-ai` | +| `X-Service-Key-Id` | The key id (`kid`) selecting which secret to sign with | +| `X-Timestamp` | Unix time in **seconds** at signing (string) | +| `X-Signature` | Lowercase hex HMAC-SHA256 of the canonical string | + +## Canonical signing string + +The signature is computed over this exact string — four fields joined by a +single `\n` (LF), with **no trailing newline**: + +``` +METHOD \n PATH \n TIMESTAMP \n sha256hex(rawBody || "") +``` + +- `METHOD` — HTTP method, uppercased (`GET`, `POST`, …). +- `PATH` — the request path exactly as sent, **including any query string** + (Express `req.originalUrl`, e.g. `/api/internal/ai/whoami`). +- `TIMESTAMP` — the same value sent in `X-Timestamp` (Unix seconds). +- `sha256hex(rawBody || "")` — lowercase hex SHA-256 of the **raw request body + bytes**; for a bodyless `GET` this is the SHA-256 of the empty string + (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`). + +Then: + +``` +signature = HMAC_SHA256(key.secret, canonicalString) // lowercase hex +``` + +### Reference client (Node.js) + +```js +import crypto from "crypto"; + +function signRequest({ method, path, secret, kid, serviceId, body }) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const bodyHash = crypto.createHash("sha256").update(body || "").digest("hex"); + const canonical = [method.toUpperCase(), path, timestamp, bodyHash].join("\n"); + const signature = crypto.createHmac("sha256", secret).update(canonical).digest("hex"); + return { + "X-Service-Id": serviceId, + "X-Service-Key-Id": kid, + "X-Timestamp": timestamp, + "X-Signature": signature, + }; +} +``` + +> **Body canonicalization caveat:** the digest is over the exact bytes the +> client transmits. Sign the *serialized* body you actually send (do not +> re-serialize on the server side). For JSON, sign the exact string passed to +> the HTTP client. + +## Replay protection + +Requests whose `X-Timestamp` differs from the server clock by more than +**±300 seconds** (`REPLAY_WINDOW_SECONDS`) — in either direction — are rejected +with `401`. Keep client and server clocks in sync (NTP). + +## Verification / response matrix + +| Condition | Result | +| ---------------------------------------------------- | ------ | +| Valid signature, permitted scope | `2xx` | +| Missing any of the four headers | `401` | +| Timestamp outside the ±300s window | `401` | +| Unknown `kid`, or a retired (`active: false`) `kid` | `401` | +| Signature mismatch (bad/forged) | `401` | +| Valid signature but key lacks the route's scope | `403` | + +All secret/signature comparisons use `crypto.timingSafeEqual` +(length-guarded — a length mismatch is a plain non-match, never a throw). + +## Scopes + +Scopes are per-key and asserted per-route. Current scopes: + +| Scope | Grants | +| ----------------- | --------------------------------------------------- | +| `ai:read-content` | Read content the AI service needs (e.g. `whoami`) | +| `ai:write-answers`| Reserved for AI write-back endpoints (future) | + +A key only passes a route when its `scopes` array `includes` that route's +required scope. + +## Key configuration (`AI_SERVICE_KEYS`) + +Keys are provisioned via the `AI_SERVICE_KEYS` env var — a JSON array: + +```json +[ + { + "kid": "k1", + "secret": "a-long-random-hmac-secret", + "scopes": ["ai:read-content"], + "active": true + } +] +``` + +- **Required in production** — boot fails fast (`validateEnv.js`) if it is + missing. Optional in development/test. +- Multiple entries may be `active` at once (this is what enables rotation). +- `active: false` retires a key without removing it. +- Malformed JSON or bad entries are skipped safely (empty key set → every S2S + request is rejected `401`; the process never crashes on parse). + +## Key-rotation runbook (zero downtime) + +Because multiple `kid`s can be active simultaneously, rotation never has a +window where valid callers are rejected: + +1. **Add** a new key with a fresh `kid` (e.g. `k2`) alongside the current one, + both `"active": true`: + ```json + [ + {"kid":"k1","secret":"OLD","scopes":["ai:read-content"],"active":true}, + {"kid":"k2","secret":"NEW","scopes":["ai:read-content"],"active":true} + ] + ``` +2. **Deploy** the backend with both keys active. Now `k1` and `k2` are both + accepted. +3. **Switch dnb-ai** to sign with `k2` (its `X-Service-Key-Id`). Verify traffic + is flowing under `k2`. +4. **Retire** `k1` by setting `"active": false` (or removing it) and redeploy. + `k1`-signed requests now get `401`; `k2` continues uninterrupted. + +At no point is there a gap where a correctly-signed request is rejected. diff --git a/docs/soroban-escrow-design.md b/docs/soroban-escrow-design.md new file mode 100644 index 00000000..930dc02e --- /dev/null +++ b/docs/soroban-escrow-design.md @@ -0,0 +1,171 @@ +# Scholarship Escrow Design + +## Scope + +This document defines the Stage 1 Soroban contract for a scholarship escrow. The +contract holds a Stellar Asset Contract (SAC) representation of the scholarship +asset. It does not custody private keys, create wallets, or perform classic +Stellar payments. The later JavaScript and API stages will build unsigned +Soroban invocation transactions around this contract. + +All monetary values are integer `i128` values in the token's smallest unit. +For the USDC integration that unit is a stroop-like seven-decimal unit, matching +the existing JavaScript `toStroops` discipline. The contract never parses +decimal strings and never uses floating point arithmetic. + +## Roles + +| Role | Responsibility | +| --- | --- | +| Donor | Any address that funds the escrow. A donor authorizes each `fund` call and can claim its recorded share after expiry. | +| Beneficiary | The fixed address receiving an approved milestone amount. | +| Arbiter | The fixed platform maintainer address that authorizes milestone releases. The arbiter is trusted to approve only completed work. | +| SAC token | The fixed Stellar Asset Contract address that receives deposits and sends releases and refunds. | + +The arbiter is a deliberate v1 trust assumption. A malicious or compromised +arbiter can release funded milestones early, although it cannot change the +beneficiary, token, milestone amounts, or expiry after initialization. A future +version should replace the single arbiter with a threshold authorization policy, +such as two of three independent maintainers or a Soroban multisignature +account, and should publish the policy in the escrow state. + +## State Machine + +```text +Uninitialized + | init(arbiter, beneficiary, token, milestones, expiry) + v +Active + | fund(donor, amount) | approve_milestone(index) + | v + | Active with released milestones + | + | ledger sequence reaches expiry + v +Expired + | refund(donor) + v +Refundable claims settled +``` + +Initialization is one time only and requires authorization from the supplied +arbiter. The milestone vector is copied into contract storage and cannot be +changed. Every amount must be positive and the sum of all milestones must fit +in `i128`. + +While active: + +- Funding requires donor authorization, moves SAC tokens from the donor to the + contract, records the donor's cumulative contribution, and rejects funding + that would exceed the fixed milestone total. +- Approval requires arbiter authorization, marks one unreleased milestone, and + transfers exactly that milestone amount from the contract to the beneficiary. +- Funding and approvals are rejected once the expiry ledger is reached. +- A milestone cannot be approved twice and cannot be approved until the escrow + has enough unreleased tokens to pay its exact amount. + +After expiry, the escrow is frozen. The first valid refund snapshots the +unreleased balance. Each donor can claim once, and its claim is based on the +ratio of its contribution to total funding. Integer division rounds down for +ordinary claims; the final unclaimed donor receives the remaining snapshot +balance, including any rounding remainder. This keeps all available tokens +claimable without introducing floating point arithmetic. A donor must +authorize its own refund. + +## Storage and Events + +The contract stores: + +- fixed role and token addresses, expiry, milestone total, and accounting totals; +- the immutable milestone vector; +- the list of donor addresses; +- each donor's cumulative contribution, refund amount, and claim status. + +The `Initialized`, `Funded`, `MilestoneApproved`, and `Refunded` events expose +every state-changing operation. Donor, milestone index, and beneficiary-facing +amounts are included so an indexer can reconstruct the state without trusting +the application database. + +## Invariants + +The contract maintains these invariants atomically: + +1. `funded_total <= milestone_total`. +2. `released_total + refunded_total <= funded_total`. +3. Each milestone is either unreleased or released exactly once. +4. A released milestone's transfer amount equals its immutable amount. +5. `refund_pool`, once created, equals the unreleased balance at expiry. +6. A donor's refund claim can be executed at most once and is authorized by that + donor. +7. The contract's accounted SAC balance equals the funded amount less released + and refunded amounts, assuming the token contract itself is correct. + +## Threat Analysis + +### Unauthorized release or refund + +`approve_milestone` calls `require_auth` on the stored arbiter. `fund` and +`refund` call `require_auth` on the supplied donor. The supplied donor is not a +database identity; Soroban authorization is the security boundary. + +### Reinitialization and parameter mutation + +Initialization checks for existing state. There are no setters for the arbiter, +beneficiary, token, expiry, or milestone vector, so later calls cannot replace +the payout destination or release schedule. + +### Over-release and accounting drift + +The contract checks the milestone release flag and available balance before +calling the SAC. It updates accounting only in the same transaction as the +token transfer, so a failed transfer rolls back the state change. All arithmetic +uses checked `i128` operations. + +### Funding after expiry + +The expiry check runs before donor authorization and token transfer. This +freezes the funding population before the refund pool is calculated and avoids +late donors changing existing pro-rata shares. + +### Token mismatch or malicious token contract + +The contract accepts one token address at initialization and uses it for every +transfer. It cannot prove that an arbitrary address is the intended USDC SAC; +deployment and Stage 2 configuration must therefore pin the network, SAC +address, issuer, and asset code. A future version may verify a known SAC +registry or store the expected asset metadata alongside the address. + +Anyone can transfer the configured SAC token directly to the contract without +calling `fund`. Such tokens are not donor contributions and are intentionally +excluded from release and refund accounting. Integrations must invoke `fund` +rather than treating the raw contract token balance as funded scholarship +value. + +### Arbiter compromise + +The single arbiter can release a milestone without an off-chain progress +agreement. This is the principal v1 trust tradeoff. The beneficiary and donors +can observe the events and balances, but cannot veto a release. Threshold +arbiter authorization is the planned mitigation. + +### Refund rounding + +Pro-rata claims use integer division. The final unclaimed donor receives the +remaining snapshot balance, so rounding dust is not trapped in the contract. +Claims are still order-sensitive by at most the integer remainder; Stage 2 +should present the snapshot and claim status clearly to donors. + +### Denial of service + +Milestones are fixed and refunds are per donor. The donor list grows with the +number of distinct funders, so deployment should set practical funding and +resource limits. A future version can use a separate claim registry or Merkle +distribution if scholarship escrows need very large donor sets. + +## Stage Boundaries + +This Stage 1 change intentionally stops at the design and contract foundation. +Stage 2 must review this state machine before adding the Soroban RPC service, +SAC deployment, and wallet signing walkthrough. Stage 3 can then add API +endpoints, transaction persistence, and live state reconciliation without +changing the contract's trust model. diff --git a/docs/transaction-lifecycle.md b/docs/transaction-lifecycle.md new file mode 100644 index 00000000..4afff53f --- /dev/null +++ b/docs/transaction-lifecycle.md @@ -0,0 +1,86 @@ +# Transaction Expiry Lifecycle and TTL Invariant + +This document describes the `expiresAt` / TTL behavior of the `Transaction` +collection and the invariant that protects confirmed purchases and donations +from being deleted. + +## The problem this invariant solves + +`Transaction` rows are created when a buyer starts a checkout. To garbage-collect +abandoned checkouts, the collection used a blanket TTL index on `expiresAt` +(`expireAfterSeconds: 0`) with a schema default of `now + 30 minutes` applied to +**every** row — including rows that later became `confirmed`. A confirmed on-chain +purchase or donation was therefore permanently deleted ~30 minutes after it was +created: the proof that the buyer paid and the educator earned silently vanished. + +## The invariant + +> **`expiresAt` is only ever set on `pending` transactions. Every terminal state +> MUST clear it, the schema enforces this on save, and the TTL index is scoped +> strictly to `status: "pending"` so the reaper cannot match anything else.** + +### Status → `expiresAt` mapping + +| Status | `expiresAt` | Rationale | +|-------------|------------------|--------------------------------------------------------------------| +| `pending` | `Date` (now + 30m) | Abandoned checkout awaiting wallet signature / submission. Eligible for TTL reaping. | +| `submitted` | retained | In-flight on the Stellar network. Transient, non-terminal. | +| `retrying` | retained | In-flight async on-chain verification. Transient, non-terminal. | +| `confirmed` | unset | Settled on-chain — item access granted / donation recorded. **Must never be reaped.** | +| `failed` | unset | Permanent failure. Kept for audit and reconciliation. | +| `expired` | unset | Cancelled by the user or explicitly timed out. Kept for audit. | +| `refunded` | unset | Refund executed on-chain. Kept for audit. | +| `disputed` | unset | Under administrator review. Kept for audit. | + +## Defense in depth + +The guarantee is enforced at three independent layers, so no single future code +path can regress confirmed rows back into the reaper's window: + +1. **Partial TTL index (structural).** `src/models/Transaction.js` declares + `transactionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } })`. + MongoDB's TTL monitor only considers documents that match the partial filter, + so a non-`pending` document is never a deletion candidate even if it somehow + still carries an `expiresAt`. + +2. **Conditional schema default.** The `expiresAt` default only produces a + timestamp when the document's status is `pending` (or unset at creation). + Records created directly in a terminal state — e.g. worker-created confirmed + donations/purchases from the reconciliation service — are never born with an + expiry. + +3. **Pre-save hook (runtime).** A `pre("save")` middleware clears `expiresAt` + whenever a document is saved in a terminal status (`confirmed`, `failed`, + `expired`, `refunded`, `disputed`). Even if a controller forgets to unset it + explicitly, the model enforces the invariant. + +On top of the schema layers, every current transition to a terminal state also +unsets `expiresAt` explicitly for clarity: + +- `submitPayment` / `submitDonation` — validation failure, Stellar error, + verification failure, and confirmation paths. +- `cancelTransaction` — `$unset: { expiresAt: 1 }` alongside `status: "expired"`. +- `submitRefund` / `escalateDispute` — `$unset: { expiresAt: 1 }` alongside the + terminal status update. +- `promoteTransaction` (reconciliation) and the `verifyPaymentOnChain` job — + cleared before saving the confirmed/failed row. + +## Migrations + +Databases created before this invariant may still hold confirmed rows with a +30-minute `expiresAt` and the old blanket TTL index. Run the idempotent +migration to rescue those rows and swap the index: + +```bash +node src/migrations/fixTtlTransactionExpiry.js +``` + +It (1) `$unset`s `expiresAt` on all non-`pending` rows and (2) drops the blanket +`{ expiresAt: 1 }` index and recreates it with the `partialFilterExpression`. +Running it again is a no-op. + +## Out of scope + +The `Session` and `Refund` collections have their own TTL indexes on +`expiresAt`; those are intentional and correct (revoked/abandoned sessions and +expired refund windows should be reaped) and are not affected by this invariant. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..ecaf623b --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,139 @@ +# Outbound Signed Webhooks (issue #45) + +DeenBridge emits signed HTTP callbacks so external consumers (the dnb-ai +service, educator tooling, analytics) can react to payment and enrollment +lifecycle events instead of polling the REST API. + +## Event catalog + +| Event | Emitted when | +| --- | --- | +| `payment.initialized` | A pending purchase transaction is created (`initializePayment`) | +| `payment.confirmed` | A payment is confirmed on-chain (`submitPayment`) | +| `payment.failed` | A payment fails validation / submission / verification (`submitPayment`) | +| `payment.expired` | A pending transaction is cancelled/expired (`cancelTransaction`) | +| `course.enrolled` | A user enrolls in a (free) course (`enrollInCourse`) | +| `wallet.connected` | A user connects a Stellar wallet (`connectWallet`) | +| `wallet.disconnected` | A user disconnects a wallet (`disconnectWallet`) | +| `ping` | Manually, via the management API, for integration testing | + +Emission is **fire-and-forget** and happens **only after the Mongo transaction +commits** — a rolled-back payment emits nothing, and a webhook problem can +never block or fail the originating HTTP request. + +## Payload envelope + +```json +{ + "eventId": "b2f1c0e6-...", // unique per event; use for idempotency + "type": "payment.confirmed", + "createdAt": "2025-01-01T12:00:00.000Z", + "apiVersion": "2025-01-01", + "data": { "transactionId": "...", "amount": "10.00", "stellarTxHash": "..." } +} +``` + +`data` is restricted to an explicit allowlist (ids, wallet public keys, +amounts, currency/network, tx hash, item references). **It never contains +emails, password hashes, secrets, or full user documents.** + +## Signature scheme + +Each delivery carries these headers: + +| Header | Value | +| --- | --- | +| `X-DeenBridge-Event` | the event type, e.g. `payment.confirmed` | +| `X-DeenBridge-Event-Id` | the `eventId` (idempotency key) | +| `X-DeenBridge-Timestamp` | unix seconds when the request was signed | +| `X-DeenBridge-Signature` | `v1=` | + +The signed string is `` `${timestamp}.${rawBody}` `` where `rawBody` is the +**exact** bytes of the request body. The server serializes the body once, signs +those bytes, and sends the same buffer — so a verifier must run the HMAC over +the raw received body, not a re-serialized copy (JSON key order can differ). + +### Consumer verification (copy-paste Node snippet) + +```js +import crypto from "crypto"; + +// `rawBody` must be the raw request body string/buffer, NOT JSON.parse'd back. +export function verifyDeenBridgeWebhook(req, rawBody, secret) { + const timestamp = req.headers["x-deenbridge-timestamp"]; + const header = req.headers["x-deenbridge-signature"] || ""; + const [version, provided] = header.split("="); + if (version !== "v1" || !provided) return false; + + // Reject stale deliveries (replay protection): 5 minutes. + const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); + if (!Number.isFinite(skew) || skew > 300) return false; + + const expected = crypto + .createHmac("sha256", secret) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(provided, "hex"); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} +``` + +Always compare in constant time (`crypto.timingSafeEqual`) and reject stale +timestamps. Respond `2xx` to acknowledge; any other status (or a timeout) +triggers a retry. + +## Delivery, retries, and dead-letter + +A background worker (`services/webhooks/deliveryWorker.js`, enabled with +`WEBHOOK_WORKER_ENABLED=true`) claims due deliveries one at a time with an +atomic `findOneAndUpdate` so multiple loops/instances never double-send the +same row. Each POST has a ~10s timeout and does **not** follow redirects. + +- `2xx` → `delivered`. +- Otherwise the attempt is recorded and the delivery is retried on an + exponential backoff-with-jitter schedule: **1m, 5m, 30m, 2h, 12h**. +- After `WEBHOOK_MAX_ATTEMPTS` (default 6) it becomes `dead`. +- Dead deliveries can be requeued via + `POST /api/webhooks/:id/deliveries/:deliveryId/redeliver`. +- After `WEBHOOK_AUTO_DISABLE_THRESHOLD` (default 5) consecutive dead + deliveries the endpoint is auto-disabled (`isActive:false`, `disabledReason`) + and a warning is logged. Re-enable it via `PATCH /api/webhooks/:id`. + +All scheduling state lives in the `WebhookDelivery` document, so the loop can +later be swapped onto the durable job queue (issue #32) without a schema change. + +## Secret storage + +Signing secrets are generated server-side, returned to the caller **exactly +once** (at creation and on rotation), and stored **encrypted at rest** +(AES-256-GCM, key derived from `WEBHOOK_SECRET_ENCRYPTION_KEY`). Encryption +(not hashing) is required because the worker must recover the plaintext to +compute the HMAC on every attempt. The encrypted column is `select:false` and +stripped from every API response; no read endpoint ever returns the secret. + +## SSRF protection + +Endpoint URLs are validated at **registration** and again at **delivery**: +`https` is required outside development, and loopback / RFC-1918 / +link-local / other non-routable targets are rejected (literal IPs always; DNS +is additionally resolved in production). Residual limitation: DNS rebinding +between resolution and connect (TOCTOU) is not fully closed — that would +require pinning the resolved IP onto the connecting socket. + +## Management API + +All routes require an authenticated **admin** (`protect` + `authorizeRoles("admin")`). + +| Method & path | Purpose | +| --- | --- | +| `POST /api/webhooks` | Register an endpoint (returns `secret` once) | +| `GET /api/webhooks` | List your endpoints (no secret) | +| `GET /api/webhooks/:id` | Get one endpoint | +| `PATCH /api/webhooks/:id` | Update url/events/description/isActive | +| `DELETE /api/webhooks/:id` | Delete an endpoint | +| `POST /api/webhooks/:id/rotate-secret` | Rotate the secret (returns new `secret` once) | +| `GET /api/webhooks/:id/deliveries` | Paginated, `?status=` filterable delivery history | +| `POST /api/webhooks/:id/deliveries/:deliveryId/redeliver` | Requeue a delivery | +| `POST /api/webhooks/:id/ping` | Emit a signed `ping` event to the endpoint | diff --git a/openapi.yaml b/openapi.yaml index 834a5f09..8897be9a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -82,6 +82,13 @@ components: scheme: bearer description: Static token from the JOBS_DASHBOARD_TOKEN environment variable. parameters: + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Unique key for request idempotency protection. Prevents duplicate charges and transactions on retried requests. + schema: + type: string Page: name: page in: query @@ -128,6 +135,11 @@ components: content: application/json: schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Request payload mismatch for idempotency key + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } TooManyRequests: description: Rate limit exceeded. /api is rate limited globally and /api/auth more strictly. content: @@ -438,7 +450,7 @@ paths: /health: get: tags: [Meta] - summary: Liveness probe used by CI and the host + summary: Readiness probe for critical dependencies security: [] responses: "200": @@ -449,8 +461,57 @@ paths: type: object properties: success: { type: boolean } - message: { type: string, examples: [pong] } - timestamp: { type: string, format: date-time } + message: { type: string } + data: + type: object + properties: + status: { type: string, enum: [healthy] } + timestamp: { type: string, format: date-time } + uptime: { type: number } + environment: { type: string } + dependencies: + type: object + properties: + mongodb: + type: object + properties: + status: { type: string, enum: [up, down] } + state: { type: string } + redis: + type: object + properties: + status: { type: string, enum: [up, down] } + "503": + description: One or more critical dependencies are unavailable + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, examples: [false] } + message: { type: string } + data: + type: object + properties: + status: { type: string, enum: [unhealthy] } + timestamp: { type: string, format: date-time } + uptime: { type: number } + environment: { type: string } + dependencies: + type: object + /ping: + get: + tags: [Meta] + summary: Dependency free liveness probe + security: [] + responses: + "200": + description: The process is alive + content: + text/plain: + schema: + type: string + example: pong /metrics: get: tags: [Meta] @@ -1671,6 +1732,12 @@ paths: post: tags: [Payments] summary: Submit a signed transaction and verify it on chain + description: > + Submits the buyer-signed transaction and verifies it on chain. When + `requestSponsorship: true` and fee sponsorship is enabled, the platform + pays the network fee via a fee-bump wrapper (see docs/fee-sponsorship.md). + Sponsorship-specific failures return a distinct non-fatal 4xx/503 with + `retryUnsponsored: true` and never mark the transaction failed. requestBody: required: true content: @@ -1680,12 +1747,17 @@ paths: properties: signedXdr: { type: string } transactionId: { $ref: "#/components/schemas/ObjectId" } + requestSponsorship: + type: boolean + description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled. required: [signedXdr] responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/BadRequest" } /api/stellar/payment/transactions: get: tags: [Payments] @@ -1879,6 +1951,18 @@ paths: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } + /api/stellar/payment/sponsorship/status: + get: + tags: [Payments] + summary: Fee-bump sponsorship status (admin) + description: > + Admin-only. Returns whether fee sponsorship is enabled, the sponsor + account public key (never the secret) and live XLM float, the configured + caps, and today's spend. See docs/fee-sponsorship.md. + responses: + "200": { $ref: "#/components/responses/Ok" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } /api/stellar/donation/stats: get: tags: [Donations] @@ -1909,6 +1993,10 @@ paths: post: tags: [Donations] summary: Submit a signed donation transaction + description: > + Submits the donor-signed transaction and verifies it on chain. Supports + the same optional `requestSponsorship: true` fee-bump flow as the payment + submit endpoint (see docs/fee-sponsorship.md). requestBody: required: true content: @@ -1917,11 +2005,17 @@ paths: type: object properties: signedXdr: { type: string } + donationId: { $ref: "#/components/schemas/ObjectId" } + requestSponsorship: + type: boolean + description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled. required: [signedXdr] responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/BadRequest" } /api/payouts/me/balance: get: tags: [Payouts] @@ -2052,4 +2146,4 @@ paths: type: array items: { type: object } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } \ No newline at end of file + "404": { $ref: "#/components/responses/NotFound" } diff --git a/package-lock.json b/package-lock.json index b48efac5..1678f6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,12 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", - "nodemailer": "^9.0.3", + "otplib": "^13.4.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "redis": "^4.7.1", "safe-stable-stringify": "^2.5.0", "socket.io": "^4.8.1", @@ -1083,6 +1084,62 @@ "node": ">=8.0.0" } }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1177,6 +1234,15 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@scure/base": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -1561,7 +1627,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2272,7 +2337,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2479,7 +2543,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2492,7 +2555,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -2825,6 +2887,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -2914,6 +2985,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3582,7 +3659,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -5210,7 +5286,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6055,15 +6130,6 @@ "node": ">=18" } }, - "node_modules/nodemailer": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", - "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -6325,6 +6391,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6345,7 +6425,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6358,7 +6437,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6374,7 +6452,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6412,7 +6489,6 @@ "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" @@ -6589,6 +6665,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -6742,6 +6827,89 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -6863,12 +7031,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -8181,6 +8354,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index 428306d6..51efca64 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "start": "node --import dotenv/config server.js", "dev": "nodemon --import dotenv/config server.js", "seed": "node src/scripts/seedDatabase.js", + "seed:categories": "node src/scripts/seedCategories.js", + "migrate:categories": "node src/scripts/migrateCategories.js", "migrate:review-stats": "node src/migrations/backfillReviewStats.js", "test-redis": "node test-redis.js", "payouts:audit": "node src/scripts/auditPayouts.js", @@ -39,10 +41,12 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", + "otplib": "^13.4.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "redis": "^4.7.1", "safe-stable-stringify": "^2.5.0", "socket.io": "^4.8.1", diff --git a/server.js b/server.js index 4921eca2..8bf53f31 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,24 @@ -import app from "./app.js"; +import dotenv from "dotenv"; +import http from "http"; import logger from "./src/config/logger.js"; +import connectDB from "./src/config/db.js"; +import validateEnv from "./src/config/validateEnv.js"; import { initRedis, closeRedis } from "./src/config/redis.js"; +import { initSockets, closeSockets } from "./src/sockets/index.js"; import { startJobs, stopJobs } from "./src/jobs/queue.js"; +import { + handleUncaughtException, + handleUnhandledRejection, +} from "./src/middlewares/errorHandler.js"; import "./src/jobs/handlers.js"; +dotenv.config(); +handleUncaughtException(); +validateEnv(); +await connectDB(); +handleUnhandledRejection(); + +const { default: app } = await import("./app.js"); const PORT = process.env.PORT || 5000; // Initialize Redis @@ -14,7 +29,11 @@ initRedis().catch((err) => { ); }); -const server = app.listen(PORT, () => { +// Real-time payment notifications (Socket.io) share the HTTP server. +const server = http.createServer(app); +initSockets(server); + +server.listen(PORT, () => { logger.info(`🚀🕌 DeenBridge API running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV}`); logger.info(`Process ID: ${process.pid}`); @@ -35,6 +54,31 @@ if (process.env.INGESTION_WORKER_ENABLED === "true") { ); } +// Start outbound webhook delivery worker if enabled +let stopWebhookWorker; +if (process.env.WEBHOOK_WORKER_ENABLED === "true") { + import("./src/services/webhooks/deliveryWorker.js").then( + ({ startDeliveryWorker, stopDeliveryWorker: stopFn }) => { + stopWebhookWorker = stopFn; + startDeliveryWorker().catch((err) => + logger.error(err, "Webhook delivery worker startup failed") + ); + } + ); +} + +let stopPledgeScheduler; +if (process.env.PLEDGE_SCHEDULER_ENABLED === "true") { + import("./src/workers/pledgeScheduler.js").then( + ({ startPledgeScheduler, stopPledgeScheduler: stopFn }) => { + stopPledgeScheduler = stopFn; + startPledgeScheduler().catch((err) => + logger.error(err, "Pledge scheduler startup failed") + ); + } + ); +} + // Graceful shutdown const gracefulShutdown = async (signal) => { logger.info(`${signal} received. Starting graceful shutdown...`); @@ -48,7 +92,16 @@ const gracefulShutdown = async (signal) => { await stopIngestionWorker(); } + if (stopWebhookWorker) { + await stopWebhookWorker(); + } + + if (stopPledgeScheduler) { + await stopPledgeScheduler(); + } + // Close Redis connection + await closeSockets(); await closeRedis(); process.exit(0); diff --git a/services/emails/sendMail.js b/services/emails/sendMail.js index d068415e..0df55995 100644 --- a/services/emails/sendMail.js +++ b/services/emails/sendMail.js @@ -76,6 +76,12 @@ const primaryButton = (href, label) => ` `; +// Test-only in-memory outbox. Populated ONLY when the email transport is +// unavailable AND NODE_ENV === "test" (the CI/unit scenario) so tests can +// assert on rendered bodies — including OTP codes and verification links — +// without those secrets ever reaching a log stream. Never logged. +export const testOutbox = []; + const sendMail = async ({ to, subject, @@ -85,25 +91,33 @@ const sendMail = async ({ cc, bcc, attachments, + template = "generic", }) => { const apiKey = process.env.SENDLIB_API_KEY || ""; const apiUrl = SENDLIB_API_URL(); const from = getFrom(); - // Security: never log the html body — it contains OTP codes / verification - // tokens. Log recipient + subject only. + // Security: never log the html/text body — it contains OTP codes and + // verification tokens. Log only non-sensitive metadata (recipient, subject, + // template id) and route it through structured fields so pino redaction + // applies to any object it serializes. if (!apiKey || !apiUrl) { logger.warn( + { template }, "SENDLIB_API_KEY / SENDLIB_API_URL not set — email not sent" ); - // Test env only (synthetic data): expose the body so tests can assert - // OTP/verification behavior. NEVER logged in dev/prod — bodies carry secrets. if (NODE_ENV() === "test") { - logger.info(`[EMAIL LOG] To: ${to} | Subject: ${subject} | Body: ${html}`); - } else { - logger.info(`[EMAIL SKIPPED] To: ${to} | Subject: ${subject}`); + // Test hook: expose the rendered message so tests can inspect + // OTP/token behavior. This is an in-memory array, not a log. + const rendered = { template, to, subject, html, text }; + testOutbox.push(rendered); + return rendered; } - return; + logger.info( + { template, to, subject }, + "[EMAIL SKIPPED] email not sent (transport unconfigured)" + ); + return null; } if (!from) { logger.warn( @@ -134,13 +148,13 @@ const sendMail = async ({ ); } const messageId = payload.messageId || payload.id; - logger.info(`Email sent to ${to}${messageId ? `: ${messageId}` : ""}`); + logger.info({ template, to, messageId }, "Email sent"); return payload; } catch (error) { - logger.error("Failed to send email:", error.message); + logger.error({ template, to, err: error.message }, "Failed to send email"); if (NODE_ENV() === "development") { // Subject only — never log the body (contains OTP/token). - logger.info(`[DEV FALLBACK] To: ${to} | Subject: ${subject}`); + logger.info({ template, to, subject }, "[DEV FALLBACK] email not sent"); return; } throw error; @@ -149,7 +163,7 @@ const sendMail = async ({ export const sendOtpEmail = async (otp, email) => { if (!email || !otp) throw new Error("Email and OTP are required"); - logger.info(`Sending OTP email to: ${email}`); + logger.info({ template: "otp", to: email }, "Sending OTP email"); const content = `

Password Reset

@@ -164,20 +178,21 @@ export const sendOtpEmail = async (otp, email) => {

`; - await sendMail({ + return sendMail({ to: email, subject: "Your Password Reset Code — DeenBridge", html: emailShell({ content, preheader: "Use this code to reset your DeenBridge password.", }), + template: "otp", }); }; export const sendReceiptEmail = async (receipt) => { if (!receipt.email || !receipt.txHash) throw new Error("Receipt email and transaction hash are required"); - logger.info(`Sending receipt for ${receipt.txHash} to: ${receipt.email}`); + logger.info({ template: "receipt", to: receipt.email }, "Sending receipt email"); const content = `

Payment Receipt

@@ -200,20 +215,21 @@ export const sendReceiptEmail = async (receipt) => {

`; - await sendMail({ + return sendMail({ to: receipt.email, subject: "Payment Receipt — DeenBridge", html: emailShell({ content, preheader: "Thank you for your contribution to DeenBridge.", }), + template: "receipt", }); }; export const sendVerificationEmail = async (email, token) => { if (!email || !token) throw new Error("Email and token are required"); const link = `${FRONTEND_URL()}/verify-email?token=${token}`; - logger.info(`Sending verification email to: ${email}`); + logger.info({ template: "verification", to: email }, "Sending verification email"); const content = `

Welcome to DeenBridge!

@@ -233,13 +249,14 @@ export const sendVerificationEmail = async (email, token) => {

`; - await sendMail({ + return sendMail({ to: email, subject: "Verify your email — DeenBridge", html: emailShell({ content, preheader: "Click to verify your email and activate your DeenBridge account.", }), + template: "verification", }); }; diff --git a/src/config/metrics.js b/src/config/metrics.js index 789022ba..1a4ca4bc 100644 --- a/src/config/metrics.js +++ b/src/config/metrics.js @@ -49,6 +49,22 @@ const paymentsFailed = new promClient.Counter({ registers: [registry], }); +// Fee-bump sponsorship (#30): one increment per sponsorship decision so the +// approve/reject ratio and rejection reasons are observable in Prometheus. +const sponsorshipsApproved = new promClient.Counter({ + name: "fee_sponsorships_approved_total", + help: "Total number of transactions approved for platform fee sponsorship", + labelNames: ["type"], + registers: [registry], +}); + +const sponsorshipsRejected = new promClient.Counter({ + name: "fee_sponsorships_rejected_total", + help: "Total number of sponsorship requests rejected before submission", + labelNames: ["type", "reason"], + registers: [registry], +}); + function observeHttpDuration(method, route, statusCode, durationMs) { httpRequestDuration.observe( { method, route: route || "unknown", status_code: String(statusCode) }, @@ -91,6 +107,8 @@ export { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, observeHttpDuration, observeHorizonDuration, metricsMiddleware, diff --git a/src/config/serviceKeys.js b/src/config/serviceKeys.js new file mode 100644 index 00000000..b4056c71 --- /dev/null +++ b/src/config/serviceKeys.js @@ -0,0 +1,106 @@ +// config/serviceKeys.js +// +// Service-to-service (S2S) key store for the AI service (dnb-ai). +// +// Keys are provisioned via the AI_SERVICE_KEYS environment variable, a JSON +// array of key objects: +// +// [ +// { "kid": "k1", "secret": "long-random-hmac-secret", +// "scopes": ["ai:read-content"], "active": true } +// ] +// +// Multiple entries may be active at once, keyed by `kid`, so a new key can be +// introduced and the old one retired WITHOUT downtime (see the rotation +// runbook in docs/service-to-service-auth.md). Each key carries its own +// allowed `scopes`; the requireServiceAuth middleware asserts the route scope. +// +// The parse is memoized against the raw env string so repeated lookups are +// cheap, yet a test (or a hot-reloaded deploy) can mutate process.env and pick +// up the new key set on the next call. Parsing is resilient: a missing or +// malformed value yields an empty Map and NEVER throws at import time. +import logger from "./logger.js"; + +let cachedRaw; +let cachedMap = new Map(); + +/** + * Parse the AI_SERVICE_KEYS env value into a Map. + * Invalid entries are skipped (and logged) rather than aborting the whole set. + * + * @param {string|undefined} raw + * @returns {Map} + */ +function parseServiceKeys(raw) { + const map = new Map(); + if (!raw || typeof raw !== "string" || raw.trim() === "") { + return map; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_err) { + logger.warn("⚠️ AI_SERVICE_KEYS is not valid JSON — no service keys loaded."); + return map; + } + + if (!Array.isArray(parsed)) { + logger.warn("⚠️ AI_SERVICE_KEYS must be a JSON array — no service keys loaded."); + return map; + } + + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const { kid, secret } = entry; + if (typeof kid !== "string" || kid === "" || typeof secret !== "string" || secret === "") { + logger.warn("⚠️ Skipping AI_SERVICE_KEYS entry missing a string kid/secret."); + continue; + } + const scopes = Array.isArray(entry.scopes) + ? entry.scopes.filter((s) => typeof s === "string") + : []; + // Default to active unless explicitly disabled (active:false retires a kid). + const active = entry.active !== false; + map.set(kid, { secret, scopes, active }); + } + + return map; +} + +/** + * Return the current service-key Map, re-parsing only when the underlying env + * value has changed since the last call. + * + * @returns {Map} + */ +export function getServiceKeys() { + const raw = process.env.AI_SERVICE_KEYS; + if (raw !== cachedRaw) { + cachedRaw = raw; + cachedMap = parseServiceKeys(raw); + } + return cachedMap; +} + +/** + * Look up a single key by its `kid`. Returns undefined for unknown ids. + * + * @param {string} kid + * @returns {{secret: string, scopes: string[], active: boolean}|undefined} + */ +export function getServiceKey(kid) { + if (typeof kid !== "string" || kid === "") return undefined; + return getServiceKeys().get(kid); +} + +/** + * Force the next getServiceKeys()/getServiceKey() call to re-parse from env. + * Primarily a test hook for rotating keys mid-suite. + */ +export function resetServiceKeys() { + cachedRaw = undefined; + cachedMap = new Map(); +} + +export default { getServiceKeys, getServiceKey, resetServiceKeys }; diff --git a/src/config/stellar.js b/src/config/stellar.js new file mode 100644 index 00000000..b78a203a --- /dev/null +++ b/src/config/stellar.js @@ -0,0 +1,177 @@ +// config/stellar.js +// +// Single source of truth for the Stellar network configuration. Everything +// that depends on which network the app is running against (network +// passphrase, Horizon URL, USDC issuer, default asset) resolves through this +// module instead of being derived ad-hoc in each service. +// +// Network values accepted: +// - "testnet" -> testnet (default when unset, for back-compat) +// - "mainnet" | "public" -> mainnet ("public" is the Stellar SDK / SDF +// name for the production network) +// +// Startup validation (validateStellarConfig) makes a misconfigured deployment +// fail at boot with a message naming the exact problem, rather than failing +// at request time on the first Horizon call. + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { getAssetConfig, getDefaultAssetCode } from "./assets.js"; + +/** Normalized network names used as registry keys / DB enum values. */ +export const STELLAR_NETWORK_ALIASES = Object.freeze({ + testnet: "testnet", + mainnet: "mainnet", + public: "mainnet", +}); + +/** Canonical Horizon endpoints per network (used when HORIZON_URLS is unset). */ +export const HORIZON_DEFAULTS = Object.freeze({ + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", +}); + +/** + * Canonical USDC issuers per network (Circle). Documented here as the + * reference; the asset registry (assets.js) must agree with it — + * validateStellarConfig() cross-checks the registry against these constants + * so a mainnet flag paired with a testnet issuer can never silently boot. + */ +export const USDC_ISSUERS = Object.freeze({ + testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", +}); + +/** + * Resolve and normalize the configured Stellar network. + * @param {string} [raw] - raw STELLAR_NETWORK value (defaults to process.env) + * @returns {"testnet"|"mainnet"} + * @throws {Error} naming the exact problem when the value is not recognized + */ +export const resolveStellarNetwork = (raw = process.env.STELLAR_NETWORK) => { + const value = String(raw ?? "").trim().toLowerCase(); + if (!value) { + // Back-compat: unset means testnet, matching the historical default. + return "testnet"; + } + const network = STELLAR_NETWORK_ALIASES[value]; + if (!network) { + throw new Error( + `Invalid STELLAR_NETWORK "${raw}": expected "testnet", "mainnet", or "public". ` + + "DeenBridge defaults to testnet; switch to mainnet/public only when the whole " + + "stack (Horizon, USDC issuer, frontend NEXT_PUBLIC_STELLAR_NETWORK) is ready " + + "for mainnet — see docs/MAINNET.md." + ); + } + return network; +}; + +/** + * Resolve the full Stellar configuration from the environment. + * @returns {{ + * network: "testnet"|"mainnet", + * networkPassphrase: string, + * horizonUrls: string[], + * primaryHorizonUrl: string, + * usdcIssuer: string, + * defaultAssetCode: string, + * }} + */ +export const resolveStellarConfig = () => { + const network = resolveStellarNetwork(); + const rawUrls = + process.env.HORIZON_URLS || HORIZON_DEFAULTS[network]; + const horizonUrls = rawUrls + .split(",") + .map((url) => url.trim()) + .filter(Boolean); + const usdc = getAssetConfig("USDC", network); + + return { + network, + networkPassphrase: + network === "mainnet" + ? StellarSdk.Networks.PUBLIC + : StellarSdk.Networks.TESTNET, + horizonUrls, + primaryHorizonUrl: horizonUrls[0] || HORIZON_DEFAULTS[network], + usdcIssuer: usdc.issuer, + defaultAssetCode: getDefaultAssetCode(network), + }; +}; + +/** + * Fail-fast startup validation of the Stellar configuration. + * + * Checks: + * 1. STELLAR_NETWORK resolves to testnet/mainnet (public alias allowed). + * 2. Every configured Horizon URL is a valid http(s) URL, and none points + * at the OTHER network's canonical Horizon (a mainnet flag paired with + * the testnet Horizon URL — or vice versa — would silently operate on + * the wrong chain). + * 3. The resolved USDC issuer is a valid Stellar public key and matches the + * canonical issuer for the selected network (a mainnet flag with a + * testnet issuer must not boot). + * + * Custom / mirror Horizon URLs are fine — only cross-network mismatches are + * rejected. + * + * @returns {{ valid: boolean, problems: string[] }} + */ +export const validateStellarConfig = () => { + const problems = []; + + let network; + try { + network = resolveStellarNetwork(); + } catch (error) { + return { valid: false, problems: [error.message] }; + } + + const config = resolveStellarConfig(); + + const otherNetwork = network === "mainnet" ? "testnet" : "mainnet"; + + for (const url of config.horizonUrls) { + let parsed; + try { + parsed = new URL(url); + } catch { + problems.push( + `Invalid HORIZON_URLS entry "${url}": not a valid URL. ` + + `Expected http(s) endpoints, comma-separated (e.g. ${HORIZON_DEFAULTS[network]}).` + ); + continue; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + problems.push( + `Invalid HORIZON_URLS entry "${url}": must be an http(s) URL.` + ); + } + if (url === HORIZON_DEFAULTS[otherNetwork]) { + problems.push( + `HORIZON_URLS entry "${url}" is the canonical ${otherNetwork} endpoint, ` + + `but STELLAR_NETWORK is "${network}". Set HORIZON_URLS to ${HORIZON_DEFAULTS[network]} ` + + `(or unset it to use the network default) — see docs/MAINNET.md.` + ); + } + } + + const expectedIssuer = USDC_ISSUERS[network]; + if (config.usdcIssuer !== expectedIssuer) { + problems.push( + `USDC issuer mismatch on ${network}: registry resolves ${config.usdcIssuer}, ` + + `expected ${expectedIssuer}. Fix src/config/assets.js or the environment — ` + + "a mainnet flag with a testnet issuer (or vice versa) must never boot." + ); + } else { + try { + StellarSdk.Keypair.fromPublicKey(config.usdcIssuer); + } catch { + problems.push( + `USDC issuer "${config.usdcIssuer}" is not a valid Stellar public key.` + ); + } + } + + return { valid: problems.length === 0, problems }; +}; diff --git a/src/config/stellarConfig.test.js b/src/config/stellarConfig.test.js new file mode 100644 index 00000000..685bcbc5 --- /dev/null +++ b/src/config/stellarConfig.test.js @@ -0,0 +1,179 @@ +import { jest } from "@jest/globals"; + +// Mock the asset registry so the issuer-mismatch validation path can be +// exercised (the real registry always agrees with the canonical constants, +// which is exactly what the cross-check is for). +const testnetIssuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const mainnetIssuer = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const getAssetConfig = jest.fn((code, network) => + code === "USDC" + ? { + code: "USDC", + issuer: network === "mainnet" ? mainnetIssuer : testnetIssuer, + isDefault: true, + } + : null +); + +jest.unstable_mockModule("./assets.js", () => ({ + getAssetConfig, + getDefaultAssetCode: jest.fn(() => "USDC"), +})); + +const { + resolveStellarNetwork, + resolveStellarConfig, + validateStellarConfig, + USDC_ISSUERS, +} = await import("./stellar.js"); + +describe("stellar config resolution", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.STELLAR_NETWORK; + delete process.env.HORIZON_URLS; + getAssetConfig.mockClear(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("defaults to testnet when STELLAR_NETWORK is unset", () => { + expect(resolveStellarNetwork()).toBe("testnet"); + const config = resolveStellarConfig(); + expect(config.network).toBe("testnet"); + expect(config.networkPassphrase).toBe("Test SDF Network ; September 2015"); + expect(config.primaryHorizonUrl).toBe("https://horizon-testnet.stellar.org"); + expect(config.usdcIssuer).toBe(USDC_ISSUERS.testnet); + expect(config.defaultAssetCode).toBe("USDC"); + }); + + it("resolves the full mainnet config when STELLAR_NETWORK=mainnet", () => { + process.env.STELLAR_NETWORK = "mainnet"; + const config = resolveStellarConfig(); + expect(config.network).toBe("mainnet"); + expect(config.networkPassphrase).toBe( + "Public Global Stellar Network ; September 2015" + ); + expect(config.primaryHorizonUrl).toBe("https://horizon.stellar.org"); + expect(config.usdcIssuer).toBe(USDC_ISSUERS.mainnet); + expect(config.usdcIssuer).not.toBe(USDC_ISSUERS.testnet); + }); + + it("treats 'public' as an alias for mainnet", () => { + process.env.STELLAR_NETWORK = "public"; + const config = resolveStellarConfig(); + expect(config.network).toBe("mainnet"); + expect(config.networkPassphrase).toBe( + "Public Global Stellar Network ; September 2015" + ); + expect(config.primaryHorizonUrl).toBe("https://horizon.stellar.org"); + }); + + it("accepts explicit HORIZON_URLS and trims/parses them", () => { + process.env.HORIZON_URLS = + " https://custom.stellar.org , https://mirror.stellar.org "; + const config = resolveStellarConfig(); + expect(config.horizonUrls).toEqual([ + "https://custom.stellar.org", + "https://mirror.stellar.org", + ]); + expect(config.primaryHorizonUrl).toBe("https://custom.stellar.org"); + }); + + it("throws a descriptive error for an unknown network", () => { + process.env.STELLAR_NETWORK = "devnet"; + expect(() => resolveStellarNetwork()).toThrow( + /Invalid STELLAR_NETWORK "devnet"/ + ); + expect(() => resolveStellarConfig()).toThrow( + /Invalid STELLAR_NETWORK "devnet"/ + ); + }); + + it("accepts case-insensitive network values", () => { + process.env.STELLAR_NETWORK = "MAINNET"; + expect(resolveStellarNetwork()).toBe("mainnet"); + }); +}); + +describe("validateStellarConfig (fail-fast startup validation)", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.STELLAR_NETWORK; + delete process.env.HORIZON_URLS; + getAssetConfig.mockClear(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("passes for a clean testnet configuration", () => { + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + expect(result.problems).toEqual([]); + }); + + it("passes for a clean mainnet configuration", () => { + process.env.STELLAR_NETWORK = "mainnet"; + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + expect(result.problems).toEqual([]); + }); + + it("reports an invalid STELLAR_NETWORK value", () => { + process.env.STELLAR_NETWORK = "devnet"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("Invalid STELLAR_NETWORK"); + }); + + it("rejects a mainnet flag pointing at the testnet Horizon URL", () => { + process.env.STELLAR_NETWORK = "mainnet"; + process.env.HORIZON_URLS = "https://horizon-testnet.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("testnet"); + expect(result.problems.join(" ")).toContain("horizon-testnet.stellar.org"); + }); + + it("rejects a testnet flag pointing at the mainnet Horizon URL", () => { + process.env.HORIZON_URLS = "https://horizon.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("mainnet"); + }); + + it("rejects a non-URL HORIZON_URLS entry", () => { + process.env.HORIZON_URLS = "not-a-url,https://horizon-testnet.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("not-a-url"); + }); + + it("allows custom (non-canonical) Horizon URLs on the right network", () => { + process.env.HORIZON_URLS = "https://custom.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + }); + + it("rejects a mainnet config whose resolved USDC issuer is the testnet issuer", () => { + process.env.STELLAR_NETWORK = "mainnet"; + // Simulate a corrupted/mismatched registry: mainnet network but testnet issuer. + getAssetConfig.mockImplementation((code) => + code === "USDC" + ? { code: "USDC", issuer: testnetIssuer, isDefault: true } + : null + ); + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("USDC issuer mismatch"); + expect(result.problems.join(" ")).toContain(testnetIssuer); + }); +}); diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 8b7fa1ae..17b915da 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -1,4 +1,6 @@ import logger from "./logger.js"; +import { validateStellarConfig, resolveStellarConfig } from "./stellar.js"; +import { validateFeeSponsorBootConfig } from "../services/stellar/feeSponsorService.js"; /** * Validate required environment variables @@ -53,26 +55,69 @@ const optionalEnvVars = [ "SIGNING_KEY", "INGESTION_WORKER_ENABLED", "INGESTION_POLL_INTERVAL_MS", + // Outbound webhooks (issue #45). WEBHOOK_SECRET_ENCRYPTION_KEY is + // security-critical (fail-fast in production, see below); the rest are + // tunables with sensible defaults. + "WEBHOOK_SECRET_ENCRYPTION_KEY", + "WEBHOOK_WORKER_ENABLED", + "WEBHOOK_POLL_INTERVAL_MS", + "WEBHOOK_MAX_ATTEMPTS", + "WEBHOOK_AUTO_DISABLE_THRESHOLD", + "WEBHOOK_BACKOFF_JITTER_MS", + "WEBHOOK_HTTP_TIMEOUT_MS", + "WEBHOOK_API_VERSION", + // Service-to-service auth keys for the AI service (dnb-ai). Required in + // production (fail-fast below); optional in development/test. + "AI_SERVICE_KEYS", + // Fee-bump sponsorship (#30). All optional — a boot with none of these set + // (the default) is unchanged. When FEE_SPONSOR_ENABLED=true, the secret is + // validated below and a bad/missing secret fails fast. + "FEE_SPONSOR_ENABLED", + "FEE_SPONSOR_SECRET", + "FEE_SPONSOR_MAX_FEE_STROOPS", + "FEE_SPONSOR_DAILY_CAP_STROOPS", + "FEE_SPONSOR_PER_USER_DAILY_LIMIT", ]; export const validateEnv = () => { + // Test mode: provide defaults for development of tests + if (process.env.NODE_ENV === "test") { + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; + process.env.MONGO_URI = process.env.MONGO_URI || "mongodb://test-db:27017/dnb-test"; + process.env.PORT = process.env.PORT || "5000"; + } + // Default values for TTLs if not provided process.env.ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL || "15m"; process.env.REFRESH_TOKEN_TTL = process.env.REFRESH_TOKEN_TTL || "30d"; - // Default values for Horizon resilient client if not provided - const network = process.env.STELLAR_NETWORK || "testnet"; + // Default values for Horizon resilient client if not provided. The + // network-aware default comes from the single source of truth + // (config/stellar.js), so the env var always reflects what the app will + // actually use after boot. if (!process.env.HORIZON_URLS) { - process.env.HORIZON_URLS = - network === "mainnet" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; + process.env.HORIZON_URLS = resolveStellarConfig().horizonUrls.join(","); } process.env.HORIZON_TIMEOUT_MS = process.env.HORIZON_TIMEOUT_MS || "10000"; process.env.HORIZON_MAX_RETRIES = process.env.HORIZON_MAX_RETRIES || "3"; process.env.HORIZON_CB_THRESHOLD = process.env.HORIZON_CB_THRESHOLD || "5"; process.env.HORIZON_CB_COOLDOWN_MS = process.env.HORIZON_CB_COOLDOWN_MS || "30000"; + // Fail fast on a misconfigured Stellar setup (bad STELLAR_NETWORK value, + // mainnet flag with testnet Horizon/issuer, etc.) instead of failing at + // request time on the first Horizon call. + const stellarValidation = validateStellarConfig(); + if (!stellarValidation.valid) { + for (const problem of stellarValidation.problems) { + logger.error(`❌ Stellar configuration error: ${problem}`); + } + logger.error( + "Stellar configuration is invalid — fix the issues above before starting. " + + "See docs/MAINNET.md for the mainnet checklist." + ); + process.exit(1); + } + const missing = []; requiredEnvVars.forEach((envVar) => { @@ -81,6 +126,23 @@ export const validateEnv = () => { } }); + // Service-to-service auth keys are REQUIRED in production so a misconfigured + // deploy fails fast rather than leaving the AI channel open/unauthenticated. + // In development/test they stay optional. + if (process.env.NODE_ENV === "production" && !process.env.AI_SERVICE_KEYS) { + missing.push("AI_SERVICE_KEYS"); + } + + // Webhook signing secrets are stored encrypted at rest; the encryption key + // is security-critical, so a production deploy must set it explicitly rather + // than fall back to the built-in dev key. + if ( + process.env.NODE_ENV === "production" && + !process.env.WEBHOOK_SECRET_ENCRYPTION_KEY + ) { + missing.push("WEBHOOK_SECRET_ENCRYPTION_KEY"); + } + if (missing.length > 0) { logger.error( `❌ Missing required environment variables: ${missing.join(", ")}` @@ -91,6 +153,15 @@ export const validateEnv = () => { process.exit(1); } + // Fee-bump sponsorship (#30): when the master switch is on, a missing or + // invalid sponsor secret is a hard boot failure so a misconfigured deploy + // never silently disables sponsorship or ships an unusable key. + const feeSponsor = validateFeeSponsorBootConfig(); + if (!feeSponsor.ok) { + logger.error(`❌ Fee sponsorship misconfigured: ${feeSponsor.message}`); + process.exit(1); + } + // Check JWT_SECRET strength if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) { logger.warn( diff --git a/src/controllers/admin/educatorVerificationAdminController.js b/src/controllers/admin/educatorVerificationAdminController.js new file mode 100644 index 00000000..480dc325 --- /dev/null +++ b/src/controllers/admin/educatorVerificationAdminController.js @@ -0,0 +1,247 @@ +import cloudinary from "../../utils/cloudinary.js"; +import mongoose from "mongoose"; +import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; +import EducatorVerification, { + VERIFICATION_STATUS, +} from "../../models/EducatorVerification.js"; +import User from "../../models/User.js"; +import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { recordAudit } from "../../services/audit/auditService.js"; + +const SIGNED_URL_TTL_SECONDS = 600; + +const buildSignedUrl = (publicId) => { + const config = cloudinary.config(); + if (!config.cloud_name || !config.api_key || !config.api_secret) { + return null; + } + try { + return cloudinary.url(publicId, { + sign_url: true, + secure: true, + expires_at: Math.floor(Date.now() / 1000) + SIGNED_URL_TTL_SECONDS, + }); + } catch (_) { + return null; + } +}; + +const serializeDocumentsWithSignedUrls = (docs) => + docs.map((d) => ({ + type: d.type, + originalFileName: d.originalFileName, + uploadedAt: d.uploadedAt, + signedUrl: buildSignedUrl(d.cloudinaryPublicId), + })); + +export const listApplications = catchAsync(async (req, res) => { + const { + status, + page = "1", + limit = "20", + } = req.query; + + const filter = {}; + if (status) { + const valid = Object.values(VERIFICATION_STATUS); + if (!valid.includes(status)) { + throw new APIError(`Invalid status. Must be one of: ${valid.join(", ")}`, 400); + } + filter.status = status; + } + + const pageNum = Math.max(1, parseInt(page, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (pageNum - 1) * limitNum; + + const [applications, total] = await Promise.all([ + EducatorVerification.find(filter) + .sort({ submittedAt: -1, createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .populate("applicant", "name email role verifiedEducator") + .populate("reviewedBy", "name email") + .lean(), + EducatorVerification.countDocuments(filter), + ]); + + const serialized = applications.map((a) => ({ + ...a, + documents: serializeDocumentsWithSignedUrls(a.documents || []), + })); + + res.status(200).json({ + success: true, + applications: serialized, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: Math.ceil(total / limitNum), + }, + }); +}); + +export const getApplicationById = catchAsync(async (req, res) => { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + + const application = await EducatorVerification.findById(id) + .populate("applicant", "name email role verifiedEducator") + .populate("reviewedBy", "name email") + .lean(); + + if (!application) { + throw new APIError("Application not found", 404); + } + + const serialized = { + ...application, + documents: serializeDocumentsWithSignedUrls(application.documents || []), + }; + + res.status(200).json({ + success: true, + application: serialized, + }); +}); + +export const getAdminDocumentSignedUrl = catchAsync(async (req, res) => { + const { id, documentIndex } = req.params; + const idx = parseInt(documentIndex, 10); + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + if (isNaN(idx) || idx < 0) { + throw new APIError("Invalid document index", 400); + } + + const verification = await EducatorVerification.findById(id); + if (!verification) { + throw new APIError("Application not found", 404); + } + if (idx >= verification.documents.length) { + throw new APIError("Document not found", 404); + } + + const doc = verification.documents[idx]; + const signedUrl = buildSignedUrl(doc.cloudinaryPublicId); + + if (!signedUrl) { + throw new APIError("Unable to generate signed URL at this time", 503); + } + + res.status(200).json({ + success: true, + data: { + signedUrl, + expiresInSeconds: SIGNED_URL_TTL_SECONDS, + }, + }); +}); + +const performReview = async (req, res, targetStatus, auditAction) => { + const { id } = req.params; + const { reviewNotes } = req.body || {}; + const reviewerId = req.user._id; + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + + const verification = await EducatorVerification.findById(id); + if (!verification) { + throw new APIError("Application not found", 404); + } + + const previousStatus = verification.status; + + if (!verification.canTransitionTo(targetStatus)) { + throw new APIError( + `Cannot transition from '${previousStatus}' to '${targetStatus}'`, + 409 + ); + } + + const applicantId = verification.applicant; + + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + verification.status = targetStatus; + verification.reviewedBy = reviewerId; + verification.reviewNotes = reviewNotes || null; + verification.reviewedAt = new Date(); + await verification.save({ session }); + + if (targetStatus === VERIFICATION_STATUS.APPROVED) { + await User.updateOne( + { _id: applicantId }, + { $set: { verifiedEducator: true } }, + { session } + ); + } + + await session.commitTransaction(); + session.endSession(); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; + } + + await recordAudit({ + action: auditAction, + actor: reviewerId, + req, + targetType: "EducatorVerification", + targetId: verification._id.toString(), + status: "success", + metadata: { + verificationId: verification._id.toString(), + previousStatus, + newStatus: targetStatus, + reviewedBy: reviewerId.toString(), + reviewNotes: reviewNotes || null, + educatorId: applicantId.toString(), + }, + }); + + res.status(200).json({ + success: true, + message: + targetStatus === VERIFICATION_STATUS.APPROVED + ? "Application approved — educator now verified" + : "Application rejected", + application: { + _id: verification._id, + status: verification.status, + reviewedBy: verification.reviewedBy, + reviewNotes: verification.reviewNotes, + reviewedAt: verification.reviewedAt, + }, + }); +}; + +export const approveApplication = catchAsync(async (req, res) => { + return performReview( + req, + res, + VERIFICATION_STATUS.APPROVED, + AUDIT_ACTIONS.EDUCATOR_VERIFY_APPROVE + ); +}); + +export const rejectApplication = catchAsync(async (req, res) => { + return performReview( + req, + res, + VERIFICATION_STATUS.REJECTED, + AUDIT_ACTIONS.EDUCATOR_VERIFY_REJECT + ); +}); diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 3aa4b37a..5c4d7edf 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -1,5 +1,5 @@ // controllers/authController.js -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import crypto from "crypto"; import User from "../models/User.js"; @@ -12,21 +12,51 @@ import { recordAudit } from "../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../models/AuditLog.js"; import { generateOtp, hashOtp, verifyOtp } from "../utils/otp.js"; import { firstPasswordIssue } from "../utils/passwordPolicy.js"; +import { isPasswordBreached } from "../utils/hibp.js"; import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import qrcode from "qrcode"; +import { + encryptSecret, + decryptSecret, + generateRecoveryCodes, + verifyAndConsumeRecoveryCode, + generateBase32Secret, + verifyTOTPCode, + generateOtpauthUrl, +} from "../utils/twoFactorCrypto.js"; + +// Runtime bootstrap validates this before serving traffic. Resolving it when a +// token operation occurs keeps importing the Express app free of process-wide +// configuration side effects for unit and integration tests. +const getJwtSecret = () => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is required to sign or verify auth tokens"); + } + return secret; +}; -const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; - -// Log JWT configuration on startup and warn if using fallback -if (!process.env.JWT_SECRET) { - logger.warn( - "⚠️ WARNING: JWT_SECRET not found in .env! Using fallback (INSECURE for production)" +// ── Progressive login lockout (issue #89) ─────────────────────────────────── +// After LOGIN_MAX_FAILED_ATTEMPTS consecutive failures, the account is locked +// for an escalating duration. Each new failure while unlocked (re)extends the +// lock with exponential backoff, capped at LOGIN_LOCKOUT_MAX_MS. +const LOGIN_MAX_FAILED_ATTEMPTS = + parseInt(process.env.LOGIN_MAX_ATTEMPTS, 10) || 5; +const LOGIN_LOCKOUT_BASE_MS = + parseInt(process.env.LOGIN_LOCKOUT_BASE_MS, 10) || 60 * 1000; // 1 min +const LOGIN_LOCKOUT_MAX_MS = + parseInt(process.env.LOGIN_LOCKOUT_MAX_MS, 10) || 24 * 60 * 60 * 1000; // 24 h +const LOGIN_LOCKOUT_MULTIPLIER = 2; + +/** Escalating backoff: base * 2^(failures - threshold), capped at the max. */ +const lockoutDurationMs = (failedAttempts) => + Math.min( + LOGIN_LOCKOUT_MAX_MS, + LOGIN_LOCKOUT_BASE_MS * + LOGIN_LOCKOUT_MULTIPLIER ** + Math.max(0, failedAttempts - LOGIN_MAX_FAILED_ATTEMPTS) ); -} else { - logger.info( - `✅ JWT_SECRET loaded from .env (length: ${process.env.JWT_SECRET.length})` - ); -} // Helper: parse duration string to ms (e.g. 15m, 30d) export const parseDurationToMs = (duration) => { @@ -81,7 +111,8 @@ export const shapeAuthUser = (user) => ({ }); // Helper: generate new session + refresh token + cookie + access token -export const createSessionAndTokens = async (user, req, res) => { +export const createSessionAndTokens = async (user, req, res, options = {}) => { + const is2FAVerified = options.is2FAVerified === true; const rawRefreshToken = crypto.randomBytes(32).toString("hex"); const refreshTokenHash = crypto.createHash("sha256").update(rawRefreshToken).digest("hex"); @@ -100,12 +131,13 @@ export const createSessionAndTokens = async (user, req, res) => { label: getDeviceLabel(req.headers["user-agent"]), }, expiresAt, + is2FAVerified, }); const accessTokenTtl = process.env.ACCESS_TOKEN_TTL || "15m"; const accessToken = jwt.sign( - { userId: user._id, role: user.role, sessionId: session._id }, - JWT_SECRET, + { userId: user._id, role: user.role, sessionId: session._id, is2FAVerified }, + getJwtSecret(), { expiresIn: accessTokenTtl } ); @@ -144,6 +176,29 @@ export const registerUser = catchAsync(async (req, res, next) => { return next(new APIError(passwordIssue, 400)); } + // Reject passwords that appear in real-world breach dumps (HIBP range API, + // SHA-1 prefix only — the password is never transmitted). Fails open on a + // HIBP outage so signups don't break. + const breached = await isPasswordBreached(password); + if (breached) { + logger.warn(`❌ Registration failed - breached password: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_REGISTER_FAILURE, + actor: null, + req, + targetType: "User", + targetId: email, + status: "failure", + metadata: { email, reason: "breached_password" }, + }); + return next( + new APIError( + "This password has appeared in a known data breach. Please choose a different one.", + 400 + ) + ); + } + // Check if user already exists const existing = await User.findOne({ email }); if (existing) { @@ -366,9 +421,62 @@ export const loginUser = catchAsync(async (req, res, next) => { return next(new APIError("Invalid credentials", 401)); } + // Per-account lockout: reject BEFORE running bcrypt while the account is + // locked. Respond with the SAME generic "Invalid credentials" used for a + // nonexistent account so a locked account is indistinguishable from one that + // does not exist (no enumeration). The lock itself is recorded in the audit + // log for operators. + const isLocked = user.lockUntil && new Date(user.lockUntil) > new Date(); + if (isLocked) { + logger.warn(`🔒 Login blocked - account locked: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email, reason: "account_locked" }, + }); + return next(new APIError("Invalid credentials", 401)); + } + // Verify password const isPasswordCorrect = await bcrypt.compare(password, user.password); if (!isPasswordCorrect) { + // Atomic increment — the DB is the single source of truth for the counter, + // so concurrent login attempts cannot race a read-then-write snapshot. + const updated = await User.findByIdAndUpdate( + user._id, + { $inc: { failedLoginAttempts: 1 } }, + { new: true } + ); + const failedAttempts = updated?.failedLoginAttempts ?? 1; + user.failedLoginAttempts = failedAttempts; + + if (failedAttempts >= LOGIN_MAX_FAILED_ATTEMPTS) { + const lockUntil = new Date( + Date.now() + lockoutDurationMs(failedAttempts) + ); + await User.updateOne( + { _id: user._id }, + { $set: { lockUntil } } + ); + user.lockUntil = lockUntil; + logger.warn( + `🔒 Account locked after ${failedAttempts} failed attempts: ${email}` + ); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email, reason: "account_locked" }, + }); + } + logger.warn(`❌ Login failed - Incorrect password: ${email}`); recordAudit({ action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE, @@ -382,23 +490,59 @@ export const loginUser = catchAsync(async (req, res, next) => { return next(new APIError("Invalid credentials", 401)); } - // Auto-promote whitelisted admin emails (self-healing for existing accounts) + // Auto-promote whitelisted admin emails (self-healing for existing accounts, only if 2FA is enabled) const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "") .split(",") .map((e) => e.trim().toLowerCase()) .filter(Boolean); if (ADMIN_EMAILS.includes(user.email.toLowerCase()) && user.role !== "admin") { - user.role = "admin"; - await user.save({ validateBeforeSave: false }); - logger.info(`👑 Promoted ${user.email} to admin (whitelisted account)`); + if (user.twoFactor?.enabled) { + user.role = "admin"; + await user.save({ validateBeforeSave: false }); + logger.info(`👑 Promoted ${user.email} to admin (whitelisted account with 2FA)`); + } else { + logger.warn(`⚠️ Whitelisted admin account ${user.email} not promoted because 2FA is not enabled`); + } + } + + // If 2FA is enabled for this user, issue a short-lived MFA challenge token instead of session tokens + if (user.twoFactor?.enabled) { + const mfaToken = jwt.sign( + { userId: user._id, type: "mfa_challenge" }, + getJwtSecret(), + { expiresIn: "5m" } + ); + + logger.info(`🔐 2FA step-up challenge issued for: ${email}`); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_CHALLENGE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email, mfaRequired: true }, + }); + + return res.status(200).json({ + success: true, + mfaRequired: true, + mfaToken, + message: "Two-factor authentication required", + }); } - // Update last login + // Update last login and clear any lockout state (success is the reset). user.lastLogin = new Date(); + if (user.failedLoginAttempts || user.lockUntil) { + user.failedLoginAttempts = 0; + user.lockUntil = null; + } await user.save({ validateBeforeSave: false }); - // Generate session and tokens + // Generate session and tokens (for users without 2FA) const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res); logger.info(`✅ Login successful: ${email} (ID: ${user._id})`); @@ -536,6 +680,19 @@ export const resetPassword = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid or expired OTP" }); } + // A reset must not be a way around a breached-password rejection either. + // Run only AFTER the caller proves account ownership via the OTP, so an + // unauthenticated attacker cannot trigger HIBP lookups for arbitrary emails. + const breached = await isPasswordBreached(newPassword); + if (breached) { + logger.warn(`❌ Password reset failed - breached password: ${email}`); + return res.status(400).json({ + success: false, + message: + "This password has appeared in a known data breach. Please choose a different one.", + }); + } + // Hash new password using cost factor 12 (aligned with registerUser) const hashedPassword = await bcrypt.hash(newPassword, 12); user.password = hashedPassword; @@ -599,6 +756,7 @@ export const refreshSession = catchAsync(async (req, res, next) => { } // Perform rotation + const is2FAVerified = session.is2FAVerified === true; const newRawToken = crypto.randomBytes(32).toString("hex"); const newHash = crypto.createHash("sha256").update(newRawToken).digest("hex"); @@ -616,6 +774,7 @@ export const refreshSession = catchAsync(async (req, res, next) => { label: getDeviceLabel(req.headers["user-agent"]), }, expiresAt: newExpiresAt, + is2FAVerified, }); session.revokedAt = new Date(); @@ -625,8 +784,8 @@ export const refreshSession = catchAsync(async (req, res, next) => { const accessTokenTtl = process.env.ACCESS_TOKEN_TTL || "15m"; const accessToken = jwt.sign( - { userId: session.user._id, role: session.user.role, sessionId: newSession._id }, - JWT_SECRET, + { userId: session.user._id, role: session.user.role, sessionId: newSession._id, is2FAVerified }, + getJwtSecret(), { expiresIn: accessTokenTtl } ); @@ -828,3 +987,282 @@ export const changePassword = catchAsync(async (req, res, next) => { "Password changed successfully. Other devices have been signed out.", }); }); + +// ── TOTP 2FA Controllers ─────────────────────────────────────────────────── + +/** + * Enrollment endpoint (POST /api/auth/2fa/setup, protect) + * Generates secret, stores encrypted pendingSecret, returns otpauth URI & QR code. + */ +export const setup2FA = catchAsync(async (req, res, next) => { + const user = await User.findById(req.user._id).select("+twoFactor.secret +twoFactor.pendingSecret"); + if (!user) { + return next(new APIError("User not found", 404)); + } + + if (user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is already enabled", 400)); + } + + const secret = generateBase32Secret(); + const encryptedSecret = encryptSecret(secret); + + if (!user.twoFactor) { + user.twoFactor = {}; + } + user.twoFactor.pendingSecret = encryptedSecret; + await user.save({ validateBeforeSave: false }); + + const otpauthUrl = generateOtpauthUrl(user.email, secret); + const qrCode = await qrcode.toDataURL(otpauthUrl); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_SETUP_INITIATED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email }, + }); + + res.status(200).json({ + success: true, + secret, + otpauthUrl, + qrCode, + message: "2FA setup initiated. Scan QR code or enter secret into your authenticator app, then confirm with a 2FA code.", + }); +}); + +/** + * Confirm/Verify endpoint (POST /api/auth/2fa/verify) + * Supports: + * 1. Setup confirmation (with protect authorization header & code) + * 2. Login step-up completion (with mfaToken & code / recoveryCode) + */ +export const verify2FA = catchAsync(async (req, res, next) => { + const { code, recoveryCode, mfaToken } = req.body; + + // Branch A: Login step-up verification + if (mfaToken) { + let decoded; + try { + decoded = jwt.verify(mfaToken, getJwtSecret()); + } catch (err) { + return next(new APIError("Invalid or expired 2FA challenge token", 401)); + } + + if (decoded.type !== "mfa_challenge" || !decoded.userId) { + return next(new APIError("Invalid 2FA challenge token", 401)); + } + + const user = await User.findById(decoded.userId).select("+twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is not enabled for this user", 400)); + } + + let isValid = false; + let isRecovery = false; + + if (code) { + const decryptedSecret = decryptSecret(user.twoFactor.secret); + isValid = verifyTOTPCode(code.toString(), decryptedSecret); + } + + if (!isValid && recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, recoveryCode); + if (isValid) isRecovery = true; + } + + // Fallback: if recovery code was passed in the code field + if (!isValid && code && !recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, code); + if (isValid) isRecovery = true; + } + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_2fa_code" }, + }); + return next(new APIError("Invalid 2FA code or recovery code", 401)); + } + + // Auto-promote whitelisted admin emails now that 2FA is verified & enabled + const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); + + if (ADMIN_EMAILS.includes(user.email.toLowerCase()) && user.role !== "admin") { + user.role = "admin"; + logger.info(`👑 Promoted ${user.email} to admin (whitelisted account with 2FA)`); + } + + user.lastLogin = new Date(); + await user.save({ validateBeforeSave: false }); + + if (isRecovery) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_RECOVERY_USED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, recoveryCodeUsed: true }, + }); + } + + const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res, { is2FAVerified: true }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, role: user.role }, + }); + + return res.status(200).json({ + success: true, + message: "Login successful", + accessToken, + refreshToken, + token: accessToken, + user: shapeAuthUser(user), + }); + } + + // Branch B: Setup confirmation + if (!req.user) { + return next(new APIError("Authentication required (provide authorization header or mfaToken)", 401)); + } + + if (!code) { + return next(new APIError("Please provide a 2FA code", 400)); + } + + const user = await User.findById(req.user._id).select("+twoFactor.pendingSecret +twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.pendingSecret) { + return next(new APIError("No 2FA setup in progress. Please call setup first.", 400)); + } + + const decryptedSecret = decryptSecret(user.twoFactor.pendingSecret); + const isValid = verifyTOTPCode(code.toString(), decryptedSecret); + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_ENABLE_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_confirmation_code" }, + }); + return next(new APIError("Invalid 2FA verification code", 401)); + } + + // Generate single-use recovery codes + const { plainCodes, hashedCodes } = await generateRecoveryCodes(10); + + user.twoFactor.secret = user.twoFactor.pendingSecret; + user.twoFactor.pendingSecret = undefined; + user.twoFactor.enabled = true; + user.twoFactor.recoveryCodes = hashedCodes; + user.twoFactor.enrolledAt = new Date(); + await user.save({ validateBeforeSave: false }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_ENABLE_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, twoFactorEnabled: true }, + }); + + return res.status(200).json({ + success: true, + message: "Two-factor authentication enabled successfully", + recoveryCodes: plainCodes, + }); +}); + +/** + * Disable 2FA endpoint (POST /api/auth/2fa/disable, protect) + * Requires a valid code or recovery code. + */ +export const disable2FA = catchAsync(async (req, res, next) => { + const { code, recoveryCode } = req.body; + + if (!code && !recoveryCode) { + return next(new APIError("Please provide a 2FA code or recovery code to disable 2FA", 400)); + } + + const user = await User.findById(req.user._id).select("+twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is not enabled", 400)); + } + + let isValid = false; + + if (code) { + const decryptedSecret = decryptSecret(user.twoFactor.secret); + isValid = verifyTOTPCode(code.toString(), decryptedSecret); + } + + if (!isValid && recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, recoveryCode); + } + + if (!isValid && code && !recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, code); + } + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_DISABLE_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_code" }, + }); + return next(new APIError("Invalid 2FA code or recovery code", 401)); + } + + user.twoFactor.enabled = false; + user.twoFactor.secret = undefined; + user.twoFactor.pendingSecret = undefined; + user.twoFactor.recoveryCodes = []; + user.twoFactor.enrolledAt = undefined; + + await user.save({ validateBeforeSave: false }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_DISABLE_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, twoFactorEnabled: false }, + }); + + res.status(200).json({ + success: true, + message: "Two-factor authentication disabled successfully", + }); +}); diff --git a/src/controllers/books/bookController.js b/src/controllers/books/bookController.js index 6ee3f7bd..9d7ffb6a 100644 --- a/src/controllers/books/bookController.js +++ b/src/controllers/books/bookController.js @@ -1,10 +1,12 @@ import axios from "axios"; +import mongoose from "mongoose"; import Book from "../../models/Book.js"; import User from "../../models/User.js"; import cloudinary from "../../utils/cloudinary.js"; import logger from "../../config/logger.js"; import { validateMagicBytes } from "../../utils/fileValidation.js"; import { createNewBookNotification } from "../notificationController.js"; +import { APIError, catchAsync } from "../../middlewares/errorHandler.js"; //cretae a book export const createBook = async (req, res) => { @@ -124,26 +126,34 @@ export const getBooksByAuthor = async (req, res) => { }; // delete book by id -export const deleteBook = async (req, res) => { - try { - const book = await Book.findById(req.params.id); - if (!book) { - return res.status(404).json({ success: false, message: "Book not found" }); - } +export const deleteBook = catchAsync(async (req, res, next) => { + const { id } = req.params; - if (req.user.role !== "admin" && book.author.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to delete this book", - }); - } + if (!mongoose.Types.ObjectId.isValid(id)) { + return next(new APIError("Invalid book id", 400)); + } - await Book.findByIdAndDelete(req.params.id); - res.json({ success: true, message: "Book deleted" }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); + const book = req.resource || (await Book.findById(id)); + if (!book) { + return next(new APIError("Book not found", 404)); } -}; + + const isOwner = + req.user?._id && book.author?.toString() === req.user._id.toString(); + const isAdmin = req.user?.role === "admin"; + if (!isOwner && !isAdmin) { + return next( + new APIError("You are not authorized to delete this book", 403) + ); + } + + await Book.findByIdAndDelete(book._id); + res.status(200).json({ + success: true, + message: "Book deleted", + data: null, + }); +}); // review books diff --git a/src/controllers/categoryController.js b/src/controllers/categoryController.js new file mode 100644 index 00000000..01fa4b5d --- /dev/null +++ b/src/controllers/categoryController.js @@ -0,0 +1,118 @@ +import mongoose from "mongoose"; +import Category from "../models/Category.js"; +import Course from "../models/Course.js"; +import { deleteCachePattern } from "../utils/cache.js"; +import { slugifyCategory, uniqueCategorySlug } from "../services/categoryService.js"; + +const categoryProjection = { + name: 1, + slug: 1, + description: 1, + icon: 1, + image: 1, + parent: 1, + order: 1, +}; + +export const listCategories = async (_req, res) => { + const categories = await Category.aggregate([ + { $match: { isActive: true } }, + { + $lookup: { + from: "courses", + localField: "_id", + foreignField: "categoryRef", + as: "courses", + }, + }, + { + $addFields: { + courseCount: { $size: "$courses" }, + enrollmentCount: { + $sum: { + $map: { input: "$courses", as: "course", in: { $size: { $ifNull: ["$$course.enrolledUsers", []] } } }, + }, + }, + freeCount: { + $size: { $filter: { input: "$courses", as: "course", cond: { $eq: ["$$course.price", 0] } } }, + }, + paidCount: { + $size: { $filter: { input: "$courses", as: "course", cond: { $gt: ["$$course.price", 0] } } }, + }, + minPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $min: "$courses.price" }, null] }, + maxPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $max: "$courses.price" }, null] }, + }, + }, + { $project: { ...categoryProjection, courseCount: 1, enrollmentCount: 1, freeCount: 1, paidCount: 1, minPrice: 1, maxPrice: 1 } }, + { $sort: { order: 1, name: 1 } }, + ]); + res.json({ success: true, categories }); +}; + +export const getCategory = async (req, res) => { + const category = await Category.findOne({ slug: slugifyCategory(req.params.slug), isActive: true }) + .select(categoryProjection) + .lean(); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20)); + const sorts = { newest: { createdAt: -1 }, popular: { enrolledUsers: -1 }, price: { price: 1 } }; + const sort = sorts[req.query.sort] || sorts.newest; + const filter = { categoryRef: category._id }; + const [courses, total] = await Promise.all([ + Course.find(filter).sort(sort).skip((page - 1) * limit).limit(limit).populate("createdBy", "name avatar"), + Course.countDocuments(filter), + ]); + res.json({ success: true, category, courses, pagination: { page, limit, total, pages: Math.ceil(total / limit) } }); +}; + +export const createCategory = async (req, res) => { + const { name, description, icon, image, parent, order, isActive } = req.body; + if (!name?.trim()) return res.status(400).json({ success: false, message: "Category name is required" }); + const duplicate = await Category.exists({ name: new RegExp(`^${name.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }); + if (duplicate) return res.status(409).json({ success: false, message: "Category name already exists" }); + if (parent) { + const parentCategory = await Category.findById(parent); + if (!parentCategory || parentCategory.parent) return res.status(400).json({ success: false, message: "Parent must be a top-level category" }); + } + const category = await Category.create({ name: name.trim(), slug: await uniqueCategorySlug(name), description, icon, image, parent: parent || null, order, isActive }); + await deleteCachePattern("categories:*"); + res.status(201).json({ success: true, category }); +}; + +export const updateCategory = async (req, res) => { + if (!mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ success: false, message: "Invalid category id" }); + const category = await Category.findById(req.params.id); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + const allowed = ["description", "icon", "image", "order", "isActive"]; + for (const field of allowed) if (req.body[field] !== undefined) category[field] = req.body[field]; + if (req.body.name && req.body.name.trim() !== category.name) { + category.name = req.body.name.trim(); + category.slug = await uniqueCategorySlug(category.name, category._id); + } + if (req.body.parent !== undefined) { + if (req.body.parent) { + const parent = await Category.findById(req.body.parent); + if (!parent || parent.parent || parent._id.equals(category._id)) return res.status(400).json({ success: false, message: "Invalid parent category" }); + } + category.parent = req.body.parent || null; + } + await category.save(); + await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]); + res.json({ success: true, category }); +}; + +export const deleteCategory = async (req, res) => { + const category = await Category.findById(req.params.id); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + const courseCount = await Course.countDocuments({ categoryRef: category._id }); + if (courseCount > 0) { + category.isActive = false; + await category.save(); + } else { + await category.deleteOne(); + } + await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]); + res.json({ success: true, softDeleted: courseCount > 0 }); +}; diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index 65780cc8..648c52b5 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -4,6 +4,12 @@ import logger from "../../config/logger.js"; import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; import { createNewCourseNotification } from "../notificationController.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; +import { + categoryTaxonomyExists, + categoryValidationError, + resolveActiveCategory, +} from "../../services/categoryService.js"; /** * Create a new course @@ -21,12 +27,17 @@ export const createCourse = catchAsync(async (req, res, next) => { new APIError("Title, description, and category are required", 400) ); } + const categoryDoc = await resolveActiveCategory(category); + if (!categoryDoc && (await categoryTaxonomyExists())) { + return next(new APIError(await categoryValidationError(), 400)); + } // Create course with URLs from frontend const course = await Course.create({ title, description, - category, + category: categoryDoc?.name || category, + categoryRef: categoryDoc?._id, price: price || 0, createdBy: req.user._id, thumbnail: thumbnail || null, // URL from frontend @@ -48,9 +59,15 @@ export const createCourse = catchAsync(async (req, res, next) => { }); // 📚 Get all courses -export const getCourses = async (_req, res) => { +export const getCourses = async (req, res) => { try { - const courses = await Course.find().populate( + const filter = {}; + if (req.query.category) { + const categoryDoc = await resolveActiveCategory(req.query.category); + if (!categoryDoc) return res.status(404).json({ success: false, message: "Category not found" }); + filter.categoryRef = categoryDoc._id; + } + const courses = await Course.find(filter).populate( "createdBy", "name email avatar" ); @@ -149,6 +166,12 @@ export const enrollInCourse = async (req, res) => { } } + await emitEvent(EVENT_TYPES.COURSE_ENROLLED, { + courseId: course._id.toString(), + itemTitle: course.title, + userId: req.user._id.toString(), + }); + res .status(200) .json({ @@ -169,23 +192,23 @@ export const updateCourse = catchAsync(async (req, res, next) => { logger.info(`Updating course: ${courseId}`); - const course = await Course.findById(courseId); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const course = req.resource || (await Course.findById(courseId)); if (!course) { return next(new APIError("Course not found", 404)); } - // Check if user is the creator or admin (authorization) - if (req.user.role !== "admin" && course.createdBy.toString() !== req.user._id.toString()) { - logger.warn(`Unauthorized course update attempt by user: ${req.user._id}`); - return next( - new APIError("You are not authorized to update this course", 403) - ); - } - // Update fields (URLs from frontend) course.title = title || course.title; course.description = description || course.description; - course.category = category || course.category; + if (category) { + const categoryDoc = await resolveActiveCategory(category); + if (!categoryDoc && (await categoryTaxonomyExists())) { + return next(new APIError(await categoryValidationError(), 400)); + } + course.category = categoryDoc?.name || category; + course.categoryRef = categoryDoc?._id; + } course.price = price !== undefined ? price : course.price; // Update media URLs if provided diff --git a/src/controllers/educatorVerificationController.js b/src/controllers/educatorVerificationController.js new file mode 100644 index 00000000..acc8f16c --- /dev/null +++ b/src/controllers/educatorVerificationController.js @@ -0,0 +1,241 @@ +import cloudinary from "../utils/cloudinary.js"; +import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import EducatorVerification, { + VERIFICATION_STATUS, +} from "../models/EducatorVerification.js"; +import User from "../models/User.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; +import { recordAudit } from "../services/audit/auditService.js"; + +const SIGNED_URL_TTL_SECONDS = 600; + +const buildSignedUrl = (publicId) => { + const config = cloudinary.config(); + if (!config.cloud_name || !config.api_key || !config.api_secret) { + return null; + } + try { + return cloudinary.url(publicId, { + sign_url: true, + secure: true, + expires_at: Math.floor(Date.now() / 1000) + SIGNED_URL_TTL_SECONDS, + }); + } catch (_) { + return null; + } +}; + +const serializeDocuments = (docs, includeSignedUrl = false) => + docs.map((d) => { + const obj = { + type: d.type, + originalFileName: d.originalFileName, + uploadedAt: d.uploadedAt, + }; + if (includeSignedUrl) { + obj.signedUrl = buildSignedUrl(d.cloudinaryPublicId); + } + return obj; + }); + +export const getMyApplication = catchAsync(async (req, res) => { + const applicantId = req.user._id; + + const verification = await EducatorVerification.findOne({ + applicant: applicantId, + }) + .sort({ createdAt: -1 }) + .lean(); + + if (!verification) { + return res.status(200).json({ + success: true, + application: null, + }); + } + + res.status(200).json({ + success: true, + application: { + ...verification, + documents: serializeDocuments(verification.documents, true), + }, + }); +}); + +export const getDocumentSignedUrl = catchAsync(async (req, res) => { + const { documentIndex } = req.params; + const applicantId = req.user._id; + const idx = parseInt(documentIndex, 10); + + if (isNaN(idx) || idx < 0) { + throw new APIError("Invalid document index", 400); + } + + const verification = await EducatorVerification.findOne({ + applicant: applicantId, + }).sort({ createdAt: -1 }); + + if (!verification) { + throw new APIError("No verification application found", 404); + } + + if (idx >= verification.documents.length) { + throw new APIError("Document not found", 404); + } + + const doc = verification.documents[idx]; + const signedUrl = buildSignedUrl(doc.cloudinaryPublicId); + + if (!signedUrl) { + throw new APIError("Unable to generate signed URL at this time", 503); + } + + res.status(200).json({ + success: true, + data: { + signedUrl, + expiresInSeconds: SIGNED_URL_TTL_SECONDS, + }, + }); +}); + +export const submitApplication = catchAsync(async (req, res) => { + const applicantId = req.user._id; + const { documents, personalStatement } = req.body || {}; + + if (!Array.isArray(documents) || documents.length === 0) { + throw new APIError( + "At least one credential document is required to submit", + 400 + ); + } + + for (const d of documents) { + if (!d.type || !d.cloudinaryPublicId || !d.originalFileName) { + throw new APIError( + "Each document must include type, cloudinaryPublicId, and originalFileName", + 400 + ); + } + } + + let verification = await EducatorVerification.findOne({ + applicant: applicantId, + status: { $in: [VERIFICATION_STATUS.DRAFT, VERIFICATION_STATUS.REJECTED] }, + }); + + let isResubmit = false; + let previousStatus = null; + + if (verification) { + if (verification.status === VERIFICATION_STATUS.REJECTED) { + isResubmit = true; + previousStatus = verification.status; + if (!verification.canTransitionTo(VERIFICATION_STATUS.PENDING)) { + throw new APIError("Cannot resubmit this application", 409); + } + verification.status = VERIFICATION_STATUS.PENDING; + verification.reviewedBy = null; + verification.reviewNotes = null; + verification.reviewedAt = null; + } else { + previousStatus = verification.status; + if (!verification.canTransitionTo(VERIFICATION_STATUS.PENDING)) { + throw new APIError("Cannot submit application from current state", 409); + } + verification.status = VERIFICATION_STATUS.PENDING; + } + verification.documents = documents; + verification.personalStatement = personalStatement || null; + verification.submittedAt = new Date(); + } else { + const existingPendingOrApproved = await EducatorVerification.findOne({ + applicant: applicantId, + status: { + $in: [VERIFICATION_STATUS.PENDING, VERIFICATION_STATUS.APPROVED], + }, + }); + if (existingPendingOrApproved) { + throw new APIError( + "An application is already pending or approved; cannot create a new one", + 409 + ); + } + + verification = new EducatorVerification({ + applicant: applicantId, + status: VERIFICATION_STATUS.PENDING, + documents, + personalStatement: personalStatement || null, + submittedAt: new Date(), + }); + previousStatus = VERIFICATION_STATUS.DRAFT; + } + + await verification.save(); + + const auditAction = isResubmit + ? AUDIT_ACTIONS.EDUCATOR_VERIFY_RESUBMIT + : AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT; + + await recordAudit({ + action: auditAction, + actor: applicantId, + req, + targetType: "EducatorVerification", + targetId: verification._id.toString(), + status: "success", + metadata: { + verificationId: verification._id.toString(), + previousStatus, + newStatus: VERIFICATION_STATUS.PENDING, + documentCount: documents.length, + }, + }); + + res.status(201).json({ + success: true, + message: isResubmit + ? "Application resubmitted for review" + : "Application submitted for review", + application: { + _id: verification._id, + status: verification.status, + submittedAt: verification.submittedAt, + documents: serializeDocuments(verification.documents, false), + }, + }); +}); + +export const generateUploadSignature = catchAsync(async (req, res) => { + const timestamp = Math.round(new Date().getTime() / 1000); + const config = cloudinary.config(); + + if (!config.api_secret) { + throw new APIError("Upload signing unavailable at this time", 503); + } + + const folder = "educator-verification"; + const signature = cloudinary.utils.api_sign_request( + { + timestamp, + folder, + type: "authenticated", + }, + config.api_secret + ); + + res.status(200).json({ + success: true, + message: "Signature generated successfully", + data: { + timestamp, + signature, + cloudName: config.cloud_name, + apiKey: config.api_key, + folder, + uploadType: "authenticated", + }, + }); +}); diff --git a/src/controllers/healthController.js b/src/controllers/healthController.js new file mode 100644 index 00000000..e7662b58 --- /dev/null +++ b/src/controllers/healthController.js @@ -0,0 +1,49 @@ +import mongoose from "mongoose"; +import { isRedisReady } from "../config/redis.js"; + +const mongoStates = { + 0: "disconnected", + 1: "connected", + 2: "connecting", + 3: "disconnecting", +}; + +export const createHealthHandler = ({ + getMongoReadyState = () => mongoose.connection.readyState, + getRedisReady = isRedisReady, + getUptime = () => process.uptime(), + getEnvironment = () => process.env.NODE_ENV || "unknown", +} = {}) => { + return (_req, res) => { + const mongoReadyState = getMongoReadyState(); + const mongoReady = mongoReadyState === 1; + const redisReady = Boolean(getRedisReady()); + const healthy = mongoReady && redisReady; + + return res.status(healthy ? 200 : 503).json({ + success: healthy, + message: healthy + ? "All critical dependencies are ready" + : "One or more critical dependencies are unavailable", + data: { + status: healthy ? "healthy" : "unhealthy", + timestamp: new Date().toISOString(), + uptime: getUptime(), + environment: getEnvironment(), + dependencies: { + mongodb: { + status: mongoReady ? "up" : "down", + state: mongoStates[mongoReadyState] || "unknown", + }, + redis: { + status: redisReady ? "up" : "down", + }, + }, + }, + }); + }; +}; + +export const healthCheck = createHealthHandler(); + +export const ping = (_req, res) => res.status(200).send("pong"); diff --git a/src/controllers/reviewController.js b/src/controllers/reviewController.js index 87678533..f8d4fe15 100644 --- a/src/controllers/reviewController.js +++ b/src/controllers/reviewController.js @@ -172,9 +172,7 @@ export const updateReviewHandler = (Model, itemType) => return next(new APIError("Review not found", 404)); } - if (review.user.toString() !== req.user._id.toString()) { - return next(new APIError("Not authorized to update this review", 403)); - } + // Ownership is enforced by authorizeReviewOwnership middleware. if (comment !== undefined) { if (typeof comment !== "string" || comment.trim() === "") { @@ -229,13 +227,7 @@ export const deleteReviewHandler = (Model, itemType) => return next(new APIError("Review not found", 404)); } - const review = item.reviews[reviewIndex]; - - const isOwner = review.user.toString() === req.user._id.toString(); - const isAdmin = req.user.role === "admin"; - if (!isOwner && !isAdmin) { - return next(new APIError("Not authorized to delete this review", 403)); - } + // Ownership is enforced by authorizeReviewOwnership middleware. item.reviews.splice(reviewIndex, 1); await recomputeReviewStats(item); diff --git a/src/controllers/spaceController.js b/src/controllers/spaceController.js index 147cb6fd..aea36797 100644 --- a/src/controllers/spaceController.js +++ b/src/controllers/spaceController.js @@ -90,18 +90,12 @@ export const updateSpace = async (req, res) => { if (req.body[key] !== undefined) updates[key] = req.body[key]; } - const existingSpace = await Space.findById(id); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const existingSpace = req.resource || (await Space.findById(id)); if (!existingSpace) { return res.status(404).json({ success: false, message: "Space not found" }); } - if (req.user.role !== "admin" && existingSpace.host.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to update this space", - }); - } - const space = await Space.findByIdAndUpdate(id, updates, { new: true, }).populate("host", "name email avatar"); @@ -158,18 +152,12 @@ export const getSpacesByHost = async (req, res) => { export const deleteSpace = async (req, res) => { try { const { id } = req.params; - const space = await Space.findById(id); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const space = req.resource || (await Space.findById(id)); if (!space) { return res.status(404).json({ success: false, message: "Space not found" }); } - if (req.user.role !== "admin" && space.host.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to delete this space", - }); - } - await Space.findByIdAndDelete(id); res.status(200).json({ success: true, message: "Space deleted" }); } catch (error) { diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index 50b05be2..08ccfb7e 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -2,16 +2,21 @@ import mongoose from "mongoose"; import Transaction from "../../models/Transaction.js"; import { - isValidPublicKey, getAccountBalance, - buildPaymentTransaction, - buildSep7Uri, submitTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, getExplorerUrl, - NETWORK, DONATION_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; +import { createDonationIntent } from "../../services/stellar/donationIntentService.js"; +import { markPledgeTransactionPaid } from "../../services/pledgeService.js"; +import { + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + SponsorshipError, +} from "../../services/stellar/feeSponsorService.js"; import logger from "../../config/logger.js"; import { enqueue } from "../../jobs/queue.js"; import { @@ -19,10 +24,10 @@ import { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, } from "../../config/metrics.js"; -const DONATION_MEMO = "DNB-SADAQAH"; - /** * Initialize a sadaqah donation - creates pending record and returns XDR to sign * POST /api/stellar/donation/initialize @@ -34,69 +39,8 @@ export const initializeDonation = async (req, res) => { try { const donorId = req.user._id; const { amount, publicKey } = req.body; - - // Donation wallet must be configured on the server - if (!DONATION_WALLET_PUBLIC_KEY) { - await session.abortTransaction(); - return res.status(503).json({ - success: false, - message: "Donations are not available right now. Please try again later.", - }); - } - - // Validate donor public key - if (!publicKey || !isValidPublicKey(publicKey)) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "Invalid Stellar public key", - }); - } - - // Validate amount (positive, max 7 decimal places) - const parsedAmount = Number(amount); - if ( - !amount || - !Number.isFinite(parsedAmount) || - parsedAmount <= 0 || - !/^\d+(\.\d{1,7})?$/.test(amount.toString()) - ) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: - "Invalid amount. Must be a positive number with at most 7 decimal places", - }); - } - - // Build the donation payment transaction (donor -> donation fund) - const paymentTx = await buildPaymentTransaction({ - sourcePublicKey: publicKey, - destinationPublicKey: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - memo: DONATION_MEMO, - }); - - // SEP-7 URI so wallets can deep-link the same payment - const sep7Uri = buildSep7Uri({ - destination: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - memo: DONATION_MEMO, - }); - - // Create pending donation record - const donation = new Transaction({ - type: "donation", - buyer: donorId, - buyerWallet: publicKey, - creatorWallet: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - network: NETWORK, - status: "pending", - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual - }); - - await donation.save({ session }); + const { transaction: donation, transactionXdr, sep7Uri, networkPassphrase } = + await createDonationIntent({ donorId, publicKey, amount, session }); await session.commitTransaction(); paymentsInitialized.inc({ type: "donation" }); @@ -105,16 +49,16 @@ export const initializeDonation = async (req, res) => { res.status(200).json({ success: true, donationId: donation._id, - transactionXdr: paymentTx.xdr, + transactionXdr, sep7Uri, - networkPassphrase: paymentTx.networkPassphrase, + networkPassphrase, }); } catch (error) { await session.abortTransaction(); logger.error("Initialize donation error:", error); - res.status(500).json({ + res.status(error.statusCode || 500).json({ success: false, - message: "Failed to initialize donation", + message: error.statusCode ? error.message : "Failed to initialize donation", error: process.env.NODE_ENV === "development" ? error.message : undefined, }); @@ -132,7 +76,7 @@ export const submitDonation = async (req, res) => { session.startTransaction(); try { - const { donationId, signedXdr } = req.body; + const { donationId, signedXdr, requestSponsorship } = req.body; const donorId = req.user._id; if (!donationId || !signedXdr) { @@ -159,7 +103,82 @@ export const submitDonation = async (req, res) => { }); } - // Update status to submitted + // Build expected payments array for validation + const expectedPayments = [ + { + destination: donation.creatorWallet, + amount: donation.amount, + }, + ]; + + // Fee-bump sponsorship (#30): opt-in and only when the master switch is on. + // Skipped entirely with the flag off — the donation submit path below is + // then byte-for-byte the original unsponsored flow. + const wantSponsor = requestSponsorship === true && isFeeSponsorEnabled(); + let submissionXdr = signedXdr; + let sponsorship = null; + + if (wantSponsor) { + try { + sponsorship = await prepareSponsoredSubmission({ + signedXdr, + transactionRow: donation, + userId: donorId, + session, + }); + submissionXdr = sponsorship.feeBumpXdr; + } catch (sponsorError) { + if (sponsorError instanceof SponsorshipError) { + // Sponsorship-specific failure: leave the donation pending so the + // client can retry unsponsored; never mark it failed. + await session.abortTransaction(); + sponsorshipsRejected.inc({ + type: "donation", + reason: sponsorError.code, + }); + logger.info( + `Sponsorship rejected for donation ${donationId}: ${sponsorError.code}` + ); + return res.status(sponsorError.httpStatus).json({ + success: false, + message: "Fee sponsorship was not applied; retry without sponsorship", + sponsorship: { approved: false, reason: sponsorError.code }, + retryUnsponsored: true, + }); + } + throw sponsorError; + } + sponsorshipsApproved.inc({ type: "donation" }); + logger.info(`Sponsorship approved for donation ${donationId}`); + } else { + // Validate signed XDR contents (memo, payments, optional source) + try { + validateSignedPaymentXdr( + signedXdr, + expectedPayments, + donation.memo, + donation.buyerWallet, + true + ); + } catch (validationError) { + donation.status = "failed"; + donation.expiresAt = undefined; + donation.failureReason = `validation_failed: ${validationError.message}`; + await donation.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "donation", reason: "validation_failed" }); + + logger.error(`Donation ${donationId} validation failed:`, validationError.message); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected payment details", + error: validationError.message, + }); + } + } + + // Update status to submitted after validation donation.status = "submitted"; donation.submittedAt = new Date(); await donation.save({ session }); @@ -168,9 +187,10 @@ export const submitDonation = async (req, res) => { // Submit to Stellar network let result; try { - result = await submitTransaction(signedXdr); + result = await submitTransaction(submissionXdr); } catch (stellarError) { donation.status = "failed"; + donation.expiresAt = undefined; donation.failureReason = stellarError.message; await donation.save({ session }); await session.commitTransaction(); @@ -185,16 +205,42 @@ export const submitDonation = async (req, res) => { }); } + // Sponsored submits: the fee-bump has landed, so account the spend (with + // the real fee_charged) and stamp the sponsorship fields. Verification and + // the stored hash use the inner-transaction hash (which matches + // `expectedHash`); the fee-bump (outer) hash is kept alongside. + if (sponsorship) { + donation.sponsored = true; + donation.feeBumpTxHash = sponsorship.outerHash; + donation.sponsorFeeCharged = + result.feeCharged != null + ? String(result.feeCharged) + : String(sponsorship.maxFeeStroops); + try { + await recordSponsorshipSpend({ + userId: donorId, + feeStroops: + result.feeCharged != null + ? Number(result.feeCharged) + : sponsorship.maxFeeStroops, + session, + }); + } catch (spendErr) { + logger.error( + `Failed to record sponsorship spend for donation ${donationId}:`, + spendErr + ); + } + } + + const settledHash = sponsorship ? sponsorship.innerHash : result.hash; + // Verify on-chain that the donation actually paid the fund (amount, destination, asset) - const verification = await verifyPaymentOperations(result.hash, [ - { - destination: donation.creatorWallet, - amount: donation.amount, - }, - ]); + // (expectedPayments already defined above for pre-submission validation) + const verification = await verifyPaymentOperations(settledHash, expectedPayments); if (!verification.verified) { - donation.stellarTxHash = result.hash; + donation.stellarTxHash = settledHash; if (verification.transient) { donation.status = "retrying"; donation.failureReason = verification.reason; @@ -205,7 +251,7 @@ export const submitDonation = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `verify:${result.hash}`, + idempotencyKey: `verify:${settledHash}`, session, } ); @@ -214,11 +260,13 @@ export const submitDonation = async (req, res) => { success: true, message: "Donation submitted; confirmation is in progress", donationId: donation._id, - txHash: result.hash, + txHash: settledHash, status: "retrying", + ...(sponsorship && { sponsored: true }), }); } donation.status = "failed"; + donation.expiresAt = undefined; donation.failureReason = `On-chain verification failed: ${verification.reason}`; await donation.save({ session }); await session.commitTransaction(); @@ -236,10 +284,11 @@ export const submitDonation = async (req, res) => { } // Mark confirmed - donation.stellarTxHash = result.hash; + donation.stellarTxHash = settledHash; donation.stellarLedger = result.ledger; donation.status = "confirmed"; donation.confirmedAt = new Date(); + donation.expiresAt = undefined; // terminal state — never TTL-reapable await donation.save({ session }); await enqueue( "generateReceipt", @@ -247,22 +296,30 @@ export const submitDonation = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `receipt:${result.hash}`, + idempotencyKey: `receipt:${settledHash}`, session, } ); await session.commitTransaction(); paymentsConfirmed.inc({ type: "donation" }); + markPledgeTransactionPaid(donation, donation.confirmedAt).catch((error) => + logger.error({ donationId, error: error.message }, "Failed to update pledge statistics") + ); logger.info( - `Donation successful: ${donationId}, Stellar TX: ${result.hash}` + `Donation successful: ${donationId}, Stellar TX: ${settledHash}${sponsorship ? " (sponsored)" : ""}` ); res.status(200).json({ success: true, message: "JazakAllah khair! Your sadaqah has been received.", - txHash: result.hash, - explorerUrl: getExplorerUrl(result.hash), + txHash: settledHash, + explorerUrl: getExplorerUrl(settledHash), + ...(sponsorship && { + sponsored: true, + feeBumpTxHash: sponsorship.outerHash, + sponsorFeeCharged: donation.sponsorFeeCharged, + }), }); } catch (error) { await session.abortTransaction(); diff --git a/src/controllers/stellar/giftController.js b/src/controllers/stellar/giftController.js new file mode 100644 index 00000000..06cac1e7 --- /dev/null +++ b/src/controllers/stellar/giftController.js @@ -0,0 +1,639 @@ +// controllers/stellar/giftController.js +// +// Gift-a-course/book flow built on Stellar claimable balances. The sender +// funds a claimable balance the recipient claims whenever they're ready +// (trustline-free), with a reclaim-after-expiry predicate. Access to the item +// is granted to the RECIPIENT on claim — never the payer. All signing is +// client-side; the server only builds unsigned XDR, records the real +// claimable-balance id, verifies on-chain, and grants access. +import User from "../../models/User.js"; +import Book from "../../models/Book.js"; +import Course from "../../models/Course.js"; +import GiftClaim from "../../models/GiftClaim.js"; +import { + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + giftExpiryFromNow, +} from "../../services/stellar/claimableBalanceService.js"; +import { + submitTransaction, + verifyTransaction, + NETWORK, + getExplorerUrl, +} from "../../services/stellar/stellarService.js"; +import { grantItemAccess } from "../../services/stellar/reconciliationService.js"; +import logger from "../../config/logger.js"; + +// Gift memos are tagged DNB-GIFT- so they are +// never mistaken for a purchase (DNB-(BOOK|COURSE)-...) or donation memo by +// the reconciliation worker. +const buildGiftMemo = (itemId) => `DNB-GIFT-${String(itemId).slice(-8)}`; + +const isBeforeExpiry = (gift, now = Date.now()) => + now < gift.expiresAt.getTime(); + +/** + * Initialize a gift: validate the recipient, build an unsigned + * create_claimable_balance XDR, and persist a pending GiftClaim. + * POST /api/stellar/gifts/initialize + */ +export const initializeGift = async (req, res) => { + try { + const senderId = req.user._id; + const { itemType, itemId, recipientUserId } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const sender = await User.findById(senderId); + if (!sender?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const Model = itemType === "book" ? Book : Course; + const populateField = itemType === "book" ? "author" : "createdBy"; + const item = await Model.findById(itemId).populate( + populateField, + "stellarWallet name" + ); + if (!item) { + return res.status(404).json({ + success: false, + message: `${itemType} not found`, + }); + } + const creator = itemType === "book" ? item.author : item.createdBy; + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no gift needed", + }); + } + + if (recipientUserId === senderId.toString()) { + return res.status(400).json({ + success: false, + message: "You cannot gift an item to yourself", + }); + } + + const recipient = await User.findById(recipientUserId); + if (!recipient) { + return res.status(404).json({ + success: false, + message: "Recipient not found", + }); + } + if (!recipient?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Recipient has not connected their Stellar wallet yet", + }); + } + + // Already-owned guard, checked against the RECIPIENT (mirrors + // initializePayment's guard in the purchase flow). + const purchasedArray = + itemType === "book" ? recipient.purchasedBooks : recipient.purchasedCourses; + const idField = itemType === "book" ? "bookId" : "courseId"; + const alreadyOwns = purchasedArray?.some( + (p) => p[idField]?.toString() === itemId + ); + if (alreadyOwns) { + return res.status(400).json({ + success: false, + message: `Recipient already owns this ${itemType}`, + }); + } + + // Duplicate-pending guard (mirrors initializePayment). + const existingGift = await GiftClaim.findOne({ + sender: senderId, + recipient: recipientUserId, + itemType, + itemId, + status: { $in: ["pending_signature", "open"] }, + }); + if (existingGift) { + return res.status(400).json({ + success: false, + message: "You already have a pending gift for this recipient and item", + giftId: existingGift._id, + }); + } + + const expiresAt = giftExpiryFromNow(); + const paymentTx = await buildCreateClaimableBalanceTx({ + sourcePublicKey: sender.stellarWallet.publicKey, + claimantPublicKey: recipient.stellarWallet.publicKey, + amount: item.price.toString(), + expiresAt, + memo: buildGiftMemo(itemId), + }); + + const gift = new GiftClaim({ + sender: senderId, + recipient: recipient._id, + recipientWallet: recipient.stellarWallet.publicKey, + creator: creator?._id, + itemType, + itemId, + itemTypeModel: itemType === "book" ? "Book" : "Course", + itemTitle: item.title, + amount: item.price.toString(), + assetCode: "USDC", + status: "pending_signature", + expiresAt, + createTxHash: paymentTx.hash, + network: NETWORK, + }); + await gift.save(); + + logger.info( + `Gift initialized: ${gift._id} from ${senderId} to ${recipient._id} for ${itemType} ${itemId}` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + payment: { + xdr: paymentTx.xdr, + networkPassphrase: paymentTx.networkPassphrase, + expectedHash: paymentTx.hash, + }, + expiresAt: gift.expiresAt, + item: { + title: item.title, + price: item.price, + type: itemType, + }, + recipient: { + name: recipient.name, + wallet: recipient.stellarWallet.publicKey, + }, + }); + } catch (error) { + logger.error("Initialize gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to initialize gift", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * Submit the signed create_claimable_balance XDR, resolve the real balance + * id, and mark the gift open. + * POST /api/stellar/gifts/submit + */ +export const submitGift = async (req, res) => { + try { + const senderId = req.user._id; + const { giftId, signedXdr } = req.body; + + if (!giftId || !signedXdr) { + return res.status(400).json({ + success: false, + message: "Gift ID and signed XDR are required", + }); + } + + const gift = await GiftClaim.findOne({ + _id: giftId, + sender: senderId, + status: "pending_signature", + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found or already processed", + }); + } + + const sender = await User.findById(senderId); + if (!sender?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + // Verify the signed XDR BEFORE any DB write or access grant — a tampered + // XDR (wrong asset/amount/claimants) is rejected outright. + try { + validateSignedGiftXdr(signedXdr, { + assetCode: gift.assetCode, + amount: gift.amount, + recipientWallet: gift.recipientWallet, + senderWallet: sender.stellarWallet.publicKey, + expiresAt: gift.expiresAt, + }); + } catch (validationError) { + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected gift details", + error: validationError.message, + }); + } + + let result; + try { + result = await submitTransaction(signedXdr); + } catch (stellarError) { + return res.status(400).json({ + success: false, + message: "Transaction failed on Stellar network", + error: stellarError.message, + }); + } + + // Resolve the REAL claimable-balance id — NOT the tx hash. + const balanceId = await resolveBalanceId(result.hash, { + amount: gift.amount, + claimantPublicKey: gift.recipientWallet, + }); + if (!balanceId) { + // Leave the gift pending_signature so the client can retry — the + // create tx is already on-chain, and a retry simply re-resolves the id. + return res.status(502).json({ + success: false, + message: "Could not resolve the claimable balance id yet; please retry", + createTxHash: result.hash, + }); + } + + gift.createTxHash = result.hash; + gift.balanceId = balanceId; + gift.status = "open"; + await gift.save(); + + logger.info( + `Gift submitted: ${gift._id}, balance ${balanceId} (tx ${result.hash})` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + balanceId, + createTxHash: result.hash, + explorerUrl: getExplorerUrl(result.hash), + }); + } catch (error) { + logger.error("Submit gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to submit gift", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * List gifts sent and received by the current user. + * GET /api/stellar/gifts + */ +export const listGifts = async (req, res) => { + try { + const userId = req.user._id; + + // Lazy expiry transition: open gifts past their expiry flip to "expired" + // (the document is never deleted — the sender still needs it to reclaim). + await GiftClaim.updateMany( + { + $or: [{ sender: userId }, { recipient: userId }], + status: "open", + expiresAt: { $lte: new Date() }, + }, + { $set: { status: "expired" } } + ); + + const gifts = await GiftClaim.find({ + $or: [{ sender: userId }, { recipient: userId }], + }) + .sort({ createdAt: -1 }) + .populate("sender", "name") + .populate("recipient", "name"); + + res.status(200).json({ success: true, gifts }); + } catch (error) { + logger.error("List gifts error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch gifts", + }); + } +}; + +/** + * Get a single gift with live Horizon status of the underlying balance. + * GET /api/stellar/gifts/:id + */ +export const getGift = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }) + .populate("sender", "name") + .populate("recipient", "name"); + + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + // Lazy expiry transition (see listGifts). + if (gift.status === "open" && !isBeforeExpiry(gift)) { + gift.status = "expired"; + await gift.save(); + } + + let live = null; + if (gift.balanceId) { + const balance = await getClaimableBalance(gift.balanceId); + live = balance.exists + ? { + state: balance.record.state, + sponsor: balance.record.sponsor, + lastModifiedLedger: balance.record.last_modified_ledger, + } + : { state: "not_found" }; + } + + res.status(200).json({ + success: true, + gift: { ...gift.toObject(), live }, + }); + } catch (error) { + logger.error("Get gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch gift", + }); + } +}; + +/** + * Build an unsigned claim (or reclaim) XDR for a gift. + * Recipient-only before expiry; sender-only after expiry (reclaim). + * POST /api/stellar/gifts/:id/claim/initialize + */ +export const claimInitialize = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + if (gift.status === "pending_signature") { + return res.status(400).json({ + success: false, + message: "Gift has not been submitted yet", + }); + } + if (gift.status === "claimed" || gift.status === "reclaimed") { + return res.status(400).json({ + success: false, + message: "Gift has already been claimed", + }); + } + + const user = await User.findById(userId); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const isRecipient = gift.recipient.toString() === userId.toString(); + const isSender = gift.sender.toString() === userId.toString(); + if (!isRecipient && !isSender) { + return res.status(403).json({ + success: false, + message: "You are not a party to this gift", + }); + } + + const beforeExpiry = isBeforeExpiry(gift); + // Lazy expiry transition before the authorization decision. + if (gift.status === "open" && !beforeExpiry) { + gift.status = "expired"; + await gift.save(); + } + + if (beforeExpiry) { + if (!isRecipient) { + return res.status(403).json({ + success: false, + message: "Only the recipient can claim this gift before it expires", + }); + } + } else if (!isSender) { + return res.status(403).json({ + success: false, + message: "This gift has expired; only the sender can reclaim it", + }); + } + + const claim = await buildClaimTx({ + claimantPublicKey: user.stellarWallet.publicKey, + balanceId: gift.balanceId, + }); + + res.status(200).json({ + success: true, + giftId: gift._id, + action: beforeExpiry ? "claim" : "reclaim", + claim: { + xdr: claim.xdr, + networkPassphrase: claim.networkPassphrase, + expectedHash: claim.hash, + includesChangeTrust: claim.includesChangeTrust, + }, + }); + } catch (error) { + logger.error("Claim initialize error:", error); + res.status(500).json({ + success: false, + message: "Failed to build claim transaction", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * Submit a signed claim (or reclaim) XDR, verify it on-chain, and — for a + * recipient claim — grant item access to the RECIPIENT. + * POST /api/stellar/gifts/:id/claim/submit + */ +export const claimSubmit = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + const { signedXdr } = req.body; + + if (!signedXdr) { + return res.status(400).json({ + success: false, + message: "Signed XDR is required", + }); + } + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + if (gift.status === "pending_signature") { + return res.status(400).json({ + success: false, + message: "Gift has not been submitted yet", + }); + } + if (gift.status === "claimed" || gift.status === "reclaimed") { + return res.status(400).json({ + success: false, + message: "Gift has already been claimed", + }); + } + + const user = await User.findById(userId); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const isRecipient = gift.recipient.toString() === userId.toString(); + const isSender = gift.sender.toString() === userId.toString(); + if (!isRecipient && !isSender) { + return res.status(403).json({ + success: false, + message: "You are not a party to this gift", + }); + } + + const beforeExpiry = isBeforeExpiry(gift); + if (gift.status === "open" && !beforeExpiry) { + gift.status = "expired"; + await gift.save(); + } + + if (beforeExpiry && !isRecipient) { + return res.status(403).json({ + success: false, + message: "Only the recipient can claim this gift before it expires", + }); + } + if (!beforeExpiry && !isSender) { + return res.status(403).json({ + success: false, + message: "This gift has expired; only the sender can reclaim it", + }); + } + + let result; + try { + result = await submitTransaction(signedXdr); + } catch (stellarError) { + return res.status(400).json({ + success: false, + message: "Transaction failed on Stellar network", + error: stellarError.message, + }); + } + + // Verify on-chain that the claim_claimable_balance op actually succeeded. + const verification = await verifyTransaction(result.hash); + if (!verification.exists || !verification.successful) { + return res.status(400).json({ + success: false, + message: "Claim transaction did not succeed on the Stellar network", + }); + } + const claimOp = (verification.operations || []).find( + (op) => op.type === "claim_claimable_balance" + ); + if (!claimOp) { + return res.status(400).json({ + success: false, + message: + "Claim transaction did not contain a claim_claimable_balance operation", + }); + } + + if (beforeExpiry) { + // Recipient claim → grant access to the RECIPIENT, never the sender. + // This deliberately inverts the buyer-centric purchase flow: the payer + // (sender) funded the balance, but the beneficiary (recipient) is the + // one who gains course/book access. + await grantItemAccess({ + buyerId: gift.recipient, + itemType: gift.itemType, + itemId: gift.itemId, + }); + gift.status = "claimed"; + } else { + gift.status = "reclaimed"; + } + gift.claimTxHash = result.hash; + await gift.save(); + + logger.info( + `Gift ${gift._id} ${beforeExpiry ? "claimed" : "reclaimed"} by ${userId} (tx ${result.hash})` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + action: beforeExpiry ? "claimed" : "reclaimed", + claimTxHash: result.hash, + explorerUrl: getExplorerUrl(result.hash), + }); + } catch (error) { + logger.error("Claim submit error:", error); + res.status(500).json({ + success: false, + message: "Failed to submit claim", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index b6911842..377f0ad8 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -10,18 +10,28 @@ import { buildSep7Uri, calculateFeeSplit, preflightPayment, + PREFLIGHT_REASON_CODES, submitTransaction, verifyTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, findPaymentPaths, applySlippage, NETWORK, + networkPassphrase, getExplorerUrl, USDC, PLATFORM_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; import { getAssetConfig, isAssetSupported, getSupportedCodes } from "../../config/assets.js"; import * as StellarSdk from "@stellar/stellar-sdk"; +import { + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + getSponsorshipStatus, + SponsorshipError, +} from "../../services/stellar/feeSponsorService.js"; import { recordSaleEarnings } from "../../services/payoutService.js"; import { grantItemAccess } from "../../services/stellar/reconciliationService.js"; import { enqueue } from "../../jobs/queue.js"; @@ -31,9 +41,12 @@ import { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, } from "../../config/metrics.js"; import { recordAudit } from "../../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; /** * Resolve the item, its creator, and the settlement destination wallet for a @@ -58,6 +71,9 @@ const resolvePaymentDestination = async ({ itemType, itemId, session }) => { error: { status: 400, message: "Creator has not connected their Stellar wallet yet", + // The buyer can still complete the purchase through the + // claimable-balance (gift) path instead of dead-ending. + fallback: "claimable_balance", }, }; } @@ -251,6 +267,7 @@ export const getPaymentPreflight = async (req, res) => { return res.status(resolved.error.status).json({ success: false, message: resolved.error.message, + ...(resolved.error.fallback && { fallback: resolved.error.fallback }), }); } @@ -284,9 +301,17 @@ export const getPaymentPreflight = async (req, res) => { assetCode, }); + // A destination without the asset trustline is exactly the case the + // claimable-balance (gift) path fixes — surface it so the frontend can + // route the buyer there instead of dead-ending on op_no_trust. + const hasNoTrustline = preflight.reasons?.some( + (r) => r.code === PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE + ); + res.status(200).json({ success: true, preflight, + ...(hasNoTrustline && { fallback: "claimable_balance" }), }); } catch (error) { logger.error("Payment preflight error:", error); @@ -342,6 +367,7 @@ export const initializePayment = async (req, res) => { return res.status(resolved.error.status).json({ success: false, message: resolved.error.message, + ...(resolved.error.fallback && { fallback: resolved.error.fallback }), }); } @@ -387,11 +413,26 @@ export const initializePayment = async (req, res) => { }).session(session); if (existingTx) { + // Idempotent initialize: a double-click or client retry while a + // pending checkout already exists must not pile up duplicate pending + // records. Return the existing record (with its original unsigned XDR + // when one was persisted) so the frontend can resume the same + // checkout; stale pending records are reaped by the TTL index on + // `expiresAt` (pending-only partial index). await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "You have a pending transaction for this item", + return res.status(200).json({ + success: true, + alreadyPending: true, transactionId: existingTx._id, + message: + "You already have a pending transaction for this item; returning it", + payment: existingTx.unsignedXdr + ? { + xdr: existingTx.unsignedXdr, + networkPassphrase, + expectedHash: existingTx.expectedHash, + } + : null, }); } @@ -499,7 +540,9 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, + expectedHash: paymentTx.hash, + unsignedXdr: paymentTx.xdr, + memo, ...(sendAssetInput && { sendAsset: sendAssetInput, sendMax, @@ -539,6 +582,21 @@ export const initializePayment = async (req, res) => { }, }); + // Fire-and-forget: emit AFTER the txn has committed (never inside it). + await emitEvent(EVENT_TYPES.PAYMENT_INITIALIZED, { + transactionId: transaction._id.toString(), + itemType, + itemId: itemId.toString(), + itemTitle: item.title, + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + settlement: settlementMode, + buyerId: buyerId.toString(), + creatorId: creator._id.toString(), + status: "pending", + }); + res.status(200).json({ success: true, transactionId: transaction._id, @@ -585,7 +643,7 @@ export const submitPayment = async (req, res) => { session.startTransaction(); try { - const { transactionId, signedXdr } = req.body; + const { transactionId, signedXdr, requestSponsorship } = req.body; const buyerId = req.user._id; if (!transactionId || !signedXdr) { @@ -596,6 +654,54 @@ export const submitPayment = async (req, res) => { }); } + // Derive the deterministic on-chain hash of the submitted transaction. + // A replayed submission of the same signed XDR produces the same hash, + // which is what makes submit naturally idempotent per transaction hash. + // Parse failures are deliberately ignored here — the XDR is validated in + // full below (validateSignedPaymentXdr), which keeps the error shape for + // malformed XDRs unchanged. + let signedTx = null; + try { + signedTx = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + } catch { + // fall through — validation below reports the malformed XDR + } + const signedTxHash = signedTx?.hash().toString("hex") || null; + + // Idempotent submit: if this exact on-chain transaction hash was already + // processed (access granted, earnings recorded, receipt queued), return + // the original success response instead of re-processing — a double-click + // or a client retry after a timeout must never grant access twice. The + // unique index on stellarTxHash is the database-level backstop for the + // concurrent case (handled below on E11000). + if (signedTxHash) { + const alreadyProcessed = await Transaction.findOne({ + buyer: buyerId, + stellarTxHash: signedTxHash, + status: "confirmed", + }).session(session); + + if (alreadyProcessed?.status === "confirmed") { + await session.commitTransaction(); + return res.status(200).json({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + id: alreadyProcessed._id, + hash: alreadyProcessed.stellarTxHash, + ledger: alreadyProcessed.stellarLedger, + itemTitle: alreadyProcessed.itemTitle, + amount: alreadyProcessed.amount, + explorerUrl: getExplorerUrl(alreadyProcessed.stellarTxHash), + }, + }); + } + } + const transaction = await Transaction.findOne({ _id: transactionId, buyer: buyerId, @@ -610,6 +716,108 @@ export const submitPayment = async (req, res) => { }); } + // Build expected payments to validate XDR BEFORE submit + const expectedPayments = transaction.platformFee?.platformAmount + ? [ + { + destination: transaction.creatorWallet, + amount: transaction.platformFee.creatorAmount, + }, + { + destination: transaction.platformFee.platformWallet, + amount: transaction.platformFee.platformAmount, + }, + ] + : [ + { + destination: transaction.creatorWallet, + amount: transaction.amount, + }, + ]; + + // Fee-bump sponsorship (#30): only when the client opts in AND the master + // switch is on. With the flag off this is skipped entirely and the flow + // below is byte-for-byte the original unsponsored path. + const wantSponsor = requestSponsorship === true && isFeeSponsorEnabled(); + let submissionXdr = signedXdr; + let sponsorship = null; + + if (wantSponsor) { + try { + // Structural whitelist + spend caps + fee-bump wrapping. This is a + // strict superset of validateSignedPaymentXdr, so it is not run again + // for the sponsored path. + sponsorship = await prepareSponsoredSubmission({ + signedXdr, + transactionRow: transaction, + userId: buyerId, + session, + }); + submissionXdr = sponsorship.feeBumpXdr; + } catch (sponsorError) { + if (sponsorError instanceof SponsorshipError) { + // Sponsorship-specific failure: DO NOT mark the row failed. Leave it + // pending so the client can retry unsponsored (user pays the fee). + await session.abortTransaction(); + sponsorshipsRejected.inc({ + type: "purchase", + reason: sponsorError.code, + }); + logger.info( + `Sponsorship rejected for transaction ${transactionId}: ${sponsorError.code}` + ); + return res.status(sponsorError.httpStatus).json({ + success: false, + message: "Fee sponsorship was not applied; retry without sponsorship", + sponsorship: { approved: false, reason: sponsorError.code }, + retryUnsponsored: true, + }); + } + throw sponsorError; + } + sponsorshipsApproved.inc({ type: "purchase" }); + logger.info(`Sponsorship approved for transaction ${transactionId}`); + } else { + // Validate signed XDR contents (memo, payments, optional source) + try { + validateSignedPaymentXdr( + signedXdr, + expectedPayments, + transaction.memo, + transaction.buyerWallet, + true + ); + } catch (validationError) { + transaction.status = "failed"; + transaction.expiresAt = undefined; + transaction.failureReason = `validation_failed: ${validationError.message}`; + await transaction.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "purchase", reason: "validation_failed" }); + + logger.error(`Transaction ${transactionId} validation failed:`, validationError.message); + + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: buyerId.toString(), + status: "failed", + failureReason: `validation_failed: ${validationError.message}`, + }); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected payment details", + error: validationError.message, + }); + } + } + + // Update status to submitted after validation transaction.status = "submitted"; transaction.submittedAt = new Date(); await transaction.save({ session }); @@ -617,9 +825,10 @@ export const submitPayment = async (req, res) => { let result; try { - result = await submitTransaction(signedXdr); + result = await submitTransaction(submissionXdr); } catch (stellarError) { transaction.status = "failed"; + transaction.expiresAt = undefined; transaction.failureReason = stellarError.message; await transaction.save({ session }); await session.commitTransaction(); @@ -627,6 +836,18 @@ export const submitPayment = async (req, res) => { logger.error(`Transaction ${transactionId} failed:`, stellarError); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: buyerId.toString(), + status: "failed", + failureReason: stellarError.message, + }); + return res.status(400).json({ success: false, message: "Transaction failed on Stellar network", @@ -634,32 +855,52 @@ export const submitPayment = async (req, res) => { }); } - const expectedPayments = transaction.platformFee?.platformAmount - ? [ - { - destination: transaction.creatorWallet, - amount: transaction.platformFee.creatorAmount, - }, - { - destination: transaction.platformFee.platformWallet, - amount: transaction.platformFee.platformAmount, - }, - ] - : [ - { - destination: transaction.creatorWallet, - amount: transaction.amount, - }, - ]; + // Sponsored submits: the platform's fee-bump has landed, so account the + // spend (with the real fee_charged) and stamp the sponsorship fields. The + // inner-transaction hash is what the payment operations verify against and + // what matches `expectedHash`; the fee-bump (outer) hash is kept alongside. + if (sponsorship) { + transaction.sponsored = true; + transaction.feeBumpTxHash = sponsorship.outerHash; + transaction.sponsorFeeCharged = + result.feeCharged != null + ? String(result.feeCharged) + : String(sponsorship.maxFeeStroops); + try { + await recordSponsorshipSpend({ + userId: buyerId, + feeStroops: + result.feeCharged != null + ? Number(result.feeCharged) + : sponsorship.maxFeeStroops, + session, + }); + } catch (spendErr) { + // Accounting must never sink an on-chain-successful payment; a sweep + // can reconcile spend later from the sponsored rows. + logger.error( + `Failed to record sponsorship spend for transaction ${transactionId}:`, + spendErr + ); + } + } + + // The hash the payment operations settle under: the inner tx for a + // sponsored submit, otherwise the submitted tx itself. + const settledHash = sponsorship ? sponsorship.innerHash : result.hash; + + // Verify on-chain that the creator (and platform, when a fee was applied) + // actually received the expected USDC amounts + // (expectedPayments already defined above for pre-submission validation) const verification = await verifyPaymentOperations( - result.hash, + settledHash, expectedPayments, transaction.currency || "USDC" ); if (!verification.verified) { - transaction.stellarTxHash = result.hash; + transaction.stellarTxHash = settledHash; if (verification.transient) { transaction.status = "retrying"; transaction.failureReason = verification.reason; @@ -671,7 +912,7 @@ export const submitPayment = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `verify:${result.hash}`, + idempotencyKey: `verify:${settledHash}`, session, } ); @@ -689,11 +930,13 @@ export const submitPayment = async (req, res) => { success: true, message: "Payment submitted; confirmation is in progress", transactionId: transaction._id, - txHash: result.hash, + txHash: settledHash, status: "retrying", + ...(sponsorship && { sponsored: true }), }); } transaction.status = "failed"; + transaction.expiresAt = undefined; transaction.failureReason = `On-chain verification failed: ${verification.reason}`; await transaction.save({ session }); await session.commitTransaction(); @@ -712,11 +955,24 @@ export const submitPayment = async (req, res) => { status: "failure", metadata: { transactionId, - stellarTxHash: result.hash, + stellarTxHash: settledHash, failureReason: `On-chain verification failed: ${verification.reason}`, }, }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + stellarTxHash: settledHash, + buyerId: buyerId.toString(), + status: "failed", + failureReason: `On-chain verification failed: ${verification.reason}`, + }); + return res.status(400).json({ success: false, message: "Payment could not be verified on the Stellar network", @@ -724,11 +980,47 @@ export const submitPayment = async (req, res) => { }); } - transaction.stellarTxHash = result.hash; + transaction.stellarTxHash = settledHash; transaction.stellarLedger = result.ledger; transaction.status = "confirmed"; transaction.confirmedAt = new Date(); - await transaction.save({ session }); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable + try { + await transaction.save({ session }); + } catch (saveError) { + // Idempotency backstop at the database layer: the unique index on + // stellarTxHash means a concurrent request already confirmed this exact + // on-chain hash. Roll back this session's writes (including the + // "submitted" status) and return the original success response. + if (saveError?.code === 11000) { + const concurrentConfirmed = await Transaction.findOne({ + buyer: buyerId, + stellarTxHash: result.hash, + status: "confirmed", + }).session(session); + + if (concurrentConfirmed) { + await session.abortTransaction(); + logger.info( + `Transaction ${transactionId} already confirmed by a concurrent request (${result.hash}); returning existing result` + ); + return res.status(200).json({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + id: concurrentConfirmed._id, + hash: concurrentConfirmed.stellarTxHash, + ledger: concurrentConfirmed.stellarLedger, + itemTitle: concurrentConfirmed.itemTitle, + amount: concurrentConfirmed.amount, + explorerUrl: getExplorerUrl(concurrentConfirmed.stellarTxHash), + }, + }); + } + } + throw saveError; + } paymentsConfirmed.inc({ type: "purchase" }); await recordSaleEarnings(transaction, { session }); @@ -747,7 +1039,7 @@ export const submitPayment = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `receipt:${result.hash}`, + idempotencyKey: `receipt:${settledHash}`, session, } ); @@ -763,7 +1055,7 @@ export const submitPayment = async (req, res) => { await session.commitTransaction(); logger.info( - `Payment successful: ${transactionId}, Stellar TX: ${result.hash}` + `Payment successful: ${transactionId}, Stellar TX: ${settledHash}${sponsorship ? " (sponsored)" : ""}` ); recordAudit({ @@ -775,25 +1067,49 @@ export const submitPayment = async (req, res) => { status: "success", metadata: { transactionId, - stellarTxHash: result.hash, + stellarTxHash: settledHash, stellarLedger: result.ledger, amount: transaction.amount, itemType: transaction.itemType, - itemId: transaction.itemId.toString(), + itemId: transaction.itemId?.toString(), settlementMode: transaction.settlement, + sponsored: !!sponsorship, }, }); + // Fire-and-forget: emit AFTER the txn commit above. + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + itemTitle: transaction.itemTitle, + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + settlement: transaction.settlement, + stellarTxHash: settledHash, + stellarLedger: result.ledger, + buyerId: buyerId.toString(), + creatorId: transaction.creator?.toString(), + status: "confirmed", + sponsored: !!sponsorship, + }); + res.status(200).json({ success: true, message: "Payment successful!", transaction: { id: transaction._id, - hash: result.hash, + hash: settledHash, ledger: result.ledger, itemTitle: transaction.itemTitle, amount: transaction.amount, - explorerUrl: getExplorerUrl(result.hash), + explorerUrl: getExplorerUrl(settledHash), + ...(sponsorship && { + sponsored: true, + feeBumpTxHash: sponsorship.outerHash, + sponsorFeeCharged: transaction.sponsorFeeCharged, + }), }, }); } catch (error) { @@ -929,8 +1245,13 @@ export const cancelTransaction = async (req, res) => { status: "pending", }, { - status: "expired", - failureReason: "Cancelled by user", + $set: { + status: "expired", + failureReason: "Cancelled by user", + }, + $unset: { + expiresAt: 1, + }, }, { new: true } ); @@ -960,6 +1281,18 @@ export const cancelTransaction = async (req, res) => { }, }); + await emitEvent(EVENT_TYPES.PAYMENT_EXPIRED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: userId.toString(), + status: "expired", + failureReason: "Cancelled by user", + }); + res.status(200).json({ success: true, message: "Transaction cancelled", @@ -971,4 +1304,24 @@ export const cancelTransaction = async (req, res) => { message: "Failed to cancel transaction", }); } +}; + +/** + * Fee-bump sponsorship status (#30) — auth-protected ops view. Exposes whether + * sponsorship is enabled, the sponsor account's public key and live XLM float, + * the configured caps, and today's spend so the float can be topped up before + * it runs dry. The sponsor secret is never read here and never returned. + * GET /api/stellar/payment/sponsorship/status + */ +export const sponsorshipStatus = async (req, res) => { + try { + const status = await getSponsorshipStatus(); + res.status(200).json({ success: true, sponsorship: status }); + } catch (error) { + logger.error("Sponsorship status error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch sponsorship status", + }); + } }; \ No newline at end of file diff --git a/src/controllers/stellar/pledgeController.js b/src/controllers/stellar/pledgeController.js new file mode 100644 index 00000000..b86f8e17 --- /dev/null +++ b/src/controllers/stellar/pledgeController.js @@ -0,0 +1,145 @@ +import mongoose from "mongoose"; +import Pledge from "../../models/Pledge.js"; +import PledgeCycle from "../../models/PledgeCycle.js"; +import { isValidPublicKey } from "../../services/stellar/stellarService.js"; +import { + createDonationIntent, + validateDonationAmount, +} from "../../services/stellar/donationIntentService.js"; +import { firstDueAt } from "../../services/pledgeService.js"; +import { submitDonation } from "./donationController.js"; + +export const createPledge = async (req, res) => { + const { publicKey, amount, cadence, anchorDay, anchorDate, startAt } = req.body; + if (!isValidPublicKey(publicKey || "")) { + return res.status(400).json({ success: false, message: "Invalid Stellar public key" }); + } + if (!validateDonationAmount(amount)) { + return res.status(400).json({ success: false, message: "Invalid amount. Must be positive with at most 7 decimal places" }); + } + if (!["daily", "weekly", "monthly"].includes(cadence)) { + return res.status(400).json({ success: false, message: "Cadence must be daily, weekly, or monthly" }); + } + if (cadence === "weekly" && anchorDay !== undefined && (!Number.isInteger(anchorDay) || anchorDay < 0 || anchorDay > 6)) { + return res.status(400).json({ success: false, message: "anchorDay must be between 0 and 6" }); + } + if (cadence === "monthly" && anchorDate !== undefined && (!Number.isInteger(anchorDate) || anchorDate < 1 || anchorDate > 31)) { + return res.status(400).json({ success: false, message: "anchorDate must be between 1 and 31" }); + } + const effectiveStart = startAt ? new Date(startAt) : new Date(); + if (Number.isNaN(effectiveStart.getTime())) { + return res.status(400).json({ success: false, message: "Invalid startAt" }); + } + const pledge = await Pledge.create({ + user: req.user._id, + publicKey, + amount: amount.toString(), + cadence, + anchorDay: cadence === "weekly" ? (anchorDay ?? effectiveStart.getUTCDay()) : undefined, + anchorDate: cadence === "monthly" ? (anchorDate ?? effectiveStart.getUTCDate()) : undefined, + nextDueAt: firstDueAt({ cadence, anchorDay, anchorDate, startAt: effectiveStart }), + }); + res.status(201).json({ success: true, pledge }); +}; + +export const listPledges = async (req, res) => { + const pledges = await Pledge.find({ user: req.user._id }).sort({ createdAt: -1 }); + res.json({ success: true, pledges }); +}; + +export const getPledgeStats = async (req, res) => { + const pledges = await Pledge.find({ user: req.user._id }).lean(); + const totals = pledges.reduce( + (stats, pledge) => { + stats.totalPaidStroops = (BigInt(stats.totalPaidStroops) + BigInt(pledge.totalPaidStroops || "0")).toString(); + stats.longestStreak = Math.max(stats.longestStreak, pledge.longestStreak || 0); + stats.active += pledge.status === "active" ? 1 : 0; + return stats; + }, + { totalPaidStroops: "0", longestStreak: 0, active: 0 } + ); + res.json({ success: true, ...totals, pledges }); +}; + +export const updatePledgeStatus = async (req, res) => { + const { status } = req.body; + if (!["active", "paused", "cancelled"].includes(status)) { + return res.status(400).json({ success: false, message: "Invalid pledge status" }); + } + const pledge = await Pledge.findOne({ _id: req.params.id, user: req.user._id }); + if (!pledge) return res.status(404).json({ success: false, message: "Pledge not found" }); + if (pledge.status === "cancelled" && status !== "cancelled") { + return res.status(409).json({ success: false, message: "Cancelled pledges cannot be resumed" }); + } + pledge.status = status; + await pledge.save(); + res.json({ success: true, pledge }); +}; + +export const listPledgeCycles = async (req, res) => { + const pledge = await Pledge.findOne({ _id: req.params.id, user: req.user._id }); + if (!pledge) return res.status(404).json({ success: false, message: "Pledge not found" }); + const cycles = await PledgeCycle.find({ pledge: pledge._id }).sort({ dueAt: -1 }).populate("transaction"); + res.json({ success: true, cycles }); +}; + +export const initializePledgeCycle = async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + try { + const cycle = await PledgeCycle.findById(req.params.cycleId).session(session); + if (!cycle || !["due", "notified"].includes(cycle.status)) { + await session.abortTransaction(); + return res.status(404).json({ success: false, message: "Payable pledge cycle not found" }); + } + const pledge = await Pledge.findOne({ _id: cycle.pledge, user: req.user._id, status: { $ne: "cancelled" } }).session(session); + if (!pledge) { + await session.abortTransaction(); + return res.status(404).json({ success: false, message: "Pledge not found" }); + } + if (cycle.windowEndsAt <= new Date()) { + cycle.status = "lapsed"; + pledge.consecutivePaid = 0; + await Promise.all([cycle.save({ session }), pledge.save({ session })]); + await session.commitTransaction(); + return res.status(410).json({ success: false, message: "Pledge cycle payment window has ended" }); + } + if (cycle.transaction) { + const transaction = await cycle.populate("transaction"); + await session.abortTransaction(); + return res.status(409).json({ success: false, message: "Pledge cycle already initialized", donationId: transaction.transaction?._id }); + } + const intent = await createDonationIntent({ + donorId: req.user._id, + publicKey: pledge.publicKey, + amount: pledge.amount, + session, + memo: "DNB-PLEDGE", + }); + cycle.transaction = intent.transaction._id; + await cycle.save({ session }); + await session.commitTransaction(); + res.json({ + success: true, + cycleId: cycle._id, + donationId: intent.transaction._id, + transactionXdr: intent.transactionXdr, + sep7Uri: intent.sep7Uri, + networkPassphrase: intent.networkPassphrase, + }); + } catch (error) { + await session.abortTransaction(); + res.status(error.statusCode || 500).json({ success: false, message: error.message }); + } finally { + session.endSession(); + } +}; + +export const submitPledgeCycle = async (req, res) => { + const cycle = await PledgeCycle.findById(req.params.cycleId).populate("pledge"); + if (!cycle || !cycle.pledge || cycle.pledge.user.toString() !== req.user._id.toString() || !cycle.transaction) { + return res.status(404).json({ success: false, message: "Initialized pledge cycle not found" }); + } + req.body.donationId = cycle.transaction.toString(); + return submitDonation(req, res); +}; diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js index e85bc9c6..88da522b 100644 --- a/src/controllers/stellar/refundController.js +++ b/src/controllers/stellar/refundController.js @@ -321,7 +321,10 @@ export const submitRefund = async (req, res) => { await Transaction.findByIdAndUpdate( refund.originalTransaction, - { status: "refunded", refund: refund._id } + { + $set: { status: "refunded", refund: refund._id }, + $unset: { expiresAt: 1 }, // terminal state — never TTL-reapable + } ); logger.info(`Refund confirmed and access revoked atomically for refund ${refund._id}`); @@ -432,7 +435,8 @@ export const escalateDispute = async (req, res) => { await refund.save(); await Transaction.findByIdAndUpdate(refund.originalTransaction, { - status: "disputed", + $set: { status: "disputed" }, + $unset: { expiresAt: 1 }, // terminal state — never TTL-reapable }); logger.info(`Refund ${refund._id} escalated to dispute by buyer ${buyerId}`); diff --git a/src/controllers/stellar/walletController.js b/src/controllers/stellar/walletController.js index 7d47c0f2..d2c772be 100644 --- a/src/controllers/stellar/walletController.js +++ b/src/controllers/stellar/walletController.js @@ -8,6 +8,7 @@ import { import logger from "../../config/logger.js"; import { recordAudit } from "../../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; /** * Connect Stellar wallet to user profile @@ -84,6 +85,12 @@ export const connectWallet = async (req, res) => { metadata: { publicKey, network: NETWORK }, }); + await emitEvent(EVENT_TYPES.WALLET_CONNECTED, { + userId: userId.toString(), + publicKey, + network: NETWORK, + }); + res.status(200).json({ success: true, message: "Wallet connected successfully", @@ -132,6 +139,11 @@ export const disconnectWallet = async (req, res) => { metadata: { previousPublicKey }, }); + await emitEvent(EVENT_TYPES.WALLET_DISCONNECTED, { + userId: userId.toString(), + publicKey: previousPublicKey, + }); + res.status(200).json({ success: true, message: "Wallet disconnected successfully", diff --git a/src/controllers/uploadController.js b/src/controllers/uploadController.js index 902fa0d3..a78d4ddd 100644 --- a/src/controllers/uploadController.js +++ b/src/controllers/uploadController.js @@ -19,8 +19,8 @@ export const generateSignature = async (req, res) => { data: { timestamp, signature, - cloudName: config.cloud_name, - apiKey: config.api_key, + cloudName: config.cloud_name || process.env.CLOUDINARY_CLOUD_NAME || "test_cloud", + apiKey: config.api_key || process.env.CLOUDINARY_API_KEY || "test_key", } }); } catch (error) { diff --git a/src/controllers/webhookController.js b/src/controllers/webhookController.js new file mode 100644 index 00000000..c7d3f7d9 --- /dev/null +++ b/src/controllers/webhookController.js @@ -0,0 +1,308 @@ +// controllers/webhookController.js +// +// Management API for outbound webhook endpoints and their deliveries. All +// routes are admin-gated (see routes/webhookRoutes.js). The signing secret is +// returned ONLY in the create and rotate-secret responses; no read endpoint +// ever returns it. +import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import WebhookEndpoint from "../models/WebhookEndpoint.js"; +import WebhookDelivery from "../models/WebhookDelivery.js"; +import { validateWebhookUrl, assertDeliverableUrl } from "../services/webhooks/urlGuard.js"; +import { generateSecret, encryptSecret } from "../services/webhooks/webhookSecret.js"; +import { emitEventToEndpoint, EVENT_TYPES } from "../services/webhooks/webhookService.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; +import logger from "../config/logger.js"; + +const DELIVERY_STATUSES = ["pending", "retrying", "delivered", "dead"]; + +// Validate a URL structurally, then (in production) resolve DNS to reject +// private targets. Throws APIError(400) on failure. +const validateUrlOrThrow = async (url) => { + try { + validateWebhookUrl(url); + } catch (err) { + throw new APIError(err.message, 400); + } + const guard = await assertDeliverableUrl(url); + if (!guard.ok) { + throw new APIError(`Webhook URL rejected: ${guard.reason}`, 400); + } +}; + +const normalizeEvents = (events) => { + if (events === undefined) return undefined; + if (!Array.isArray(events) || events.length === 0) { + throw new APIError("`events` must be a non-empty array of event types", 400); + } + const valid = new Set([...Object.values(EVENT_TYPES), "*"]); + for (const e of events) { + if (!valid.has(e)) { + throw new APIError(`Unknown event type: ${e}`, 400); + } + } + return events; +}; + +/** + * POST /api/webhooks + * Register a new endpoint. Returns the plaintext signing secret ONCE. + */ +export const createEndpoint = catchAsync(async (req, res) => { + const { url, events, description } = req.body; + + if (!url) throw new APIError("`url` is required", 400); + await validateUrlOrThrow(url); + const normalizedEvents = normalizeEvents(events) || ["*"]; + + const secret = generateSecret(); + const endpoint = await WebhookEndpoint.create({ + url, + secretEncrypted: encryptSecret(secret), + events: normalizedEvents, + description, + owner: req.user._id, + }); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_CREATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString(), url, events: normalizedEvents }, + }); + + logger.info({ endpointId: endpoint._id.toString() }, "webhook: endpoint created"); + + // Secret shown exactly once. endpoint.toJSON() strips secretEncrypted. + res.status(201).json({ + success: true, + message: "Webhook endpoint created. Store the secret now — it is shown only once.", + endpoint, + secret, + }); +}); + +/** + * GET /api/webhooks + * List the caller's endpoints (never includes the secret). + */ +export const listEndpoints = catchAsync(async (req, res) => { + const endpoints = await WebhookEndpoint.find({ owner: req.user._id }).sort({ + createdAt: -1, + }); + res.status(200).json({ success: true, endpoints }); +}); + +const findOwnedEndpoint = async (id, ownerId) => { + const endpoint = await WebhookEndpoint.findOne({ _id: id, owner: ownerId }); + if (!endpoint) throw new APIError("Webhook endpoint not found", 404); + return endpoint; +}; + +/** + * GET /api/webhooks/:id + */ +export const getEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + res.status(200).json({ success: true, endpoint }); +}); + +/** + * PATCH /api/webhooks/:id + * Update url / events / description / isActive. Re-enabling clears the + * disabled markers. Never returns or rotates the secret. + */ +export const updateEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const { url, events, description, isActive } = req.body; + + if (url !== undefined) { + await validateUrlOrThrow(url); + endpoint.url = url; + } + if (events !== undefined) { + endpoint.events = normalizeEvents(events); + } + if (description !== undefined) { + endpoint.description = description; + } + if (isActive !== undefined) { + endpoint.isActive = Boolean(isActive); + if (isActive) { + // Re-enable: reset failure state so it isn't immediately re-disabled. + endpoint.consecutiveFailures = 0; + endpoint.disabledAt = undefined; + endpoint.disabledReason = undefined; + } + } + + await endpoint.save(); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_UPDATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ success: true, endpoint }); +}); + +/** + * DELETE /api/webhooks/:id + */ +export const deleteEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + await WebhookEndpoint.deleteOne({ _id: endpoint._id }); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_DELETED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ success: true, message: "Webhook endpoint deleted" }); +}); + +/** + * POST /api/webhooks/:id/rotate-secret + * Generate a new signing secret and return it ONCE. + */ +export const rotateSecret = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const secret = generateSecret(); + endpoint.secretEncrypted = encryptSecret(secret); + await endpoint.save(); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_SECRET_ROTATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ + success: true, + message: "Secret rotated. Store the new secret now — it is shown only once.", + secret, + }); +}); + +/** + * GET /api/webhooks/:id/deliveries?status=&page=&limit= + * Paginated, filterable delivery history for an endpoint. + */ +export const listDeliveries = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const query = { endpoint: endpoint._id }; + + if (req.query.status) { + if (!DELIVERY_STATUSES.includes(req.query.status)) { + throw new APIError(`Invalid status filter: ${req.query.status}`, 400); + } + query.status = req.query.status; + } + + const [deliveries, total] = await Promise.all([ + WebhookDelivery.find(query) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit), + WebhookDelivery.countDocuments(query), + ]); + + res.status(200).json({ + success: true, + deliveries, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); +}); + +/** + * POST /api/webhooks/:id/deliveries/:deliveryId/redeliver + * Requeue a delivery (typically a dead one) for immediate re-attempt. + */ +export const redeliver = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const delivery = await WebhookDelivery.findOne({ + _id: req.params.deliveryId, + endpoint: endpoint._id, + }); + if (!delivery) throw new APIError("Delivery not found", 404); + + if (delivery.status === "delivered") { + throw new APIError("Delivery already succeeded; nothing to redeliver", 400); + } + + // Atomic status transition via $set — avoids full-document re-validation of + // the Mixed `payload` field (Mongoose's required check trips on re-save) and + // matches the worker's claim pattern. + const requeued = await WebhookDelivery.findByIdAndUpdate( + delivery._id, + { $set: { status: "pending", nextAttemptAt: new Date() } }, + { new: true } + ); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_DELIVERY_REDELIVERED, + actor: req.user._id, + req, + targetType: "WebhookDelivery", + targetId: delivery._id.toString(), + status: "success", + metadata: { + endpointId: endpoint._id.toString(), + deliveryId: delivery._id.toString(), + eventType: delivery.eventType, + }, + }); + + res.status(200).json({ success: true, message: "Delivery requeued", delivery: requeued }); +}); + +/** + * POST /api/webhooks/:id/ping + * Emit a signed `ping` event to this endpoint for integration testing. + */ +export const pingEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + + const { eventId, delivery } = await emitEventToEndpoint( + endpoint._id, + EVENT_TYPES.PING, + { message: "ping", type: "ping" } + ); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_PING, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: delivery ? "success" : "failure", + metadata: { endpointId: endpoint._id.toString(), eventType: "ping" }, + }); + + res.status(202).json({ + success: true, + message: "Ping queued for delivery", + eventId, + deliveryId: delivery?._id, + }); +}); diff --git a/src/jobs/handlers.js b/src/jobs/handlers.js index c45d3fce..8b95234a 100644 --- a/src/jobs/handlers.js +++ b/src/jobs/handlers.js @@ -5,6 +5,7 @@ import { sendOtpEmail, sendReceiptEmail } from "../../services/emails/sendMail.j import { verifyPaymentOperations, getExplorerUrl } from "../services/stellar/stellarService.js"; import { recordSaleEarnings } from "../services/payoutService.js"; import { registerJob, enqueue } from "./queue.js"; +import { markPledgeTransactionPaid } from "../services/pledgeService.js"; const expectedPaymentsFor = (transaction) => transaction.type === "donation" @@ -48,6 +49,7 @@ registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { throw new Error(verification.reason); } transaction.status = "failed"; + transaction.expiresAt = undefined; // terminal state — never TTL-reapable transaction.failureReason = `On-chain verification failed: ${verification.reason}`; await transaction.save(); return; @@ -55,9 +57,14 @@ registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { transaction.status = "confirmed"; transaction.confirmedAt = new Date(); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable transaction.failureReason = undefined; await transaction.save(); + if (transaction.type === "donation") { + await markPledgeTransactionPaid(transaction, transaction.confirmedAt); + } + if (transaction.type === "purchase") { await recordSaleEarnings(transaction); const purchase = { purchaseDate: transaction.confirmedAt }; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js index dc62e188..de69bd79 100644 --- a/src/middlewares/authMiddleware.js +++ b/src/middlewares/authMiddleware.js @@ -27,6 +27,7 @@ export const protect = async (req, res, next) => { } req.sessionId = decoded.sessionId; + req.is2FAVerified = decoded.is2FAVerified === true; next(); } catch (error) { @@ -49,10 +50,48 @@ export const authorizeRoles = (...roles) => { message: `Forbidden: Access requires one of the following roles: ${roles.join(", ")}`, }); } + + // Enforce 2FA for admin role + if (req.user.role === "admin") { + if (!req.user.twoFactor?.enabled) { + return res.status(403).json({ + success: false, + message: "Forbidden: Admin access requires TOTP two-factor authentication to be enabled.", + }); + } + if (!req.is2FAVerified) { + return res.status(403).json({ + success: false, + message: "Forbidden: Admin access requires a 2FA-verified session.", + }); + } + } + next(); }; }; +export const require2FA = (req, res, next) => { + if (!req.user) { + return res + .status(401) + .json({ success: false, message: "Not authenticated" }); + } + if (!req.user.twoFactor?.enabled) { + return res.status(403).json({ + success: false, + message: "Two-factor authentication is required to be enabled for this action.", + }); + } + if (!req.is2FAVerified) { + return res.status(403).json({ + success: false, + message: "This action requires a 2FA-verified session.", + }); + } + next(); +}; + export const requireVerified = (req, res, next) => { if (!req.user) { return res @@ -68,5 +107,24 @@ export const requireVerified = (req, res, next) => { next(); }; +export const requireVerifiedEducator = (req, res, next) => { + if (!req.user) { + return res + .status(401) + .json({ success: false, message: "Not authenticated" }); + } + if (req.user.role === "admin") { + return next(); + } + if (!req.user.verifiedEducator) { + return res.status(403).json({ + success: false, + message: + "Forbidden: You must be a verified educator to create content. Please submit a verification application via /api/educator-verification.", + }); + } + next(); +}; + export const restrictTo = (...roles) => authorizeRoles(...roles); export const authorize = (...roles) => authorizeRoles(...roles); diff --git a/src/middlewares/authorize.js b/src/middlewares/authorize.js new file mode 100644 index 00000000..f43992e0 --- /dev/null +++ b/src/middlewares/authorize.js @@ -0,0 +1,121 @@ +// middlewares/authorize.js +// +// Centralized resource-ownership authorization layer. +// +// These guards run after `protect` (which sets req.user) and enforce that the +// authenticated user either owns the target resource or is an admin before a +// mutating handler runs. Ownership denials are written to the audit log +// (fire-and-forget) and surfaced as a 403 via the global error handler. +import { APIError, catchAsync } from "./errorHandler.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; + +/** + * Guard that enforces ownership of a top-level resource (Book, Course, Space). + * + * Loads the document by id, allows owners and admins, and denies everyone else + * with a 403 (auditing the denial). On success the loaded doc is attached as + * `req.resource` so the handler can reuse it. + * + * @param {object} opts + * @param {import("mongoose").Model} opts.model - Mongoose model to load from + * @param {string} opts.ownerField - Field holding the owner ObjectId + * @param {string} opts.resourceType - Human-readable type (e.g. "Book") + * @param {string} [opts.idParam] - req.params key for the id + */ +export const authorizeOwnership = ({ model, ownerField, resourceType, idParam = "id" }) => + catchAsync(async (req, _res, next) => { + const doc = await model.findById(req.params[idParam]); + if (!doc) { + return next(new APIError(`${resourceType} not found`, 404)); + } + + const isAdmin = req.user?.role === "admin"; + const isOwner = doc[ownerField]?.toString() === req.user._id.toString(); + + if (!isAdmin && !isOwner) { + recordAudit({ + action: AUDIT_ACTIONS.AUTHZ_OWNERSHIP_DENIED, + actor: req.user._id, + req, + targetType: resourceType, + targetId: String(doc._id), + status: "failure", + metadata: { reason: `not owner of ${resourceType}`, role: req.user.role }, + }); + return next( + new APIError(`You are not authorized to modify this ${resourceType.toLowerCase()}`, 403) + ); + } + + req.resource = doc; + next(); + }); + +/** + * Guard that enforces ownership of a review subdocument living on a parent + * item (Book or Course). Supports two shapes the review controllers accept: + * - id-scoped: /:id/reviews/:reviewId -> owner-check the named review + * - self-scoped: /:id/reviews -> operate on the caller's own review + * + * On success the parent doc is attached as `req.parentResource` and the target + * review as `req.review`. + * + * @param {object} opts + * @param {import("mongoose").Model} opts.model - Parent model (Book/Course) + * @param {string} [opts.resourceType] - Type label for audit/errors + * @param {string} [opts.idParam] - req.params key for the parent id + * @param {string} [opts.reviewParam] - req.params key for the review id + */ +export const authorizeReviewOwnership = ({ + model, + resourceType = "Review", + idParam = "id", + reviewParam = "reviewId", +}) => + catchAsync(async (req, _res, next) => { + const parent = await model.findById(req.params[idParam]); + if (!parent) { + return next(new APIError(`${resourceType} not found`, 404)); + } + + const reviewId = req.params[reviewParam]; + let review; + + if (reviewId) { + review = parent.reviews.id(reviewId); + if (!review) { + return next(new APIError("Review not found", 404)); + } + + const isAdmin = req.user?.role === "admin"; + const isOwner = review.user?.toString() === req.user._id.toString(); + + if (!isAdmin && !isOwner) { + recordAudit({ + action: AUDIT_ACTIONS.AUTHZ_OWNERSHIP_DENIED, + actor: req.user._id, + req, + targetType: resourceType, + targetId: String(review._id), + status: "failure", + metadata: { reason: `not owner of ${resourceType}`, role: req.user.role }, + }); + return next(new APIError("You are not authorized to modify this review", 403)); + } + } else { + // Self-scoped: operate on the caller's own review (owner by construction). + review = parent.reviews.find( + (r) => r.user?.toString() === req.user._id.toString() + ); + if (!review) { + return next(new APIError("Review not found", 404)); + } + } + + req.parentResource = parent; + req.review = review; + next(); + }); + +export default { authorizeOwnership, authorizeReviewOwnership }; diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index c92dd9ab..da31760f 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -1,11 +1,12 @@ import logger from "../config/logger.js"; export class APIError extends Error { - constructor(message, statusCode = 500, isOperational = true) { + constructor(message, statusCode = 500, isOperational = true, errors) { super(message); this.statusCode = statusCode; this.isOperational = isOperational; this.status = `${statusCode}`.startsWith("4") ? "fail" : "error"; + if (errors) this.errors = errors; Error.captureStackTrace(this, this.constructor); } } @@ -43,6 +44,8 @@ const sendErrorDev = (err, req, res) => { status: err.status, error: err, message: err.message, + data: null, + ...(err.errors && { errors: err.errors }), stack: err.stack, reqId: req?.id, }); @@ -58,6 +61,8 @@ const sendErrorProd = (err, req, res) => { success: false, status: err.status, message: err.message, + data: null, + ...(err.errors && { errors: err.errors }), reqId: req?.id, }); } else { diff --git a/src/middlewares/idempotency.js b/src/middlewares/idempotency.js new file mode 100644 index 00000000..31808330 --- /dev/null +++ b/src/middlewares/idempotency.js @@ -0,0 +1,133 @@ +import crypto from "crypto"; +import IdempotencyKey from "../models/IdempotencyKey.js"; +import { APIError, catchAsync } from "./errorHandler.js"; +import logger from "../config/logger.js"; + +/** + * Middleware to enforce request-level idempotency on mutating endpoints. + * + * @param {Object} options + * @param {boolean} [options.required=false] - Whether the Idempotency-Key header is mandatory for the route. + */ +export const idempotency = ({ required = false } = {}) => { + return catchAsync(async (req, res, next) => { + const rawKey = + req.headers["idempotency-key"] || req.headers["x-idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + + if (!idempotencyKey || !idempotencyKey.trim()) { + if (required) { + throw new APIError("Idempotency-Key header is required", 400); + } + return next(); + } + + const trimmedKey = idempotencyKey.trim(); + const userId = req.user?._id; + if (!userId) { + throw new APIError("Authentication required for idempotency protection", 401); + } + + const endpoint = (req.originalUrl || req.path || "").split("?")[0]; + const requestHash = crypto + .createHash("sha256") + .update(JSON.stringify(req.body || {})) + .digest("hex"); + + let record; + try { + record = await IdempotencyKey.create({ + key: trimmedKey, + userId, + endpoint, + requestHash, + status: "in_progress", + }); + } catch (err) { + if (err.code === 11000) { + const existing = await IdempotencyKey.findOne({ + key: trimmedKey, + userId, + endpoint, + }); + + if (!existing) { + throw err; + } + + if (existing.requestHash !== requestHash) { + throw new APIError("Idempotency key payload mismatch", 422); + } + + if (existing.status === "in_progress") { + throw new APIError( + "A request with this idempotency key is currently in progress", + 409 + ); + } + + if (existing.status === "completed") { + return res.status(existing.statusCode).json(existing.responseBody); + } + } + throw err; + } + + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + let captured = false; + + const captureResponse = (body) => { + if (captured) return; + captured = true; + const statusCode = res.statusCode || 200; + + if (statusCode >= 500) { + IdempotencyKey.deleteOne({ _id: record._id }).catch((e) => + logger.error({ err: e }, "Failed to delete idempotency key on server error") + ); + } else { + IdempotencyKey.updateOne( + { _id: record._id }, + { + $set: { + status: "completed", + statusCode, + responseBody: body, + }, + } + ).catch((e) => + logger.error({ err: e }, "Failed to update idempotency key to completed") + ); + } + }; + + res.json = function (body) { + captureResponse(body); + return originalJson(body); + }; + + res.send = function (body) { + if (!captured) { + let parsed = body; + if (typeof body === "string") { + try { + parsed = JSON.parse(body); + } catch (_) {} + } + captureResponse(parsed); + } + return originalSend(body); + }; + + res.on("close", () => { + if (!captured && !res.writableEnded) { + IdempotencyKey.deleteOne({ _id: record._id }).catch(() => {}); + } + }); + + next(); + }); +}; + +export default idempotency; diff --git a/src/middlewares/security.js b/src/middlewares/security.js index c8e18fb4..28eaf9fc 100644 --- a/src/middlewares/security.js +++ b/src/middlewares/security.js @@ -1,8 +1,9 @@ import helmet from "helmet"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import mongoSanitize from "express-mongo-sanitize"; import hpp from "hpp"; import logger from "../config/logger.js"; +import { verifyCaptcha } from "../utils/captcha.js"; /** * Helmet - Sets various HTTP headers for security @@ -43,8 +44,9 @@ export const apiLimiter = rateLimit({ * @param {number} defaultMax – default max requests in the window * @param {number} defaultWindow – default window in ms * @param {string} prefix – env var prefix (e.g. "RATE_LIMIT_AUTH") + * @param {object} [extra] – extra express-rate-limit options (e.g. keyGenerator) */ -function makeLimiter(defaultMax, defaultWindow, prefix) { +function makeLimiter(defaultMax, defaultWindow, prefix, extra = {}) { const max = parseInt(process.env[`${prefix}_MAX`], 10) || defaultMax; const windowMs = parseInt(process.env[`${prefix}_WINDOW_MS`], 10) || defaultWindow; @@ -62,9 +64,84 @@ function makeLimiter(defaultMax, defaultWindow, prefix) { message: "Too many requests, please try again later.", }); }, + ...extra, }); } +/** + * Normalize an email to its canonical lowercase form so rate-limit keys are + * stable regardless of case/whitespace the client sends. + */ +const normalizeEmail = (email = "") => + String(email || "").trim().toLowerCase(); + +/** + * Per-EMAIL throttle for signup and verification-resend (issue #89). + * Keyed on the normalized email address — NOT just the IP — so rotating IPs + * cannot defeat it. Unlike authLimiter, it does NOT skip in the test env, so + * the burst behavior is asserted by the test suite. Unlike authLimiter it + * counts successful requests too, since a signup/verification flood is the + * abuse being mitigated. + * + * Env overrides: RATE_LIMIT_EMAIL_AUTH_MAX, RATE_LIMIT_EMAIL_AUTH_WINDOW_MS, + * RATE_LIMIT_EMAIL_AUTH_DISABLE. + */ +export const emailAuthLimiter = makeLimiter( + 20, + 15 * 60 * 1000, + "RATE_LIMIT_EMAIL_AUTH", + { + keyGenerator: (req) => `email:${normalizeEmail(req.body?.email)}`, + }, +); + +/** + * Pluggable captcha gate (no-op when CAPTCHA_SECRET_KEY is unset). Wire onto + * /register and /resend-verification to add burst mitigation beyond the + * email limiter once a provider is configured. + */ +export const captchaGate = () => async (req, res, next) => { + const token = + req.body?.captchaToken || + req.body?.["g-recaptcha-response"] || + req.body?.["h-captcha-response"]; + const ok = await verifyCaptcha(token); + if (!ok) { + logger.warn(`Captcha verification failed for ${req.ip}`); + return res.status(400).json({ + success: false, + message: "Captcha verification failed. Please try again.", + data: null, + }); + } + next(); +}; + +/** + * Per-USER throttle for the Stellar payment endpoints (initialize/submit). + * Keyed on the authenticated user id (falling back to the IP when + * unauthenticated) so one account cannot hammer payment routes from many IPs + * and one IP cannot hammer many accounts. Stricter than the global + * generousLimiter because these routes mutate money state. Like + * emailAuthLimiter it does NOT skip in the test env, so the burst behavior is + * asserted by the test suite. + * + * Env overrides: RATE_LIMIT_PAYMENT_MAX, RATE_LIMIT_PAYMENT_WINDOW_MS, + * RATE_LIMIT_PAYMENT_DISABLE. + */ +export const paymentLimiter = makeLimiter( + 30, + 15 * 60 * 1000, + "RATE_LIMIT_PAYMENT", + { + // Per-user key when authenticated; ipKeyGenerator for the unauthenticated + // fallback so IPv6 subnets are bucketed correctly (express-rate-limit v8 + // validation requires the helper for any req.ip usage). + keyGenerator: (req) => + `payment:${req.user?._id?.toString() || ipKeyGenerator(req.ip)}`, + }, +); + /** * Moderate – for mutation endpoints (purchase, email, upload, payouts). * 100 requests per 15 minutes by default. @@ -123,6 +200,24 @@ export const refreshLimiter = rateLimit({ }, }); +/** + * Rate limiting specifically for 2FA verification routes + */ +export const twoFactorLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Limit each IP to 5 attempts + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === "test" && process.env.ENABLE_TEST_RATE_LIMIT !== "true", + handler: (req, res) => { + logger.warn(`2FA rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + success: false, + message: "Too many 2FA verification attempts, please try again later.", + }); + }, +}); + /** * MongoDB Injection Protection * Custom implementation for Express 5 compatibility @@ -229,6 +324,8 @@ export default { generousLimiter, authLimiter, refreshLimiter, + emailAuthLimiter, + captchaGate, mongoSanitizeMiddleware, hppMiddleware, customSecurityHeaders, diff --git a/src/middlewares/serviceAuth.js b/src/middlewares/serviceAuth.js new file mode 100644 index 00000000..4c4c59e4 --- /dev/null +++ b/src/middlewares/serviceAuth.js @@ -0,0 +1,150 @@ +// middlewares/serviceAuth.js +// +// Service-to-service (S2S) authentication for the AI service (dnb-ai). +// +// Instead of a single static bearer token, callers sign each request with an +// HMAC-SHA256 signature over a canonical string, using a scoped key selected by +// `kid`. This gives us: replay protection (timestamp window), constant-time +// comparison (crypto.timingSafeEqual), per-key scopes, and zero-downtime key +// rotation via overlapping active `kid`s (see config/serviceKeys.js and +// docs/service-to-service-auth.md). +// +// ── Signing contract (the dnb-ai client MUST reproduce this exactly) ───────── +// Headers sent by the caller: +// X-Service-Id logical caller id (e.g. "dnb-ai") +// X-Service-Key-Id the key id (`kid`) selecting which secret to use +// X-Timestamp Unix time in SECONDS at signing (string) +// X-Signature lowercase hex HMAC-SHA256 of the canonical string +// +// Canonical string (LF-separated, no trailing newline): +// METHOD \n PATH \n TIMESTAMP \n sha256hex(rawBody || "") +// +// METHOD = HTTP method, uppercased (e.g. "GET", "POST") +// PATH = request path exactly as sent, including any query string +// (Express req.originalUrl — e.g. "/api/internal/ai/whoami") +// TIMESTAMP = the same value sent in X-Timestamp +// rawBody = the raw request body bytes ("" for bodyless GETs) +// +// signature = HMAC_SHA256(key.secret, canonicalString) in lowercase hex +import crypto from "crypto"; +import { APIError, catchAsync } from "./errorHandler.js"; +import { getServiceKey } from "../config/serviceKeys.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; + +// Requests whose X-Timestamp is more than this many seconds away from the +// server clock (past OR future) are rejected as stale/replayed. +export const REPLAY_WINDOW_SECONDS = 300; + +/** sha256 hex of a buffer/string. */ +function sha256hex(input) { + return crypto.createHash("sha256").update(input ?? "").digest("hex"); +} + +/** + * Build the canonical string that is HMAC-signed. Exported so tests (and, by + * mirror, the dnb-ai client) can reproduce the exact byte sequence. + * + * @param {object} p + * @param {string} p.method HTTP method (any case; uppercased here) + * @param {string} p.path request path incl. query (req.originalUrl) + * @param {string|number} p.timestamp Unix seconds + * @param {Buffer|string} [p.rawBody] raw request body bytes + * @returns {string} + */ +export function buildCanonicalString({ method, path, timestamp, rawBody }) { + return [ + String(method).toUpperCase(), + path, + String(timestamp), + sha256hex(rawBody || ""), + ].join("\n"); +} + +/** Constant-time equality on two hex strings, safe on length mismatch. */ +function timingSafeEqualHex(a, b) { + const bufA = Buffer.from(String(a), "utf8"); + const bufB = Buffer.from(String(b), "utf8"); + // timingSafeEqual throws on unequal lengths, so guard first. Returning early + // on a length mismatch is safe: signatures are fixed-length hex, so an + // attacker learns nothing an equal-length compare wouldn't already leak. + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Guard a route with signed, scoped service-to-service auth. + * + * @param {object} opts + * @param {string} opts.scope the scope this route requires (e.g. "ai:read-content") + * @returns Express middleware + */ +export function requireServiceAuth({ scope } = {}) { + return catchAsync(async (req, _res, next) => { + const serviceId = req.headers["x-service-id"]; + const kid = req.headers["x-service-key-id"]; + const timestamp = req.headers["x-timestamp"]; + const signature = req.headers["x-signature"]; + + // Shared denial path: audit (fire-and-forget) then propagate an APIError. + const deny = (reason, statusCode) => { + recordAudit({ + action: AUDIT_ACTIONS.SERVICE_AUTH_DENIED, + actor: null, + req, + targetType: "Service", + targetId: serviceId || kid || "unknown", + status: "failure", + metadata: { reason, kid, scope }, + }); + return next(new APIError(reason, statusCode)); + }; + + // 1. All four headers are required. + if (!serviceId || !kid || !timestamp || !signature) { + return deny("Missing service authentication headers", 401); + } + + // 2. Reject stale / future timestamps (replay protection). + const ts = Number(timestamp); + if (!Number.isFinite(ts)) { + return deny("Invalid service authentication timestamp", 401); + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) { + return deny("Service authentication timestamp outside replay window", 401); + } + + // 3. Resolve the key by kid; unknown or retired (active:false) → 401. + const key = getServiceKey(kid); + if (!key || key.active !== true) { + return deny("Unknown or retired service key", 401); + } + + // 4. Recompute the signature and compare in constant time. + const canonical = buildCanonicalString({ + method: req.method, + path: req.originalUrl, + timestamp, + rawBody: req.rawBody, + }); + const expected = crypto + .createHmac("sha256", key.secret) + .update(canonical) + .digest("hex"); + if (!timingSafeEqualHex(signature, expected)) { + return deny("Invalid service signature", 401); + } + + // 5. Enforce scope (403 — authenticated but not permitted). + if (!scope || !Array.isArray(key.scopes) || !key.scopes.includes(scope)) { + return deny("Service key missing required scope", 403); + } + + // 6. Success — attach the authenticated service context. + req.service = { id: String(serviceId), kid, scopes: key.scopes }; + return next(); + }); +} + +export default requireServiceAuth; diff --git a/src/middlewares/validate.js b/src/middlewares/validate.js index efb19900..841768f9 100644 --- a/src/middlewares/validate.js +++ b/src/middlewares/validate.js @@ -10,11 +10,19 @@ export const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { - const errorMessages = errors.array().map((err) => err.msg); - logger.warn(`Validation failed for ${req.originalUrl}:`, errorMessages); + const validationErrors = errors + .array({ onlyFirstError: true }) + .map((err) => ({ + field: err.path || err.param || "request", + message: err.msg, + })); + logger.warn( + `Validation failed for ${req.baseUrl}${req.path}:`, + validationErrors.map(({ field, message }) => `${field}: ${message}`) + ); return next( - new APIError(`Validation Error: ${errorMessages.join(", ")}`, 400) + new APIError("Validation failed", 400, true, validationErrors) ); } @@ -60,8 +68,13 @@ export const requireFields = (fields) => { logger.warn(`Missing required fields: ${missingFields.join(", ")}`); return next( new APIError( - `Missing required fields: ${missingFields.join(", ")}`, - 400 + "Validation failed", + 400, + true, + missingFields.map((field) => ({ + field, + message: `${field} is required`, + })) ) ); } diff --git a/src/migrations/fixTtlTransactionExpiry.js b/src/migrations/fixTtlTransactionExpiry.js new file mode 100644 index 00000000..507c89b9 --- /dev/null +++ b/src/migrations/fixTtlTransactionExpiry.js @@ -0,0 +1,82 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Transaction from "../models/Transaction.js"; +import logger from "../config/logger.js"; + +dotenv.config(); + +/** + * Migration: fixTtlTransactionExpiry + * + * The `Transaction` collection previously had a blanket TTL index on + * `expiresAt` ({ expireAfterSeconds: 0 }) and a schema default that stamped a + * 30-minute expiry on every row regardless of status. Because confirm paths + * never cleared `expiresAt`, confirmed purchases/donations were reaped ~30 + * minutes after creation — deleting the proof of payment and leaving orphaned + * earnings behind. + * + * This migration: + * 1. `$unset`s `expiresAt` on every existing non-`pending` transaction so + * the TTL reaper can never touch already-confirmed (or otherwise + * terminal) rows before the index swap completes. + * 2. Drops the blanket `{ expiresAt: 1 }` TTL index (if present) and + * recreates it as a partial index scoped strictly to + * `{ status: "pending" }`, so reaping is structurally impossible for + * non-pending rows even if a future code path forgets step 1. + * + * Idempotent: running it again is a no-op for data (no non-pending rows carry + * `expiresAt`) and for the index (the partial index already matches). + */ +export const fixTtlTransactionExpiry = async () => { + const collection = Transaction.collection; + + // 1. Rescue legacy terminal rows from the TTL reaper before touching indexes. + const updateResult = await Transaction.updateMany( + { + status: { $ne: "pending" }, + expiresAt: { $exists: true, $ne: null }, + }, + { $unset: { expiresAt: 1 } } + ); + + const modifiedCount = updateResult.modifiedCount ?? updateResult.nModified ?? 0; + logger.info(`Unset expiresAt on ${modifiedCount} non-pending transaction(s).`); + + // 2. Replace the blanket TTL index with the partial-filter version. A schema + // `.index()` edit does NOT alter an already-built index, so this must be + // done explicitly. + const indexes = await collection.indexes(); + const ttlIndex = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + + const hasPendingPartialFilter = + ttlIndex?.partialFilterExpression?.status === "pending"; + + if (ttlIndex && !hasPendingPartialFilter) { + logger.info(`Dropping blanket TTL index "${ttlIndex.name}"...`); + await collection.dropIndex(ttlIndex.name); + } + + // If the correct partial index already exists this is a no-op. + await collection.createIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } } + ); + logger.info("Ensured partial TTL index scoped to status: pending."); + + return { modifiedCount }; +}; + +// Standalone CLI execution +if (process.argv[1] && process.argv[1].endsWith("fixTtlTransactionExpiry.js")) { + if (!process.env.MONGO_URI) { + throw new Error("MONGO_URI must be set to run TTL transaction expiry migration"); + } + + try { + await mongoose.connect(process.env.MONGO_URI); + const result = await fixTtlTransactionExpiry(); + console.log(`Migration complete: ${result.modifiedCount} documents updated.`); + } finally { + await mongoose.disconnect(); + } +} diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js index b7b9f133..7884f235 100644 --- a/src/models/AuditLog.js +++ b/src/models/AuditLog.js @@ -21,11 +21,23 @@ export const AUDIT_ACTIONS = Object.freeze({ AUTH_REGISTER_FAILURE: "auth.register.failure", AUTH_LOGIN_SUCCESS: "auth.login.success", AUTH_LOGIN_FAILURE: "auth.login.failure", + AUTH_ACCOUNT_LOCKED: "auth.account_locked", AUTH_LOGOUT: "auth.logout", AUTH_PASSWORD_RESET_REQUEST: "auth.password_reset.request", AUTH_PASSWORD_RESET_COMPLETE: "auth.password_reset.complete", AUTH_PASSWORD_CHANGE: "auth.password_change", + // 2FA + AUTH_2FA_SETUP_INITIATED: "auth.2fa.setup_initiated", + AUTH_2FA_ENABLE_SUCCESS: "auth.2fa.enable.success", + AUTH_2FA_ENABLE_FAILURE: "auth.2fa.enable.failure", + AUTH_2FA_LOGIN_CHALLENGE: "auth.2fa.login.challenge", + AUTH_2FA_LOGIN_SUCCESS: "auth.2fa.login.success", + AUTH_2FA_LOGIN_FAILURE: "auth.2fa.login.failure", + AUTH_2FA_DISABLE_SUCCESS: "auth.2fa.disable.success", + AUTH_2FA_DISABLE_FAILURE: "auth.2fa.disable.failure", + AUTH_2FA_RECOVERY_USED: "auth.2fa.recovery_used", + // Wallet WALLET_CONNECT_SUCCESS: "wallet.connect.success", WALLET_CONNECT_FAILURE: "wallet.connect.failure", @@ -41,10 +53,31 @@ export const AUDIT_ACTIONS = Object.freeze({ // Entitlements (access grants) ENTITLEMENT_GRANT: "entitlement.grant", + // Authorization + AUTHZ_OWNERSHIP_DENIED: "authz.ownership.denied", + + // Service-to-service auth (dnb-ai) + SERVICE_AUTH_DENIED: "service_auth.denied", + // Extension points — to be instrumented with issue #20 / #28 PAYOUT_BATCH_INITIATED: "payout.batch.initiated", PAYOUT_BATCH_CONFIRMED: "payout.batch.confirmed", ROLE_CHANGE: "role.change", + + // Educator verification pipeline (issue #92) + EDUCATOR_VERIFY_SUBMIT: "educator_verify.submit", + EDUCATOR_VERIFY_RESUBMIT: "educator_verify.resubmit", + EDUCATOR_VERIFY_APPROVE: "educator_verify.approve", + EDUCATOR_VERIFY_REJECT: "educator_verify.reject", + + // Outbound webhooks (issue #45) + WEBHOOK_ENDPOINT_CREATED: "webhook.endpoint.created", + WEBHOOK_ENDPOINT_UPDATED: "webhook.endpoint.updated", + WEBHOOK_ENDPOINT_DELETED: "webhook.endpoint.deleted", + WEBHOOK_ENDPOINT_DISABLED: "webhook.endpoint.disabled", + WEBHOOK_SECRET_ROTATED: "webhook.secret.rotated", + WEBHOOK_DELIVERY_REDELIVERED: "webhook.delivery.redelivered", + WEBHOOK_PING: "webhook.ping", }); const ACTION_VALUES = Object.values(AUDIT_ACTIONS); diff --git a/src/models/Book.js b/src/models/Book.js index 458aca70..2309cb7b 100644 --- a/src/models/Book.js +++ b/src/models/Book.js @@ -12,6 +12,7 @@ const bookSchema = new mongoose.Schema({ required: true, }, category: String, + categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true }, price: { type: Number, default: 0, diff --git a/src/models/Category.js b/src/models/Category.js new file mode 100644 index 00000000..1c40936f --- /dev/null +++ b/src/models/Category.js @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; + +const categorySchema = new mongoose.Schema( + { + name: { type: String, required: true, trim: true, unique: true }, + slug: { type: String, required: true, trim: true, lowercase: true, unique: true, index: true }, + description: { type: String, default: "" }, + icon: { type: String, default: "" }, + image: { type: String, default: "" }, + parent: { type: mongoose.Schema.Types.ObjectId, ref: "Category", default: null }, + order: { type: Number, default: 0 }, + isActive: { type: Boolean, default: true, index: true }, + }, + { timestamps: true } +); + +categorySchema.index({ parent: 1, order: 1 }); + +export default mongoose.model("Category", categorySchema); diff --git a/src/models/Course.js b/src/models/Course.js index 57847a9f..000d962e 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -16,6 +16,7 @@ const courseSchema = new mongoose.Schema( type: String, required: true, }, + categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true }, thumbnail: { type: String, // image URL }, @@ -95,4 +96,3 @@ const courseSchema = new mongoose.Schema( courseSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); courseSchema.index({ rating: -1 }); export default mongoose.model("Course", courseSchema); - diff --git a/src/models/EducatorVerification.js b/src/models/EducatorVerification.js new file mode 100644 index 00000000..c3cefabe --- /dev/null +++ b/src/models/EducatorVerification.js @@ -0,0 +1,138 @@ +import mongoose from "mongoose"; + +export const VERIFICATION_STATUS = Object.freeze({ + DRAFT: "draft", + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", +}); + +export const LEGAL_TRANSITIONS = Object.freeze({ + [VERIFICATION_STATUS.DRAFT]: [VERIFICATION_STATUS.PENDING], + [VERIFICATION_STATUS.PENDING]: [ + VERIFICATION_STATUS.APPROVED, + VERIFICATION_STATUS.REJECTED, + ], + [VERIFICATION_STATUS.APPROVED]: [], + [VERIFICATION_STATUS.REJECTED]: [VERIFICATION_STATUS.PENDING], +}); + +const STATUS_VALUES = Object.values(VERIFICATION_STATUS); + +const documentSchema = new mongoose.Schema( + { + type: { + type: String, + required: [true, "Document type is required"], + enum: [ + "government_id", + "teaching_certificate", + "degree", + "work_sample", + "other", + ], + }, + cloudinaryPublicId: { + type: String, + required: [true, "Document cloudinaryPublicId is required"], + }, + originalFileName: { + type: String, + required: [true, "Document originalFileName is required"], + }, + uploadedAt: { + type: Date, + default: Date.now, + }, + }, + { _id: false, versionKey: false } +); + +const educatorVerificationSchema = new mongoose.Schema( + { + applicant: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: [true, "Applicant is required"], + index: true, + }, + + status: { + type: String, + enum: STATUS_VALUES, + default: VERIFICATION_STATUS.DRAFT, + required: [true, "Status is required"], + index: true, + }, + + documents: { + type: [documentSchema], + default: [], + }, + + personalStatement: { + type: String, + maxlength: 2000, + default: null, + }, + + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, + + reviewNotes: { + type: String, + maxlength: 2000, + default: null, + }, + + submittedAt: { + type: Date, + default: null, + }, + + reviewedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true, versionKey: false } +); + +educatorVerificationSchema.index( + { applicant: 1, status: 1 }, + { unique: true, partialFilterExpression: { status: { $in: ["draft", "pending"] } } } +); + +educatorVerificationSchema.statics.isValidTransition = function (from, to) { + const allowed = LEGAL_TRANSITIONS[from]; + return Array.isArray(allowed) && allowed.includes(to); +}; + +educatorVerificationSchema.methods.canTransitionTo = function (targetStatus) { + return this.constructor.isValidTransition(this.status, targetStatus); +}; + +educatorVerificationSchema.pre("save", function (next) { + if (!this.isModified("status")) return next(); + if (this.isNew) return next(); + + const prev = this.modifiedPaths().includes("status") + ? this.$locals.previousStatus + : null; + next(); +}); + +educatorVerificationSchema.pre("findOneAndUpdate", function (next) { + const update = this.getUpdate(); + const nextStatus = update?.$set?.status ?? update?.status; + if (!nextStatus) return next(); + next(); +}); + +export default mongoose.model( + "EducatorVerification", + educatorVerificationSchema +); diff --git a/src/models/GiftClaim.js b/src/models/GiftClaim.js new file mode 100644 index 00000000..5927ec57 --- /dev/null +++ b/src/models/GiftClaim.js @@ -0,0 +1,113 @@ +// models/GiftClaim.js +// +// A gift of a course/book paid via a Stellar claimable balance. The sender +// creates an on-ledger balance the recipient can claim whenever they're ready +// (trustline-free), with a reclaim-after-expiry predicate so funds are never +// stranded. Access to the item is granted to the RECIPIENT on claim, never the +// sender. +// +// NOTE: unlike Transaction.expiresAt, this schema deliberately has NO TTL +// index. A gift record must survive past its expiry so the sender can still +// fetch a reclaim XDR afterward — the expiry transition only flips `status` +// to "expired" and never deletes the document. +import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; + +const giftClaimSchema = new mongoose.Schema( + { + // Who funded the balance (the payer). + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + // Who receives the item (the beneficiary). Deliberately distinct from the + // sender — access is granted to this user on claim. + recipient: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + recipientWallet: { + type: String, + required: true, + }, + // The item's creator (for display only — the creator is not paid through + // the claimable-balance path; that stays out of scope). + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + itemType: { + type: String, + enum: ["book", "course"], + required: true, + }, + itemId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + refPath: "itemTypeModel", + }, + itemTypeModel: { + type: String, + enum: ["Book", "Course"], + required: true, + }, + itemTitle: { + type: String, + required: true, + }, + // Amount stored as string to preserve precision (USDC only). + amount: { + type: String, + required: true, + }, + assetCode: { + type: String, + default: "USDC", + enum: getSupportedCodes(), + }, + // The REAL claimable-balance id (hex-encoded XDR of ClaimableBalanceId), + // resolved from the create transaction's result — NOT the tx hash. + // Unique + sparse because it is only known after the create tx lands. + balanceId: { + type: String, + unique: true, + sparse: true, + }, + status: { + type: String, + enum: ["pending_signature", "open", "claimed", "reclaimed", "expired"], + default: "pending_signature", + index: true, + }, + // Predicate expiry for the balance. Sender reclaims after this instant. + // No TTL index — see comment at top of file. + expiresAt: { + type: Date, + required: true, + }, + createTxHash: { + type: String, + }, + claimTxHash: { + type: String, + }, + network: { + type: String, + enum: ["testnet", "mainnet"], + required: true, + }, + }, + { timestamps: true } +); + +giftClaimSchema.index({ sender: 1, status: 1 }); +giftClaimSchema.index({ recipient: 1, status: 1 }); +giftClaimSchema.index({ recipient: 1, itemType: 1, itemId: 1 }); + +export default mongoose.model("GiftClaim", giftClaimSchema); diff --git a/src/models/IdempotencyKey.js b/src/models/IdempotencyKey.js new file mode 100644 index 00000000..b2550ad6 --- /dev/null +++ b/src/models/IdempotencyKey.js @@ -0,0 +1,52 @@ +import mongoose from "mongoose"; + +const idempotencyKeySchema = new mongoose.Schema( + { + key: { + type: String, + required: true, + trim: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + endpoint: { + type: String, + required: true, + trim: true, + }, + requestHash: { + type: String, + required: true, + }, + statusCode: { + type: Number, + }, + responseBody: { + type: mongoose.Schema.Types.Mixed, + }, + status: { + type: String, + enum: ["in_progress", "completed"], + default: "in_progress", + index: true, + }, + createdAt: { + type: Date, + default: Date.now, + expires: 86400, // 24-hour TTL index + }, + }, + { timestamps: true } +); + +// Unique compound index on { key, userId, endpoint } for concurrency lock +idempotencyKeySchema.index( + { key: 1, userId: 1, endpoint: 1 }, + { unique: true } +); + +export default mongoose.model("IdempotencyKey", idempotencyKeySchema); diff --git a/src/models/Notification.js b/src/models/Notification.js index e76cd33f..f1bb95ca 100644 --- a/src/models/Notification.js +++ b/src/models/Notification.js @@ -26,6 +26,7 @@ const notificationSchema = new mongoose.Schema( "system", // System notification "welcome", // Welcome notification "recommendation", // New recommendation + "pledge_due", // Recurring sadaqah cycle ready to sign ], required: true, }, @@ -55,6 +56,8 @@ const notificationSchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, ref: "Reel", }, + pledgeId: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge" }, + pledgeCycleId: { type: mongoose.Schema.Types.ObjectId, ref: "PledgeCycle" }, commentId: String, // Any other relevant data }, diff --git a/src/models/Pledge.js b/src/models/Pledge.js new file mode 100644 index 00000000..4cfa7986 --- /dev/null +++ b/src/models/Pledge.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; + +const pledgeSchema = new mongoose.Schema( + { + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, + publicKey: { type: String, required: true }, + amount: { type: String, required: true }, + cadence: { type: String, enum: ["daily", "weekly", "monthly"], required: true }, + anchorDay: { type: Number, min: 0, max: 6 }, + anchorDate: { type: Number, min: 1, max: 31 }, + status: { type: String, enum: ["active", "paused", "cancelled"], default: "active", index: true }, + nextDueAt: { type: Date, required: true, index: true }, + consecutivePaid: { type: Number, default: 0 }, + longestStreak: { type: Number, default: 0 }, + totalPaidStroops: { type: String, default: "0" }, + lastPaidAt: Date, + schedulerLockUntil: Date, + }, + { timestamps: true } +); + +pledgeSchema.index({ status: 1, nextDueAt: 1 }); +export default mongoose.model("Pledge", pledgeSchema); diff --git a/src/models/PledgeCycle.js b/src/models/PledgeCycle.js new file mode 100644 index 00000000..2e0ea655 --- /dev/null +++ b/src/models/PledgeCycle.js @@ -0,0 +1,15 @@ +import mongoose from "mongoose"; + +const pledgeCycleSchema = new mongoose.Schema( + { + pledge: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge", required: true, index: true }, + dueAt: { type: Date, required: true }, + status: { type: String, enum: ["due", "notified", "paid", "skipped", "lapsed"], default: "due", index: true }, + transaction: { type: mongoose.Schema.Types.ObjectId, ref: "Transaction", default: null }, + windowEndsAt: { type: Date, required: true, index: true }, + }, + { timestamps: true } +); + +pledgeCycleSchema.index({ pledge: 1, dueAt: 1 }, { unique: true }); +export default mongoose.model("PledgeCycle", pledgeCycleSchema); diff --git a/src/models/Session.js b/src/models/Session.js index 6f0b49da..cf69e76c 100644 --- a/src/models/Session.js +++ b/src/models/Session.js @@ -40,6 +40,10 @@ const sessionSchema = new mongoose.Schema( type: Date, default: Date.now, }, + is2FAVerified: { + type: Boolean, + default: false, + }, }, { timestamps: true } ); diff --git a/src/models/SponsorshipSpend.js b/src/models/SponsorshipSpend.js new file mode 100644 index 00000000..8ba9da69 --- /dev/null +++ b/src/models/SponsorshipSpend.js @@ -0,0 +1,43 @@ +// models/SponsorshipSpend.js +// +// Durable spend accounting for fee-bump sponsorship (#30). One document per +// UTC day tracks how much XLM (in stroops) the platform sponsor account has +// spent on network fees and how many sponsored transactions each user has +// been granted, so the per-day total cap and per-user daily count cap can be +// enforced across process restarts and horizontal replicas. +import mongoose from "mongoose"; + +const sponsorshipSpendSchema = new mongoose.Schema( + { + // UTC calendar day, formatted YYYY-MM-DD. Unique so `$inc` upserts race + // safely on a single row per day. + day: { + type: String, + required: true, + unique: true, + index: true, + }, + // Total XLM fees paid by the sponsor account today, in stroops. Daily caps + // are small (well under Number.MAX_SAFE_INTEGER), so a Number here keeps + // atomic `$inc` accounting simple without BigInt gymnastics. + totalStroops: { + type: Number, + default: 0, + }, + // Count of transactions sponsored today (across all users). + sponsoredCount: { + type: Number, + default: 0, + }, + // Per-user sponsored-transaction counts for today, keyed by user id string. + // Enforces FEE_SPONSOR_PER_USER_DAILY_LIMIT. + userCounts: { + type: Map, + of: Number, + default: () => new Map(), + }, + }, + { timestamps: true } +); + +export default mongoose.model("SponsorshipSpend", sponsorshipSpendSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index f8b52be2..887e472e 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -1,16 +1,31 @@ // models/Transaction.js import mongoose from "mongoose"; import { getSupportedCodes } from "../config/assets.js"; +import { notifyPaymentStatus } from "../sockets/paymentNotifier.js"; const transactionSchema = new mongoose.Schema( { // Transaction identification stellarTxHash: { type: String, - required: true, + sparse: true, unique: true, index: true, }, + expectedHash: { + type: String, + index: true, + }, + // The unsigned XDR returned at initialize, persisted so a duplicate + // initialize for the same pending checkout can replay the exact same + // transaction to sign (idempotent initialize). Never used for + // verification — only for replay of the pending record. + unsignedXdr: { + type: String, + }, + memo: { + type: String, + }, stellarLedger: { type: Number, }, @@ -114,6 +129,25 @@ const transactionSchema = new mongoose.Schema( default: "direct", index: true, }, + // Fee-bump sponsorship (#30): set only when the platform paid this + // transaction's network fee via a fee-bump wrapper. Absent/false means the + // user paid their own fee (the default, unchanged flow). + sponsored: { + type: Boolean, + default: false, + }, + // Actual XLM fee (in stroops) the sponsor account paid, taken from the + // Horizon submit response `fee_charged`. Stored as a string to stay + // consistent with the precision-preserving `amount` field. + sponsorFeeCharged: { + type: String, + }, + // Horizon returns the fee-bump (outer) transaction hash; `stellarTxHash` + // continues to hold the inner-transaction hash (which matches + // `expectedHash` from initialize), so both are recorded for a sponsored row. + feeBumpTxHash: { + type: String, + }, // Status tracking status: { type: String, @@ -140,15 +174,60 @@ const transactionSchema = new mongoose.Schema( confirmedAt: Date, expiresAt: { type: Date, - default: () => new Date(Date.now() + 30 * 60 * 1000), // 30 minutes + // Only abandoned `pending` checkouts get a reaping deadline. Records + // created directly in a terminal state (e.g. worker-created confirmed + // donations/purchases) must never be born with an expiry. + default: function () { + return this.status === "pending" || !this.status + ? new Date(Date.now() + 30 * 60 * 1000) // 30 minutes + : undefined; + }, }, }, { timestamps: true } ); + +// Terminal statuses are permanent records (paid purchases, donations, refunds, +// disputes, failures) that must never be reaped by the TTL monitor. +const TERMINAL_STATUSES = ["confirmed", "failed", "expired", "refunded", "disputed"]; + +// TTL invariant: `expiresAt` is only meaningful for abandoned `pending` +// checkouts. Enforce it at the schema level so a future code path that forgets +// to clear `expiresAt` cannot regress confirmed/terminal rows back into the +// TTL reaper's window — defense in depth on top of the partial index below. +transactionSchema.pre("save", function (next) { + if (TERMINAL_STATUSES.includes(this.status)) { + this.expiresAt = undefined; + } + + // Remember whether this save is worth a realtime push (creation or any + // status transition). Read back by the post-save hook below; mongoose + // clears its modified-path tracking after save completes, so the flag is + // captured here while it is still reliable. + this.$locals.__statusChanged = this.isNew || this.isModified("status"); + next(); +}); + +// Realtime payment notifications: push status transitions to subscribed +// websocket clients (see src/sockets/). Best-effort — a gateway outage must +// never fail a save. Covers every code path that persists through `save()` +// (donation controller, reconciliation service, ingestion worker). +transactionSchema.post("save", function (doc, next) { + if (doc.$locals?.__statusChanged) { + Promise.resolve(notifyPaymentStatus(doc)).catch(() => {}); + } + next(); +}); + // Indexes for efficient queries transactionSchema.index({ buyer: 1, status: 1 }); transactionSchema.index({ creator: 1, status: 1 }); transactionSchema.index({ itemType: 1, itemId: 1 }); transactionSchema.index({ type: 1, status: 1, createdAt: -1 }); // Donation stats -transactionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL for expired pending +// TTL for expired pending checkouts only — a blanket index would also reap +// confirmed purchases/donations once their original 30-minute expiry passes. +transactionSchema.index( + { expiresAt: 1 }, + { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } } +); export default mongoose.model("Transaction", transactionSchema); \ No newline at end of file diff --git a/src/models/User.js b/src/models/User.js index e377b178..db15358b 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -53,16 +53,52 @@ const userSchema = new mongoose.Schema( type: Boolean, default: false, }, + verifiedEducator: { + type: Boolean, + default: false, + }, lastLogin: { type: Date, }, + // Progressive login lockout (issue #89): consecutive failures increment + // failedLoginAttempts; after the env-configurable threshold the account is + // temporarily locked until lockUntil. Reset to 0 / null on successful login. + failedLoginAttempts: { + type: Number, + default: 0, + }, + lockUntil: { + type: Date, + default: null, + }, resetTokenHash: { type: String, }, resetTokenExpiry: { type: Date, }, + twoFactor: { + enabled: { + type: Boolean, + default: false, + }, + secret: { + type: String, + select: false, + }, + pendingSecret: { + type: String, + select: false, + }, + recoveryCodes: { + type: [String], + select: false, + }, + enrolledAt: { + type: Date, + }, + }, // Follow system following: [ { diff --git a/src/models/WebhookDelivery.js b/src/models/WebhookDelivery.js new file mode 100644 index 00000000..e307e22b --- /dev/null +++ b/src/models/WebhookDelivery.js @@ -0,0 +1,79 @@ +// models/WebhookDelivery.js +// +// One row per (event, subscribed endpoint). ALL scheduling state lives in the +// document (status, attemptCount, nextAttemptAt) rather than in worker memory, +// so the delivery loop can later be swapped onto the durable job queue (issue +// #32) without a schema change. `nextAttemptAt` is indexed for the claim query. +import mongoose from "mongoose"; + +// Bound the stored attempt history and per-attempt error text so a flapping +// consumer can't grow a document unbounded. +export const MAX_STORED_ATTEMPTS = 20; +export const MAX_ERROR_LENGTH = 500; + +const attemptSchema = new mongoose.Schema( + { + at: { type: Date, default: Date.now }, + statusCode: Number, + error: String, + durationMs: Number, + }, + { _id: false } +); + +const webhookDeliverySchema = new mongoose.Schema( + { + endpoint: { + type: mongoose.Schema.Types.ObjectId, + ref: "WebhookEndpoint", + required: true, + index: true, + }, + // Stable per-event id used by consumers for idempotency. Shared across the + // fan-out of one event to multiple endpoints. + eventId: { + type: String, + required: true, + index: true, + }, + eventType: { + type: String, + required: true, + }, + // Frozen event envelope ({ eventId, type, createdAt, apiVersion, data }). + // Serialized ONCE at delivery time so the signed bytes match the sent body. + payload: { + type: mongoose.Schema.Types.Mixed, + required: true, + }, + attempts: { + type: [attemptSchema], + default: [], + }, + status: { + type: String, + enum: ["pending", "retrying", "delivered", "dead"], + default: "pending", + index: true, + }, + attemptCount: { + type: Number, + default: 0, + }, + // When the delivery becomes eligible for its next attempt. Indexed and + // used by the atomic claim query. + nextAttemptAt: { + type: Date, + default: Date.now, + index: true, + }, + deliveredAt: Date, + lastError: String, + }, + { timestamps: true } +); + +// Compound index backing the worker's claim query. +webhookDeliverySchema.index({ status: 1, nextAttemptAt: 1 }); + +export default mongoose.model("WebhookDelivery", webhookDeliverySchema); diff --git a/src/models/WebhookEndpoint.js b/src/models/WebhookEndpoint.js new file mode 100644 index 00000000..9a9b0a57 --- /dev/null +++ b/src/models/WebhookEndpoint.js @@ -0,0 +1,74 @@ +// models/WebhookEndpoint.js +// +// A registered outbound webhook subscription. The signing `secret` is stored +// ENCRYPTED at rest (AES-256-GCM via services/webhooks/webhookSecret.js) — the +// delivery worker must recover the plaintext to sign each request, so a +// one-way hash cannot be used. The encrypted field is `select:false` so it is +// never returned by an accidental find(); read endpoints strip it explicitly. +import mongoose from "mongoose"; + +const webhookEndpointSchema = new mongoose.Schema( + { + // Destination URL. Validated (https-only outside development, no private + // targets) by services/webhooks/urlGuard.js at registration and delivery. + url: { + type: String, + required: true, + trim: true, + }, + // AES-256-GCM ciphertext (`iv:authTag:ciphertext`, hex). Never selected by + // default; never returned by the API after creation/rotation. + secretEncrypted: { + type: String, + required: true, + select: false, + }, + // Subscribed event types. `["*"]` subscribes to everything. + events: { + type: [String], + default: ["*"], + }, + isActive: { + type: Boolean, + default: true, + index: true, + }, + description: { + type: String, + trim: true, + maxlength: 500, + }, + // Count of CONSECUTIVE dead deliveries. Reset to 0 on any successful + // delivery. When it reaches the auto-disable threshold the endpoint is + // deactivated by the delivery worker. + consecutiveFailures: { + type: Number, + default: 0, + }, + lastDeliveryAt: Date, + lastSuccessAt: Date, + disabledAt: Date, + disabledReason: String, + // The admin who registered the endpoint. + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + }, + { timestamps: true } +); + +webhookEndpointSchema.index({ owner: 1, createdAt: -1 }); + +// Defense in depth: never leak the encrypted secret through toJSON/toObject, +// even if a caller forgot to `.select("-secretEncrypted")`. +const stripSecret = (_doc, ret) => { + delete ret.secretEncrypted; + return ret; +}; +webhookEndpointSchema.set("toJSON", { transform: stripSecret }); +webhookEndpointSchema.set("toObject", { transform: stripSecret }); + +export default mongoose.model("WebhookEndpoint", webhookEndpointSchema); diff --git a/src/routes/admin/educatorVerificationAdminRoutes.js b/src/routes/admin/educatorVerificationAdminRoutes.js new file mode 100644 index 00000000..b696d152 --- /dev/null +++ b/src/routes/admin/educatorVerificationAdminRoutes.js @@ -0,0 +1,24 @@ +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { + listApplications, + getApplicationById, + getAdminDocumentSignedUrl, + approveApplication, + rejectApplication, +} from "../../controllers/admin/educatorVerificationAdminController.js"; + +const router = express.Router(); + +router.use(protect, authorizeRoles("admin")); + +router.get("/", listApplications); +router.get("/:id", getApplicationById); +router.get( + "/:id/documents/:documentIndex/signed-url", + getAdminDocumentSignedUrl +); +router.post("/:id/approve", approveApplication); +router.post("/:id/reject", rejectApplication); + +export default router; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index b5a952fb..ff69626c 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -13,23 +13,63 @@ import { changePassword, verifyEmail, resendVerification, + setup2FA, + verify2FA, + disable2FA, } from "../controllers/authController.js"; import { getStellarChallenge, verifyStellarChallenge, } from "../controllers/stellar/sep10Controller.js"; import { protect } from "../middlewares/authMiddleware.js"; -import { refreshLimiter } from "../middlewares/security.js"; +import { + refreshLimiter, + twoFactorLimiter, + emailAuthLimiter, + captchaGate, +} from "../middlewares/security.js"; +import { validate } from "../middlewares/validate.js"; +import { + registerValidation, + loginValidation, +} from "../validators/requestValidators.js"; const router = express.Router(); -// Public routes with auth rate limit -router.post("/register", registerUser); -router.post("/login", loginUser); +// Public routes with auth rate limit. +// /register and /resend-verification also carry a per-EMAIL limiter (survives +// IP rotation) plus a pluggable captcha gate (no-op when unconfigured) — +// see issue #89. +router.post( + "/register", + emailAuthLimiter, + captchaGate(), + registerValidation, + validate, + registerUser +); +router.post("/login", loginValidation, validate, loginUser); router.post("/request-password-reset", requestPasswordReset); router.post("/reset-password", resetPassword); router.get("/verify-email/:token", verifyEmail); -router.post("/resend-verification", resendVerification); +router.post( + "/resend-verification", + emailAuthLimiter, + captchaGate(), + resendVerification +); + +// 2FA Routes +router.post("/2fa/setup", protect, twoFactorLimiter, setup2FA); +router.post("/2fa/verify", twoFactorLimiter, (req, res, next) => { + // If authorization header is provided and no mfaToken, pass through protect middleware first + if (req.headers.authorization && !req.body.mfaToken) { + return protect(req, res, next); + } + next(); +}, verify2FA); +router.post("/2fa/login", twoFactorLimiter, verify2FA); +router.post("/2fa/disable", protect, twoFactorLimiter, disable2FA); // Stellar SEP-10 Web Authentication ("Sign in with Stellar"). Returns 503 when // the feature is unconfigured (SEP10_SIGNING_SECRET/domains unset). See #25. diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js index 56b3a833..d4a4a5b5 100644 --- a/src/routes/books/bookRoutes.js +++ b/src/routes/books/bookRoutes.js @@ -19,7 +19,12 @@ import { checkIfBookBookmarked, removeBookBookmark, } from "../../controllers/books/bookmarkBookController.js"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../../middlewares/authorize.js"; +import Book from "../../models/Book.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -38,6 +43,7 @@ const booksByAuthorCacheKey = (req) => router.post( "/", protect, + requireVerifiedEducator, uploadBook.fields([ { name: "thumbnail", maxCount: 1 }, { name: "file", maxCount: 1 }, @@ -84,6 +90,7 @@ router.get( router.delete( "/:id", protect, + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`, `${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.EDUCATORS}*`]), deleteBook ); @@ -98,36 +105,42 @@ router.post( router.put( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.patch( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.put( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.patch( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.delete( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), deleteBookReview ); router.delete( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), deleteBookReview ); diff --git a/src/routes/categoryRoutes.js b/src/routes/categoryRoutes.js new file mode 100644 index 00000000..b8bde138 --- /dev/null +++ b/src/routes/categoryRoutes.js @@ -0,0 +1,13 @@ +import express from "express"; +import { authorizeRoles, protect } from "../middlewares/authMiddleware.js"; +import { cacheMiddleware, invalidateCacheMiddleware } from "../middlewares/cache.js"; +import { CACHE_TTL } from "../utils/cache.js"; +import { createCategory, deleteCategory, getCategory, listCategories, updateCategory } from "../controllers/categoryController.js"; + +const router = express.Router(); +router.get("/", cacheMiddleware(CACHE_TTL.COURSES, () => "categories:list"), listCategories); +router.get("/:slug", cacheMiddleware(CACHE_TTL.COURSES, (req) => `categories:${req.originalUrl}`), getCategory); +router.post("/", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), createCategory); +router.patch("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), updateCategory); +router.delete("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), deleteCategory); +export default router; diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index 57c46a1b..9c709e3d 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -22,7 +22,12 @@ import { getCourseProgress, updateCourseProgress, } from "../../controllers/analytics/analyticsController.js"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../../middlewares/authorize.js"; +import Course from "../../models/Course.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -32,7 +37,7 @@ import { CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; const router = express.Router(); // Cache key generators -const coursesListCacheKey = () => `${CACHE_KEYS.COURSES}list`; +const coursesListCacheKey = (req) => `${CACHE_KEYS.COURSES}list:${req.query.category || "all"}`; const courseDetailCacheKey = (req) => `${CACHE_KEYS.COURSE}${req.params.id}`; const coursesByUserCacheKey = (req) => `${CACHE_KEYS.COURSES}user:${req.query.createdBy}`; @@ -72,6 +77,7 @@ router.get( router.post( "/", protect, + requireVerifiedEducator, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createCourse ); @@ -90,45 +96,51 @@ router.post( router.put( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.patch( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.put( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.patch( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.delete( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), deleteCourseReview ); router.delete( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), deleteCourseReview ); router.put( "/:id", protect, + authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]), updateCourse ); export default router; - diff --git a/src/routes/educatorVerificationRoutes.js b/src/routes/educatorVerificationRoutes.js new file mode 100644 index 00000000..2f2dac2b --- /dev/null +++ b/src/routes/educatorVerificationRoutes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + getMyApplication, + submitApplication, + getDocumentSignedUrl, + generateUploadSignature, +} from "../controllers/educatorVerificationController.js"; + +const router = express.Router(); + +router.use(protect); + +router.get("/", getMyApplication); +router.get("/documents/:documentIndex/signed-url", getDocumentSignedUrl); +router.get("/upload-signature", generateUploadSignature); +router.post("/submit", submitApplication); + +export default router; diff --git a/src/routes/internal/aiRoutes.js b/src/routes/internal/aiRoutes.js new file mode 100644 index 00000000..ccdfc42a --- /dev/null +++ b/src/routes/internal/aiRoutes.js @@ -0,0 +1,27 @@ +// routes/internal/aiRoutes.js +// +// Internal, service-to-service routes callable ONLY by the AI service (dnb-ai) +// over the signed-request channel. Every route here is guarded by +// requireServiceAuth with an explicit scope — there is no end-user JWT path in. +// See docs/service-to-service-auth.md for the signing contract. +import express from "express"; +import { requireServiceAuth } from "../../middlewares/serviceAuth.js"; + +const router = express.Router(); + +// GET /api/internal/ai/whoami +// Reflects the authenticated service identity — a genuine, mountable endpoint a +// reviewer (or the dnb-ai client) can hit to confirm its credentials work. +router.get( + "/whoami", + requireServiceAuth({ scope: "ai:read-content" }), + (req, res) => { + res.json({ + success: true, + service: req.service, + timestamp: new Date().toISOString(), + }); + } +); + +export default router; diff --git a/src/routes/jobsRoutes.js b/src/routes/jobsRoutes.js index ef55df21..25b45fa3 100644 --- a/src/routes/jobsRoutes.js +++ b/src/routes/jobsRoutes.js @@ -1,7 +1,18 @@ import express from "express"; +import crypto from "crypto"; import Job from "../models/Job.js"; const router = express.Router(); + +// Constant-time bearer-token comparison. timingSafeEqual throws on unequal +// lengths, so guard first — a length mismatch is simply a non-match and must +// not short-circuit through a timing side channel. +const safeTokenEqual = (a, b) => { + const bufA = Buffer.from(String(a), "utf8"); + const bufB = Buffer.from(String(b), "utf8"); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +}; const escapeHtml = (value) => String(value) .replaceAll("&", "&") @@ -13,7 +24,8 @@ const escapeHtml = (value) => router.use((req, res, next) => { const token = process.env.JOBS_DASHBOARD_TOKEN; if (!token) return res.status(404).json({ success: false, message: "Not found" }); - if (req.headers.authorization !== `Bearer ${token}`) { + const provided = req.headers.authorization || ""; + if (!safeTokenEqual(provided, `Bearer ${token}`)) { return res.status(401).json({ success: false, message: "Unauthorized" }); } next(); diff --git a/src/routes/payoutRoutes.js b/src/routes/payoutRoutes.js index cbb1a31c..25e38b93 100644 --- a/src/routes/payoutRoutes.js +++ b/src/routes/payoutRoutes.js @@ -1,6 +1,7 @@ // routes/payoutRoutes.js import express from "express"; import { protect } from "../middlewares/authMiddleware.js"; +import { idempotency } from "../middlewares/idempotency.js"; import { buildBatch, submitBatch, @@ -20,7 +21,7 @@ router.get("/me/statement", getMyStatement); router.get("/me/history", getMyHistory); // Operator endpoints (gated by PAYOUT_ADMIN_USER_IDS allowlist in controller) -router.post("/build", buildBatch); -router.post("/:batchId/submit", submitBatch); +router.post("/build", idempotency(), buildBatch); +router.post("/:batchId/submit", idempotency(), submitBatch); export default router; diff --git a/src/routes/spaceRoutes.js b/src/routes/spaceRoutes.js index 92f08b71..934530b1 100644 --- a/src/routes/spaceRoutes.js +++ b/src/routes/spaceRoutes.js @@ -1,5 +1,7 @@ import express from "express"; -import { protect } from "../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../middlewares/authMiddleware.js"; +import { authorizeOwnership } from "../middlewares/authorize.js"; +import Space from "../models/Space.js"; import upload from "../middlewares/upload.js"; import { cacheMiddleware, @@ -50,6 +52,7 @@ router.get( router.post( "/", protect, + requireVerifiedEducator, upload.fields([{ name: "thumbnail", maxCount: 1 }]), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createSpace @@ -67,6 +70,7 @@ router.post( router.put( "/update/:id", protect, + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`]), updateSpace ); @@ -75,6 +79,7 @@ router.put( router.delete( "/:id", protect, + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`, `${CACHE_KEYS.EDUCATORS}*`]), deleteSpace ); diff --git a/src/routes/stellar/donationRoutes.js b/src/routes/stellar/donationRoutes.js index c44a54a9..354d4883 100644 --- a/src/routes/stellar/donationRoutes.js +++ b/src/routes/stellar/donationRoutes.js @@ -1,6 +1,7 @@ // routes/stellar/donationRoutes.js import express from "express"; import { protect } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; import { initializeDonation, submitDonation, @@ -13,7 +14,7 @@ const router = express.Router(); router.get("/stats", getDonationStats); // Protected routes (require authentication) -router.post("/initialize", protect, initializeDonation); -router.post("/submit", protect, submitDonation); +router.post("/initialize", protect, idempotency(), initializeDonation); +router.post("/submit", protect, idempotency(), submitDonation); export default router; diff --git a/src/routes/stellar/giftRoutes.js b/src/routes/stellar/giftRoutes.js new file mode 100644 index 00000000..b7906f58 --- /dev/null +++ b/src/routes/stellar/giftRoutes.js @@ -0,0 +1,30 @@ +// routes/stellar/giftRoutes.js +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { + initializeGift, + submitGift, + listGifts, + getGift, + claimInitialize, + claimSubmit, +} from "../../controllers/stellar/giftController.js"; + +const router = express.Router(); + +// All gift routes require authentication. +router.use(protect); + +// Gift flow (sender funds a claimable balance for the recipient) +router.post("/initialize", initializeGift); +router.post("/submit", submitGift); + +// Gift listing / detail +router.get("/", listGifts); +router.get("/:id", getGift); + +// Claim / reclaim flow +router.post("/:id/claim/initialize", claimInitialize); +router.post("/:id/claim/submit", claimSubmit); + +export default router; diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index e6b9539b..93d8e360 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -1,6 +1,7 @@ // routes/stellar/paymentRoutes.js import express from "express"; import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; import { initializePayment, submitPayment, @@ -9,6 +10,7 @@ import { getTransactionHistory, getTransaction, cancelTransaction, + sponsorshipStatus, } from "../../controllers/stellar/paymentController.js"; import { requestRefund, @@ -19,6 +21,11 @@ import { arbitrateDispute, } from "../../controllers/stellar/refundController.js"; import { reconciliationStatus } from "../../controllers/stellar/reconciliationController.js"; +import { validate } from "../../middlewares/validate.js"; +import { + initializePaymentValidation, + submitPaymentValidation, +} from "../../validators/requestValidators.js"; const router = express.Router(); @@ -28,8 +35,20 @@ router.use(protect); // Payment flow router.post("/quote", getQuote); router.post("/preflight", getPaymentPreflight); -router.post("/initialize", initializePayment); -router.post("/submit", submitPayment); +router.post( + "/initialize", + initializePaymentValidation, + validate, + idempotency(), + initializePayment +); +router.post( + "/submit", + submitPaymentValidation, + validate, + idempotency(), + submitPayment +); // Transaction management router.get("/transactions", getTransactionHistory); @@ -37,11 +56,11 @@ router.get("/transactions/:transactionId", getTransaction); router.delete("/transactions/:transactionId", cancelTransaction); // Refund & Dispute flow -router.post("/transactions/:id/refund-request", requestRefund); -router.post("/refunds/:refundId/build", buildRefundXdr); -router.post("/refunds/:refundId/submit", submitRefund); -router.post("/refunds/:refundId/reject", rejectRefund); -router.post("/refunds/:refundId/dispute", escalateDispute); +router.post("/transactions/:id/refund-request", idempotency(), requestRefund); +router.post("/refunds/:refundId/build", idempotency(), buildRefundXdr); +router.post("/refunds/:refundId/submit", idempotency(), submitRefund); +router.post("/refunds/:refundId/reject", idempotency(), rejectRefund); +router.post("/refunds/:refundId/dispute", idempotency(), escalateDispute); router.patch( "/refunds/:refundId/arbitrate", authorizeRoles("admin"), @@ -55,4 +74,11 @@ router.get( reconciliationStatus ); +// Fee-bump sponsorship status (admin) — sponsor float + today's spend (#30) +router.get( + "/sponsorship/status", + authorizeRoles("admin"), + sponsorshipStatus +); + export default router; diff --git a/src/routes/stellar/pledgeRoutes.js b/src/routes/stellar/pledgeRoutes.js new file mode 100644 index 00000000..f8296b4a --- /dev/null +++ b/src/routes/stellar/pledgeRoutes.js @@ -0,0 +1,23 @@ +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; +import { + createPledge, + getPledgeStats, + initializePledgeCycle, + listPledgeCycles, + listPledges, + submitPledgeCycle, + updatePledgeStatus, +} from "../../controllers/stellar/pledgeController.js"; + +const router = express.Router(); +router.use(protect); +router.get("/", listPledges); +router.get("/stats", getPledgeStats); +router.post("/", idempotency(), createPledge); +router.patch("/:id/status", updatePledgeStatus); +router.get("/:id/cycles", listPledgeCycles); +router.post("/cycles/:cycleId/initialize", idempotency(), initializePledgeCycle); +router.post("/cycles/:cycleId/submit", idempotency(), submitPledgeCycle); +export default router; diff --git a/src/routes/stellar/walletRoutes.js b/src/routes/stellar/walletRoutes.js index 7a8e6898..e58d4ec2 100644 --- a/src/routes/stellar/walletRoutes.js +++ b/src/routes/stellar/walletRoutes.js @@ -8,11 +8,19 @@ import { getMyWallet, checkUserWallet, } from "../../controllers/stellar/walletController.js"; +import { validate } from "../../middlewares/validate.js"; +import { connectWalletValidation } from "../../validators/requestValidators.js"; const router = express.Router(); // Protected routes (require authentication) -router.post("/connect", protect, connectWallet); +router.post( + "/connect", + protect, + connectWalletValidation, + validate, + connectWallet +); router.delete("/disconnect", protect, disconnectWallet); router.get("/me", protect, getMyWallet); diff --git a/src/routes/webhookRoutes.js b/src/routes/webhookRoutes.js new file mode 100644 index 00000000..85615286 --- /dev/null +++ b/src/routes/webhookRoutes.js @@ -0,0 +1,42 @@ +// routes/webhookRoutes.js +// +// Management API for outbound webhooks. Mounted at /api/webhooks in app.js. +// Every route requires an authenticated admin (issue #20 role gate). +import express from "express"; +import { protect, authorizeRoles } from "../middlewares/authMiddleware.js"; +import { + createEndpoint, + listEndpoints, + getEndpoint, + updateEndpoint, + deleteEndpoint, + rotateSecret, + listDeliveries, + redeliver, + pingEndpoint, +} from "../controllers/webhookController.js"; + +const router = express.Router(); + +// Authentication + admin privilege gate for the whole management surface. +router.use(protect); +router.use(authorizeRoles("admin")); + +// Endpoint CRUD +router.post("/", createEndpoint); +router.get("/", listEndpoints); +router.get("/:id", getEndpoint); +router.patch("/:id", updateEndpoint); +router.delete("/:id", deleteEndpoint); + +// Secret rotation +router.post("/:id/rotate-secret", rotateSecret); + +// Deliveries + dead-letter redelivery +router.get("/:id/deliveries", listDeliveries); +router.post("/:id/deliveries/:deliveryId/redeliver", redeliver); + +// Integration-test ping +router.post("/:id/ping", pingEndpoint); + +export default router; diff --git a/src/scripts/migrateCategories.js b/src/scripts/migrateCategories.js new file mode 100644 index 00000000..7caaa628 --- /dev/null +++ b/src/scripts/migrateCategories.js @@ -0,0 +1,28 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Book from "../models/Book.js"; +import Category from "../models/Category.js"; +import Course from "../models/Course.js"; +import { slugifyCategory, uniqueCategorySlug } from "../services/categoryService.js"; + +dotenv.config(); + +export async function migrateCategories() { + const values = [...new Set([...(await Course.distinct("category")), ...(await Book.distinct("category"))].filter(Boolean))]; + for (const value of values) { + const base = slugifyCategory(value); + let category = await Category.findOne({ $or: [{ slug: base }, { name: new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }] }); + if (!category) category = await Category.create({ name: value.trim(), slug: await uniqueCategorySlug(value) }); + await Promise.all([ + Course.updateMany({ category: value, categoryRef: { $exists: false } }, { $set: { categoryRef: category._id, category: category.name } }), + Book.updateMany({ category: value, categoryRef: { $exists: false } }, { $set: { categoryRef: category._id, category: category.name } }), + ]); + } +} + +if (process.argv[1]?.endsWith("migrateCategories.js")) { + if (!process.env.MONGO_URI) throw new Error("MONGO_URI is required"); + await mongoose.connect(process.env.MONGO_URI); + await migrateCategories(); + await mongoose.disconnect(); +} diff --git a/src/scripts/seedCategories.js b/src/scripts/seedCategories.js new file mode 100644 index 00000000..b71a4118 --- /dev/null +++ b/src/scripts/seedCategories.js @@ -0,0 +1,32 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Category from "../models/Category.js"; + +dotenv.config(); + +export const CATEGORY_SEEDS = [ + { name: "Qur'an", slug: "quran", order: 10, children: [{ name: "Tajweed", slug: "tajweed" }, { name: "Tafsir", slug: "tafsir" }] }, + { name: "Hadith", slug: "hadith", order: 20 }, + { name: "Aqeedah", slug: "aqeedah", order: 30 }, + { name: "Fiqh", slug: "fiqh", order: 40 }, + { name: "Seerah / History", slug: "seerah-history", order: 50 }, + { name: "Arabic Language", slug: "arabic-language", order: 60 }, + { name: "Islamic Finance", slug: "islamic-finance", order: 70 }, + { name: "Spirituality / Tazkiyah", slug: "spirituality-tazkiyah", order: 80 }, +]; + +export async function seedCategories() { + for (const seed of CATEGORY_SEEDS) { + const parent = await Category.findOneAndUpdate({ slug: seed.slug }, { $set: { name: seed.name, order: seed.order, isActive: true } }, { upsert: true, new: true, setDefaultsOnInsert: true }); + for (const child of seed.children || []) { + await Category.findOneAndUpdate({ slug: child.slug }, { $set: { name: child.name, parent: parent._id, isActive: true } }, { upsert: true, setDefaultsOnInsert: true }); + } + } +} + +if (process.argv[1]?.endsWith("seedCategories.js")) { + if (!process.env.MONGO_URI) throw new Error("MONGO_URI is required"); + await mongoose.connect(process.env.MONGO_URI); + await seedCategories(); + await mongoose.disconnect(); +} diff --git a/src/services/audit/auditService.js b/src/services/audit/auditService.js index 4d5dca0c..eb80f98f 100644 --- a/src/services/audit/auditService.js +++ b/src/services/audit/auditService.js @@ -30,6 +30,11 @@ const METADATA_ALLOWLIST = new Set([ "assignedRole", "name", + // 2FA + "mfaRequired", + "recoveryCodeUsed", + "twoFactorEnabled", + // Wallet "publicKey", "network", @@ -59,9 +64,30 @@ const METADATA_ALLOWLIST = new Set([ "newRole", "changedBy", + // Educator verification (issue #92) + "verificationId", + "previousStatus", + "newStatus", + "reviewedBy", + "reviewNotes", + "documentCount", + // Generic error context "reason", "conflictUserId", + + // Service-to-service auth (dnb-ai) + "serviceId", + "kid", + "scope", + + // Outbound webhooks (issue #45) + "endpointId", + "deliveryId", + "eventType", + "url", + "events", + "disabledReason", ]); /** @@ -105,8 +131,12 @@ export function recordAudit({ status, metadata = null, }) { - // Schedule asynchronously — do not block caller - Promise.resolve() + // Schedule asynchronously so the caller is never blocked by the write. + // The chain is `return`ed (and its .catch always swallows errors) so that + // security-critical callers MAY `await recordAudit(...)` to guarantee the + // row is durable before responding — but awaiting is optional and never + // throws to the caller. + return Promise.resolve() .then(async () => { // If DB is not connected and AuditLog.create is not mocked (e.g. unit tests without DB), // skip write to prevent 10s Mongoose buffer timeouts. diff --git a/src/services/categoryService.js b/src/services/categoryService.js new file mode 100644 index 00000000..2d5f9399 --- /dev/null +++ b/src/services/categoryService.js @@ -0,0 +1,46 @@ +import mongoose from "mongoose"; +import Category from "../models/Category.js"; + +export const slugifyCategory = (value) => + value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[’']/g, "") + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +export const uniqueCategorySlug = async (name, excludeId = null) => { + const base = slugifyCategory(name) || "category"; + let slug = base; + let suffix = 2; + while ( + await Category.exists({ + slug, + ...(excludeId ? { _id: { $ne: excludeId } } : {}), + }) + ) { + slug = `${base}-${suffix}`; + suffix += 1; + } + return slug; +}; + +export const resolveActiveCategory = async (value) => { + if (!value) return null; + const query = mongoose.Types.ObjectId.isValid(value) + ? { _id: value, isActive: true } + : { slug: slugifyCategory(value), isActive: true }; + return Category.findOne(query); +}; + +export const getValidCategorySlugs = async () => + Category.find({ isActive: true }).sort({ order: 1, name: 1 }).distinct("slug"); + +export const categoryTaxonomyExists = async () => Boolean(await Category.exists({})); + +export const categoryValidationError = async () => { + const validSlugs = await getValidCategorySlugs(); + return `Unknown or inactive category. Valid slugs: ${validSlugs.join(", ")}`; +}; diff --git a/src/services/pledgeService.js b/src/services/pledgeService.js new file mode 100644 index 00000000..17d2603e --- /dev/null +++ b/src/services/pledgeService.js @@ -0,0 +1,56 @@ +import mongoose from "mongoose"; + +import Pledge from "../models/Pledge.js"; +import PledgeCycle from "../models/PledgeCycle.js"; + +const STROOPS_PER_UNIT = 10000000n; +const toStroops = (amount) => { + const [whole, fraction = ""] = amount.toString().split("."); + return BigInt(whole || "0") * STROOPS_PER_UNIT + BigInt((fraction + "0000000").slice(0, 7)); +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const addPledgeCadence = (date, pledge) => { + const source = new Date(date); + if (pledge.cadence === "daily") return new Date(source.getTime() + DAY_MS); + if (pledge.cadence === "weekly") return new Date(source.getTime() + 7 * DAY_MS); + const year = source.getUTCFullYear(); + const month = source.getUTCMonth() + 1; + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const day = Math.min(pledge.anchorDate || source.getUTCDate(), lastDay); + return new Date(Date.UTC(year, month, day, source.getUTCHours(), source.getUTCMinutes(), source.getUTCSeconds(), source.getUTCMilliseconds())); +}; + +export const firstDueAt = ({ cadence, anchorDay, anchorDate, startAt = new Date() }) => { + const start = new Date(startAt); + if (cadence === "daily") return start; + if (cadence === "weekly") { + const target = anchorDay ?? start.getUTCDay(); + const delta = (target - start.getUTCDay() + 7) % 7; + return new Date(start.getTime() + delta * DAY_MS); + } + const target = anchorDate ?? start.getUTCDate(); + const lastDay = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate(); + if (start.getUTCDate() <= Math.min(target, lastDay)) { + return new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), Math.min(target, lastDay), start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds())); + } + return addPledgeCadence(start, { cadence, anchorDate: target }); +}; + +export const markPledgeTransactionPaid = async (transaction, paidAt = new Date()) => { + if (!mongoose.Types.ObjectId.isValid(transaction?._id)) return null; + const cycle = await PledgeCycle.findOne({ transaction: transaction._id, status: { $ne: "paid" } }); + if (!cycle) return null; + cycle.status = "paid"; + await cycle.save(); + const pledge = await Pledge.findById(cycle.pledge); + if (!pledge) return cycle; + const consecutivePaid = pledge.consecutivePaid + 1; + pledge.consecutivePaid = consecutivePaid; + pledge.longestStreak = Math.max(pledge.longestStreak, consecutivePaid); + pledge.totalPaidStroops = (BigInt(pledge.totalPaidStroops || "0") + toStroops(transaction.amount)).toString(); + pledge.lastPaidAt = paidAt; + await pledge.save(); + return cycle; +}; diff --git a/src/services/stellar/claimableBalanceService.js b/src/services/stellar/claimableBalanceService.js new file mode 100644 index 00000000..9a3c21a0 --- /dev/null +++ b/src/services/stellar/claimableBalanceService.js @@ -0,0 +1,335 @@ +// services/stellar/claimableBalanceService.js +// +// Stellar claimable balances for gifting courses/books and trustline-free +// receiving. The sender creates an on-ledger USDC balance the recipient can +// claim whenever they're ready, with a reclaim-after-expiry predicate so +// funds are never stranded: +// +// - recipient claimant: predicateBeforeAbsoluteTime(expiresAt) +// - sender claimant: predicateNot(predicateBeforeAbsoluteTime(expiresAt)) +// +// All signing stays client-side; this service only builds unsigned XDR, +// resolves the REAL claimable-balance id after inclusion, verifies on-chain, +// and lets the controller grant access to the recipient. +// +// The #1 trap this module exists to avoid: the claimable-balance id is NOT +// the transaction hash. It is the hex-encoded XDR of the ClaimableBalanceId +// produced by the create_claimable_balance operation result. + +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; +import { client } from "./horizonClient.js"; +import { + server, + networkPassphrase, + USDC, + USDC_ISSUER, + toStroops, + hasUsdcTrustline, +} from "./stellarService.js"; + +/** How long a gifted balance stays claimable before the sender can reclaim. */ +export const GIFT_EXPIRY_DAYS = 30; +export const giftExpiryFromNow = () => + new Date(Date.now() + GIFT_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + +/** + * Build an unsigned transaction that creates a USDC claimable balance for the + * recipient, with the sender as the reclaim-after-expiry claimant. + * @returns {Promise<{xdr: string, hash: string, networkPassphrase: string, expiresAt: Date}>} + */ +export const buildCreateClaimableBalanceTx = async ({ + sourcePublicKey, + claimantPublicKey, + amount, + expiresAt, + memo = "DeenBridge Gift", +}) => { + const sourceAccount = await client.execute((srv) => + srv.loadAccount(sourcePublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + builder.addOperation( + StellarSdk.Operation.createClaimableBalance({ + asset: USDC, + amount: amount.toString(), + claimants: [ + // Recipient can claim up until the expiry instant. + new StellarSdk.Claimant( + claimantPublicKey, + StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt) + ), + // Sender can reclaim once the expiry instant has passed — strictly + // complementary predicates, so there is no window where neither (or + // both) can claim. + new StellarSdk.Claimant( + sourcePublicKey, + StellarSdk.Claimant.predicateNot( + StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt) + ) + ), + ], + }) + ); + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memo)) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + expiresAt, + }; +}; + +/** + * Build an unsigned claim transaction for a claimable balance. When the + * claimant has no USDC trustline yet, a changeTrust(USDC) operation is + * prepended IN THE SAME TRANSACTION so claiming is a single signature — + * this is the trustline-free receiving path. + * @returns {Promise<{xdr: string, hash: string, networkPassphrase: string, includesChangeTrust: boolean}>} + */ +export const buildClaimTx = async ({ claimantPublicKey, balanceId }) => { + const includesChangeTrust = !(await hasUsdcTrustline(claimantPublicKey)); + const sourceAccount = await client.execute((srv) => + srv.loadAccount(claimantPublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + if (includesChangeTrust) { + builder.addOperation(StellarSdk.Operation.changeTrust({ asset: USDC })); + } + builder.addOperation( + StellarSdk.Operation.claimClaimableBalance({ balanceId }) + ); + + const transaction = builder.setTimeout(300).build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + includesChangeTrust, + }; +}; + +/** + * Resolve the REAL claimable-balance id for a create transaction. + * + * Primary path: parse the transaction result XDR + * (CreateClaimableBalanceResult → balanceId). This is deterministic and + * immune to races — unlike querying Horizon by claimant, which is ambiguous + * when the same account has created several balances. + * + * Fallback: when the result XDR is unavailable (e.g. Horizon lag), query + * `claimableBalances().forClaimant(source)` and match the amount/asset. + * + * @param {string} createTxHash - hash of the create_claimable_balance tx + * @param {object} [opts] - { amount, claimantPublicKey } to disambiguate the fallback + * @returns {Promise} the balance id (hex XDR), or null + */ +export const resolveBalanceId = async ( + createTxHash, + { amount, claimantPublicKey } = {} +) => { + // Primary: parse the operation result from the transaction result XDR. + try { + const tx = await client.execute((srv) => + srv.transactions().transaction(createTxHash).call() + ); + if (tx?.result_xdr) { + const result = StellarSdk.xdr.TransactionResult.fromXDR( + tx.result_xdr, + "base64" + ); + const operationResults = result.result().results() || []; + for (const opResult of operationResults) { + const createResult = opResult.tr().createClaimableBalanceResult(); + if (createResult && createResult.balanceId()) { + const balanceId = createResult.balanceId().toXDR("hex"); + if (balanceId && balanceId !== createTxHash) { + return balanceId; + } + } + } + } + } catch (error) { + logger.warn( + { createTxHash, err: error.message }, + "resolveBalanceId: could not parse transaction result XDR, falling back to Horizon query" + ); + } + + // Fallback: query by the balance's sponsor (the create tx source account). + try { + let sourceAccount; + if (claimantPublicKey) { + sourceAccount = claimantPublicKey; + } else { + const tx = await client.execute((srv) => + srv.transactions().transaction(createTxHash).call() + ); + sourceAccount = tx?.source_account; + } + if (!sourceAccount) return null; + + const page = await client.execute((srv) => + srv.claimableBalances().forClaimant(sourceAccount).call() + ); + const records = page?.records || []; + const match = records.find((r) => { + // Horizon encodes the asset as "CODE:ISSUER" on claimable balances. + const assetIsUsdc = + typeof r.asset === "string" && r.asset.startsWith("USDC:"); + if (!assetIsUsdc) return false; + if (amount != null && toStroops(r.amount) !== toStroops(amount)) { + return false; + } + return true; + }); + return match?.id || null; + } catch (error) { + logger.warn( + { createTxHash, err: error.message }, + "resolveBalanceId: fallback Horizon query failed" + ); + return null; + } +}; + +/** + * Look up a claimable balance on Horizon for live status/predicate checks. + * @returns {Promise<{exists: boolean, record?: object}>} + */ +export const getClaimableBalance = async (balanceId) => { + try { + const record = await client.execute((srv) => + srv.claimableBalances().claimableBalance(balanceId).call() + ); + return { exists: true, record }; + } catch (error) { + if (error.response?.status === 404) { + return { exists: false }; + } + logger.error("Error fetching claimable balance:", error); + throw error; + } +}; + +/** + * Decode a claim predicate XDR into a plain, comparable shape. + * @returns {{type: string, time?: string, seconds?: string, children?: Array}} + */ +export const describePredicate = (pred) => { + const name = pred?._switch?.name; + switch (name) { + case "claimPredicateUnconditional": + return { type: "unconditional" }; + case "claimPredicateAnd": + return { type: "and", children: (pred._value || []).map(describePredicate) }; + case "claimPredicateOr": + return { type: "or", children: (pred._value || []).map(describePredicate) }; + case "claimPredicateNot": + return { type: "not", child: describePredicate(pred._value) }; + case "claimPredicateBeforeAbsoluteTime": + return { type: "before_absolute_time", time: String(pred._value?._value ?? "") }; + case "claimPredicateBeforeRelativeTime": + return { type: "before_relative_time", seconds: String(pred._value?._value ?? "") }; + default: + return { type: "unknown", name }; + } +}; + +/** + * Validate a signed gift XDR against the expected create_claimable_balance + * before ANY database write or access grant. Mirrors the discipline of + * validateSignedPaymentXdr (stellarService.js): a tampered XDR (wrong asset, + * wrong amount, or altered claimants/predicates) is rejected outright. + * + * @param {string} signedXdr + * @param {{assetCode?: string, amount: string, recipientWallet: string, senderWallet: string, expiresAt: Date|string|number}} expected + * @returns {object} the parsed transaction + */ +export const validateSignedGiftXdr = (signedXdr, expected) => { + const { + assetCode = "USDC", + amount, + recipientWallet, + senderWallet, + expiresAt, + } = expected; + + const tx = StellarSdk.TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + const giftOps = tx.operations.filter( + (op) => op.type === "createClaimableBalance" + ); + if (giftOps.length === 0) { + throw new Error( + "Signed XDR missing a create_claimable_balance operation" + ); + } + const op = giftOps[0]; + + const assetMatches = + (op.asset?.code === assetCode && op.asset?.issuer === USDC_ISSUER) || + (op.asset_type === "credit_alphanum4" && + op.asset?.code === assetCode && + op.asset?.issuer === USDC_ISSUER); + if (!assetMatches) { + throw new Error( + `Signed XDR create_claimable_balance uses the wrong asset (expected ${assetCode})` + ); + } + + if (toStroops(op.amount) !== toStroops(amount)) { + throw new Error( + `Signed XDR create_claimable_balance amount mismatch (expected ${amount})` + ); + } + + const expectedTime = String(new Date(expiresAt).getTime()); + const claimants = (op.claimants || []).map((c) => ({ + destination: c.destination, + predicate: describePredicate(c.predicate), + })); + + const recipientClaimant = claimants.find( + (c) => c.destination === recipientWallet + ); + const recipientPredicateOk = + recipientClaimant?.predicate.type === "before_absolute_time" && + recipientClaimant.predicate.time === expectedTime; + if (!recipientPredicateOk) { + throw new Error( + "Signed XDR missing the recipient claimant with before_absolute_time(expiresAt)" + ); + } + + const senderClaimant = claimants.find((c) => c.destination === senderWallet); + const senderPredicateOk = + senderClaimant?.predicate.type === "not" && + senderClaimant.predicate.child?.type === "before_absolute_time" && + senderClaimant.predicate.child.time === expectedTime; + if (!senderPredicateOk) { + throw new Error( + "Signed XDR missing the sender claimant with not(before_absolute_time(expiresAt))" + ); + } + + return tx; +}; + +export { server }; diff --git a/src/services/stellar/donationIntentService.js b/src/services/stellar/donationIntentService.js new file mode 100644 index 00000000..862f9439 --- /dev/null +++ b/src/services/stellar/donationIntentService.js @@ -0,0 +1,68 @@ +import Transaction from "../../models/Transaction.js"; +import { + isValidPublicKey, + buildPaymentTransaction, + buildSep7Uri, + NETWORK, + DONATION_WALLET_PUBLIC_KEY, +} from "./stellarService.js"; + +export const DONATION_MEMO = "DNB-SADAQAH"; + +export const validateDonationAmount = (amount) => { + const parsedAmount = Number(amount); + return Boolean( + amount && + Number.isFinite(parsedAmount) && + parsedAmount > 0 && + /^\d+(\.\d{1,7})?$/.test(amount.toString()) + ); +}; + +export const createDonationIntent = async ({ donorId, publicKey, amount, session, memo = DONATION_MEMO }) => { + if (!DONATION_WALLET_PUBLIC_KEY) { + const error = new Error("Donations are not available right now. Please try again later."); + error.statusCode = 503; + throw error; + } + if (!publicKey || !isValidPublicKey(publicKey)) { + const error = new Error("Invalid Stellar public key"); + error.statusCode = 400; + throw error; + } + if (!validateDonationAmount(amount)) { + const error = new Error("Invalid amount. Must be a positive number with at most 7 decimal places"); + error.statusCode = 400; + throw error; + } + + const paymentTx = await buildPaymentTransaction({ + sourcePublicKey: publicKey, + destinationPublicKey: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + memo, + }); + const sep7Uri = buildSep7Uri({ + destination: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + memo, + }); + const transaction = new Transaction({ + type: "donation", + buyer: donorId, + buyerWallet: publicKey, + creatorWallet: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + network: NETWORK, + status: "pending", + expectedHash: paymentTx.hash, + memo, + }); + await transaction.save({ session }); + return { + transaction, + transactionXdr: paymentTx.xdr, + sep7Uri, + networkPassphrase: paymentTx.networkPassphrase, + }; +}; diff --git a/src/services/stellar/feeSponsorService.js b/src/services/stellar/feeSponsorService.js new file mode 100644 index 00000000..2b503270 --- /dev/null +++ b/src/services/stellar/feeSponsorService.js @@ -0,0 +1,528 @@ +// services/stellar/feeSponsorService.js +// +// Fee-bump sponsorship (#30). The platform can pay a user's Stellar network +// fee by wrapping the user-signed inner transaction in a fee-bump transaction +// signed by a dedicated, low-balance "fee-source" account. The user still +// signs (and only signs) their own payment operations; the sponsor key only +// ever signs the fee-bump wrapper and can never move user funds. +// +// Because the server signs on behalf of the platform, this path is guarded by: +// 1. a reject-by-default STRUCTURAL WHITELIST — the user's inner transaction +// must match, operation-for-operation, the pending Transaction row the +// server already built (see validateInnerTransaction); and +// 2. durable SPEND CAPS — per-transaction, per-UTC-day total, and per-user +// per-day (see SponsorshipSpend + checkSpendCaps). +// +// Everything here is a no-op unless FEE_SPONSOR_ENABLED=true; with the flag off +// the caller never reaches this module and the base payment/donation flow is +// byte-for-byte unchanged. +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; +import SponsorshipSpend from "../../models/SponsorshipSpend.js"; +import { + toStroops, + resolveAsset, + getAccountBalance, + networkPassphrase, +} from "./stellarService.js"; + +// StellarSdk's minimum fee-bump base fee (per operation), in stroops. +const MIN_BASE_FEE_STROOPS = Number(StellarSdk.BASE_FEE); // 100 + +// Sensible defaults applied when a numeric cap env var is unset or invalid. +// The master switch (FEE_SPONSOR_ENABLED) and the secret have no defaults. +export const FEE_SPONSOR_DEFAULTS = Object.freeze({ + maxFeeStroops: 1_000_000, // 0.1 XLM per-transaction fee ceiling + dailyCapStroops: 100_000_000, // 10 XLM total per UTC day + perUserDailyLimit: 10, // sponsored transactions per user per UTC day +}); + +/** + * A sponsorship-specific failure. These MUST NOT mark the user's Transaction + * `failed`: the user can always retry the submit without sponsorship and pay + * their own fee. `httpStatus` is a distinct non-fatal 4xx (or 503 for a + * server-side misconfiguration) and `retryUnsponsored` signals the client to + * fall back to the normal flow. + */ +export class SponsorshipError extends Error { + constructor(code, message, { httpStatus = 422 } = {}) { + super(message); + this.name = "SponsorshipError"; + this.code = code; + this.httpStatus = httpStatus; + this.retryUnsponsored = true; + } +} + +// ── Config ────────────────────────────────────────────────────────────────── + +const parsePositiveInt = (value, fallback) => { + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) return fallback; + return n; +}; + +export const isFeeSponsorEnabled = () => + process.env.FEE_SPONSOR_ENABLED === "true"; + +/** + * Resolve the sponsorship config from the environment. Read at call time so a + * deploy can tune caps without a code change; the numeric caps fall back to + * FEE_SPONSOR_DEFAULTS when unset/invalid. + */ +export const getFeeSponsorConfig = () => ({ + enabled: isFeeSponsorEnabled(), + maxFeeStroops: parsePositiveInt( + process.env.FEE_SPONSOR_MAX_FEE_STROOPS, + FEE_SPONSOR_DEFAULTS.maxFeeStroops + ), + dailyCapStroops: parsePositiveInt( + process.env.FEE_SPONSOR_DAILY_CAP_STROOPS, + FEE_SPONSOR_DEFAULTS.dailyCapStroops + ), + perUserDailyLimit: parsePositiveInt( + process.env.FEE_SPONSOR_PER_USER_DAILY_LIMIT, + FEE_SPONSOR_DEFAULTS.perUserDailyLimit + ), +}); + +// The sponsor secret is read from env and parsed into a Keypair once, then +// cached by its secret value. It is never logged and never returned over HTTP. +let cachedKeypair = null; +let cachedSecret = null; + +/** + * Parse the sponsor secret into a Keypair, caching the result. Throws a + * SponsorshipError (503) when the secret is missing or invalid so the caller + * can surface a non-fatal "retry unsponsored" without leaking the secret. + */ +export const getFeeSponsorKeypair = () => { + const secret = process.env.FEE_SPONSOR_SECRET; + if (!secret) { + throw new SponsorshipError( + "sponsor_misconfigured", + "Fee sponsor secret is not configured", + { httpStatus: 503 } + ); + } + if (cachedSecret === secret && cachedKeypair) return cachedKeypair; + try { + cachedKeypair = StellarSdk.Keypair.fromSecret(secret); + cachedSecret = secret; + return cachedKeypair; + } catch { + throw new SponsorshipError( + "sponsor_misconfigured", + "Fee sponsor secret is invalid", + { httpStatus: 503 } + ); + } +}; + +/** Public key of the sponsor account, or null if not configured/invalid. */ +export const getFeeSponsorPublicKey = () => { + try { + return getFeeSponsorKeypair().publicKey(); + } catch { + return null; + } +}; + +/** + * Boot-time validation: when FEE_SPONSOR_ENABLED=true, the secret must be a + * valid Stellar secret key. Returns { ok } / { ok:false, message } so the + * caller (validateEnv) can fail fast with a clear message. A no-op when the + * flag is off. + */ +export const validateFeeSponsorBootConfig = () => { + if (!isFeeSponsorEnabled()) return { ok: true }; + const secret = process.env.FEE_SPONSOR_SECRET; + if (!secret) { + return { + ok: false, + message: + "FEE_SPONSOR_ENABLED=true but FEE_SPONSOR_SECRET is not set. Provide the dedicated fee-source secret or disable sponsorship.", + }; + } + try { + StellarSdk.Keypair.fromSecret(secret); + } catch { + return { + ok: false, + message: + "FEE_SPONSOR_SECRET is not a valid Stellar secret key (expected an S... seed).", + }; + } + return { ok: true }; +}; + +// ── Structural whitelist ───────────────────────────────────────────────────── + +/** UTC calendar day (YYYY-MM-DD) used to key daily spend accounting. */ +export const utcDay = (date = new Date()) => date.toISOString().slice(0, 10); + +const assetsEqual = (a, b) => { + if (!a || !b) return false; + if (a.isNative() || b.isNative()) return a.isNative() && b.isNative(); + return a.getCode() === b.getCode() && a.getIssuer() === b.getIssuer(); +}; + +const extractTextMemo = (memo) => { + if (!memo) return null; + const type = memo.type ?? memo._type; + if (type !== "text") return null; + const value = memo.value ?? memo._value; + if (value == null) return null; + return Buffer.isBuffer(value) ? value.toString("utf8") : String(value); +}; + +/** + * The settlement asset for a row. Donations and purchases both settle in + * `row.currency` (defaulting to USDC for legacy rows with none set). + */ +const settlementAssetFor = (row) => resolveAsset(row.currency || "USDC"); + +/** + * Build the exact, ordered set of payment operations the inner transaction is + * allowed to contain, derived entirely from the server-persisted row: + * - a fee split → [creator op, platform op] (order matches buildPaymentTransaction); + * - otherwise → [single settlement op] (direct purchase or donation). + * Amounts are compared in stroops. + */ +export const buildExpectedOperations = (row) => { + const asset = settlementAssetFor(row); + if (row.platformFee && row.platformFee.platformAmount) { + return [ + { + destination: row.creatorWallet, + amountStroops: toStroops(row.platformFee.creatorAmount), + asset, + }, + { + destination: row.platformFee.platformWallet, + amountStroops: toStroops(row.platformFee.platformAmount), + asset, + }, + ]; + } + return [ + { + destination: row.creatorWallet, + amountStroops: toStroops(row.amount), + asset, + }, + ]; +}; + +const reject = (detail) => { + throw new SponsorshipError( + "whitelist_rejected", + `Structural whitelist rejected the signed transaction: ${detail}`, + { httpStatus: 422 } + ); +}; + +/** + * The structural whitelist. Reject-by-default: the inner transaction is + * accepted ONLY if it is, operation-for-operation, exactly what the server + * built for `row`. Enforced by allow-list (only `payment` ops in the settled + * asset are permitted) and exact count, so any foreign/extra operation — of + * any type, including one not yet invented — fails. + * + * @param {StellarSdk.Transaction} innerTx decoded user-signed inner transaction + * @param {object} row the pending Transaction document + * @returns {true} on success; throws SponsorshipError otherwise + */ +export const validateInnerTransaction = (innerTx, row) => { + if (!innerTx || innerTx instanceof StellarSdk.FeeBumpTransaction) { + reject("expected a plain inner transaction"); + } + + // Source must be the buyer/donor wallet the server recorded. + if (innerTx.source !== row.buyerWallet) { + reject(`source ${innerTx.source} does not match buyerWallet`); + } + + // Memo must match exactly. + const memoText = extractTextMemo(innerTx.memo); + if ((row.memo ?? null) !== memoText) { + reject("memo does not match the row"); + } + + const expected = buildExpectedOperations(row); + + // Exact operation count — rejects both extra/foreign ops and a missing op. + if (innerTx.operations.length !== expected.length) { + reject( + `operation count ${innerTx.operations.length} does not equal expected ${expected.length}` + ); + } + + // Every operation, positionally, must be a payment matching the row. The + // server builds these in a deterministic order (creator then platform), and + // wallets sign the exact envelope, so a positional check is the strictest + // form and never rejects a legitimate signature. + for (let i = 0; i < expected.length; i++) { + const op = innerTx.operations[i]; + // Allow-list: only `payment` is permitted. changeTrust, setOptions, + // manageData, accountMerge, createAccount, pathPayment*, or any unknown + // future type falls through here and is rejected. + if (op.type !== "payment") { + reject(`operation ${i} is a non-payment "${op.type}" operation`); + } + if (op.destination !== expected[i].destination) { + reject(`operation ${i} destination does not match the row`); + } + if (!assetsEqual(op.asset, expected[i].asset)) { + reject(`operation ${i} asset does not match the settlement asset`); + } + if (toStroops(op.amount) !== expected[i].amountStroops) { + reject(`operation ${i} amount does not match the row`); + } + } + + return true; +}; + +// ── Fee-bump wrapping ───────────────────────────────────────────────────────── + +/** + * Compute the fee-bump base fee (per operation) and the resulting total max + * fee, clamped to the per-transaction ceiling. The fee-bump is priced over the + * inner operations PLUS the wrapper (inner ops + 1), verified against the + * installed @stellar/stellar-sdk. We declare the highest per-op fee the ceiling + * allows so the sponsor tolerates fee surges up to the cap; Horizon still only + * charges the true network fee, which is recorded as the actual spend. + */ +export const computeFeeBumpFee = (innerTx, config = getFeeSponsorConfig()) => { + const innerOps = innerTx.operations.length; + const units = innerOps + 1; // inner operations + fee-bump wrapper + const innerPerOp = Math.ceil(Number(innerTx.fee) / innerOps); + const perOpCeiling = Math.floor(config.maxFeeStroops / units); + + // The per-op fee must be at least the inner tx's per-op fee and the network + // minimum. If the ceiling can't cover even that, the ceiling is too low to + // sponsor this transaction at all. + const minPerOp = Math.max(MIN_BASE_FEE_STROOPS, innerPerOp); + if (perOpCeiling < minPerOp) { + throw new SponsorshipError( + "fee_ceiling_too_low", + `Per-transaction fee ceiling ${config.maxFeeStroops} stroops is below the minimum required to fee-bump ${innerOps} operation(s)`, + { httpStatus: 503 } + ); + } + + const baseFeePerOp = perOpCeiling; // highest per-op fee within the ceiling + const totalMaxFeeStroops = baseFeePerOp * units; + return { baseFeePerOp, totalMaxFeeStroops, units }; +}; + +/** + * Wrap a validated inner transaction in a fee-bump signed by the sponsor key. + * The inner transaction (and its user signature) is left untouched. + */ +export const wrapWithFeeBump = ( + innerTx, + { keypair = getFeeSponsorKeypair(), baseFeePerOp } = {} +) => { + const { baseFeePerOp: computed } = + baseFeePerOp == null ? computeFeeBumpFee(innerTx) : { baseFeePerOp }; + const feeBump = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + keypair, + String(computed), + innerTx, + networkPassphrase + ); + feeBump.sign(keypair); + return feeBump; +}; + +// ── Spend accounting ────────────────────────────────────────────────────────── + +/** + * Read today's spend row and enforce caps BEFORE any wrapping/submission: + * - per-user daily count (FEE_SPONSOR_PER_USER_DAILY_LIMIT), and + * - per-UTC-day total stroops (FEE_SPONSOR_DAILY_CAP_STROOPS), reserving the + * worst-case fee for this transaction. + * Throws a distinct non-fatal SponsorshipError (429) when a cap is hit. + */ +export const checkSpendCaps = async ({ + userId, + estimatedFeeStroops, + config = getFeeSponsorConfig(), + session = null, +}) => { + const day = utcDay(); + const query = SponsorshipSpend.findOne({ day }); + const doc = session ? await query.session(session) : await query; + + const userCount = doc?.userCounts?.get?.(String(userId)) ?? 0; + if (userCount >= config.perUserDailyLimit) { + throw new SponsorshipError( + "per_user_daily_limit", + `Per-user daily sponsorship limit (${config.perUserDailyLimit}) reached`, + { httpStatus: 429 } + ); + } + + const currentTotal = doc?.totalStroops ?? 0; + if (currentTotal + estimatedFeeStroops > config.dailyCapStroops) { + throw new SponsorshipError( + "daily_cap_exceeded", + `Daily sponsorship spend cap (${config.dailyCapStroops} stroops) would be exceeded`, + { httpStatus: 429 } + ); + } +}; + +/** + * Record a successful sponsorship: atomically increment today's total spend + * (by the actual fee charged), the global count, and the per-user count. + * Called only AFTER the fee-bump has landed on-chain. + */ +export const recordSponsorshipSpend = async ({ + userId, + feeStroops, + session = null, +}) => { + const day = utcDay(); + const amount = Number.isFinite(Number(feeStroops)) ? Number(feeStroops) : 0; + await SponsorshipSpend.updateOne( + { day }, + { + $inc: { + totalStroops: amount, + sponsoredCount: 1, + [`userCounts.${String(userId)}`]: 1, + }, + }, + { upsert: true, ...(session ? { session } : {}) } + ); +}; + +// ── Orchestration ───────────────────────────────────────────────────────────── + +/** + * Refuse (non-fatally) if the sponsor account cannot cover the declared max + * fee, so an underfunded float never causes a Stellar submit failure that + * would mark the user's transaction `failed`. If the balance can't be read we + * do NOT block — a genuine failure still surfaces at submit time. + */ +const assertSponsorFunded = async ({ publicKey, requiredStroops, loadBalance }) => { + let balance; + try { + balance = await loadBalance(publicKey); + } catch { + return; // undeterminable — let submission proceed rather than false-refuse + } + const available = balance?.exists ? toStroops(balance.xlmBalance || "0") : 0n; + if (!balance?.exists || available < BigInt(requiredStroops)) { + throw new SponsorshipError( + "sponsor_underfunded", + "Sponsor float is insufficient to cover the network fee", + { httpStatus: 503 } + ); + } +}; + +/** + * Validate → cap-check → float-check → wrap. Returns everything the controller + * needs to submit the fee-bump and record the outcome. Throws SponsorshipError + * on any guard failure (whitelist, cap, underfunded, or sponsor + * misconfiguration) so the caller returns a distinct non-fatal 4xx and leaves + * the row untouched. + * + * NOTE: caps are only READ here; the spend is recorded (with the real + * fee_charged) via recordSponsorshipSpend after the fee-bump confirms. + * `loadBalance` is injectable so the float pre-check is unit-testable offline. + */ +export const prepareSponsoredSubmission = async ({ + signedXdr, + transactionRow, + userId, + session = null, + loadBalance = getAccountBalance, +}) => { + const config = getFeeSponsorConfig(); + const keypair = getFeeSponsorKeypair(); + + let decoded; + try { + decoded = StellarSdk.TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + } catch { + throw new SponsorshipError( + "whitelist_rejected", + "Signed XDR could not be decoded", + { httpStatus: 422 } + ); + } + + validateInnerTransaction(decoded, transactionRow); + + const { baseFeePerOp, totalMaxFeeStroops } = computeFeeBumpFee(decoded, config); + await checkSpendCaps({ + userId, + estimatedFeeStroops: totalMaxFeeStroops, + config, + session, + }); + + await assertSponsorFunded({ + publicKey: keypair.publicKey(), + requiredStroops: totalMaxFeeStroops, + loadBalance, + }); + + const feeBump = wrapWithFeeBump(decoded, { keypair, baseFeePerOp }); + + return { + innerHash: decoded.hash().toString("hex"), + outerHash: feeBump.hash().toString("hex"), + feeBumpXdr: feeBump.toXDR(), + maxFeeStroops: totalMaxFeeStroops, + }; +}; + +/** + * Auth-protected status snapshot for ops: whether sponsorship is on, the + * sponsor account's public key (never the secret) and live XLM float, the + * configured caps, and today's spend. Used to top up the float before it runs + * dry. + */ +export const getSponsorshipStatus = async () => { + const config = getFeeSponsorConfig(); + const publicKey = getFeeSponsorPublicKey(); + const day = utcDay(); + const doc = await SponsorshipSpend.findOne({ day }); + const totalStroops = doc?.totalStroops ?? 0; + + let float = null; + if (publicKey) { + try { + const balance = await getAccountBalance(publicKey); + float = { exists: balance.exists, xlmBalance: balance.xlmBalance }; + } catch (error) { + logger.warn( + { err: error, sponsorAccount: publicKey }, + "Failed to read sponsor float balance" + ); + } + } + + return { + enabled: config.enabled, + sponsorAccount: publicKey, // public key only — the secret is never exposed + caps: { + maxFeeStroops: config.maxFeeStroops, + dailyCapStroops: config.dailyCapStroops, + perUserDailyLimit: config.perUserDailyLimit, + }, + today: { + day, + totalStroops, + sponsoredCount: doc?.sponsoredCount ?? 0, + remainingStroops: Math.max(0, config.dailyCapStroops - totalStroops), + }, + float, + }; +}; diff --git a/src/services/stellar/horizonClient.js b/src/services/stellar/horizonClient.js index f16aa3e7..9ea80dbd 100644 --- a/src/services/stellar/horizonClient.js +++ b/src/services/stellar/horizonClient.js @@ -1,5 +1,6 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import logger from "../../config/logger.js"; +import { resolveStellarConfig } from "../../config/stellar.js"; export class HorizonClient { constructor(urls, timeoutMs = 10000) { @@ -184,15 +185,12 @@ export class HorizonClient { } } -// Resolve Horizon endpoints from the environment. The default is network-aware -// (mainnet vs testnet) so a mainnet deployment never silently falls back to -// testnet Horizon when HORIZON_URLS is left unset. +// Resolve Horizon endpoints from the single source of truth +// (config/stellar.js). The default is network-aware (mainnet vs testnet) so +// a mainnet deployment never silently falls back to testnet Horizon when +// HORIZON_URLS is left unset. function resolveHorizonEndpoints() { - const fallback = - process.env.STELLAR_NETWORK === "mainnet" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; - return (process.env.HORIZON_URLS || fallback).split(",").map((u) => u.trim()); + return resolveStellarConfig().horizonUrls; } // Construct the client lazily on first use, so it reads HORIZON_URLS / diff --git a/src/services/stellar/reconciliationService.js b/src/services/stellar/reconciliationService.js index 41d7d1df..5703e148 100644 --- a/src/services/stellar/reconciliationService.js +++ b/src/services/stellar/reconciliationService.js @@ -55,6 +55,7 @@ const promoteTransaction = async (transaction, paymentRecord) => { transaction.stellarLedger = paymentRecord.ledger || undefined; transaction.status = "confirmed"; transaction.confirmedAt = new Date(); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable await transaction.save(); await recordSaleEarnings(transaction); @@ -86,6 +87,10 @@ const createConfirmedDonation = async ({ sourceAccount, amount, hash, memo }) => status: "confirmed", stellarTxHash: hash, confirmedAt: new Date(), + // Terminal state: the conditional schema default omits expiresAt, and the + // pre-save hook enforces it — an already-confirmed row must never carry a + // TTL deadline. + expiresAt: undefined, }); await donation.save(); @@ -130,6 +135,10 @@ const createConfirmedPurchase = async ({ sourceAccount, amount, hash, memo, item status: "confirmed", stellarTxHash: hash, confirmedAt: new Date(), + // Terminal state: the conditional schema default omits expiresAt, and the + // pre-save hook enforces it — an already-confirmed row must never carry a + // TTL deadline. + expiresAt: undefined, }); await purchase.save(); diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 7f1b6600..3d25dcff 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -8,21 +8,23 @@ import { getDefaultAssetCode, getSupportedCodes, } from "../../config/assets.js"; +import { resolveStellarConfig } from "../../config/stellar.js"; import { client } from "./horizonClient.js"; -const NETWORK = process.env.STELLAR_NETWORK || "testnet"; -const networkPassphrase = - NETWORK === "mainnet" - ? StellarSdk.Networks.PUBLIC - : StellarSdk.Networks.TESTNET; - -// Back-compat: USDC / USDC_ISSUER are now derived from the registry -// instead of being hardcoded, but keep the same exported shape so -// existing callers (and the path-payment flow, out of scope for #60) -// keep working unchanged. -const USDC_CONFIG = getAssetConfig("USDC", NETWORK); -const USDC_ISSUER = USDC_CONFIG.issuer; +// Single source of truth for network identity (see config/stellar.js): +// network name, network passphrase, Horizon URLs, and USDC issuer all +// resolve here so a deployment can never mix testnet/mainnet settings. +const { + network: NETWORK, + networkPassphrase, + usdcIssuer: USDC_ISSUER, +} = resolveStellarConfig(); + +// Back-compat: USDC / USDC_ISSUER are derived from the registry via the +// config module instead of being hardcoded, but keep the same exported +// shape so existing callers (and the path-payment flow, out of scope for +// #60) keep working unchanged. const USDC = new StellarSdk.Asset("USDC", USDC_ISSUER); const DEFAULT_ASSET_CODE = getDefaultAssetCode(NETWORK); @@ -614,6 +616,10 @@ export const submitTransaction = async (signedXdr) => { hash: result.hash, ledger: result.ledger, successful: result.successful, + // `fee_charged` is the actual fee the network took. For a fee-bump + // submission (#30) this is what the sponsor account paid; undefined for + // transactions where the response omits it (e.g. the verifyFn dedupe path). + feeCharged: result.fee_charged, }; } catch (error) { logger.error("Error submitting transaction:", error); @@ -646,6 +652,71 @@ export const submitTransaction = async (signedXdr) => { } }; +/** + * Validate a signed transaction XDR against expected payments and memo/source + * @param {string} signedXdr + * @param {Array<{destination:string, amount:string}>} expectedPayments + * @param {string} expectedMemo + * @param {string} expectedSource + * @param {boolean} requireSource + */ +export const validateSignedPaymentXdr = ( + signedXdr, + expectedPayments = [], + expectedMemo, + expectedSource, + requireSource = true +) => { + const tx = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + + // Memo check + if (expectedMemo) { + const memo = tx.memo; + let memoText = null; + if (memo && memo._type === "text") { + const val = memo._value; + memoText = Buffer.isBuffer(val) ? val.toString() : String(val); + } + if (memoText !== expectedMemo) { + throw new Error("Memo mismatch"); + } + } + + // Source check + if (requireSource && expectedSource) { + if (tx.source !== expectedSource) { + throw new Error("Source account mismatch"); + } + } + + // Payment operations check + const paymentOps = tx.operations.filter((op) => op.type === "payment"); + + for (const expected of expectedPayments) { + const match = paymentOps.find((op) => { + const assetMatches = + (op.asset && op.asset.code === "USDC" && op.asset.issuer === USDC_ISSUER) || + (op.asset_type === "credit_alphanum4" && op.asset?.code === "USDC" && op.asset?.issuer === USDC_ISSUER); + + const amountMatches = toStroops(op.amount) === toStroops(expected.amount); + const destMatches = op.destination === expected.destination; + + return assetMatches && amountMatches && destMatches; + }); + + if (!match) { + throw new Error( + `Signed XDR missing expected USDC payment of ${expected.amount} to ${expected.destination}` + ); + } + } + + return tx; +}; + export const verifyTransaction = async (txHash) => { try { const tx = await timedHorizonCall("fetchTransaction", () => diff --git a/src/services/webhooks/deliveryWorker.js b/src/services/webhooks/deliveryWorker.js new file mode 100644 index 00000000..6ad3f1d0 --- /dev/null +++ b/src/services/webhooks/deliveryWorker.js @@ -0,0 +1,308 @@ +// services/webhooks/deliveryWorker.js +// +// Out-of-band delivery loop for outbound webhooks. Claims one due delivery at +// a time with an atomic findOneAndUpdate (so two loops/instances never +// double-send the same row), POSTs the signed body with a strict timeout and +// NO redirect following, and records the outcome. Failures are retried on an +// exponential backoff-with-jitter schedule and land in `dead` after the max +// attempts. Sustained dead deliveries auto-disable the endpoint. +// +// All scheduling state lives in the WebhookDelivery document, so this loop can +// later be replaced by the durable job queue (issue #32) without schema change. +import axios from "axios"; +import mongoose from "mongoose"; +import WebhookEndpoint from "../../models/WebhookEndpoint.js"; +import WebhookDelivery, { + MAX_STORED_ATTEMPTS, + MAX_ERROR_LENGTH, +} from "../../models/WebhookDelivery.js"; +import logger from "../../config/logger.js"; +import { decryptSecret } from "./webhookSecret.js"; +import { signPayload, WEBHOOK_HEADERS } from "./signing.js"; +import { assertDeliverableUrl } from "./urlGuard.js"; + +// Backoff schedule between attempts: 1m, 5m, 30m, 2h, 12h. +export const BACKOFF_SCHEDULE_MS = [ + 60_000, + 5 * 60_000, + 30 * 60_000, + 2 * 60 * 60_000, + 12 * 60 * 60_000, +]; + +// Total delivery attempts before a delivery is declared dead. +export const MAX_ATTEMPTS = parseInt(process.env.WEBHOOK_MAX_ATTEMPTS || "6", 10); + +// Consecutive dead deliveries that auto-disable an endpoint. +export const AUTO_DISABLE_THRESHOLD = parseInt( + process.env.WEBHOOK_AUTO_DISABLE_THRESHOLD || "5", + 10 +); + +// Max random jitter added to each backoff. Tests set this to 0 for +// deterministic scheduling assertions. +const BACKOFF_JITTER_MS = parseInt(process.env.WEBHOOK_BACKOFF_JITTER_MS || "30000", 10); + +// While a claim is in flight the row's nextAttemptAt is pushed forward by this +// lock window so a concurrent tick cannot re-claim it mid-POST. +const CLAIM_LOCK_MS = 30_000; + +const HTTP_TIMEOUT_MS = parseInt(process.env.WEBHOOK_HTTP_TIMEOUT_MS || "10000", 10); +const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MS || "5000", 10); +const MAX_PER_TICK = parseInt(process.env.WEBHOOK_MAX_PER_TICK || "50", 10); + +/** + * Compute the delay before the next attempt given how many attempts have + * already been made. `attemptCount` is 1-based (1 = first attempt just failed). + */ +export const computeBackoffMs = (attemptCount) => { + const idx = Math.min(attemptCount - 1, BACKOFF_SCHEDULE_MS.length - 1); + const base = BACKOFF_SCHEDULE_MS[Math.max(0, idx)]; + const jitter = BACKOFF_JITTER_MS > 0 ? Math.floor(Math.random() * BACKOFF_JITTER_MS) : 0; + return base + jitter; +}; + +const truncate = (str) => + typeof str === "string" && str.length > MAX_ERROR_LENGTH + ? str.slice(0, MAX_ERROR_LENGTH) + : str; + +// Default HTTP client: a thin axios wrapper that never throws on status and +// never follows redirects. Tests inject their own `post` to stay offline. +const defaultPost = async (url, body, headers) => { + const res = await axios.post(url, body, { + headers, + timeout: HTTP_TIMEOUT_MS, + maxRedirects: 0, + // We classify status ourselves; don't let axios throw on 4xx/5xx. + validateStatus: () => true, + transformRequest: [(data) => data], // body is already a serialized string + }); + return { status: res.status }; +}; + +/** + * Atomically claim the next due delivery. Only ONE concurrent caller can win a + * given row: the claim flips it to `retrying`, increments `attemptCount`, and + * pushes `nextAttemptAt` forward by the lock window in a single update. + * + * @returns {Promise} + */ +export const claimNextDelivery = async (now = new Date()) => { + return WebhookDelivery.findOneAndUpdate( + { + status: { $in: ["pending", "retrying"] }, + nextAttemptAt: { $lte: now }, + }, + { + $set: { status: "retrying", nextAttemptAt: new Date(now.getTime() + CLAIM_LOCK_MS) }, + $inc: { attemptCount: 1 }, + }, + { new: true, sort: { nextAttemptAt: 1 } } + ).select("+_id"); +}; + +const recordSuccessOnEndpoint = async (endpointId, when) => { + await WebhookEndpoint.updateOne( + { _id: endpointId }, + { $set: { consecutiveFailures: 0, lastSuccessAt: when, lastDeliveryAt: when } } + ); +}; + +const recordDeadOnEndpoint = async (endpointId, when) => { + const ep = await WebhookEndpoint.findOneAndUpdate( + { _id: endpointId }, + { $inc: { consecutiveFailures: 1 }, $set: { lastDeliveryAt: when } }, + { new: true } + ); + if (ep && ep.isActive && ep.consecutiveFailures >= AUTO_DISABLE_THRESHOLD) { + ep.isActive = false; + ep.disabledAt = when; + ep.disabledReason = `Auto-disabled after ${ep.consecutiveFailures} consecutive failed deliveries`; + await ep.save(); + logger.warn( + { endpointId: String(endpointId), consecutiveFailures: ep.consecutiveFailures }, + "webhook: endpoint auto-disabled after sustained failures" + ); + } +}; + +/** + * Deliver a single already-claimed delivery: sign, POST, record the outcome, + * and schedule a retry / mark dead / mark delivered as appropriate. + * + * @param {Document} delivery a claimed (status `retrying`) delivery document + * @param {object} [opts] + * @param {Function} [opts.post] injected HTTP client `(url, body, headers) => { status }` + * @param {Date} [opts.now] + * @returns {Promise} the updated delivery + */ +export const deliverClaimed = async (delivery, { post = defaultPost, now = new Date() } = {}) => { + const endpoint = await WebhookEndpoint.findById(delivery.endpoint).select( + "+secretEncrypted" + ); + + // Endpoint gone or deactivated — nothing to deliver to. Mark dead so it + // doesn't churn forever. + if (!endpoint || !endpoint.isActive) { + delivery.status = "dead"; + delivery.lastError = "Endpoint missing or inactive"; + delivery.attempts.push({ + at: now, + error: delivery.lastError, + durationMs: 0, + }); + await delivery.save(); + return delivery; + } + + // Delivery-time SSRF re-check (DNS resolution in production). + const guard = await assertDeliverableUrl(endpoint.url); + if (!guard.ok) { + return finalizeFailure(delivery, endpoint, { + statusCode: undefined, + error: `Blocked by SSRF guard: ${guard.reason}`, + durationMs: 0, + now, + }); + } + + let secret; + try { + secret = decryptSecret(endpoint.secretEncrypted); + } catch (err) { + return finalizeFailure(delivery, endpoint, { + statusCode: undefined, + error: `Secret decrypt failed: ${err.message}`, + durationMs: 0, + now, + }); + } + + // Serialize ONCE, sign those exact bytes, POST the same string. + const rawBody = JSON.stringify(delivery.payload); + const timestamp = Math.floor(now.getTime() / 1000).toString(); + const signature = signPayload({ secret, timestamp, rawBody }); + + const headers = { + "Content-Type": "application/json", + [WEBHOOK_HEADERS.EVENT]: delivery.eventType, + [WEBHOOK_HEADERS.EVENT_ID]: delivery.eventId, + [WEBHOOK_HEADERS.TIMESTAMP]: timestamp, + [WEBHOOK_HEADERS.SIGNATURE]: signature, + }; + + const started = Date.now(); + let statusCode; + let error; + try { + const res = await post(endpoint.url, rawBody, headers); + statusCode = res?.status; + } catch (err) { + error = err?.message || "delivery request failed"; + } + const durationMs = Date.now() - started; + + const delivered = statusCode >= 200 && statusCode < 300; + if (delivered) { + delivery.status = "delivered"; + delivery.deliveredAt = now; + delivery.lastError = undefined; + pushAttempt(delivery, { at: now, statusCode, durationMs }); + await delivery.save(); + await recordSuccessOnEndpoint(endpoint._id, now); + return delivery; + } + + return finalizeFailure(delivery, endpoint, { + statusCode, + error: error || `Non-2xx response: ${statusCode}`, + durationMs, + now, + }); +}; + +const pushAttempt = (delivery, attempt) => { + delivery.attempts.push({ ...attempt, error: truncate(attempt.error) }); + if (delivery.attempts.length > MAX_STORED_ATTEMPTS) { + delivery.attempts = delivery.attempts.slice(-MAX_STORED_ATTEMPTS); + } +}; + +async function finalizeFailure(delivery, endpoint, { statusCode, error, durationMs, now }) { + pushAttempt(delivery, { at: now, statusCode, error, durationMs }); + delivery.lastError = truncate(error); + + if (delivery.attemptCount >= MAX_ATTEMPTS) { + delivery.status = "dead"; + await delivery.save(); + await recordDeadOnEndpoint(endpoint._id, now); + logger.warn( + { deliveryId: String(delivery._id), attempts: delivery.attemptCount }, + "webhook: delivery moved to dead-letter" + ); + } else { + delivery.status = "retrying"; + delivery.nextAttemptAt = new Date(now.getTime() + computeBackoffMs(delivery.attemptCount)); + await delivery.save(); + } + return delivery; +} + +/** + * Claim + deliver a single due delivery. Returns the delivery, or null when + * nothing is due. Used by the interval loop and driveable directly by tests. + */ +export const processOne = async ({ post = defaultPost, now = new Date() } = {}) => { + const delivery = await claimNextDelivery(now); + if (!delivery) return null; + return deliverClaimed(delivery, { post, now }); +}; + +/** + * Drain all currently-due deliveries (bounded per invocation). + * @returns {Promise} number of deliveries processed + */ +export const runDueDeliveries = async ({ post = defaultPost, now = new Date() } = {}) => { + let processed = 0; + while (processed < MAX_PER_TICK) { + const delivery = await processOne({ post, now }); + if (!delivery) break; + processed += 1; + } + return processed; +}; + +// ── Interval loop (guarded by env flag, like the ingestion worker) ────────── +let running = false; +let pollTimer = null; + +const loop = async () => { + if (!running) return; + try { + if (mongoose.connection.readyState === 1) { + await runDueDeliveries({ now: new Date() }); + } + } catch (err) { + logger.error({ err }, "webhook: delivery loop iteration failed"); + } + if (!running) return; + pollTimer = setTimeout(loop, POLL_INTERVAL_MS); + if (pollTimer && typeof pollTimer.unref === "function") pollTimer.unref(); +}; + +export const startDeliveryWorker = async () => { + if (running) return; + running = true; + logger.info({ intervalMs: POLL_INTERVAL_MS }, "webhook: delivery worker started"); + loop(); +}; + +export const stopDeliveryWorker = async () => { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + logger.info("webhook: delivery worker stopped"); +}; diff --git a/src/services/webhooks/signing.js b/src/services/webhooks/signing.js new file mode 100644 index 00000000..f5d6950a --- /dev/null +++ b/src/services/webhooks/signing.js @@ -0,0 +1,83 @@ +// services/webhooks/signing.js +// +// HMAC-SHA256 request signing for outbound webhooks (Stripe/Svix-style). +// +// Canonical string that is signed: +// +// `${timestamp}.${rawBody}` +// +// where `timestamp` is unix seconds (as a string) and `rawBody` is the EXACT +// serialized bytes that are POSTed. Serialize the body ONCE, sign those bytes, +// and send the same buffer — re-serializing JSON can reorder keys and break +// verification on the consumer side. +import crypto from "crypto"; + +export const SIGNATURE_VERSION = "v1"; + +// Consumers must reject deliveries whose timestamp is older than this to +// blunt replay attacks. Documented in docs/webhooks.md. +export const DEFAULT_TOLERANCE_SEC = 300; // 5 minutes + +export const WEBHOOK_HEADERS = Object.freeze({ + EVENT: "X-DeenBridge-Event", + EVENT_ID: "X-DeenBridge-Event-Id", + TIMESTAMP: "X-DeenBridge-Timestamp", + SIGNATURE: "X-DeenBridge-Signature", +}); + +/** + * Build the canonical string that gets HMAC'd. + * @param {string|number} timestamp unix seconds + * @param {string} rawBody the exact serialized body being sent + */ +export const buildSignatureBase = (timestamp, rawBody) => + `${timestamp}.${rawBody}`; + +/** + * Produce the value for the X-DeenBridge-Signature header: + * `v1=` + */ +export const signPayload = ({ secret, timestamp, rawBody }) => { + const digest = crypto + .createHmac("sha256", secret) + .update(buildSignatureBase(timestamp, rawBody)) + .digest("hex"); + return `${SIGNATURE_VERSION}=${digest}`; +}; + +/** + * Constant-time verification of a signature header. This mirrors the snippet + * documented for consumers in docs/webhooks.md and is used by the test suite. + * + * @returns {boolean} true only if the version matches, the timestamp is fresh, + * and the HMAC matches in constant time. + */ +export const verifySignature = ({ + secret, + timestamp, + rawBody, + signatureHeader, + toleranceSec = DEFAULT_TOLERANCE_SEC, +}) => { + if (!signatureHeader || typeof signatureHeader !== "string") return false; + + const [version, provided] = signatureHeader.split("="); + if (version !== SIGNATURE_VERSION || !provided) return false; + + // Reject stale timestamps (replay protection). + const ts = Number(timestamp); + if (!Number.isFinite(ts)) return false; + const nowSec = Math.floor(Date.now() / 1000); + if (Math.abs(nowSec - ts) > toleranceSec) return false; + + const expected = crypto + .createHmac("sha256", secret) + .update(buildSignatureBase(timestamp, rawBody)) + .digest("hex"); + + // timingSafeEqual throws if the buffers differ in length, so guard first. + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(provided, "hex"); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +}; diff --git a/src/services/webhooks/urlGuard.js b/src/services/webhooks/urlGuard.js new file mode 100644 index 00000000..31352fd5 --- /dev/null +++ b/src/services/webhooks/urlGuard.js @@ -0,0 +1,135 @@ +// services/webhooks/urlGuard.js +// +// SSRF guard for outbound webhook targets. Validated at BOTH registration time +// (synchronous, structural checks) and delivery time (DNS resolution in +// production). Rejects non-https (outside development), loopback, RFC-1918 +// private ranges, link-local, and other non-routable targets. +// +// RESIDUAL LIMITATION (TOCTOU): DNS is resolved at delivery time, but a +// malicious operator who controls the endpoint's DNS could still rebind the +// hostname to a private address in the window between our resolution and the +// actual socket connect. Fully closing this requires pinning the resolved IP +// onto the connecting socket (custom agent/lookup), which is out of scope +// here. Registration-time literal-IP checks plus delivery-time resolution +// cover the common cases. +import net from "net"; +import dns from "dns"; + +const isDevelopment = () => process.env.NODE_ENV === "development"; + +/** + * Classify an IPv4/IPv6 address as private / non-routable and therefore an + * illegitimate webhook target. + */ +export const isPrivateAddress = (ip) => { + const family = net.isIP(ip); + if (family === 4) { + const parts = ip.split(".").map(Number); + const [a, b] = parts; + if (a === 0) return true; // 0.0.0.0/8 "this network" + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a >= 224) return true; // multicast / reserved + return false; + } + if (family === 6) { + const addr = ip.toLowerCase(); + if (addr === "::1" || addr === "::") return true; // loopback / unspecified + if (addr.startsWith("fe80")) return true; // link-local + if (addr.startsWith("fc") || addr.startsWith("fd")) return true; // unique local fc00::/7 + // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) — re-check the embedded v4. + const mapped = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateAddress(mapped[1]); + return false; + } + return false; +}; + +/** + * Structural, synchronous validation used at registration time and as a first + * pass at delivery time. Throws Error with a human-readable message on failure. + * @returns {URL} the parsed URL + */ +export const validateWebhookUrl = (rawUrl) => { + let url; + try { + url = new URL(rawUrl); + } catch { + throw new Error("Invalid webhook URL"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("Webhook URL must use http or https"); + } + + // https is mandatory everywhere except local development. + if (url.protocol !== "https:" && !isDevelopment()) { + throw new Error("Webhook URL must use https"); + } + + const hostname = url.hostname.toLowerCase(); + if (!hostname) { + throw new Error("Webhook URL must include a host"); + } + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new Error("Webhook URL host is not allowed (loopback)"); + } + + // If the host is a literal IP, reject private/non-routable ranges outright — + // no DNS needed, and enforced in every environment. + if (net.isIP(hostname) && isPrivateAddress(hostname)) { + throw new Error("Webhook URL points at a private or non-routable address"); + } + + return url; +}; + +/** + * Delivery-time (and production registration-time) check: resolve the hostname + * and reject if ANY resolved address is private/non-routable. In non-production + * environments only the structural checks run (DNS is skipped so tests and dev + * don't depend on network resolution). + * + * @returns {Promise<{ ok: boolean, reason?: string }>} + */ +export const assertDeliverableUrl = async (rawUrl) => { + let url; + try { + url = validateWebhookUrl(rawUrl); + } catch (err) { + return { ok: false, reason: err.message }; + } + + // Only resolve DNS in production. Elsewhere the structural checks above + // (including literal-IP rejection) are sufficient and keep the worker + // offline-testable. + if (process.env.NODE_ENV !== "production") { + return { ok: true }; + } + + const hostname = url.hostname.toLowerCase(); + if (net.isIP(hostname)) { + // Already validated as a public literal above. + return { ok: true }; + } + + try { + const records = await dns.promises.lookup(hostname, { all: true }); + for (const { address } of records) { + if (isPrivateAddress(address)) { + return { + ok: false, + reason: `Resolved address ${address} is private or non-routable`, + }; + } + } + } catch (err) { + return { ok: false, reason: `DNS resolution failed: ${err.message}` }; + } + + return { ok: true }; +}; diff --git a/src/services/webhooks/webhookSecret.js b/src/services/webhooks/webhookSecret.js new file mode 100644 index 00000000..75fb7cbb --- /dev/null +++ b/src/services/webhooks/webhookSecret.js @@ -0,0 +1,69 @@ +// services/webhooks/webhookSecret.js +// +// Webhook signing secrets are stored ENCRYPTED at rest (AES-256-GCM), not +// hashed — the delivery worker must recover the plaintext to compute the HMAC +// signature on every attempt, so a one-way hash is not an option. The secret +// is generated server-side, returned to the caller exactly once at creation +// (and once again on rotation), and never returned by any read endpoint. +// +// The encryption key is derived (SHA-256) from WEBHOOK_SECRET_ENCRYPTION_KEY. +// That variable is REQUIRED in production (validateEnv fails fast if missing); +// in development/test a fixed fallback key is used so the app boots without +// extra setup. Rotating WEBHOOK_SECRET_ENCRYPTION_KEY invalidates all stored +// secrets — rotate individual endpoint secrets via the API instead. +import crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // GCM standard nonce length +const DEV_FALLBACK_KEY_MATERIAL = "dnb-webhook-dev-fallback-key-do-not-use-in-prod"; + +const deriveKey = () => { + const material = process.env.WEBHOOK_SECRET_ENCRYPTION_KEY; + if (!material) { + if (process.env.NODE_ENV === "production") { + // Should never happen — validateEnv fails fast — but never fall back to + // a well-known key in production. + throw new Error("WEBHOOK_SECRET_ENCRYPTION_KEY is required in production"); + } + return crypto.createHash("sha256").update(DEV_FALLBACK_KEY_MATERIAL).digest(); + } + return crypto.createHash("sha256").update(material).digest(); +}; + +/** Generate a fresh, high-entropy webhook signing secret (hex). */ +export const generateSecret = () => crypto.randomBytes(32).toString("hex"); + +/** + * Encrypt a plaintext secret for storage. Returns `iv:authTag:ciphertext`, + * all hex-encoded. + */ +export const encryptSecret = (plaintext) => { + const key = deriveKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return [iv.toString("hex"), authTag.toString("hex"), ciphertext.toString("hex")].join(":"); +}; + +/** Decrypt a stored `iv:authTag:ciphertext` secret back to plaintext. */ +export const decryptSecret = (stored) => { + if (!stored || typeof stored !== "string") { + throw new Error("No stored secret to decrypt"); + } + const [ivHex, tagHex, ctHex] = stored.split(":"); + if (!ivHex || !tagHex || !ctHex) { + throw new Error("Malformed stored webhook secret"); + } + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ctHex, "hex")), + decipher.final(), + ]); + return plaintext.toString("utf8"); +}; diff --git a/src/services/webhooks/webhookService.js b/src/services/webhooks/webhookService.js new file mode 100644 index 00000000..3ff33637 --- /dev/null +++ b/src/services/webhooks/webhookService.js @@ -0,0 +1,166 @@ +// services/webhooks/webhookService.js +// +// Typed event catalog and the fire-and-forget `emitEvent` used by controllers. +// +// CONTRACT: emitEvent MUST NEVER throw or reject into the request path. It is +// called AFTER the Mongo transaction commits (a rolled-back write emits +// nothing). It resolves matching active endpoints, persists one +// WebhookDelivery per endpoint (status `pending`, `nextAttemptAt = now`), and +// returns. The delivery worker does the actual HTTP work out of band. All +// errors are caught and logged so a webhook problem can never break a payment. +import crypto from "crypto"; +import mongoose from "mongoose"; +import WebhookEndpoint from "../../models/WebhookEndpoint.js"; +import WebhookDelivery from "../../models/WebhookDelivery.js"; +import logger from "../../config/logger.js"; + +// Bumped when the payload envelope shape changes so consumers can branch. +export const API_VERSION = process.env.WEBHOOK_API_VERSION || "2025-01-01"; + +// The full event catalog. `ping` is emitted only via the management API. +export const EVENT_TYPES = Object.freeze({ + PAYMENT_INITIALIZED: "payment.initialized", + PAYMENT_CONFIRMED: "payment.confirmed", + PAYMENT_FAILED: "payment.failed", + PAYMENT_EXPIRED: "payment.expired", + COURSE_ENROLLED: "course.enrolled", + WALLET_CONNECTED: "wallet.connected", + WALLET_DISCONNECTED: "wallet.disconnected", + PING: "ping", +}); + +const EVENT_CATALOG = new Set(Object.values(EVENT_TYPES)); + +// Explicit allowlist of fields permitted inside `data`. Anything else (emails, +// password hashes, full user documents, secrets) is stripped before the +// envelope is persisted or sent. IDs, wallet public keys, amounts, tx hashes, +// and item references only. +const EVENT_DATA_ALLOWLIST = new Set([ + "transactionId", + "type", + "itemType", + "itemId", + "itemTitle", + "amount", + "currency", + "network", + "settlement", + "stellarTxHash", + "stellarLedger", + "status", + "failureReason", + "buyerId", + "creatorId", + "buyerWallet", + "creatorWallet", + "publicKey", + "courseId", + "userId", + "message", +]); + +/** + * Strip any key not on the allowlist. Never mutates the caller's object. + */ +export const sanitizeEventData = (data) => { + if (!data || typeof data !== "object") return {}; + const safe = {}; + for (const key of Object.keys(data)) { + if (EVENT_DATA_ALLOWLIST.has(key) && data[key] !== undefined) { + safe[key] = data[key]; + } + } + return safe; +}; + +/** + * Build the signed event envelope. Exposed for reuse/testing. + */ +export const buildEventEnvelope = (type, data) => ({ + eventId: crypto.randomUUID(), + type, + createdAt: new Date().toISOString(), + apiVersion: API_VERSION, + data: sanitizeEventData(data), +}); + +const buildPendingDelivery = (endpointId, envelope) => ({ + endpoint: endpointId, + eventId: envelope.eventId, + eventType: envelope.type, + payload: envelope, + status: "pending", + attemptCount: 0, + nextAttemptAt: new Date(), +}); + +/** + * Emit an event to every active endpoint subscribed to it (explicitly or via + * `["*"]`). Never rejects. Awaited after the txn commit; persists rows and returns while + * the HTTP delivery happens out of band (worker). No-ops when the DB is down. + * + * @returns {Promise<{ eventId: string, deliveries: number }>} + */ +export const emitEvent = async (type, data = {}) => { + try { + if (!EVENT_CATALOG.has(type)) { + logger.warn({ type }, "webhook: refusing to emit unknown event type"); + return { eventId: null, deliveries: 0 }; + } + + const envelope = buildEventEnvelope(type, data); + + // Skip persistence when the DB isn't connected (e.g. unit tests that mock + // models without a live connection, or a DB outage) — mirrors the audit + // service, so awaiting emitEvent after commit never hangs the request path. + if (mongoose.connection.readyState !== 1) { + return { eventId: envelope.eventId, deliveries: 0 }; + } + + const endpoints = await WebhookEndpoint.find({ + isActive: true, + $or: [{ events: type }, { events: "*" }], + }).select("_id"); + + if (endpoints.length === 0) { + return { eventId: envelope.eventId, deliveries: 0 }; + } + + const docs = endpoints.map((ep) => buildPendingDelivery(ep._id, envelope)); + await WebhookDelivery.insertMany(docs); + + logger.info( + { eventId: envelope.eventId, type, deliveries: docs.length }, + "webhook: event emitted" + ); + return { eventId: envelope.eventId, deliveries: docs.length }; + } catch (err) { + // Emission must never surface to the request path. + logger.error({ err, type }, "webhook: emitEvent failed"); + return { eventId: null, deliveries: 0 }; + } +}; + +/** + * Emit an event to a single, specific endpoint (used by the `ping` action). + * Also fire-and-forget-safe. Creates the delivery regardless of subscription + * so an operator can test any endpoint. + * + * @returns {Promise<{ eventId: string|null, delivery: object|null }>} + */ +export const emitEventToEndpoint = async (endpointId, type, data = {}) => { + try { + if (!EVENT_CATALOG.has(type)) { + logger.warn({ type }, "webhook: refusing to emit unknown event type"); + return { eventId: null, delivery: null }; + } + const envelope = buildEventEnvelope(type, data); + const delivery = await WebhookDelivery.create( + buildPendingDelivery(endpointId, envelope) + ); + return { eventId: envelope.eventId, delivery }; + } catch (err) { + logger.error({ err, type, endpointId }, "webhook: emitEventToEndpoint failed"); + return { eventId: null, delivery: null }; + } +}; diff --git a/src/sockets/index.js b/src/sockets/index.js new file mode 100644 index 00000000..11e66404 --- /dev/null +++ b/src/sockets/index.js @@ -0,0 +1,77 @@ +/** + * Socket.io bootstrap for real-time payment notifications. + * --------------------------------------------------------------------------- + * Owns the singleton Socket.io server instance: + * + * - `initSockets(httpServer)` — attach Socket.io to the HTTP server with + * CORS aligned to the REST API's allow-list and register the payment + * gateway. Call once from the server entrypoint. + * - `getIO()` — safe accessor used by emitters; returns `null` when sockets + * are not running (e.g. worker processes that import models but never + * start an HTTP server), so callers can no-op instead of crashing. + * - `closeSockets()` — graceful shutdown hook. + */ +import { Server } from "socket.io"; +import logger from "../config/logger.js"; +import { registerPaymentGateway } from "./paymentGateway.js"; + +/** Origins allowed to open a websocket, kept in sync with app.js corsOptions. */ +const ALLOWED_ORIGINS = [ + "https://dnb-frontend.vercel.app", + "http://localhost:3000", + "http://localhost:3001", + "https://deenbridge.vercel.app", + "http://deenbridge.vercel.app", +]; + +/** @type {import("socket.io").Server|null} */ +let io = null; + +/** + * Attach Socket.io to the given HTTP server and wire up handlers. + * + * @param {import("http").Server} httpServer + * @returns {import("socket.io").Server} + */ +export function initSockets(httpServer) { + if (io) return io; + + io = new Server(httpServer, { + cors: { + origin(origin, callback) { + // Non-browser clients (curl, workers) send no Origin header. + if (!origin || ALLOWED_ORIGINS.includes(origin)) { + return callback(null, true); + } + logger.warn({ origin }, "Blocked websocket connection from origin"); + return callback(new Error("Not allowed by CORS")); + }, + credentials: true, + methods: ["GET", "POST"], + }, + }); + + registerPaymentGateway(io); + logger.info("🔌 Socket.io payment gateway ready"); + + return io; +} + +/** + * Current Socket.io server, or null when sockets were never initialized. + * + * @returns {import("socket.io").Server|null} + */ +export function getIO() { + return io; +} + +/** + * Disconnect every client and release the instance (graceful shutdown). + */ +export async function closeSockets() { + if (!io) return; + await new Promise((resolve) => io.close(() => resolve())); + io = null; + logger.info("Socket.io connections closed"); +} diff --git a/src/sockets/paymentEvents.js b/src/sockets/paymentEvents.js new file mode 100644 index 00000000..f8515ec6 --- /dev/null +++ b/src/sockets/paymentEvents.js @@ -0,0 +1,110 @@ +/** + * Payment event contracts for the Socket.io real-time notification channel. + * --------------------------------------------------------------------------- + * This module is the single source of truth for every event name and payload + * shape emitted over the payment namespace. It is the equivalent of a DTO + * layer for our WebSocket surface: clients (and tests) should import the + * constants from here instead of hard-coding strings, and the JSDoc typedefs + * below document exactly what each event carries. + * + * Rooms + * ----- + * - `user:` — every connected socket joins its own room on + * authentication; user-scoped events land here. + * - `payment:` — opt-in room for a specific transaction. A + * socket may only join it if its authenticated + * user is the buyer or the creator of that + * transaction (see paymentGateway.js). + * + * Status mapping + * -------------- + * Internal Transaction statuses are normalized to the coarse client-facing + * statuses required by the realtime contract: + * + * pending | submitted | retrying → "pending" + * confirmed → "success" + * failed | expired | refunded | disputed → "failed" + * + * The raw internal status always travels alongside in `payment.rawStatus` so + * clients that need finer granularity can branch without a second event. + */ + +/** + * @typedef {"pending"|"submitted"|"retrying"|"confirmed"|"failed"| + * "expired"|"refunded"|"disputed"} InternalPaymentStatus + * Raw status as stored on the Transaction document. + */ + +/** + * @typedef {"pending"|"success"|"failed"} ClientPaymentStatus + * Normalized status pushed to clients. + */ + +/** + * Payload for the `payment:status` event. + * + * @typedef {object} PaymentStatusEvent + * @property {string} transactionId Transaction `_id`. + * @property {string} [reference] `expectedHash` of the transaction, when + * set — the client-side checkout handle. + * @property {ClientPaymentStatus} status Normalized lifecycle status. + * @property {InternalPaymentStatus} rawStatus Raw stored status. + * @property {("purchase"|"donation")} type Transaction kind. + * @property {string} [itemTitle] Title of the purchased item (purchases). + * @property {string} amount Amount as a precision-preserving string. + * @property {string} currency Asset code (e.g. "USDC"). + * @property {string} [stellarTxHash] On-chain hash once submitted. + * @property {string} [failureReason] Why the payment failed, when it did. + * @property {string} updatedAt ISO timestamp of the transition. + */ + +/** Event emitted whenever a transaction's status changes. */ +export const PAYMENT_STATUS_EVENT = "payment:status"; + +/** Client → server: ask to follow one specific transaction's updates. */ +export const SUBSCRIBE_PAYMENT = "payment:subscribe"; + +/** Client → server: stop following a previously subscribed transaction. */ +export const UNSUBSCRIBE_PAYMENT = "payment:unsubscribe"; + +/** Server → client: ack/error reply to subscribe/unsubscribe requests. */ +export const PAYMENT_ACK = "payment:ack"; + +/** + * Normalize an internal Transaction status into the client-facing one. + * + * @param {InternalPaymentStatus} rawStatus + * @returns {ClientPaymentStatus} + */ +export function toClientStatus(rawStatus) { + if (rawStatus === "confirmed") return "success"; + if ( + rawStatus === "failed" || + rawStatus === "expired" || + rawStatus === "refunded" || + rawStatus === "disputed" + ) { + return "failed"; + } + return "pending"; +} + +/** + * Build the room name holding every socket belonging to one user. + * + * @param {string} userId + * @returns {string} e.g. "user:507f1f77bcf86cd799439011" + */ +export function userRoom(userId) { + return `user:${userId}`; +} + +/** + * Build the opt-in room for a single transaction. + * + * @param {string} transactionId + * @returns {string} e.g. "payment:507f1f77bcf86cd799439012" + */ +export function paymentRoom(transactionId) { + return `payment:${transactionId}`; +} diff --git a/src/sockets/paymentGateway.js b/src/sockets/paymentGateway.js new file mode 100644 index 00000000..3edfa221 --- /dev/null +++ b/src/sockets/paymentGateway.js @@ -0,0 +1,179 @@ +/** + * Socket.io gateway for authenticated real-time payment updates. + * --------------------------------------------------------------------------- + * Handles the lifecycle of a payment websocket connection: + * + * 1. **Handshake auth** — mirrors `authMiddleware.protect`: the client must + * present a valid JWT (in `auth.token`, an `Authorization: Bearer` header, + * or the `authToken` cookie). The token is verified and the user must + * still exist; otherwise the connection is rejected before any handler + * runs. Defense in depth only — authorization of *data* is still enforced + * by ownership checks below. + * 2. **Personal room** — every authenticated socket automatically joins + * `user:` so user-scoped payment events can reach all its tabs. + * 3. **Transaction rooms** — clients may opt into `payment:` rooms via + * the subscribe handlers. Joining is only allowed when the authenticated + * user is the buyer or the creator (donations have no creator) of that + * transaction, so payment details never leak across accounts. + */ +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import logger from "../config/logger.js"; +import User from "../models/User.js"; +import { + PAYMENT_ACK, + SUBSCRIBE_PAYMENT, + UNSUBSCRIBE_PAYMENT, + userRoom, + paymentRoom, +} from "./paymentEvents.js"; + +/** + * Lazily resolve the Transaction model instead of importing it statically so + * this module keeps a one-way dependency direction (sockets → models stays + * runtime-only, avoiding an import cycle with Transaction's save hook). + */ +function transactionModel() { + return mongoose.model("Transaction"); +} + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +/** Extract the JWT from wherever our REST clients legitimately put it. */ +function extractToken(auth = {}) { + if (auth.token) return auth.token; + + const header = auth.headers?.authorization ?? auth.authorization; + if (typeof header === "string" && header.startsWith("Bearer ")) { + return header.slice(7); + } + + // Same cookie name the REST session flow sets (js-cookie on the frontend). + const rawCookie = auth.cookie; + if (typeof rawCookie === "string") { + const match = /(?:^|;\s*)authToken=([^;]+)/.exec(rawCookie); + if (match) return decodeURIComponent(match[1]); + } + return null; +} + +/** + * Socket.io middleware: authenticate the handshake or reject it. + * Mirrors the checks performed by `protect` in authMiddleware.js. + */ +async function authenticateSocket(socket, next) { + try { + const token = extractToken(socket.handshake.auth); + if (!token) return next(new Error("No token, authorization denied")); + + let decoded; + try { + decoded = jwt.verify(token, JWT_SECRET); + } catch { + return next(new Error("Not authorized, token failed")); + } + + const user = await User.findById(decoded.userId).select("_id"); + if (!user) return next(new Error("User not found")); + + socket.data.userId = String(user._id); + next(); + } catch (err) { + logger.error(err, "Socket authentication failed unexpectedly"); + next(new Error("Authentication error")); + } +} + +/** + * Subscribe a socket to one transaction's room after an ownership check. + * Accepts either the transaction `_id` or its `expectedHash` reference. + */ +async function handleSubscribe(socket, payload, callback) { + const ack = + typeof callback === "function" + ? callback + : () => {}; // fire-and-forget callers still work + + try { + const identifier = payload?.transactionId ?? payload?.reference; + if (!identifier || typeof identifier !== "string") { + return ack({ ok: false, error: "transactionId is required" }); + } + + const filter = mongoose.isValidObjectId(identifier) + ? { _id: identifier } + : { expectedHash: identifier }; + + const transaction = await transactionModel() + .findById(filter) + .select("buyer creator") + .lean(); + if (!transaction) { + return ack({ ok: false, error: "Transaction not found" }); + } + + const userId = socket.data.userId; + const isBuyer = transaction.buyer && String(transaction.buyer) === userId; + const isCreator = + transaction.creator && String(transaction.creator) === userId; + if (!isBuyer && !isCreator) { + logger.warn( + { userId, transactionId: String(transaction._id) }, + "Blocked payment room subscription — not a party to the transaction" + ); + return ack({ ok: false, error: "Not authorized for this transaction" }); + } + + const room = paymentRoom(String(transaction._id)); + await socket.join(room); + return ack({ ok: true, room }); + } catch (err) { + logger.error(err, "payment:subscribe failed"); + return ack({ ok: false, error: "Subscription failed" }); + } +} + +/** Remove a socket from a transaction room it previously joined. */ +async function handleUnsubscribe(socket, payload, callback) { + const ack = typeof callback === "function" ? callback : () => {}; + const identifier = payload?.transactionId ?? payload?.reference; + if (!identifier || typeof identifier !== "string") { + return ack({ ok: false, error: "transactionId is required" }); + } + await socket.leave(paymentRoom(String(identifier))); + return ack({ ok: true }); +} + +/** + * Register connection handling + rooms on a Socket.io server instance. + * Called once from initSockets() in ./index.js. + */ +export function registerPaymentGateway(io) { + io.use(authenticateSocket); + + io.on("connection", async (socket) => { + // Personal room so events can target a user across tabs/devices. + await socket.join(userRoom(socket.data.userId)); + logger.debug( + { userId: socket.data.userId, socketId: socket.id }, + "Payment socket connected" + ); + + socket.on(SUBSCRIBE_PAYMENT, (payload, cb) => + handleSubscribe(socket, payload, cb) + ); + socket.on(UNSUBSCRIBE_PAYMENT, (payload, cb) => + handleUnsubscribe(socket, payload, cb) + ); + socket.on(PAYMENT_ACK, () => { + /* reserved for future ack-only probes */ + }); + + socket.on("disconnect", (reason) => { + logger.debug( + { userId: socket.data.userId, socketId: socket.id, reason }, + "Payment socket disconnected" + ); + }); + }); +} diff --git a/src/sockets/paymentNotifier.js b/src/sockets/paymentNotifier.js new file mode 100644 index 00000000..329f902a --- /dev/null +++ b/src/sockets/paymentNotifier.js @@ -0,0 +1,97 @@ +/** + * Emitter helpers pushing payment status changes to connected clients. + * --------------------------------------------------------------------------- + * All emits are no-ops when Socket.io is not running (workers, tests, scripts) + * so payment code paths never depend on the gateway being up. + * + * Events and payload shapes are defined in ./paymentEvents.js — import the + * constants from there rather than inlining strings. + */ +import logger from "../config/logger.js"; +import { getIO } from "./index.js"; +import { + PAYMENT_STATUS_EVENT, + toClientStatus, + userRoom, + paymentRoom, +} from "./paymentEvents.js"; + +/** + * Build the client-facing payload from a Transaction document. + * + * @param {object} transaction A Transaction document (or lean object). + * @returns {import("./paymentEvents.js").PaymentStatusEvent} + */ +function buildStatusPayload(transaction) { + return { + transactionId: String(transaction._id), + reference: transaction.expectedHash ?? undefined, + status: toClientStatus(transaction.status), + rawStatus: transaction.status, + type: transaction.type, + itemTitle: transaction.itemTitle ?? undefined, + amount: transaction.amount, + currency: transaction.currency ?? "USDC", + stellarTxHash: transaction.stellarTxHash ?? undefined, + failureReason: transaction.failureReason ?? undefined, + updatedAt: + transaction.updatedAt?.toISOString?.() ?? + new Date().toISOString(), + }; +} + +/** + * Notify every interested socket that a transaction's status changed. + * + * Fans out to: + * - the `payment:` room (opt-in subscribers), + * - the buyer's personal room, + * - the creator's personal room, for purchases. + * + * Safe to call with no Socket.io server running. + * + * @param {object} transaction Transaction document AFTER its save/update. + * @returns {Promise} + */ +export async function notifyPaymentStatus(transaction) { + const socketServer = getIO(); + if (!socketServer) return; + + try { + const payload = buildStatusPayload(transaction); + const rooms = [paymentRoom(payload.transactionId)]; + + if (transaction.buyer) { + rooms.push(userRoom(String(transaction.buyer._id ?? transaction.buyer))); + } + if (transaction.creator) { + rooms.push( + userRoom(String(transaction.creator._id ?? transaction.creator)) + ); + } + + for (const room of rooms) { + socketServer.to(room).emit(PAYMENT_STATUS_EVENT, payload); + } + } catch (err) { + // Realtime is best-effort: a fan-out failure must never break payments. + logger.error( + { err, transactionId: String(transaction?._id ?? "") }, + "Failed to emit payment status update" + ); + } +} + +/** + * Convenience wrapper for flows that persist via `findOneAndUpdate` and hold + * only a plain result object instead of a full document. + * + * @param {object} rawTransaction Lean object with at least `_id` and `status`. + */ +export async function notifyPaymentStatusLean(rawTransaction) { + if (!rawTransaction) return; + await notifyPaymentStatus({ + ...rawTransaction, + updatedAt: rawTransaction.updatedAt ?? new Date(), + }); +} diff --git a/src/utils/cache.js b/src/utils/cache.js index 867093d5..3d1deb79 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -36,6 +36,7 @@ export const CACHE_KEYS = { REEL: "reel:", SEARCH: "search:", EDUCATORS: "educators:", + CATEGORIES: "categories:", }; /** diff --git a/src/utils/captcha.js b/src/utils/captcha.js new file mode 100644 index 00000000..c6415737 --- /dev/null +++ b/src/utils/captcha.js @@ -0,0 +1,47 @@ +// utils/captcha.js +// +// Pluggable captcha gate for burst-mitigation on auth endpoints. +// +// No-op when CAPTCHA_SECRET_KEY is unset — so local, dev, and test flows are +// never blocked by an unconfigured integration. When configured, verifies a +// client token against the provider's siteverify endpoint (hCaptcha and +// Google reCAPTCHA v2/v3 share the same POST + form-encoded protocol), and +// fails OPEN (logs + allows) if the captcha provider is unreachable so a +// captcha outage does not lock users out. +import axios from "axios"; +import logger from "../config/logger.js"; + +const CAPTCHA_VERIFY_URL = + process.env.CAPTCHA_VERIFY_URL || "https://hcaptcha.com/siteverify"; +const CAPTCHA_TIMEOUT_MS = + parseInt(process.env.CAPTCHA_TIMEOUT_MS, 10) || 5000; + +/** + * Verify a captcha token. Returns true when captcha is not configured + * (no-op), when the token passes, or when the provider is unreachable + * (fail-open). Returns false only when a configured provider rejects the token. + * + * @param {string} [token] + * @returns {Promise} + */ +export async function verifyCaptcha(token) { + const secret = process.env.CAPTCHA_SECRET_KEY; + if (!secret) return true; // not configured — no-op + + try { + const { data } = await axios.post( + CAPTCHA_VERIFY_URL, + new URLSearchParams({ secret, response: token || "" }), + { timeout: CAPTCHA_TIMEOUT_MS } + ); + return Boolean(data && data.success === true); + } catch (err) { + logger.warn( + { err: err.message }, + "captcha: verification failed, failing open" + ); + return true; + } +} + +export default verifyCaptcha; diff --git a/src/utils/hibp.js b/src/utils/hibp.js new file mode 100644 index 00000000..55af3a4a --- /dev/null +++ b/src/utils/hibp.js @@ -0,0 +1,81 @@ +// utils/hibp.js +// +// Breached-password check against the HaveIBeenPwned (HIBP) k-anonymity range +// API (https://haveibeenpwned.com/API/v3#PwnedPasswords). +// +// Security model: the full password is NEVER sent. We send only the first 5 +// hex characters of its SHA-1 digest (the "prefix"), and the API returns every +// known-breach suffix for that prefix. We compare locally. Because the request +// is keyed on a 20-bit prefix shared by ~millions of passwords, HIBP learns +// nothing about the specific password. +// +// Fail-open policy: on any network error / timeout / outage we return `false` +// (not breached) and log, so a HIBP outage never blocks legitimate signups or +// password resets. The static password policy (passwordPolicy.js) remains the +// hard boundary; this check is a progressive hardening layer on top. +import axios from "axios"; +import crypto from "crypto"; +import logger from "../config/logger.js"; + +const HIBP_RANGE_URL = + process.env.HIBP_RANGE_URL || "https://api.pwnedpasswords.com/range/"; +const HIBP_TIMEOUT_MS = parseInt(process.env.HIBP_TIMEOUT_MS, 10) || 2000; + +/** + * Returns true when the password has appeared in a known breach. + * Only the 5-char SHA-1 prefix is transmitted; the password never leaves the + * process. Fails open (returns false) on outage/timeout and logs the degraded + * state. + * + * @param {string} password + * @returns {Promise} + */ +export async function isPasswordBreached(password) { + if (typeof password !== "string" || password.length === 0) { + return false; + } + + const sha1 = crypto + .createHash("sha1") + .update(password) + .digest("hex") + .toUpperCase(); + const prefix = sha1.slice(0, 5); + const suffix = sha1.slice(5); + + try { + const { data } = await axios.get(`${HIBP_RANGE_URL}${prefix}`, { + timeout: HIBP_TIMEOUT_MS, + headers: { + "User-Agent": "DeenBridgeBackend/1.0", + // Ask HIBP to append padding records so responses are a fixed size and + // cannot be fingerprinted over TLS. + "Add-Padding": "true", + }, + }); + // Each line is ":". Padding records have count 0 + // and must be ignored — a real breach always has count >= 1. + const records = String(data || "") + .split("\n") + .map((line) => line.trim().split(":")) + .filter((parts) => parts.length >= 2) + .filter((parts) => parseInt(parts[1], 10) > 0); + const breached = records.some( + (parts) => parts[0].toUpperCase() === suffix + ); + + if (breached) { + logger.warn("hibp: password is present in a known breach"); + } + return breached; + } catch (err) { + // Fail open — never break signup/reset because HIBP is unreachable. + logger.warn( + { err: err.message }, + "hibp: breached-password check unavailable, failing open" + ); + return false; + } +} + +export default isPasswordBreached; diff --git a/src/utils/otp.js b/src/utils/otp.js index 44fe8306..50534532 100644 --- a/src/utils/otp.js +++ b/src/utils/otp.js @@ -1,5 +1,5 @@ import crypto from "crypto"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; /** * Generate a cryptographically secure 6-digit numeric OTP. diff --git a/src/utils/twoFactorCrypto.js b/src/utils/twoFactorCrypto.js new file mode 100644 index 00000000..f7fb1c30 --- /dev/null +++ b/src/utils/twoFactorCrypto.js @@ -0,0 +1,167 @@ +// utils/twoFactorCrypto.js +import crypto from "crypto"; +import bcrypt from "bcryptjs"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_STRING = + process.env.TWO_FACTOR_ENCRYPTION_KEY || + process.env.JWT_SECRET || + "deenbridge-default-2fa-encryption-secret-key-32-chars!"; + +// Derive a 32-byte key from key string +const getKey = () => crypto.createHash("sha256").update(KEY_STRING).digest(); + +/** + * Encrypt a plain secret string (AES-256-GCM) + * Format: ivHex:tagHex:encryptedHex + */ +export const encryptSecret = (text) => { + if (!text) return text; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + const tag = cipher.getAuthTag().toString("hex"); + return `${iv.toString("hex")}:${tag}:${encrypted}`; +}; + +/** + * Decrypt an encrypted secret string (AES-256-GCM) + */ +export const decryptSecret = (encryptedText) => { + if (!encryptedText) return encryptedText; + const parts = encryptedText.split(":"); + if (parts.length !== 3) { + // If not in iv:tag:ciphertext format (e.g. legacy/testing), return as-is + return encryptedText; + } + const [ivHex, tagHex, encryptedDataHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(encryptedDataHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +}; + +/** + * Generate N single-use recovery codes. + * Returns { plainCodes, hashedCodes } + */ +export const generateRecoveryCodes = async (count = 10) => { + const plainCodes = []; + const hashedCodes = []; + for (let i = 0; i < count; i++) { + const raw = crypto.randomBytes(6).toString("hex").toUpperCase(); + const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; + plainCodes.push(formatted); + const hashed = await bcrypt.hash(formatted, 10); + hashedCodes.push(hashed); + } + return { plainCodes, hashedCodes }; +}; + +/** + * Check input code against user's hashed recovery codes. + * If a match is found, remove the code (single-use) and return true. + */ +export const verifyAndConsumeRecoveryCode = async (user, inputCode) => { + if ( + !user.twoFactor || + !Array.isArray(user.twoFactor.recoveryCodes) || + user.twoFactor.recoveryCodes.length === 0 + ) { + return false; + } + + const formattedInput = inputCode.trim().toUpperCase(); + + for (let i = 0; i < user.twoFactor.recoveryCodes.length; i++) { + const hashed = user.twoFactor.recoveryCodes[i]; + const isMatch = await bcrypt.compare(formattedInput, hashed); + if (isMatch) { + user.twoFactor.recoveryCodes.splice(i, 1); + return true; + } + } + + return false; +}; + +/** + * Base32 decoding helper for TOTP secrets + */ +const base32Decode = (base32) => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const clean = base32.replace(/=+$/, "").toUpperCase(); + let bits = 0; + let value = 0; + const bytes = []; + for (let i = 0; i < clean.length; i++) { + const idx = alphabet.indexOf(clean[i]); + if (idx === -1) continue; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(bytes); +}; + +/** + * Generate a random Base32 TOTP secret string (20 chars) + */ +export const generateBase32Secret = (length = 20) => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const randomBytes = crypto.randomBytes(length); + let secret = ""; + for (let i = 0; i < length; i++) { + secret += alphabet[randomBytes[i] % 32]; + } + return secret; +}; + +/** + * Generate a 6-digit TOTP code (RFC 6238) for a given time step (default: current 30s window) + */ +export const generateTOTPCode = (secret, timeStep = Math.floor(Date.now() / 1000 / 30)) => { + const key = base32Decode(secret); + const buf = Buffer.alloc(8); + buf.writeBigInt64BE(BigInt(timeStep)); + const hmac = crypto.createHmac("sha1", key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const codeInt = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (codeInt % 1000000).toString().padStart(6, "0"); +}; + +/** + * Verify a 6-digit TOTP token against a Base32 secret with +-window steps (default 1 = +-30s) + */ +export const verifyTOTPCode = (token, secret, window = 1) => { + if (!token || !secret) return false; + const cleanToken = token.toString().trim(); + const currentStep = Math.floor(Date.now() / 1000 / 30); + for (let i = -window; i <= window; i++) { + const expected = generateTOTPCode(secret, currentStep + i); + if (cleanToken === expected) { + return true; + } + } + return false; +}; + +/** + * Build standard otpauth:// URI + */ +export const generateOtpauthUrl = (email, secret, issuer = "DeenBridge") => { + const label = `${issuer}:${email}`; + return `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; +}; + diff --git a/src/validators/requestValidators.js b/src/validators/requestValidators.js new file mode 100644 index 00000000..d6d72858 --- /dev/null +++ b/src/validators/requestValidators.js @@ -0,0 +1,123 @@ +import { body } from "express-validator"; +import mongoose from "mongoose"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { isValidPublicKey, NETWORK } from "../services/stellar/stellarService.js"; +import { PASSWORD_MIN } from "../utils/passwordPolicy.js"; + +const networkPassphrase = + NETWORK === "mainnet" + ? StellarSdk.Networks.PUBLIC + : StellarSdk.Networks.TESTNET; + +const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value); + +const isWellFormedXdr = (value) => { + try { + StellarSdk.TransactionBuilder.fromXDR(value, networkPassphrase); + return true; + } catch { + return false; + } +}; + +const requiredString = (field, message) => + body(field) + .exists({ values: "null" }) + .withMessage(message) + .bail() + .isString() + .withMessage(`${field} must be a string`) + .bail() + .trim() + .notEmpty() + .withMessage(message) + .bail(); + +const objectIdField = (field) => + requiredString(field, `${field} is required`) + .custom(isValidObjectId) + .withMessage(`${field} must be a valid Mongo ObjectId`); + +export const registerValidation = [ + requiredString("name", "Name is required"), + body("email") + .exists({ values: "null" }) + .withMessage("Email is required") + .bail() + .isString() + .withMessage("Email must be a string") + .bail() + .trim() + .isEmail() + .withMessage("Email must be a valid email address") + .normalizeEmail(), + body("password") + .exists({ values: "null" }) + .withMessage("Password is required") + .bail() + .isString() + .withMessage("Password must be a string") + .bail() + .isLength({ min: PASSWORD_MIN }) + .withMessage(`Password must be at least ${PASSWORD_MIN} characters`), + body("role") + .optional({ values: "undefined" }) + .isIn(["student", "mentor", "admin"]) + .withMessage("Role must be one of: student, mentor, admin"), +]; + +export const loginValidation = [ + body("email") + .exists({ values: "null" }) + .withMessage("Email is required") + .bail() + .isString() + .withMessage("Email must be a string") + .bail() + .trim() + .isEmail() + .withMessage("Email must be a valid email address") + .normalizeEmail(), + body("password") + .exists({ values: "null" }) + .withMessage("Password is required") + .bail() + .isString() + .withMessage("Password must be a string") + .bail() + .custom((value) => value.trim().length > 0) + .withMessage("Password is required"), +]; + +export const initializePaymentValidation = [ + body("itemType") + .exists({ values: "null" }) + .withMessage("itemType is required") + .bail() + .isIn(["book", "course"]) + .withMessage("itemType must be one of: book, course"), + objectIdField("itemId"), + requiredString("buyerWallet", "buyerWallet is required").custom(isValidPublicKey) + .withMessage("buyerWallet must be a valid Stellar public key"), +]; + +export const submitPaymentValidation = [ + objectIdField("transactionId"), + requiredString("signedXdr", "signedXdr is required") + .custom(isWellFormedXdr) + .withMessage("signedXdr must be a well-formed Stellar transaction XDR"), +]; + +export const connectWalletValidation = [ + requiredString("publicKey", "publicKey is required") + .custom(isValidPublicKey) + .withMessage("publicKey must be a valid Stellar public key"), +]; + +export default { + registerValidation, + loginValidation, + initializePaymentValidation, + submitPaymentValidation, + connectWalletValidation, +}; diff --git a/src/workers/pledgeScheduler.js b/src/workers/pledgeScheduler.js new file mode 100644 index 00000000..c15de0b0 --- /dev/null +++ b/src/workers/pledgeScheduler.js @@ -0,0 +1,68 @@ +import Pledge from "../models/Pledge.js"; +import PledgeCycle from "../models/PledgeCycle.js"; +import { sendNotificationToUser } from "../controllers/notificationController.js"; +import { addPledgeCadence } from "../services/pledgeService.js"; +import logger from "../config/logger.js"; + +const INTERVAL_MS = Number(process.env.PLEDGE_SCHEDULER_INTERVAL_MS || 60000); +const WINDOW_MS = Number(process.env.PLEDGE_PAYMENT_WINDOW_MS || 3 * 24 * 60 * 60 * 1000); +let running = false; +let timer = null; + +export const tickPledgeScheduler = async (now = new Date()) => { + const lapsed = await PledgeCycle.find({ status: { $in: ["due", "notified"] }, windowEndsAt: { $lte: now } }).select("pledge"); + if (lapsed.length) { + const ids = lapsed.map((cycle) => cycle._id); + await PledgeCycle.updateMany({ _id: { $in: ids } }, { $set: { status: "lapsed" } }); + await Pledge.updateMany({ _id: { $in: lapsed.map((cycle) => cycle.pledge) } }, { $set: { consecutivePaid: 0 } }); + } + + while (true) { + const pledge = await Pledge.findOneAndUpdate( + { + status: "active", + nextDueAt: { $lte: now }, + $or: [{ schedulerLockUntil: { $exists: false } }, { schedulerLockUntil: { $lte: now } }], + }, + { $set: { schedulerLockUntil: new Date(now.getTime() + 30000) } }, + { new: true, sort: { nextDueAt: 1 } } + ); + if (!pledge) break; + const dueAt = pledge.nextDueAt; + const nextDueAt = addPledgeCadence(dueAt, pledge); + try { + const cycle = await PledgeCycle.findOneAndUpdate( + { pledge: pledge._id, dueAt }, + { $setOnInsert: { status: "due", windowEndsAt: new Date(dueAt.getTime() + WINDOW_MS) } }, + { upsert: true, new: true } + ); + if (cycle.status === "due") { + await sendNotificationToUser(pledge.user, { + sender: pledge.user, + type: "pledge_due", + title: "Your sadaqah pledge is due", + message: `${pledge.amount} USDC is ready for your signature.`, + data: { pledgeId: pledge._id, pledgeCycleId: cycle._id }, + priority: "high", + }); + cycle.status = "notified"; + await cycle.save(); + } + await Pledge.updateOne({ _id: pledge._id, nextDueAt: dueAt }, { $set: { nextDueAt }, $unset: { schedulerLockUntil: 1 } }); + } catch (error) { + await Pledge.updateOne({ _id: pledge._id }, { $unset: { schedulerLockUntil: 1 } }); + logger.error({ pledgeId: pledge._id, error: error.message }, "Pledge scheduler tick failed"); + throw error; + } + } +}; + +const loop = async () => { + if (!running) return; + try { await tickPledgeScheduler(); } catch (error) { logger.error(error, "Pledge scheduler failed"); } + timer = setTimeout(loop, INTERVAL_MS); + timer.unref?.(); +}; + +export const startPledgeScheduler = async () => { if (!running) { running = true; loop(); } }; +export const stopPledgeScheduler = async () => { running = false; if (timer) clearTimeout(timer); timer = null; }; diff --git a/test/app.test.js b/test/app.test.js index acc40e2f..9cbfdb7a 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -17,9 +17,12 @@ import { let mongoServer; beforeAll(async () => { - if (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { try { - await mongoose.connect(process.env.MONGO_URI); + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); return; } catch (_err) { // Fallback to MongoMemoryServer @@ -53,10 +56,34 @@ describe("DeenBridge API", () => { expect(res.text).toContain("Welcome to DeenBridge API"); }); - it("should respond to GET /health", async () => { + it("should report degraded readiness when Redis is unavailable", async () => { const res = await request(app).get("/health"); + expect(res.statusCode).toBe(503); + expect(res.body).toMatchObject({ + success: false, + data: { + status: "unhealthy", + environment: "test", + dependencies: { + mongodb: { + status: "up", + state: "connected", + }, + redis: { + status: "down", + }, + }, + }, + }); + expect(res.body.data.uptime).toEqual(expect.any(Number)); + expect(Number.isNaN(Date.parse(res.body.data.timestamp))).toBe(false); + }); + + it("should respond to GET /ping without checking dependencies", async () => { + const res = await request(app).get("/ping"); + expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty("success", true); + expect(res.text).toBe("pong"); }); it("should respond to GET /api/courses", async () => { diff --git a/test/auditLog.test.js b/test/auditLog.test.js index 9854d001..ea3ecc32 100644 --- a/test/auditLog.test.js +++ b/test/auditLog.test.js @@ -7,6 +7,7 @@ import { jest } from "@jest/globals"; import request from "supertest"; import mongoose from "mongoose"; +import axios from "axios"; import app from "../app.js"; import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; import User from "../src/models/User.js"; @@ -26,18 +27,23 @@ const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; // Helper: mint a JWT for a user in usersStore const mintToken = (user) => - jwt.sign({ userId: user._id, role: user.role, sessionId: "sess-1" }, JWT_SECRET, { - expiresIn: "15m", - }); + jwt.sign( + { userId: user._id, role: user.role, sessionId: "sess-1", is2FAVerified: true }, + JWT_SECRET, + { expiresIn: "15m" } + ); // Helper: make a minimal user object const makeUser = (overrides = {}) => { const _id = new mongoose.Types.ObjectId().toString(); + const role = overrides.role || "student"; + const defaultTwoFactor = role === "admin" ? { enabled: true } : { enabled: false }; return { _id, name: "Test User", email: `user_${_id}@example.com`, - role: "student", + role, + twoFactor: defaultTwoFactor, save: async function () { return this; }, ...overrides, }; @@ -47,6 +53,9 @@ const makeUser = (overrides = {}) => { // Global mock setup // ───────────────────────────────────────────────────────────────────────────── beforeAll(() => { + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); + // ── AuditLog mocks ────────────────────────────────────────────────────── jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { const doc = { diff --git a/test/auth.test.js b/test/auth.test.js index 73317513..bc77e3a1 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -23,6 +23,8 @@ describe("Authentication & Session Management", () => { beforeAll(() => { // Mock axios to prevent network calls during tests jest.spyOn(axios, "post").mockResolvedValue({ status: 200, statusText: "OK", data: {} }); + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); // Mock User methods jest.spyOn(User, "findOne").mockImplementation((query) => { diff --git a/test/auth2FA.test.js b/test/auth2FA.test.js new file mode 100644 index 00000000..31361aaa --- /dev/null +++ b/test/auth2FA.test.js @@ -0,0 +1,495 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import bcrypt from "bcryptjs"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { + encryptSecret, + decryptSecret, + generateBase32Secret, + generateTOTPCode, +} from "../src/utils/twoFactorCrypto.js"; + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +describe("TOTP Two-Factor Authentication (2FA)", () => { + let usersStore = []; + let sessionsStore = []; + let auditStore = []; + + const makeUser = (overrides = {}) => { + const _id = new mongoose.Types.ObjectId().toString(); + const userDoc = { + _id, + name: "Test 2FA User", + email: `user_${_id}@example.com`, + password: "", // set in test + role: "mentor", + isVerified: true, + twoFactor: { + enabled: false, + secret: undefined, + pendingSecret: undefined, + recoveryCodes: [], + enrolledAt: undefined, + }, + save: async function () { + const idx = usersStore.findIndex((u) => u._id.toString() === this._id.toString()); + if (idx >= 0) usersStore[idx] = this; + else usersStore.push(this); + return this; + }, + ...overrides, + }; + return userDoc; + }; + + const mintToken = (user, is2FAVerified = false) => + jwt.sign( + { userId: user._id, role: user.role, sessionId: "sess-1", is2FAVerified }, + JWT_SECRET, + { expiresIn: "15m" } + ); + + beforeAll(() => { + // ── AuditLog mocks ────────────────────────────────────────────────────── + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { + _id: new mongoose.Types.ObjectId().toString(), + createdAt: new Date(), + ...data, + }; + auditStore.push(doc); + return doc; + }); + + jest.spyOn(AuditLog, "find").mockImplementation((filter = {}) => { + const filtered = auditStore.filter((d) => { + if (filter.action && d.action !== filter.action) return false; + if (filter.status && d.status !== filter.status) return false; + return true; + }); + return { + sort: function () { return this; }, + skip: function (n) { return this; }, + limit: function (n) { return this; }, + populate: function () { return this; }, + lean: async function () { return filtered; }, + then: (resolve) => resolve(filtered), + }; + }); + + jest.spyOn(AuditLog, "countDocuments").mockImplementation(async () => auditStore.length); + + // ── User mocks ────────────────────────────────────────────────────────── + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { + select: (fields) => { + // If select(+password) or select(+twoFactor.secret) is called + return found || null; + }, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { + select: (fields) => found || null, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const user = makeUser(data); + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + // ── Session mocks ─────────────────────────────────────────────────────── + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const sess = { + _id: new mongoose.Types.ObjectId().toString(), + revokedAt: null, + replacedBy: null, + lastUsedAt: new Date(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ...data, + save: async function () { return this; }, + }; + sessionsStore.push(sess); + return sess; + }); + + jest.spyOn(Session, "findOne").mockImplementation((query) => { + let found = null; + if (query?.refreshTokenHash) { + found = sessionsStore.find((s) => s.refreshTokenHash === query.refreshTokenHash); + } else if (query?._id) { + found = sessionsStore.find((s) => s._id.toString() === query._id.toString()); + } + return { + populate: () => { + if (!found) return null; + const userObj = usersStore.find((u) => u._id.toString() === (found.user?._id || found.user)?.toString()); + if (userObj) found.user = userObj; + return found; + }, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(Session, "find").mockImplementation((query) => { + let results = sessionsStore; + if (query?.user) results = results.filter((s) => (s.user?._id || s.user)?.toString() === query.user.toString()); + if (query?.revokedAt === null) results = results.filter((s) => s.revokedAt === null); + return results; + }); + + jest.spyOn(Session, "deleteMany").mockImplementation(async () => { + sessionsStore = []; + return { acknowledged: true }; + }); + }); + + beforeEach(() => { + usersStore = []; + sessionsStore = []; + auditStore = []; + delete process.env.ENABLE_TEST_RATE_LIMIT; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 1. ADMIN 2FA ENFORCEMENT + // ───────────────────────────────────────────────────────────────────────────── + describe("Admin 2FA Enforcement", () => { + it("rejects admin actions with 403 when admin has NOT enabled 2FA", async () => { + const adminNo2FA = makeUser({ + name: "Admin No 2FA", + email: "admin_no_2fa@example.com", + role: "admin", + twoFactor: { enabled: false }, + }); + usersStore.push(adminNo2FA); + const token = mintToken(adminNo2FA, false); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(403); + expect(res.body.message).toContain("Admin access requires TOTP two-factor authentication to be enabled"); + }); + + it("rejects admin actions with 403 when admin token is NOT 2FA-verified", async () => { + const adminWith2FA = makeUser({ + name: "Admin With 2FA", + email: "admin_with_2fa@example.com", + role: "admin", + twoFactor: { enabled: true, secret: encryptSecret(generateBase32Secret()) }, + }); + usersStore.push(adminWith2FA); + // Mint token without is2FAVerified claim (e.g. single factor session) + const token = mintToken(adminWith2FA, false); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(403); + expect(res.body.message).toContain("Admin access requires a 2FA-verified session"); + }); + + it("allows admin actions when admin has 2FA enabled AND token is 2FA-verified", async () => { + const adminWith2FA = makeUser({ + name: "Admin Verified", + email: "admin_verified@example.com", + role: "admin", + twoFactor: { enabled: true, secret: encryptSecret(generateBase32Secret()) }, + }); + usersStore.push(adminWith2FA); + const verifiedToken = mintToken(adminWith2FA, true); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${verifiedToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 2. ENROLL -> CONFIRM -> LOGIN ROUND-TRIP + // ───────────────────────────────────────────────────────────────────────────── + describe("Enrollment, Confirmation & Step-up Login Flow", () => { + it("completes full 2FA lifecycle (setup -> confirm -> step-up login)", async () => { + const plainPassword = "Qx7#vLmp92Zt"; + const hashedPassword = await bcrypt.hash(plainPassword, 12); + const user = makeUser({ + email: "mentor_2fa@example.com", + password: hashedPassword, + role: "mentor", + }); + usersStore.push(user); + const initialToken = mintToken(user, false); + + // ── Step 1: POST /api/auth/2fa/setup ──────────────────────────────────── + const setupRes = await request(app) + .post("/api/auth/2fa/setup") + .set("Authorization", `Bearer ${initialToken}`); + + expect(setupRes.statusCode).toBe(200); + expect(setupRes.body.success).toBe(true); + expect(setupRes.body.secret).toBeDefined(); + expect(setupRes.body.otpauthUrl).toContain("otpauth://totp/"); + expect(setupRes.body.qrCode).toContain("data:image/png;base64,"); + + const plainSecret = setupRes.body.secret; + expect(user.twoFactor.enabled).toBe(false); + expect(user.twoFactor.pendingSecret).toBeDefined(); + + // Check setup initiated audit log + await new Promise((r) => setImmediate(r)); + const setupAudit = auditStore.find((a) => a.action === AUDIT_ACTIONS.AUTH_2FA_SETUP_INITIATED); + expect(setupAudit).toBeDefined(); + + // ── Step 2: POST /api/auth/2fa/verify (Confirm Setup with wrong code) ─── + const wrongConfirmRes = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${initialToken}`) + .send({ code: "000000" }); + + expect(wrongConfirmRes.statusCode).toBe(401); + expect(user.twoFactor.enabled).toBe(false); + + // ── Step 3: POST /api/auth/2fa/verify (Confirm Setup with valid code) ──── + const validSetupCode = generateTOTPCode(plainSecret); + + const confirmRes = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${initialToken}`) + .send({ code: validSetupCode }); + + expect(confirmRes.statusCode).toBe(200); + expect(confirmRes.body.success).toBe(true); + expect(confirmRes.body.recoveryCodes).toBeDefined(); + expect(confirmRes.body.recoveryCodes.length).toBe(10); + expect(user.twoFactor.enabled).toBe(true); + expect(user.twoFactor.pendingSecret).toBeUndefined(); + + const recoveryCodes = confirmRes.body.recoveryCodes; + + // ── Step 4: POST /api/auth/login (First Factor Password Check) ─────────── + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + expect(loginRes.statusCode).toBe(200); + expect(loginRes.body.mfaRequired).toBe(true); + expect(loginRes.body.mfaToken).toBeDefined(); + // Ensure NO access or refresh tokens in password-only response! + expect(loginRes.body.accessToken).toBeUndefined(); + expect(loginRes.body.refreshToken).toBeUndefined(); + expect(loginRes.body.token).toBeUndefined(); + + const mfaToken = loginRes.body.mfaToken; + + // ── Step 5: POST /api/auth/2fa/verify (Login Step-up with invalid code) ── + const badMfaRes = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken, code: "999999" }); + + expect(badMfaRes.statusCode).toBe(401); + + // ── Step 6: POST /api/auth/2fa/verify (Login Step-up with valid code) ─── + const validLoginCode = generateTOTPCode(plainSecret); + + const mfaSuccessRes = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken, code: validLoginCode }); + + expect(mfaSuccessRes.statusCode).toBe(200); + expect(mfaSuccessRes.body.success).toBe(true); + expect(mfaSuccessRes.body.accessToken).toBeDefined(); + expect(mfaSuccessRes.body.refreshToken).toBeDefined(); + + // Verify the issued JWT access token has is2FAVerified: true + const decodedAccess = jwt.verify(mfaSuccessRes.body.accessToken, JWT_SECRET); + expect(decodedAccess.is2FAVerified).toBe(true); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 3. SINGLE-USE RECOVERY CODES + // ───────────────────────────────────────────────────────────────────────────── + describe("Single-Use Recovery Codes", () => { + it("allows login via recovery code and burns the used code immediately", async () => { + const plainPassword = "Qx7#vLmp92Zt"; + const hashedPassword = await bcrypt.hash(plainPassword, 12); + const secret = generateBase32Secret(); + + // Generate hashed recovery codes + const rawCode = "A1B2-C3D4-E5F6"; + const hashedCode = await bcrypt.hash(rawCode, 10); + + const user = makeUser({ + email: "recovery_user@example.com", + password: hashedPassword, + role: "mentor", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + recoveryCodes: [hashedCode], + enrolledAt: new Date(), + }, + }); + usersStore.push(user); + + // 1. Password check + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + expect(loginRes.body.mfaRequired).toBe(true); + const mfaToken1 = loginRes.body.mfaToken; + + // 2. Submit recovery code for second factor + const mfaRes1 = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken: mfaToken1, recoveryCode: rawCode }); + + expect(mfaRes1.statusCode).toBe(200); + expect(mfaRes1.body.accessToken).toBeDefined(); + + // Verify the code was burned (removed from user.twoFactor.recoveryCodes) + expect(user.twoFactor.recoveryCodes.length).toBe(0); + + // 3. Attempt to reuse the SAME recovery code on a new login attempt + const loginRes2 = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + const mfaToken2 = loginRes2.body.mfaToken; + + const mfaRes2 = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken: mfaToken2, recoveryCode: rawCode }); + + expect(mfaRes2.statusCode).toBe(401); + expect(mfaRes2.body.message).toContain("Invalid 2FA code or recovery code"); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 4. SECRET & RECOVERY CODE SERIALIZATION EXCLUSION + // ───────────────────────────────────────────────────────────────────────────── + describe("Secret & Recovery Code Privacy", () => { + it("never serializes secret or recovery code hashes in user responses", async () => { + const secret = generateBase32Secret(); + const user = makeUser({ + email: "privacy_user@example.com", + role: "student", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + recoveryCodes: ["$2b$10$hashedrecoverycode"], + }, + }); + usersStore.push(user); + + const token = mintToken(user, true); + + // Check logout/user responses + const userRes = await request(app) + .get("/api/auth/sessions") + .set("Authorization", `Bearer ${token}`); + + const jsonStr = JSON.stringify(userRes.body); + expect(jsonStr).not.toContain("twoFactor"); + expect(jsonStr).not.toContain("secret"); + expect(jsonStr).not.toContain("recoveryCodes"); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 5. RATE LIMITING + // ───────────────────────────────────────────────────────────────────────────── + describe("Rate Limiting on 2FA Verification", () => { + it("throttles repeated invalid verify requests to 429 when enabled", async () => { + process.env.ENABLE_TEST_RATE_LIMIT = "true"; + + const user = makeUser({ email: "ratelimit_user@example.com" }); + usersStore.push(user); + const token = mintToken(user, false); + + let lastStatus = 0; + for (let i = 0; i < 6; i++) { + const res = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${token}`) + .send({ code: "000000" }); + lastStatus = res.statusCode; + } + + expect(lastStatus).toBe(429); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 6. DISABLE 2FA + // ───────────────────────────────────────────────────────────────────────────── + describe("Disable 2FA", () => { + it("requires a valid code to disable 2FA", async () => { + const secret = generateBase32Secret(); + const user = makeUser({ + email: "disable_user@example.com", + role: "mentor", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + enrolledAt: new Date(), + }, + }); + usersStore.push(user); + + const token = mintToken(user, true); + + // Invalid code -> 401 + const badDisableRes = await request(app) + .post("/api/auth/2fa/disable") + .set("Authorization", `Bearer ${token}`) + .send({ code: "111111" }); + + expect(badDisableRes.statusCode).toBe(401); + expect(user.twoFactor.enabled).toBe(true); + + // Valid code -> 200 + const validCode = generateTOTPCode(secret); + + const validDisableRes = await request(app) + .post("/api/auth/2fa/disable") + .set("Authorization", `Bearer ${token}`) + .send({ code: validCode }); + + expect(validDisableRes.statusCode).toBe(200); + expect(user.twoFactor.enabled).toBe(false); + expect(user.twoFactor.secret).toBeUndefined(); + }); + }); +}); diff --git a/test/authRoles.test.js b/test/authRoles.test.js index d760c800..c4dc4dfb 100644 --- a/test/authRoles.test.js +++ b/test/authRoles.test.js @@ -2,7 +2,6 @@ import { jest } from "@jest/globals"; import express from "express"; import request from "supertest"; import mongoose from "mongoose"; -import { MongoMemoryServer } from "mongodb-memory-server"; import User from "../src/models/User.js"; import PendingUser from "../src/models/PendingUser.js"; import Book from "../src/models/Book.js"; @@ -10,33 +9,205 @@ import Space from "../src/models/Space.js"; import Course from "../src/models/Course.js"; import "../src/jobs/handlers.js"; import { protect, authorize, restrictTo } from "../src/middlewares/authMiddleware.js"; +import { authorizeOwnership } from "../src/middlewares/authorize.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; import { registerUser } from "../src/controllers/authController.js"; import { deleteBook } from "../src/controllers/books/bookController.js"; import { deleteSpace, updateSpace } from "../src/controllers/spaceController.js"; import { updateUser, deleteUser, getUser } from "../src/controllers/userController.js"; -import { updateCourse } from "../src/controllers/courses/courseController.js"; describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { - let mongoServer; - - beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); - }, 30000); - - afterAll(async () => { - await mongoose.disconnect(); - if (mongoServer) { - await mongoServer.stop(); - } + let usersStore = []; + let pendingStore = []; + let booksStore = []; + let spacesStore = []; + let coursesStore = []; + + beforeAll(() => { + jest.spyOn(User, "create").mockImplementation(async (data) => { + if (data.role && !["student", "mentor", "admin"].includes(data.role)) { + throw new Error(`User validation failed: role: \`${data.role}\` is not a valid enum value for path \`role\`.`); + } + const _id = new mongoose.Types.ObjectId().toString(); + const user = { + _id, + role: "student", + following: [], + followers: [], + purchasedBooks: [], + purchasedCourses: [], + save: async function () { return this; }, + toObject: function () { + const clone = { ...this }; + delete clone.save; + delete clone.toObject; + return clone; + }, + ...data, + }; + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "findOne").mockImplementation(async (query) => { + if (query?.email) return usersStore.find((u) => u.email === query.email) || null; + if (query?._id) return usersStore.find((u) => u._id.toString() === query._id.toString()) || null; + return null; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id?.toString()); + let selectedFields = null; + const queryObj = { + select: (fields) => { + selectedFields = fields; + return queryObj; + }, + then: (resolve) => { + if (!found) return resolve(null); + const clone = { ...found }; + if (selectedFields && typeof selectedFields === "string") { + if (selectedFields.includes("-password")) { + delete clone.password; + } else { + const allowed = selectedFields.split(" "); + Object.keys(clone).forEach((key) => { + if (key !== "_id" && !allowed.includes(key)) { + delete clone[key]; + } + }); + } + } + return resolve(clone); + }, + }; + return queryObj; + }); + + jest.spyOn(User, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const user = usersStore.find((u) => u._id.toString() === id?.toString()); + if (!user) return null; + const targetEmail = update.email || update.$set?.email; + if (targetEmail) { + const existing = usersStore.find((u) => u.email === targetEmail && u._id.toString() !== id.toString()); + if (existing) { + const err = new Error("E11000 duplicate key error collection"); + err.code = 11000; + throw err; + } + } + if (update.$set) Object.assign(user, update.$set); + else Object.assign(user, update); + return user; + }); + + jest.spyOn(User, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = usersStore.findIndex((u) => u._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = usersStore[idx]; + usersStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) { + Object.assign(pending, update); + } else { + pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pendingStore.push(pending); + } + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.email) return pendingStore.find((p) => p.email === query.email) || null; + return null; + }); + + jest.spyOn(PendingUser, "deleteMany").mockImplementation(async () => { + pendingStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Book, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const book = { _id, ...data }; + booksStore.push(book); + return book; + }); + + jest.spyOn(Book, "findById").mockImplementation(async (id) => { + return booksStore.find((b) => b._id.toString() === id?.toString()) || null; + }); + + jest.spyOn(Book, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = booksStore.findIndex((b) => b._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = booksStore[idx]; + booksStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(Book, "deleteMany").mockImplementation(async () => { + booksStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Space, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const space = { _id, ...data }; + spacesStore.push(space); + return space; + }); + + jest.spyOn(Space, "findById").mockImplementation(async (id) => { + return spacesStore.find((s) => s._id.toString() === id?.toString()) || null; + }); + + jest.spyOn(Space, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = spacesStore.findIndex((s) => s._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = spacesStore[idx]; + spacesStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(Space, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const space = spacesStore.find((s) => s._id.toString() === id?.toString()); + if (!space) return null; + Object.assign(space, update); + return space; + }); + + jest.spyOn(Space, "deleteMany").mockImplementation(async () => { + spacesStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Course, "deleteMany").mockImplementation(async () => { + coursesStore = []; + return { acknowledged: true }; + }); }); - beforeEach(async () => { - await User.deleteMany({}); - await PendingUser.deleteMany({}); - await Book.deleteMany({}); - await Space.deleteMany({}); - await Course.deleteMany({}); + beforeEach(() => { + usersStore = []; + pendingStore = []; + booksStore = []; + spacesStore = []; + coursesStore = []; }); describe("User Model Role Enum Validation", () => { @@ -71,7 +242,8 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { app.get( "/admin-only", (req, _res, next) => { - req.user = { role: "admin" }; + req.user = { role: "admin", twoFactor: { enabled: true } }; + req.is2FAVerified = true; next(); }, authorize("admin"), @@ -191,6 +363,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { email: "admin_auth@example.com", password: "Qx7#vLmp92Zt", role: "admin", + twoFactor: { enabled: true }, }); testBook = await Book.create({ @@ -222,11 +395,16 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { req.user = studentUser; next(); }); - app.delete("/books/:id", deleteBook); + app.delete( + "/books/:id", + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), + deleteBook + ); + app.use(errorHandler); const res = await request(app).delete(`/books/${testBook._id}`); expect(res.status).toBe(403); - expect(res.body.message).toContain("Not authorized to delete this book"); + expect(res.body.message).toContain("not authorized to modify this book"); // Verify book still exists const bookExists = await Book.findById(testBook._id); @@ -260,6 +438,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { const appAdmin = express(); appAdmin.use((req, _res, next) => { req.user = adminUser; + req.is2FAVerified = true; next(); }); appAdmin.delete("/books/:id", deleteBook); @@ -275,8 +454,17 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { req.user = studentUser; next(); }); - app.delete("/spaces/:id", deleteSpace); - app.put("/spaces/:id", updateSpace); + app.delete( + "/spaces/:id", + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + deleteSpace + ); + app.put( + "/spaces/:id", + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + updateSpace + ); + app.use(errorHandler); const resDelete = await request(app).delete(`/spaces/${testSpace._id}`); expect(resDelete.status).toBe(403); @@ -328,6 +516,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { app.use(express.json()); app.use((req, _res, next) => { req.user = adminUser; + req.is2FAVerified = true; next(); }); app.put("/users/:id", updateUser); diff --git a/test/authSecurity.test.js b/test/authSecurity.test.js new file mode 100644 index 00000000..e1d3b6ea --- /dev/null +++ b/test/authSecurity.test.js @@ -0,0 +1,366 @@ +// test/authSecurity.test.js +// +// Jest + supertest tests for the authentication abuse hardening (issue #89): +// - progressive per-account login lockout (failedLoginAttempts + lockUntil) +// - AUTH_ACCOUNT_LOCKED audit emission +// - per-email signup / verification-resend throttling (survives IP rotation) +// - captcha gate no-op when unconfigured +// +// Uses the in-memory mock-store pattern (no DB / no network / no HIBP). +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import axios from "axios"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; + +// Set a small per-email window BEFORE importing the app so the email limiter +// (created at module load) picks up max=3 for the burst assertions. Cleaned up +// in afterAll so other suites re-import with the production defaults. +let app; + +describe("Authentication abuse hardening (issue #89)", () => { + let usersStore = []; + let sessionsStore = []; + let pendingStore = []; + let auditStore = []; + let emailAuthLimiter; + let usedEmails = new Set(); + + beforeAll(async () => { + process.env.RATE_LIMIT_EMAIL_AUTH_MAX = "3"; + process.env.RATE_LIMIT_EMAIL_AUTH_WINDOW_MS = String(60 * 1000); + ({ default: app } = await import("../app.js")); + ({ emailAuthLimiter } = await import("../src/middlewares/security.js")); + + // Block real network: HIBP range GET + any POST axios would make. + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); + jest.spyOn(axios, "post").mockResolvedValue({ status: 200, statusText: "OK", data: {} }); + + // ── AuditLog mock (recordAudit writes into auditStore) ──────────────── + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { _id: new mongoose.Types.ObjectId().toString(), createdAt: new Date(), ...data }; + auditStore.push(doc); + return doc; + }); + + // ── User mocks ───────────────────────────────────────────────────────── + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + // Atomic $inc used by loginUser's failed-login path — applies the + // increment to the same store object the assertions inspect. + jest.spyOn(User, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const user = usersStore.find((u) => u._id.toString() === id.toString()); + if (!user) return null; + if (update?.$inc) { + for (const [field, amount] of Object.entries(update.$inc)) { + user[field] = (user[field] || 0) + amount; + } + } + return user; + }); + + jest.spyOn(User, "updateOne").mockImplementation(async (query, update) => { + const user = usersStore.find((u) => u._id.toString() === query?._id?.toString()); + if (user && update?.$set) Object.assign(user, update.$set); + return { acknowledged: true, modifiedCount: 1 }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { _id, ...data, save: async function () { return this; } }; + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + // ── PendingUser mocks (register -> pending -> verify flow) ──────────── + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) Object.assign(pending, update); + else pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.verificationToken) { + return pendingStore.find((p) => p.verificationToken === query.verificationToken) || null; + } + if (query?.email) { + return pendingStore.find((p) => p.email === query.email) || null; + } + return null; + }); + + jest.spyOn(PendingUser, "deleteOne").mockImplementation(async (query) => { + pendingStore = pendingStore.filter((p) => p._id !== query?._id && p.email !== query?.email); + return { deletedCount: 1 }; + }); + + // ── Session mocks ────────────────────────────────────────────────────── + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const session = { + _id, + revokedAt: null, + replacedBy: null, + lastUsedAt: new Date(), + ...data, + save: async function () { return this; }, + }; + sessionsStore.push(session); + return session; + }); + + jest.spyOn(Session, "find").mockImplementation((query) => sessionsStore); + jest.spyOn(Session, "updateOne").mockImplementation(async () => ({ acknowledged: true })); + jest.spyOn(Session, "updateMany").mockImplementation(async () => ({ acknowledged: true })); + jest.spyOn(Session, "deleteMany").mockImplementation(async () => { + sessionsStore = []; + return { acknowledged: true }; + }); + }); + + afterAll(() => { + delete process.env.RATE_LIMIT_EMAIL_AUTH_MAX; + delete process.env.RATE_LIMIT_EMAIL_AUTH_WINDOW_MS; + jest.restoreAllMocks(); + }); + + beforeEach(() => { + usersStore = []; + sessionsStore = []; + pendingStore = []; + auditStore = []; + // Reset the per-email limiter buckets so counters never carry across tests. + for (const email of usedEmails) { + emailAuthLimiter?.resetKey(`email:${email}`); + } + usedEmails = new Set(); + }); + + const trackEmail = (email) => usedEmails.add(email); + + const makeUser = (overrides = {}) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { + _id, + name: "Lockout User", + email: "lockout@example.com", + password: "hash", + role: "student", + isVerified: true, + failedLoginAttempts: 0, + lockUntil: null, + ...overrides, + }; + user.save = async function () { return this; }; + return user; + }; + + const flushAudit = () => new Promise((resolve) => setImmediate(resolve)); + + // ── 1. Progressive login lockout ───────────────────────────────────────── + describe("progressive login lockout", () => { + it("locks the account after N consecutive failures with escalating backoff", async () => { + const email = "lockout@example.com"; + const user = makeUser({ email }); + usersStore.push(user); + + // 4 failures: still just 401s, counters increment. + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "wrong-password" }); + expect(res.statusCode).toBe(401); + } + expect(user.failedLoginAttempts).toBe(4); + expect(user.lockUntil).toBeNull(); + + // 5th failure: lock is applied. + const fifth = await request(app) + .post("/api/auth/login") + .send({ email, password: "wrong-password" }); + expect(fifth.statusCode).toBe(401); + + expect(user.failedLoginAttempts).toBe(5); + expect(user.lockUntil).toBeInstanceOf(Date); + expect(new Date(user.lockUntil).getTime()).toBeGreaterThan(Date.now()); + + await flushAudit(); + expect( + auditStore.some((a) => a.action === AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED && a.status === "failure") + ).toBe(true); + }); + + it("rejects even a correct password while locked, without leaking the account exists", async () => { + const email = "locked@example.com"; + const hashed = await bcrypt.hash("CorrectPass123!", 4); + const user = makeUser({ email, password: hashed, failedLoginAttempts: 5, lockUntil: new Date(Date.now() + 60 * 1000) }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "CorrectPass123!" }); + + // Identical to a nonexistent-account login — no enumeration. + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe("Invalid credentials"); + // The response must not reveal the account exists beyond the generic + // "Invalid credentials" phrasing. + expect(res.body.message).not.toMatch(/user|account exists|found|attempts/i); + expect(user.failedLoginAttempts).toBe(5); // untouched while locked + }); + + it("auto-clears the lock after backoff and resets the counters on success", async () => { + const email = "recovering@example.com"; + const hashed = await bcrypt.hash("CorrectPass123!", 4); + const user = makeUser({ + email, + password: hashed, + failedLoginAttempts: 6, + // Simulate the backoff window having elapsed (time advance). + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "CorrectPass123!" }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(user.failedLoginAttempts).toBe(0); + expect(user.lockUntil).toBeNull(); + }); + + it("extends the lock with a longer backoff on further failures after expiry", async () => { + const email = "escalating@example.com"; + const user = makeUser({ + email, + failedLoginAttempts: 5, + // Backoff for the first lock has elapsed (time advance). + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "still-wrong" }); + + expect(res.statusCode).toBe(401); + expect(user.failedLoginAttempts).toBe(6); + // 6th failure => base * 2^(6-5) = 2 minutes, not the 1-minute base. + const remainingMs = new Date(user.lockUntil).getTime() - Date.now(); + expect(remainingMs).toBeGreaterThan(90 * 1000); + expect(remainingMs).toBeLessThanOrEqual(120 * 1000); + }); + + it("caps the escalating backoff at LOGIN_LOCKOUT_MAX_MS", async () => { + const email = "capped@example.com"; + const user = makeUser({ + email, + failedLoginAttempts: 20, + // Prior lock has long since elapsed. + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "still-wrong" }); + + expect(res.statusCode).toBe(401); + expect(user.failedLoginAttempts).toBe(21); + const maxMs = 24 * 60 * 60 * 1000; // LOGIN_LOCKOUT_MAX_MS default (24h) + const remainingMs = new Date(user.lockUntil).getTime() - Date.now(); + expect(remainingMs).toBeGreaterThan(maxMs - 60 * 1000); + expect(remainingMs).toBeLessThanOrEqual(maxMs); + }); + }); + + // ── 2. Per-email signup / verification throttling ──────────────────────── + describe("per-email signup/verification throttling", () => { + it("returns 429 for burst signups with the same email (works in test env)", async () => { + const email = "burst-signup@example.com"; + trackEmail(email); + const statuses = []; + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/register") + .send({ + name: `Burst ${i}`, + email, + password: "Qx7#vLmp92Zt", + role: "student", + }); + statuses.push(res.statusCode); + } + + expect(statuses.slice(0, 3)).toEqual([201, 201, 201]); + expect(statuses[3]).toBe(429); + }); + + it("returns 429 for burst verification resends with the same email", async () => { + const email = "burst-resend@example.com"; + trackEmail(email); + const pending = { + _id: new mongoose.Types.ObjectId().toString(), + email, + name: "Resend", + verificationToken: "initial-token", + }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + + const statuses = []; + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/resend-verification") + .send({ email }); + statuses.push(res.statusCode); + } + + expect(statuses.slice(0, 3)).toEqual([200, 200, 200]); + expect(statuses[3]).toBe(429); + }); + }); + + // ── 3. Captcha gate is a pluggable no-op when unconfigured ─────────────── + describe("captcha gate", () => { + it("passes register requests when captcha is not configured", async () => { + const email = "nocaptcha@example.com"; + trackEmail(email); + const res = await request(app) + .post("/api/auth/register") + .send({ + name: "No Captcha", + email, + password: "Qx7#vLmp92Zt", + captchaToken: "whatever-client-token", + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/bookDeleteAuthorization.test.js b/test/bookDeleteAuthorization.test.js new file mode 100644 index 00000000..9a4ac6b6 --- /dev/null +++ b/test/bookDeleteAuthorization.test.js @@ -0,0 +1,119 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; +import app from "../app.js"; +import Book from "../src/models/Book.js"; +import Session from "../src/models/Session.js"; +import User from "../src/models/User.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +describe("Book deletion authorization", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + beforeEach(async () => { + await Promise.all([ + Book.deleteMany({}), + Session.deleteMany({}), + User.deleteMany({}), + ]); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + const createBook = (author) => + Book.create({ + title: "Protected Book", + author, + category: "History", + description: "A book that only its author may delete", + image: "https://example.com/book.jpg", + fileUrl: "https://example.com/book.pdf", + }); + + it("rejects unauthenticated deletion with 401", async () => { + const book = await createBook(new mongoose.Types.ObjectId()); + + const response = await request(app).delete(`/api/books/${book._id}`); + + expect(response.status).toBe(401); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("rejects deletion by a non-owner with 403", async () => { + const owner = await User.create({ + name: "Book Owner", + email: "book.owner@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + isVerified: true, + }); + const { token } = await seedUserAndLogin(app, { + name: "Other User", + email: "other.book.user@example.com", + }); + const book = await createBook(owner._id); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(403); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("allows the author to delete their book", async () => { + const { token, user } = await seedUserAndLogin(app, { + name: "Book Owner", + email: "deleting.owner@example.com", + role: "mentor", + }); + const book = await createBook(user._id); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + message: "Book deleted", + data: null, + }); + expect(await Book.exists({ _id: book._id })).toBeNull(); + }); + + it("returns 404 when the book does not exist", async () => { + const { token } = await seedUserAndLogin(app, { + name: "Missing Book Owner", + email: "missing.book.owner@example.com", + }); + + const response = await request(app) + .delete(`/api/books/${new mongoose.Types.ObjectId()}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe("Book not found"); + }); + + it("returns 400 for an invalid book id", async () => { + const { token } = await seedUserAndLogin(app, { + name: "Invalid Book Owner", + email: "invalid.book.owner@example.com", + }); + + const response = await request(app) + .delete("/api/books/not-a-book-id") + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(400); + }); +}); diff --git a/test/bookUpload.test.js b/test/bookUpload.test.js index a2d6ebcc..88fe5263 100644 --- a/test/bookUpload.test.js +++ b/test/bookUpload.test.js @@ -23,8 +23,20 @@ describe("Media Upload Hardening", () => { let mongoServer; beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } // Mock cloudinary upload stream jest.spyOn(cloudinary.uploader, "upload_stream").mockImplementation((options, cb) => { @@ -41,20 +53,13 @@ describe("Media Upload Hardening", () => { const { token: authToken, user } = await seedUserAndLogin(app, { name: "Uploader", email: "uploader@example.com", + role: "mentor", + verifiedEducator: true, }); token = authToken; testUser = user; }); - afterAll(async () => { - await mongoose.disconnect(); - if (mongoServer) { - await mongoServer.stop(); - } - jest.restoreAllMocks(); - }); - - it("should reject oversized files (Multer limits)", async () => { const largeBuffer = Buffer.alloc(55 * 1024 * 1024); // 55MB (limit is 50MB) @@ -68,8 +73,8 @@ describe("Media Upload Hardening", () => { .field("price", 10) .field("description", "Test Description"); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/File too large/i); + expect([400, 413]).toContain(res.status); + expect(res.body.message).toMatch(/File too large|Payload Too Large/i); }); it("should reject mismatched magic bytes (server validation)", async () => { @@ -149,4 +154,14 @@ describe("Media Upload Hardening", () => { expect(res.status).toBe(302); expect(res.headers.location).toBe("https://example.com/signed-url"); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); + diff --git a/test/breachedPassword.test.js b/test/breachedPassword.test.js new file mode 100644 index 00000000..949f2b4f --- /dev/null +++ b/test/breachedPassword.test.js @@ -0,0 +1,239 @@ +// test/breachedPassword.test.js +// +// Jest + supertest tests for the HaveIBeenPwned breached-password check +// (issue #89): rejects breached passwords at register AND reset, only sends +// the 5-char SHA-1 prefix, and fails OPEN on a HIBP outage. +// +// The HIBP range call is ALWAYS mocked — never hits the network in CI. +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import crypto from "crypto"; +import axios from "axios"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { hashOtp } from "../src/utils/otp.js"; + +// A password that passes the static policy so ONLY the breach check decides. +const STRONG_PASSWORD = "Qx7#vLmp92Zt"; +const SHA1 = crypto + .createHash("sha1") + .update(STRONG_PASSWORD) + .digest("hex") + .toUpperCase(); +const PREFIX = SHA1.slice(0, 5); +const SUFFIX = SHA1.slice(5); + +// HIBP range response listing our password as breached (suffix:count lines). +const BREACHED_BODY = `${SUFFIX}:873482\n0123ABCDEF:2\nFFFF0000AA:1`; + +describe("Breached-password rejection (HIBP)", () => { + let usersStore = []; + let pendingStore = []; + let auditStore = []; + let getSpy; + + beforeAll(async () => { + // Mock AuditLog.create so recordAudit writes into auditStore. + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { _id: new mongoose.Types.ObjectId().toString(), createdAt: new Date(), ...data }; + auditStore.push(doc); + return doc; + }); + + // User mocks (login/reset read by email; register writes pending). + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { _id, ...data, save: async function () { return this; } }; + usersStore.push(user); + return user; + }); + + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) Object.assign(pending, update); + else pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.email) return pendingStore.find((p) => p.email === query.email) || null; + return null; + }); + + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const session = { _id, ...data, save: async function () { return this; } }; + return session; + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + usersStore = []; + pendingStore = []; + auditStore = []; + if (getSpy) getSpy.mockRestore(); + }); + + // Default: mock the range GET as NOT breached (empty body). Accepts either a + // response body string or a custom mock implementation (e.g. a rejection for + // outage tests) so the suite's shared getSpy is always the spy that gets + // restored by beforeEach. + const mockHibp = (dataOrImpl) => { + getSpy = + typeof dataOrImpl === "function" + ? jest.spyOn(axios, "get").mockImplementation(dataOrImpl) + : jest + .spyOn(axios, "get") + .mockResolvedValue({ status: 200, statusText: "OK", data: dataOrImpl }); + return getSpy; + }; + + const flushAudit = () => new Promise((resolve) => setImmediate(resolve)); + + describe("register", () => { + it("rejects a known-breached password with 400 and audits the failure", async () => { + mockHibp(BREACHED_BODY); + const email = "breached-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Breached", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/breach/i); + // No pending user was created. + expect(pendingStore.find((p) => p.email === email)).toBeFalsy(); + + await flushAudit(); + const row = auditStore.find((a) => a.action === AUDIT_ACTIONS.AUTH_REGISTER_FAILURE); + expect(row).toBeDefined(); + expect(row.metadata?.reason).toBe("breached_password"); + }); + + it("sends only the 5-char SHA-1 prefix — never the full hash or password", async () => { + const getSpy = mockHibp(BREACHED_BODY); + + await request(app) + .post("/api/auth/register") + .send({ name: "Prefix", email: "prefix@example.com", password: STRONG_PASSWORD }); + + const [url] = getSpy.mock.calls[0]; + expect(url).toContain(`/range/${PREFIX}`); + expect(url).not.toContain(SHA1); + expect(url).not.toContain(SUFFIX); + }); + + it("accepts a non-breached password", async () => { + mockHibp(""); // no suffixes + const email = "clean-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Clean", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("ignores HIBP padding records (count 0) and never treats them as a breach", async () => { + // Add-Padding responses append fake suffixes with a 0 occurrence count; + // even our own suffix must be ignored when its count is 0. + mockHibp(`${SUFFIX}:0\n0123ABCDEF:2\nFFFF0000AA:1`); + const email = "padding-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Padding", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("fails OPEN (allows signup) when the HIBP API is down", async () => { + mockHibp(() => + Promise.reject(new Error("ECONNREFUSED — HIBP outage")) + ); + const email = "outage-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Outage", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("reset password", () => { + it("rejects a known-breached new password at reset", async () => { + const email = "breached-reset@example.com"; + const hashedPassword = await bcrypt.hash("oldPassword123", 4); + const hashedOtp = await hashOtp("123456"); + const user = { + _id: new mongoose.Types.ObjectId().toString(), + name: "Reset", + email, + password: hashedPassword, + role: "student", + resetTokenHash: hashedOtp, + resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000), + save: async function () { return this; }, + }; + usersStore.push(user); + + mockHibp(BREACHED_BODY); + + const res = await request(app) + .post("/api/auth/reset-password") + .send({ email, otp: "123456", newPassword: STRONG_PASSWORD }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/breach/i); + }); + + it("accepts a non-breached new password at reset", async () => { + const email = "clean-reset@example.com"; + const hashedPassword = await bcrypt.hash("oldPassword123", 4); + const hashedOtp = await hashOtp("123456"); + const user = { + _id: new mongoose.Types.ObjectId().toString(), + name: "Reset", + email, + password: hashedPassword, + role: "student", + resetTokenHash: hashedOtp, + resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000), + save: async function () { return this; }, + }; + usersStore.push(user); + + mockHibp(""); // not breached + + const res = await request(app) + .post("/api/auth/reset-password") + .send({ email, otp: "123456", newPassword: STRONG_PASSWORD }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/claimableBalanceService.test.js b/test/claimableBalanceService.test.js new file mode 100644 index 00000000..2c1e5503 --- /dev/null +++ b/test/claimableBalanceService.test.js @@ -0,0 +1,267 @@ +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + describePredicate, +} from "../src/services/stellar/claimableBalanceService.js"; +import { + server, + networkPassphrase, + USDC_ISSUER, +} from "../src/services/stellar/stellarService.js"; + +const TESTNET_USDC = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +// Craft a TransactionResult XDR containing a create_claimable_balance success +// whose balanceId is the hex-encoded ClaimableBalanceId of the given hash. +const craftCreateBalanceResultXdr = (hashHex) => { + const x = StellarSdk.xdr; + const balId = x.ClaimableBalanceId.claimableBalanceIdTypeV0( + Buffer.from(hashHex, "hex") + ); + const createResult = + x.CreateClaimableBalanceResult.createClaimableBalanceSuccess(balId); + const opTr = x.OperationResultTr.createClaimableBalance(createResult); + const opRes = new x.OperationResult(x.OperationResultCode.opInner(), opTr); + const result = x.TransactionResultResult.txSuccess([opRes]); + const txResult = new x.TransactionResult({ + feeCharged: 100n, + result, + ext: new x.TransactionResultExt(0), + }); + return { + balanceId: balId.toXDR("hex"), + resultXdr: txResult.toXDR("base64"), + }; +}; + +describe("claimableBalanceService: build + predicates", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("builds a create_claimable_balance tx with complementary recipient/sender predicates", async () => { + const source = StellarSdk.Keypair.random(); + const claimant = StellarSdk.Keypair.random(); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + jest + .spyOn(server, "loadAccount") + .mockResolvedValue(new StellarSdk.Account(source.publicKey(), "1")); + + const built = await buildCreateClaimableBalanceTx({ + sourcePublicKey: source.publicKey(), + claimantPublicKey: claimant.publicKey(), + amount: "15", + expiresAt, + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(tx.operations).toHaveLength(1); + const op = tx.operations[0]; + expect(op.type).toBe("createClaimableBalance"); + expect(op.asset.code).toBe("USDC"); + expect(op.asset.issuer).toBe(TESTNET_USDC); + expect(op.amount).toBe("15.0000000"); + expect(op.claimants).toHaveLength(2); + + const [recipientClaimant, senderClaimant] = op.claimants; + expect(recipientClaimant.destination).toBe(claimant.publicKey()); + expect(describePredicate(recipientClaimant.predicate)).toEqual({ + type: "before_absolute_time", + time: String(expiresAt.getTime()), + }); + expect(senderClaimant.destination).toBe(source.publicKey()); + expect(describePredicate(senderClaimant.predicate)).toEqual({ + type: "not", + child: { + type: "before_absolute_time", + time: String(expiresAt.getTime()), + }, + }); + }); + + it("prepends changeTrust(USDC) when the claimant has no USDC trustline", async () => { + const claimant = StellarSdk.Keypair.random(); + const balanceId = "00000000" + "ab".repeat(32); + + // First loadAccount (hasUsdcTrustline → getAccountBalance) returns no USDC + // balance; second loadAccount returns the source account for the builder. + jest + .spyOn(server, "loadAccount") + .mockResolvedValueOnce({ + balances: [{ asset_type: "native", balance: "2.5" }], + }) + .mockResolvedValueOnce(new StellarSdk.Account(claimant.publicKey(), "1")); + + const built = await buildClaimTx({ + claimantPublicKey: claimant.publicKey(), + balanceId, + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(built.includesChangeTrust).toBe(true); + expect(tx.operations.map((o) => o.type)).toEqual([ + "changeTrust", + "claimClaimableBalance", + ]); + expect(tx.operations[0].line.code).toBe("USDC"); + }); + + it("omits changeTrust when the claimant already has a USDC trustline", async () => { + const claimant = StellarSdk.Keypair.random(); + const balanceId = "00000000" + "cd".repeat(32); + + jest + .spyOn(server, "loadAccount") + .mockResolvedValueOnce({ + balances: [ + { asset_type: "native", balance: "2.5" }, + { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: TESTNET_USDC, balance: "1" }, + ], + }) + .mockResolvedValueOnce(new StellarSdk.Account(claimant.publicKey(), "1")); + + const built = await buildClaimTx({ + claimantPublicKey: claimant.publicKey(), + balanceId, + }); + + expect(built.includesChangeTrust).toBe(false); + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(tx.operations.map((o) => o.type)).toEqual(["claimClaimableBalance"]); + }); +}); + +describe("claimableBalanceService: validateSignedGiftXdr", () => { + const source = StellarSdk.Keypair.random(); + const claimant = StellarSdk.Keypair.random(); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + + const buildSignedGift = ({ amount = "15", asset = new StellarSdk.Asset("USDC", TESTNET_USDC), extraClaimant } = {}) => { + const account = new StellarSdk.Account(source.publicKey(), "1"); + const claimants = [ + new StellarSdk.Claimant(claimant.publicKey(), StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt)), + new StellarSdk.Claimant(source.publicKey(), StellarSdk.Claimant.predicateNot(StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt))), + ...(extraClaimant ? [extraClaimant] : []), + ]; + const tx = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, networkPassphrase }) + .addOperation(StellarSdk.Operation.createClaimableBalance({ asset, amount, claimants })) + .setTimeout(300) + .build(); + tx.sign(source); + return tx.toXDR(); + }; + + it("accepts a correctly-formed signed gift XDR", () => { + const xdr = buildSignedGift(); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).not.toThrow(); + }); + + it("rejects a tampered amount", () => { + const xdr = buildSignedGift({ amount: "999" }); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/amount mismatch/i); + }); + + it("rejects a wrong asset", () => { + const xdr = buildSignedGift({ asset: StellarSdk.Asset.native() }); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/wrong asset/i); + }); + + it("rejects a missing recipient claimant", () => { + const other = StellarSdk.Keypair.random(); + const xdr = buildSignedGift(); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: other.publicKey(), // not a claimant + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/recipient claimant/i); + }); +}); + +describe("claimableBalanceService: resolveBalanceId + getClaimableBalance", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("parses the balance id from the transaction result XDR (not the tx hash)", async () => { + const txHash = "a".repeat(64); + const { balanceId, resultXdr } = craftCreateBalanceResultXdr("ab".repeat(32)); + + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ call: async () => ({ result_xdr: resultXdr }) }), + }); + + const resolved = await resolveBalanceId(txHash, { amount: "15" }); + expect(resolved).toBe(balanceId); + expect(resolved).not.toBe(txHash); + }); + + it("falls back to the forClaimant query when result XDR is unavailable", async () => { + const txHash = "b".repeat(64); + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ call: async () => ({}) }), + }); + jest.spyOn(server, "claimableBalances").mockReturnValue({ + forClaimant: () => ({ + call: async () => ({ + records: [{ id: "fallback-balance-id", amount: "15.0000000", asset: `USDC:${USDC_ISSUER}` }], + }), + }), + }); + + const resolved = await resolveBalanceId(txHash, { + amount: "15", + claimantPublicKey: "GCLAIMANT", + }); + expect(resolved).toBe("fallback-balance-id"); + }); + + it("returns { exists: true } with the record for a known balance", async () => { + jest.spyOn(server, "claimableBalances").mockReturnValue({ + claimableBalance: () => ({ + call: async () => ({ id: "balance-1", state: "available", amount: "15.0000000" }), + }), + }); + const result = await getClaimableBalance("balance-1"); + expect(result.exists).toBe(true); + expect(result.record.state).toBe("available"); + }); + + it("returns { exists: false } for a 404", async () => { + const err = new Error("not found"); + err.response = { status: 404 }; + jest.spyOn(server, "claimableBalances").mockReturnValue({ + claimableBalance: () => ({ call: async () => { throw err; } }), + }); + const result = await getClaimableBalance("missing"); + expect(result).toEqual({ exists: false }); + }); +}); diff --git a/test/coreFlows.test.js b/test/coreFlows.test.js new file mode 100644 index 00000000..acb5b8e0 --- /dev/null +++ b/test/coreFlows.test.js @@ -0,0 +1,177 @@ +import { spawnSync } from "child_process"; +import { jest } from "@jest/globals"; +import bcrypt from "bcryptjs"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; +import axios from "axios"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import Book from "../src/models/Book.js"; +import { testOutbox } from "../services/emails/sendMail.js"; + +const PASSWORD = "Qx7#vLmp92Zt"; + +describe("Core auth, authorization, and wallet flows", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + jest.spyOn(axios, "get").mockResolvedValue({ data: "" }); + }, 30000); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), + PendingUser.deleteMany({}), + Session.deleteMany({}), + Book.deleteMany({}), + ]); + testOutbox.length = 0; + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + const createVerifiedUser = async (overrides = {}) => { + const { password = PASSWORD, ...fields } = overrides; + return User.create({ + name: "Test User", + email: "user@example.com", + password: await bcrypt.hash(password, 12), + role: "student", + isVerified: true, + ...fields, + }); + }; + + const login = async (email, password = PASSWORD) => { + const response = await request(app) + .post("/api/auth/login") + .send({ email, password }); + return response.body.accessToken; + }; + + it("imports the app without database or environment validation side effects", () => { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + 'import("./app.js").then(() => console.log("app-imported"))', + ], + { + cwd: process.cwd(), + env: { NODE_ENV: "test", PATH: process.env.PATH }, + encoding: "utf8", + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("app-imported"); + expect(result.stderr).toBe(""); + }); + + it("registers, verifies, and logs in a user", async () => { + const email = "new.user@example.com"; + const registration = await request(app).post("/api/auth/register").send({ + name: "New User", + email, + password: PASSWORD, + role: "student", + }); + + expect(registration.status).toBe(201); + expect(registration.body.success).toBe(true); + expect(testOutbox).toHaveLength(1); + + const pending = await PendingUser.findOne({ email }); + expect(pending).not.toBeNull(); + expect(pending.password).not.toBe(PASSWORD); + + const verification = await request(app).get( + `/api/auth/verify-email/${pending.verificationToken}` + ); + expect(verification.status).toBe(200); + expect(verification.body.accessToken).toBeTruthy(); + + const loginResponse = await request(app) + .post("/api/auth/login") + .send({ email, password: PASSWORD }); + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.accessToken).toBeTruthy(); + expect(loginResponse.body.user.email).toBe(email); + }); + + it("rejects an incorrect password without issuing a token", async () => { + await createVerifiedUser({ email: "wrong.password@example.com" }); + + const response = await request(app).post("/api/auth/login").send({ + email: "wrong.password@example.com", + password: "NotTheRightPassword1!", + }); + + expect(response.status).toBe(401); + expect(response.body.message).toBe("Invalid credentials"); + expect(response.body.accessToken).toBeUndefined(); + }); + + it("rejects a non-owner attempting to delete another user's book", async () => { + const owner = await createVerifiedUser({ + name: "Owner", + email: "owner@example.com", + }); + const otherUser = await createVerifiedUser({ + name: "Other User", + email: "other@example.com", + }); + const book = await Book.create({ + title: "Owner's Book", + author: owner._id, + category: "History", + description: "A protected book", + image: "https://example.com/image.jpg", + fileUrl: "https://example.com/book.pdf", + }); + const otherToken = await login(otherUser.email); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${otherToken}`); + + expect(response.status).toBe(403); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("rejects an invalid Stellar public key before querying Horizon", async () => { + const user = await createVerifiedUser({ email: "wallet@example.com" }); + const token = await login(user.email); + + const response = await request(app) + .post("/api/stellar/wallet/connect") + .set("Authorization", `Bearer ${token}`) + .send({ publicKey: "not-a-stellar-public-key" }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + success: false, + message: "Validation failed", + data: null, + errors: [ + { + field: "publicKey", + message: "publicKey must be a valid Stellar public key", + }, + ], + }); + + const persisted = await User.findById(user._id).select("stellarWallet"); + expect(persisted.stellarWallet?.publicKey).toBeUndefined(); + }); +}); diff --git a/test/courseProgress.test.js b/test/courseProgress.test.js index dcd1cdec..5ee34d4a 100644 --- a/test/courseProgress.test.js +++ b/test/courseProgress.test.js @@ -15,6 +15,15 @@ describe("Course progress endpoints", () => { let learner; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); await mongoose.connect(mongoServer.getUri()); }); @@ -43,11 +52,6 @@ describe("Course progress endpoints", () => { learnerToken = jwt.sign({ userId: learner._id, sessionId: "l1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); }); - afterAll(async () => { - await mongoose.disconnect(); - await mongoServer.stop(); - }); - it("creates progress for a learner and computes percent completion idempotently", async () => { const course = await Course.create({ title: "Course 1", @@ -113,4 +117,14 @@ describe("Course progress endpoints", () => { expect(res.body.courses).toHaveLength(1); expect(res.body.courses[0].percentComplete).toBe(50); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); + diff --git a/test/educatorVerification.test.js b/test/educatorVerification.test.js new file mode 100644 index 00000000..5f14ea57 --- /dev/null +++ b/test/educatorVerification.test.js @@ -0,0 +1,737 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Space from "../src/models/Space.js"; +import EducatorVerification, { + VERIFICATION_STATUS, + LEGAL_TRANSITIONS, +} from "../src/models/EducatorVerification.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { requireVerifiedEducator } from "../src/middlewares/authMiddleware.js"; +import express from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; +const mintToken = (user) => + jwt.sign( + { + userId: user._id.toString(), + role: user.role, + sessionId: "sess-test", + is2FAVerified: user.role === "admin" ? true : false, + }, + JWT_SECRET, + { expiresIn: "15m" } + ); + +describe("Issue #92 — Educator Verification Pipeline + Content Gating", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Course.deleteMany({}); + await Space.deleteMany({}); + await EducatorVerification.deleteMany({}); + await AuditLog.collection.deleteMany({}); + }); + + // ── Shared helpers ────────────────────────────────────────────────────── + const createUsers = async () => { + const student = await User.create({ + name: "Student User", + email: "student@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + const mentor = await User.create({ + name: "Mentor User", + email: "mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + const verifiedEducator = await User.create({ + name: "Verified Educator", + email: "verified@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + verifiedEducator: true, + }); + const admin = await User.create({ + name: "Admin User", + email: "admin@example.com", + password: "Qx7#vLmp92Zt", + role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, + }); + return { student, mentor, verifiedEducator, admin }; + }; + + const authHeader = (user) => `Bearer ${mintToken(user)}`; + + const sampleDocuments = () => [ + { + type: "government_id", + cloudinaryPublicId: "educator-verification/sample-id", + originalFileName: "government_id.pdf", + }, + { + type: "teaching_certificate", + cloudinaryPublicId: "educator-verification/sample-cert", + originalFileName: "teaching_cert.pdf", + }, + ]; + + describe("1. EducatorVerification Model — State Machine", () => { + it("exposes correct status enum values", () => { + expect(VERIFICATION_STATUS).toEqual({ + DRAFT: "draft", + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", + }); + }); + + it("defines only legal transitions", () => { + expect(LEGAL_TRANSITIONS).toEqual({ + draft: ["pending"], + pending: ["approved", "rejected"], + approved: [], + rejected: ["pending"], + }); + }); + + it("allows draft→pending transition", () => { + expect( + EducatorVerification.isValidTransition("draft", "pending") + ).toBe(true); + }); + + it("allows pending→approved and pending→rejected transitions", () => { + expect( + EducatorVerification.isValidTransition("pending", "approved") + ).toBe(true); + expect( + EducatorVerification.isValidTransition("pending", "rejected") + ).toBe(true); + }); + + it("allows rejected→pending (resubmit) transition", () => { + expect( + EducatorVerification.isValidTransition("rejected", "pending") + ).toBe(true); + }); + + it("rejects illegal transitions", () => { + const illegal = [ + ["draft", "approved"], + ["draft", "rejected"], + ["pending", "draft"], + ["approved", "pending"], + ["approved", "rejected"], + ["rejected", "approved"], + ["rejected", "rejected"], + ["approved", "draft"], + ]; + for (const [from, to] of illegal) { + expect(EducatorVerification.isValidTransition(from, to)).toBe(false); + } + }); + + it("instance method canTransitionTo mirrors the static check", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.REJECTED, + }); + expect(v.canTransitionTo(VERIFICATION_STATUS.PENDING)).toBe(true); + expect(v.canTransitionTo(VERIFICATION_STATUS.APPROVED)).toBe(false); + }); + + it("rejects invalid status strings at model level", async () => { + const { mentor } = await createUsers(); + await expect( + EducatorVerification.create({ + applicant: mentor._id, + status: "totally_invalid_status", + }) + ).rejects.toThrow(); + }); + }); + + describe("2. requireVerifiedEducator Middleware", () => { + const setupApp = () => { + const a = express(); + a.post("/create", (req, _res, next) => { + const hdr = req.headers.authorization || ""; + const tok = hdr.startsWith("Bearer ") ? hdr.slice(7) : null; + if (tok) { + try { + const dec = jwt.verify(tok, JWT_SECRET); + req.user = { + _id: dec.userId, + role: dec.role, + verifiedEducator: + dec.userId === "verified-1" || dec.role === "admin", + }; + } catch (_) {} + } + next(); + }, requireVerifiedEducator, (_req, res) => + res.status(200).json({ success: true, created: true }) + ); + return a; + }; + + it("returns 401 when no authenticated user", async () => { + const a = setupApp(); + const res = await request(a).post("/create"); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when a normal (unverified) user hits the route", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "student-1", role: "student", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("allows admin to bypass the verifiedEducator gate", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "admin-1", role: "admin", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("allows verified educator through", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "verified-1", role: "mentor", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.created).toBe(true); + }); + }); + + describe("3. Applicant API — Submit / Resubmit / Get own", () => { + it("returns null application when applicant has not applied yet", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .get("/api/educator-verification") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.application).toBeNull(); + }); + + it("requires auth for applicant endpoints — returns 401", async () => { + const res = await request(app).get("/api/educator-verification"); + expect(res.status).toBe(401); + + const res2 = await request(app) + .post("/api/educator-verification/submit") + .send({ documents: [] }); + expect(res2.status).toBe(401); + }); + + it("rejects submit with no documents (400)", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: [] }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/document/); + }); + + it("rejects submit with malformed document entries (400)", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: [{ type: "government_id" }] }); + expect(res.status).toBe(400); + }); + + it("submits a new application — moves to PENDING, writes AUDIT submit", async () => { + const { mentor } = await createUsers(); + const docs = sampleDocuments(); + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: docs, personalStatement: "I love teaching" }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.application.status).toBe(VERIFICATION_STATUS.PENDING); + expect(res.body.application.documents.length).toBe(2); + expect(res.body.application.submittedAt).toBeDefined(); + + const v = await EducatorVerification.findOne({ + applicant: mentor._id, + }); + expect(v).not.toBeNull(); + expect(v.status).toBe(VERIFICATION_STATUS.PENDING); + expect(v.personalStatement).toBe("I love teaching"); + expect(v.documents.length).toBe(2); + + const audit = await AuditLog.findOne({ + targetId: v._id.toString(), + }); + expect(audit).not.toBeNull(); + expect(audit.action).toBe(AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT); + expect(audit.status).toBe("success"); + expect(audit.actor.toString()).toBe(mentor._id.toString()); + }); + + it("prevents creating duplicate application while one is pending (409)", async () => { + const { mentor } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(res.status).toBe(409); + }); + + it("resubmit after rejection — returns PENDING + RESUBMIT audit", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.REJECTED, + documents: sampleDocuments(), + submittedAt: new Date(Date.now() - 86400000), + reviewNotes: "Need more docs", + reviewedAt: new Date(), + }); + + const newDocs = [ + ...sampleDocuments(), + { + type: "degree", + cloudinaryPublicId: "educator-verification/degree-v2", + originalFileName: "degree.pdf", + }, + ]; + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: newDocs }); + + expect(res.status).toBe(201); + expect(res.body.message).toMatch(/resubmitted/i); + expect(res.body.application.status).toBe(VERIFICATION_STATUS.PENDING); + + const reloaded = await EducatorVerification.findById(v._id); + expect(reloaded.status).toBe(VERIFICATION_STATUS.PENDING); + expect(reloaded.reviewNotes).toBeNull(); + expect(reloaded.reviewedBy).toBeNull(); + expect(reloaded.reviewedAt).toBeNull(); + expect(reloaded.documents.length).toBe(3); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_RESUBMIT, + }); + expect(audit).not.toBeNull(); + }); + + it("GET /api/educator-verification returns applicant's own application", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get("/api/educator-verification") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.application._id.toString()).toBe(v._id.toString()); + expect(res.body.application.documents.length).toBe(2); + }); + + it("GET /upload-signature returns signed upload credentials", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .get("/api/educator-verification/upload-signature") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.timestamp).toBeDefined(); + expect(res.body.data.signature).toBeDefined(); + expect(res.body.data.folder).toBe("educator-verification"); + expect(res.body.data.uploadType).toBe("authenticated"); + }); + }); + + describe("4. Admin Review Queue — Admin-Only Gating", () => { + it("non-admin users get 403 on all admin endpoints", async () => { + const { student, mentor, verifiedEducator } = await createUsers(); + const nonAdmins = [student, mentor, verifiedEducator]; + for (const u of nonAdmins) { + const list = await request(app) + .get("/api/admin/educator-verification") + .set("Authorization", authHeader(u)); + expect(list.status).toBe(403); + } + }); + + it("admin can list pending applications", async () => { + const { admin, mentor, student } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + await EducatorVerification.create({ + applicant: student._id, + status: VERIFICATION_STATUS.REJECTED, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .get("/api/admin/educator-verification?status=pending") + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.applications.length).toBe(1); + expect(res.body.pagination.total).toBe(1); + }); + + it("admin can fetch a single application by id", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get(`/api/admin/educator-verification/${v._id}`) + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(200); + expect(res.body.application.applicant).toBeDefined(); + expect(res.body.application.documents.length).toBe(2); + }); + + it("admin approval sets verifiedEducator=true + APPROVE audit", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/approve`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Credentials look good." }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/approved/i); + + const reloadedV = await EducatorVerification.findById(v._id); + expect(reloadedV.status).toBe(VERIFICATION_STATUS.APPROVED); + expect(reloadedV.reviewedBy.toString()).toBe(admin._id.toString()); + expect(reloadedV.reviewNotes).toBe("Credentials look good."); + expect(reloadedV.reviewedAt).not.toBeNull(); + + const reloadedUser = await User.findById(mentor._id); + expect(reloadedUser.verifiedEducator).toBe(true); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_APPROVE, + }); + expect(audit).not.toBeNull(); + expect(audit.targetId).toBe(v._id.toString()); + }); + + it("admin rejection does NOT set verifiedEducator + REJECT audit", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/reject`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Please upload a clearer ID." }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/rejected/i); + + const reloadedV = await EducatorVerification.findById(v._id); + expect(reloadedV.status).toBe(VERIFICATION_STATUS.REJECTED); + + const reloadedUser = await User.findById(mentor._id); + expect(reloadedUser.verifiedEducator).toBe(false); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_REJECT, + }); + expect(audit).not.toBeNull(); + }); + + it("illegal transition (approve already APPROVED) returns 409", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.APPROVED, + submittedAt: new Date(), + reviewedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/approve`) + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(409); + }); + }); + + describe("5. Content Creation Gating — 403 for Unverified Educator", () => { + it("POST /api/courses (createCourse) returns 403 for unverified user", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(student)) + .send({ + title: "My Course", + description: "Intro", + category: "Fiqh", + }); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("POST /api/courses succeeds for verifiedEducator user (2xx)", async () => { + const { verifiedEducator } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(verifiedEducator)) + .send({ + title: "Fiqh 101", + description: "An intro to fiqh", + category: "Fiqh", + price: 0, + }); + expect(res.status).toBeLessThan(400); + }); + + it("POST /api/courses succeeds for admin (bypass) — 2xx", async () => { + const { admin } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(admin)) + .send({ + title: "Admin Course", + description: "Admin intro", + category: "General", + price: 0, + }); + expect(res.status).toBeLessThan(400); + }); + + it("POST /api/books (createBook) — live session gating: book create route returns 403 for unverified", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/books") + .set("Authorization", authHeader(student)); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("POST /api/spaces (createSpace — the live-session create route per issue #92) returns 403 for unverified", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/spaces") + .set("Authorization", authHeader(student)) + .send({ + title: "Live Tafsir", + description: "Session", + category: "Tafsir", + eventDate: new Date().toISOString(), + eventTime: "10:00 AM", + duration: 60, + }); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + }); + + describe("6. Full Lifecycle — Submit → Pending → Approve (and Reject→Resubmit path)", () => { + it("happy path: submit → pending → approve → verifiedEducator=true → can create course", async () => { + const { mentor, admin } = await createUsers(); + + const submitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(submitRes.status).toBe(201); + const vId = submitRes.body.application._id; + + const beforeCreate = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(mentor)) + .send({ + title: "Awaiting Approval", + description: "x", + category: "Fiqh", + price: 0, + }); + expect(beforeCreate.status).toBe(403); + + const approveRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(approveRes.status).toBe(200); + + const afterCreate = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(mentor)) + .send({ + title: "Fiqh 303", + description: "Advanced", + category: "Fiqh", + price: 0, + }); + expect(afterCreate.status).toBeLessThan(400); + }); + + it("reject → resubmit → approve lifecycle works end-to-end", async () => { + const { mentor, admin } = await createUsers(); + + const submitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + const vId = submitRes.body.application._id; + + const rejectRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/reject`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Please resubmit with clearer images." }); + expect(rejectRes.status).toBe(200); + + const illegalApprove = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(illegalApprove.status).toBe(409); + + const resubmitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(resubmitRes.status).toBe(201); + + const approveRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(approveRes.status).toBe(200); + + const u = await User.findById(mentor._id); + expect(u.verifiedEducator).toBe(true); + }); + }); + + describe("7. Metadata allowlist stores educator verification keys", () => { + it("recordAudit stores verificationId, newStatus, previousStatus in metadata", async () => { + const { mentor } = await createUsers(); + await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT, + }).lean(); + expect(audit).not.toBeNull(); + expect(audit.metadata.verificationId).toBeDefined(); + expect(audit.metadata.newStatus).toBe(VERIFICATION_STATUS.PENDING); + expect(audit.metadata.documentCount).toBe(2); + }); + }); + + describe("8. Signed document URL security", () => { + it("returns 404 for an invalid document index", async () => { + const { mentor } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get("/api/educator-verification/documents/99/signed-url") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(404); + }); + + it("admin endpoint returns 403 for non-admin even with valid id", async () => { + const { mentor, admin } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get(`/api/admin/educator-verification/${v._id}/documents/0/signed-url`) + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/test/educators.test.js b/test/educators.test.js index 9ecd6888..5ef82dae 100644 --- a/test/educators.test.js +++ b/test/educators.test.js @@ -6,15 +6,37 @@ import Book from "../src/models/Book.js"; import User from "../src/models/User.js"; import Space from "../src/models/Space.js"; +import { MongoMemoryServer } from "mongodb-memory-server"; + let userId1; let userId2; let userId3; let adminId; +let mongoServer; beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_educators`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_educators`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + beforeEach(async () => { await Course.deleteMany({}); await Book.deleteMany({}); diff --git a/test/emailRoutes.test.js b/test/emailRoutes.test.js index 9e9a011e..bf966a85 100644 --- a/test/emailRoutes.test.js +++ b/test/emailRoutes.test.js @@ -2,30 +2,43 @@ import { jest } from "@jest/globals"; import request from "supertest"; import app from "../app.js"; import logger from "../src/config/logger.js"; +import { testOutbox } from "../services/emails/sendMail.js"; // SENDLIB_API_KEY/URL are stripped by test/jest.setup.js, so in the test env -// sendMail logs the email body via [EMAIL LOG] instead of delivering. That lets -// us inspect the generated OTP. (Bodies are never logged in dev/prod.) - -const extractOtpFromLog = (logCalls) => { - const emailLog = logCalls - .map((call) => call[0]) - .find((msg) => typeof msg === "string" && msg.includes("[EMAIL LOG]")); - const match = emailLog && emailLog.match(/#166534;">(\d+)<\/span>/); +// sendMail captures the rendered message in its in-memory testOutbox instead +// of delivering. That lets us inspect the generated OTP without the body ever +// reaching a log stream (bodies are never logged in any environment). + +const extractOtpFromHtml = (html) => { + const match = html.match(/#166534;">(\d+)<\/span>/); return match ? match[1] : null; }; +const lastEmail = () => testOutbox[testOutbox.length - 1] || null; + +const capturedLogText = (spy) => + spy.mock.calls + .map((call) => call.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")) + .join("\n"); + describe("OTP email route", () => { - let loggerInfoSpy; + let loggerSpy; beforeAll(() => { - loggerInfoSpy = jest.spyOn(logger, "info"); + loggerSpy = jest.spyOn(logger, "info"); + jest.spyOn(logger, "warn"); + jest.spyOn(logger, "error"); }); afterAll(() => { jest.restoreAllMocks(); }); + beforeEach(() => { + testOutbox.length = 0; + loggerSpy.mockClear(); + }); + it("generates a fresh OTP per request (no shared module-level code)", async () => { const res1 = await request(app).post("/api/email").send({ email: "one@example.com" }); const res2 = await request(app).post("/api/email").send({ email: "two@example.com" }); @@ -34,13 +47,10 @@ describe("OTP email route", () => { expect(res1.body.success).toBe(true); expect(res2.statusCode).toBe(200); - const otp1 = extractOtpFromLog(loggerInfoSpy.mock.calls); - // Logs accumulate across requests; take the last two [EMAIL LOG] entries. - const calls = loggerInfoSpy.mock.calls.map((c) => c[0]); - const emailLogs = calls.filter((m) => typeof m === "string" && m.includes("[EMAIL LOG]")); - const otp2 = emailLogs.length >= 2 - ? (emailLogs[emailLogs.length - 1].match(/#166534;">(\d+)<\/span>/) || [])[1] - : null; + // The outbox accumulates across requests; the last two entries are the + // two emails just sent. + const otp1 = extractOtpFromHtml(testOutbox[testOutbox.length - 2].html); + const otp2 = extractOtpFromHtml(lastEmail().html); expect(otp1).toBeDefined(); expect(otp2).toBeDefined(); @@ -57,6 +67,23 @@ describe("OTP email route", () => { expect(res.body).not.toHaveProperty("otp"); }); + it("never writes the OTP or email body to the logger", async () => { + const res = await request(app).post("/api/email").send({ email: "secret@example.com" }); + + expect(res.statusCode).toBe(200); + + // The generated OTP exists in the test outbox but must never appear in + // any log line (interpolated or structured). + const otp = extractOtpFromHtml(lastEmail().html); + expect(otp).toBeDefined(); + + const logs = capturedLogText(loggerSpy); + expect(logs).not.toContain(otp); + // No full email body / HTML should ever reach the logs either. + expect(logs).not.toContain(" { const missing = await request(app).post("/api/email").send({}); expect(missing.statusCode).toBe(400); diff --git a/test/feeSponsorService.test.js b/test/feeSponsorService.test.js new file mode 100644 index 00000000..9b3cfae5 --- /dev/null +++ b/test/feeSponsorService.test.js @@ -0,0 +1,533 @@ +// Fee-bump sponsorship service (#30) — structural whitelist, fee-bump fee +// correctness, spend caps, and secret handling. Uses the REAL @stellar/stellar-sdk +// (no live network) so the whitelist and fee math are exercised end-to-end. +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +// A dedicated sponsor account for the whole suite. Set before importing the +// service so getFeeSponsorKeypair() can read it. +const SPONSOR = StellarSdk.Keypair.random(); +process.env.STELLAR_NETWORK = "testnet"; +process.env.FEE_SPONSOR_ENABLED = "true"; +process.env.FEE_SPONSOR_SECRET = SPONSOR.secret(); + +const { + validateInnerTransaction, + buildExpectedOperations, + computeFeeBumpFee, + wrapWithFeeBump, + prepareSponsoredSubmission, + checkSpendCaps, + recordSponsorshipSpend, + getFeeSponsorKeypair, + getFeeSponsorPublicKey, + getFeeSponsorConfig, + validateFeeSponsorBootConfig, + SponsorshipError, + utcDay, +} = await import("../src/services/stellar/feeSponsorService.js"); +const { networkPassphrase, toStroops } = await import( + "../src/services/stellar/stellarService.js" +); +const SponsorshipSpend = (await import("../src/models/SponsorshipSpend.js")) + .default; + +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC = new StellarSdk.Asset("USDC", USDC_ISSUER); + +// A single Mongo connection for the whole suite (caps + orchestration tests +// touch SponsorshipSpend). Mirrors the fallback pattern used by other DB tests: +// prefer the CI-provided MONGO_URI, otherwise spin an in-memory server. +let mongoServer; +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) await mongoose.disconnect(); + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_feesponsor`, { + serverSelectionTimeoutMS: 2000, + }); + return; + } catch { + /* fall back to in-memory */ + } + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); + } + if (mongoServer) await mongoServer.stop(); +}); + +const BUYER = StellarSdk.Keypair.random(); +const CREATOR = StellarSdk.Keypair.random(); +const PLATFORM = StellarSdk.Keypair.random(); +const OTHER = StellarSdk.Keypair.random(); +const MEMO = "DNB-BOOK-abcd1234"; + +const directRow = { + buyerWallet: BUYER.publicKey(), + creatorWallet: CREATOR.publicKey(), + amount: "15", + currency: "USDC", + memo: MEMO, +}; + +const splitRow = { + buyerWallet: BUYER.publicKey(), + creatorWallet: CREATOR.publicKey(), + amount: "15", + currency: "USDC", + memo: MEMO, + platformFee: { + platformWallet: PLATFORM.publicKey(), + platformAmount: "1.5", + creatorAmount: "13.5", + }, +}; + +// Build a signed inner transaction from a list of operation builders. +const buildInner = (ops, { memo = MEMO, source = BUYER } = {}) => { + const account = new StellarSdk.Account(source.publicKey(), "7"); + const builder = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + for (const op of ops) builder.addOperation(op); + const tx = builder.addMemo(StellarSdk.Memo.text(memo)).setTimeout(300).build(); + tx.sign(source); + return tx; +}; +const pay = (dest, amount, asset = USDC) => + StellarSdk.Operation.payment({ destination: dest, asset, amount }); + +const expectRejected = (tx, row) => { + expect(() => validateInnerTransaction(tx, row)).toThrow(SponsorshipError); + try { + validateInnerTransaction(tx, row); + } catch (e) { + expect(e.code).toBe("whitelist_rejected"); + expect(e.httpStatus).toBe(422); + expect(e.retryUnsponsored).toBe(true); + } +}; + +describe("feeSponsorService — boot config", () => { + const withEnv = (env, fn) => { + const saved = { ...process.env }; + Object.assign(process.env, env); + try { + return fn(); + } finally { + process.env = saved; + } + }; + + it("passes when disabled regardless of secret", () => { + withEnv({ FEE_SPONSOR_ENABLED: "false", FEE_SPONSOR_SECRET: "" }, () => { + expect(validateFeeSponsorBootConfig().ok).toBe(true); + }); + }); + + it("fails fast when enabled but the secret is missing", () => { + withEnv({ FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: "" }, () => { + const res = validateFeeSponsorBootConfig(); + expect(res.ok).toBe(false); + expect(res.message).toMatch(/FEE_SPONSOR_SECRET/); + }); + }); + + it("fails fast when enabled but the secret is invalid", () => { + withEnv( + { FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: "not-a-secret" }, + () => { + expect(validateFeeSponsorBootConfig().ok).toBe(false); + } + ); + }); + + it("passes when enabled with a valid secret", () => { + withEnv( + { FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: SPONSOR.secret() }, + () => { + expect(validateFeeSponsorBootConfig().ok).toBe(true); + } + ); + }); +}); + +describe("feeSponsorService — sponsor keypair", () => { + it("parses the secret and never exposes it, only the public key", () => { + expect(getFeeSponsorPublicKey()).toBe(SPONSOR.publicKey()); + const kp = getFeeSponsorKeypair(); + expect(kp.publicKey()).toBe(SPONSOR.publicKey()); + // The status/public surface must never carry the secret. + expect(getFeeSponsorPublicKey()).not.toContain(SPONSOR.secret()); + }); +}); + +describe("feeSponsorService — buildExpectedOperations", () => { + it("derives a single settlement op for a direct row", () => { + const ops = buildExpectedOperations(directRow); + expect(ops).toHaveLength(1); + expect(ops[0].destination).toBe(CREATOR.publicKey()); + expect(ops[0].amountStroops).toBe(toStroops("15")); + }); + + it("derives creator+platform ops (in order) for a fee-split row", () => { + const ops = buildExpectedOperations(splitRow); + expect(ops).toHaveLength(2); + expect(ops[0].destination).toBe(CREATOR.publicKey()); + expect(ops[0].amountStroops).toBe(toStroops("13.5")); + expect(ops[1].destination).toBe(PLATFORM.publicKey()); + expect(ops[1].amountStroops).toBe(toStroops("1.5")); + }); +}); + +describe("feeSponsorService — structural whitelist (adversarial matrix)", () => { + it("accepts a valid, exactly-matching direct payment", () => { + const tx = buildInner([pay(CREATOR.publicKey(), "15")]); + expect(validateInnerTransaction(tx, directRow)).toBe(true); + }); + + it("accepts a valid fee-split payment", () => { + const tx = buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]); + expect(validateInnerTransaction(tx, splitRow)).toBe(true); + }); + + it("rejects a wrong source account", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15")], { source: OTHER }), + directRow + ); + }); + + it("rejects an extra/foreign changeTrust appended to a valid payment", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.changeTrust({ asset: USDC }), + ]), + directRow + ); + }); + + it("rejects a second, unexpected payment appended to a valid payment", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + pay(OTHER.publicKey(), "1"), + ]), + directRow + ); + }); + + it("rejects a setOptions operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.setOptions({ homeDomain: "evil.example" }), + ]), + directRow + ); + }); + + it("rejects a manageData operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.manageData({ name: "x", value: "y" }), + ]), + directRow + ); + }); + + it("rejects an accountMerge operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.accountMerge({ destination: OTHER.publicKey() }), + ]), + directRow + ); + }); + + it("rejects a createAccount operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.createAccount({ + destination: OTHER.publicKey(), + startingBalance: "1", + }), + ]), + directRow + ); + }); + + it("rejects a pathPaymentStrictReceive operation (allow-list: only plain payment)", () => { + // A lone non-payment op with the right count still fails: the allow-list + // permits only `payment`, so any other type — including one not explicitly + // block-listed — is rejected by construction. + expectRejected( + buildInner([ + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: "100", + destination: CREATOR.publicKey(), + destAsset: USDC, + destAmount: "15", + path: [], + }), + ]), + directRow + ); + }); + + it("rejects a wrong asset (native XLM instead of USDC)", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15", StellarSdk.Asset.native())]), + directRow + ); + }); + + it("rejects a wrong issuer for the correct code", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15", new StellarSdk.Asset("USDC", OTHER.publicKey())), + ]), + directRow + ); + }); + + it("rejects an amount that is too high", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "16")]), directRow); + }); + + it("rejects an amount that is too low", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "14.9999999")]), directRow); + }); + + it("rejects a wrong destination", () => { + expectRejected(buildInner([pay(OTHER.publicKey(), "15")]), directRow); + }); + + it("rejects a fee-split where the split amounts do not match platformFee", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "14"), + pay(PLATFORM.publicKey(), "1"), + ]), + splitRow + ); + }); + + it("rejects a memo mismatch", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15")], { memo: "WRONG-MEMO" }), + directRow + ); + }); + + it("rejects when 1 op is present but 2 are expected (split)", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "13.5")]), splitRow); + }); + + it("rejects when 2 ops are present but 1 is expected (direct)", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]), + directRow + ); + }); + + it("rejects a fee-bump envelope where a plain inner transaction is expected", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const fb = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + SPONSOR, + "200", + inner, + networkPassphrase + ); + expectRejected(fb, directRow); + }); +}); + +describe("feeSponsorService — fee-bump fee correctness", () => { + it("prices a 1-op inner over (ops + 1) units and clamps to the ceiling", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const config = getFeeSponsorConfig(); + const { baseFeePerOp, totalMaxFeeStroops, units } = computeFeeBumpFee( + inner, + config + ); + expect(units).toBe(2); // 1 inner op + wrapper + expect(totalMaxFeeStroops).toBe(baseFeePerOp * units); + expect(totalMaxFeeStroops).toBeLessThanOrEqual(config.maxFeeStroops); + + const fb = wrapWithFeeBump(inner, { keypair: SPONSOR, baseFeePerOp }); + // The built envelope's actual fee is asserted against the SDK, not trusted. + expect(Number(fb.fee)).toBe(totalMaxFeeStroops); + expect(Number(fb.fee)).toBeLessThanOrEqual(config.maxFeeStroops); + }); + + it("prices a 2-op inner over 3 units", () => { + const inner = buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]); + const config = getFeeSponsorConfig(); + const { totalMaxFeeStroops, units } = computeFeeBumpFee(inner, config); + expect(units).toBe(3); + expect(totalMaxFeeStroops).toBeLessThanOrEqual(config.maxFeeStroops); + const fb = wrapWithFeeBump(inner); + expect(Number(fb.fee)).toBeLessThanOrEqual(config.maxFeeStroops); + }); + + it("refuses when the per-transaction ceiling is too low to fee-bump at all", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + // Ceiling below (ops + 1) * MIN_BASE_FEE (=200 for 1 op) cannot build. + expect(() => + computeFeeBumpFee(inner, { maxFeeStroops: 150 }) + ).toThrow(SponsorshipError); + try { + computeFeeBumpFee(inner, { maxFeeStroops: 150 }); + } catch (e) { + expect(e.code).toBe("fee_ceiling_too_low"); + } + }); +}); + +describe("feeSponsorService — fee-bump leaves the inner transaction untouched", () => { + it("keeps inner bytes and the user signature; the sponsor signs only the wrapper", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const innerXdrBefore = inner.toEnvelope().toXDR("base64"); + + const fb = wrapWithFeeBump(inner, { keypair: SPONSOR }); + + // Fee source is the sponsor; user signature on the inner tx is preserved. + expect(fb.feeSource).toBe(SPONSOR.publicKey()); + expect(fb.innerTransaction.signatures).toHaveLength(1); + expect(fb.signatures).toHaveLength(1); + + // Round-trip the envelope; the inner transaction bytes are byte-identical. + const decoded = StellarSdk.TransactionBuilder.fromXDR( + fb.toXDR(), + networkPassphrase + ); + expect(decoded).toBeInstanceOf(StellarSdk.FeeBumpTransaction); + expect(decoded.innerTransaction.toEnvelope().toXDR("base64")).toBe( + innerXdrBefore + ); + // The inner's own signature is the user's, and the sponsor did not sign it. + expect( + decoded.innerTransaction.signatures.map((s) => s.signature().toString("base64")) + ).toEqual(inner.signatures.map((s) => s.signature().toString("base64"))); + }); +}); + +describe("feeSponsorService — spend caps (durable accounting)", () => { + beforeEach(async () => { + await SponsorshipSpend.deleteMany({}); + }); + + const config = { maxFeeStroops: 1000000, dailyCapStroops: 1000000, perUserDailyLimit: 2 }; + + it("allows spend under all caps", async () => { + await expect( + checkSpendCaps({ userId: "user-a", estimatedFeeStroops: 400000, config }) + ).resolves.toBeUndefined(); + }); + + it("refuses when the per-UTC-day total cap would be exceeded", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 800000 }); + await expect( + checkSpendCaps({ userId: "user-b", estimatedFeeStroops: 400000, config }) + ).rejects.toMatchObject({ code: "daily_cap_exceeded", httpStatus: 429 }); + }); + + it("refuses when the per-user daily count limit is reached", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 10 }); + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 10 }); + await expect( + checkSpendCaps({ userId: "user-a", estimatedFeeStroops: 10, config }) + ).rejects.toMatchObject({ code: "per_user_daily_limit", httpStatus: 429 }); + // A different user with headroom is still allowed. + await expect( + checkSpendCaps({ userId: "user-b", estimatedFeeStroops: 10, config }) + ).resolves.toBeUndefined(); + }); + + it("records spend atomically per UTC day and per user", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 123 }); + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 77 }); + await recordSponsorshipSpend({ userId: "user-b", feeStroops: 50 }); + const doc = await SponsorshipSpend.findOne({ day: utcDay() }); + expect(doc.totalStroops).toBe(250); + expect(doc.sponsoredCount).toBe(3); + expect(doc.userCounts.get("user-a")).toBe(2); + expect(doc.userCounts.get("user-b")).toBe(1); + }); +}); + +describe("feeSponsorService — prepareSponsoredSubmission", () => { + const fundedBalance = async () => ({ exists: true, xlmBalance: "100" }); + const emptyBalance = async () => ({ exists: false, xlmBalance: "0" }); + + it("validates, wraps, and returns both hashes for a valid submit", async () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const result = await prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: fundedBalance, + }); + expect(result.innerHash).toBe(inner.hash().toString("hex")); + expect(result.outerHash).not.toBe(result.innerHash); + expect(result.maxFeeStroops).toBeLessThanOrEqual( + getFeeSponsorConfig().maxFeeStroops + ); + // The returned envelope decodes to a fee-bump wrapping the exact inner tx. + const decoded = StellarSdk.TransactionBuilder.fromXDR( + result.feeBumpXdr, + networkPassphrase + ); + expect(decoded.feeSource).toBe(SPONSOR.publicKey()); + expect(decoded.innerTransaction.hash().toString("hex")).toBe(result.innerHash); + }); + + it("propagates a whitelist rejection", async () => { + const inner = buildInner([pay(OTHER.publicKey(), "15")]); + await expect( + prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: fundedBalance, + }) + ).rejects.toMatchObject({ code: "whitelist_rejected" }); + }); + + it("refuses when the sponsor float is underfunded", async () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + await expect( + prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: emptyBalance, + }) + ).rejects.toMatchObject({ code: "sponsor_underfunded", httpStatus: 503 }); + }); +}); diff --git a/test/feeSponsorSubmit.test.js b/test/feeSponsorSubmit.test.js new file mode 100644 index 00000000..fdacb0c1 --- /dev/null +++ b/test/feeSponsorSubmit.test.js @@ -0,0 +1,380 @@ +// Fee-bump sponsorship (#30) — controller wiring for the payment AND donation +// submit paths. Proves: +// - flag-off is byte-for-byte the original unsponsored path (regression guard) +// for BOTH payment and donation, even when requestSponsorship:true is sent; +// - flag-on sponsors the submit (fee-bump XDR, inner-hash verification, +// sponsored fields, spend recorded); +// - sponsorship-specific rejections (cap/whitelist) return a distinct 4xx and +// never mark the row `failed`. +// +// stellarService is mocked (no network); feeSponsorService is mocked so the +// controller integration is tested in isolation from the service internals, +// which are covered end-to-end in feeSponsorService.test.js. +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +const submitTransaction = jest.fn(); +const verifyPaymentOperations = jest.fn(); +const validateSignedPaymentXdr = jest.fn(); +const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); +const recordSaleEarnings = jest.fn(); +const grantItemAccess = jest.fn(); +const enqueue = jest.fn(); + +// Sponsorship service mock — controllable per test. +const isFeeSponsorEnabled = jest.fn(); +const prepareSponsoredSubmission = jest.fn(); +const recordSponsorshipSpend = jest.fn(); +const getSponsorshipStatus = jest.fn(); +class SponsorshipError extends Error { + constructor(code, message, { httpStatus = 422 } = {}) { + super(message); + this.name = "SponsorshipError"; + this.code = code; + this.httpStatus = httpStatus; + this.retryUnsponsored = true; + } +} + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn(() => null), + buildSep7Uri: jest.fn(), + isValidPublicKey: jest.fn(() => true), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn(), + buildPaymentTransaction: jest.fn(), + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr, + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + DONATION_WALLET_PUBLIC_KEY: "GDONATION", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/stellar/feeSponsorService.js", () => ({ + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + getSponsorshipStatus, + SponsorshipError, +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ enqueue })); + +const { submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const { submitDonation } = await import( + "../src/controllers/stellar/donationController.js" +); +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), +}); + +const buyerWallet = "GBUYER"; +const creatorWallet = "GCREATOR"; + +const mountApp = (userId, handler, path = "/submit") => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post(path, handler); + return app; +}; + +let buyerId; +let session; + +beforeEach(() => { + jest.clearAllMocks(); + delete process.env.FEE_SPONSOR_ENABLED; + buyerId = new mongoose.Types.ObjectId(); + session = makeSession(); + jest.spyOn(mongoose, "startSession").mockResolvedValue(session); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: true }); + grantItemAccess.mockResolvedValue(undefined); + enqueue.mockResolvedValue(undefined); +}); + +afterEach(() => jest.restoreAllMocks()); + +// ── PAYMENT ────────────────────────────────────────────────────────────────── + +describe("submitPayment — fee sponsorship", () => { + const makePurchaseTx = () => ({ + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + creator: new mongoose.Types.ObjectId(), + buyerWallet, + creatorWallet, + itemType: "book", + itemId: new mongoose.Types.ObjectId(), + itemTitle: "Book", + amount: "15", + currency: "USDC", + memo: "DNB-BOOK-abcd1234", + settlement: "direct", + status: "pending", + save: jest.fn(function () { + return Promise.resolve(this); + }), + }); + + it("REGRESSION: flag off passes the raw XDR straight through; never sponsors", async () => { + isFeeSponsorEnabled.mockReturnValue(false); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ hash: "H_RAW", ledger: 10, successful: true }); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + // Same XDR submitted as-is; no fee-bump path taken. + expect(prepareSponsoredSubmission).not.toHaveBeenCalled(); + expect(submitTransaction).toHaveBeenCalledWith("RAW_XDR"); + // Base validation ran (unchanged behaviour). + expect(validateSignedPaymentXdr).toHaveBeenCalledTimes(1); + // Verification runs against the submitted hash, not an inner hash. + expect(verifyPaymentOperations).toHaveBeenCalledWith( + "H_RAW", + expect.any(Array), + "USDC" + ); + expect(tx.status).toBe("confirmed"); + expect(tx.sponsored).toBeFalsy(); + expect(res.body.transaction.sponsored).toBeUndefined(); + expect(recordSponsorshipSpend).not.toHaveBeenCalled(); + }); + + it("flag on: wraps as a fee-bump, verifies the inner hash, records spend, marks sponsored", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockResolvedValue({ + innerHash: "H_INNER", + outerHash: "H_OUTER", + feeBumpXdr: "FEEBUMP_XDR", + maxFeeStroops: 1000000, + }); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: "H_OUTER", + ledger: 20, + successful: true, + feeCharged: "300", + }); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(prepareSponsoredSubmission).toHaveBeenCalledTimes(1); + // The fee-bump envelope is submitted, not the raw inner XDR. + expect(submitTransaction).toHaveBeenCalledWith("FEEBUMP_XDR"); + // Base validation is NOT re-run (structural whitelist supersedes it). + expect(validateSignedPaymentXdr).not.toHaveBeenCalled(); + // On-chain verification runs against the INNER hash. + expect(verifyPaymentOperations).toHaveBeenCalledWith( + "H_INNER", + expect.any(Array), + "USDC" + ); + expect(recordSponsorshipSpend).toHaveBeenCalledWith( + expect.objectContaining({ feeStroops: 300 }) + ); + expect(tx.sponsored).toBe(true); + expect(tx.feeBumpTxHash).toBe("H_OUTER"); + expect(tx.sponsorFeeCharged).toBe("300"); + expect(tx.stellarTxHash).toBe("H_INNER"); + expect(res.body.transaction).toMatchObject({ + sponsored: true, + feeBumpTxHash: "H_OUTER", + hash: "H_INNER", + }); + }); + + it("cap rejection returns a distinct 4xx and does NOT mark the row failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("daily_cap_exceeded", "cap", { httpStatus: 429 }) + ); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(429); + expect(res.body).toMatchObject({ + success: false, + retryUnsponsored: true, + sponsorship: { approved: false, reason: "daily_cap_exceeded" }, + }); + // Row is untouched: not failed, not submitted, no on-network submit. + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(session.abortTransaction).toHaveBeenCalledTimes(1); + expect(session.commitTransaction).not.toHaveBeenCalled(); + }); + + it("whitelist rejection returns 422 and does NOT mark the row failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("whitelist_rejected", "bad", { httpStatus: 422 }) + ); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(422); + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + }); +}); + +// ── DONATION ───────────────────────────────────────────────────────────────── + +describe("submitDonation — fee sponsorship", () => { + const makeDonationTx = () => ({ + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creatorWallet: "GDONATION", + type: "donation", + amount: "5", + currency: "USDC", + memo: "DNB-SADAQAH", + status: "pending", + save: jest.fn(function () { + return Promise.resolve(this); + }), + }); + + it("REGRESSION: flag off passes the raw XDR straight through; never sponsors", async () => { + isFeeSponsorEnabled.mockReturnValue(false); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ hash: "H_RAW", ledger: 10, successful: true }); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(prepareSponsoredSubmission).not.toHaveBeenCalled(); + expect(submitTransaction).toHaveBeenCalledWith("RAW_XDR"); + expect(validateSignedPaymentXdr).toHaveBeenCalledTimes(1); + expect(verifyPaymentOperations).toHaveBeenCalledWith("H_RAW", expect.any(Array)); + expect(tx.status).toBe("confirmed"); + expect(tx.sponsored).toBeFalsy(); + expect(res.body.sponsored).toBeUndefined(); + expect(recordSponsorshipSpend).not.toHaveBeenCalled(); + }); + + it("flag on: wraps as a fee-bump, verifies the inner hash, records spend", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockResolvedValue({ + innerHash: "H_INNER", + outerHash: "H_OUTER", + feeBumpXdr: "FEEBUMP_XDR", + maxFeeStroops: 1000000, + }); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: "H_OUTER", + ledger: 20, + successful: true, + feeCharged: "200", + }); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(submitTransaction).toHaveBeenCalledWith("FEEBUMP_XDR"); + expect(validateSignedPaymentXdr).not.toHaveBeenCalled(); + expect(verifyPaymentOperations).toHaveBeenCalledWith("H_INNER", expect.any(Array)); + expect(recordSponsorshipSpend).toHaveBeenCalledWith( + expect.objectContaining({ feeStroops: 200 }) + ); + expect(tx.sponsored).toBe(true); + expect(tx.feeBumpTxHash).toBe("H_OUTER"); + expect(res.body).toMatchObject({ sponsored: true, feeBumpTxHash: "H_OUTER", txHash: "H_INNER" }); + }); + + it("cap rejection returns a distinct 4xx and does NOT mark the donation failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("daily_cap_exceeded", "cap", { httpStatus: 429 }) + ); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(429); + expect(res.body.retryUnsponsored).toBe(true); + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(session.abortTransaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/giftClaimableBalances.test.js b/test/giftClaimableBalances.test.js new file mode 100644 index 00000000..27d12d8a --- /dev/null +++ b/test/giftClaimableBalances.test.js @@ -0,0 +1,562 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; + +// ── Mocks ─────────────────────────────────────────────────────────────────── +const submitTransaction = jest.fn(); +const verifyTransaction = jest.fn(); +const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); +const buildCreateClaimableBalanceTx = jest.fn(); +const buildClaimTx = jest.fn(); +const resolveBalanceId = jest.fn(); +const getClaimableBalance = jest.fn(); +const validateSignedGiftXdr = jest.fn(); +const giftExpiryFromNow = jest.fn(() => new Date(Date.now() + 30 * 24 * 3600 * 1000)); +const grantItemAccess = jest.fn(); +const recordSaleEarnings = jest.fn(); +const enqueue = jest.fn(); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn(), + buildSep7Uri: jest.fn(), + isValidPublicKey: jest.fn(), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn(), + buildPaymentTransaction: jest.fn(), + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction, + verifyPaymentOperations: jest.fn(), + validateSignedPaymentXdr: jest.fn(), + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: TESTNET_PASSPHRASE, + DONATION_WALLET_PUBLIC_KEY: "", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/stellar/claimableBalanceService.js", () => ({ + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + giftExpiryFromNow, +})); + +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +const { + initializeGift, + submitGift, + listGifts, + getGift, + claimInitialize, + claimSubmit, +} = await import("../src/controllers/stellar/giftController.js"); +const { initializePayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Course = (await import("../src/models/Course.js")).default; +const GiftClaim = (await import("../src/models/GiftClaim.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + select: jest.fn(() => query), + sort: jest.fn(() => query), + skip: jest.fn(() => query), + limit: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const mountGiftApp = (userId) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post("/initialize", initializeGift); + app.post("/submit", submitGift); + app.get("/", listGifts); + app.get("/:id", getGift); + app.post("/:id/claim/initialize", claimInitialize); + app.post("/:id/claim/submit", claimSubmit); + return app; +}; + +const senderWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const recipientWallet = "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI"; +const BALANCE_ID = "00000000" + "ab".repeat(32); + +describe("Gift controller (claimable balances)", () => { + let senderId; + let recipientId; + let creatorId; + let itemId; + let savedGifts; + + beforeEach(() => { + jest.restoreAllMocks(); + submitTransaction.mockReset(); + verifyTransaction.mockReset(); + getExplorerUrl.mockClear(); + buildCreateClaimableBalanceTx.mockReset(); + buildClaimTx.mockReset(); + resolveBalanceId.mockReset(); + getClaimableBalance.mockReset(); + validateSignedGiftXdr.mockReset(); + giftExpiryFromNow.mockClear(); + grantItemAccess.mockReset().mockResolvedValue(undefined); + recordSaleEarnings.mockReset(); + enqueue.mockReset().mockResolvedValue(undefined); + + senderId = new mongoose.Types.ObjectId(); + recipientId = new mongoose.Types.ObjectId(); + creatorId = new mongoose.Types.ObjectId(); + itemId = new mongoose.Types.ObjectId(); + savedGifts = []; + + jest.spyOn(GiftClaim.prototype, "save").mockImplementation(function () { + savedGifts.push(this); + return Promise.resolve(this); + }); + jest.spyOn(GiftClaim, "updateMany").mockResolvedValue({ modifiedCount: 0 }); + jest.spyOn(Transaction.prototype, "save").mockImplementation(function () { + return Promise.resolve(this); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("initializes a gift and persists a pending_signature GiftClaim", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { _id: recipientId, name: "Recipient", stellarWallet: { publicKey: recipientWallet }, purchasedBooks: [] }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(null)); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + giftExpiryFromNow.mockReturnValue(expiresAt); + buildCreateClaimableBalanceTx.mockResolvedValue({ + xdr: "unsigned-gift-xdr", + hash: "expected-gift-hash", + networkPassphrase: TESTNET_PASSPHRASE, + expiresAt, + }); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), recipientUserId: recipientId.toString() }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + payment: { xdr: "unsigned-gift-xdr", expectedHash: "expected-gift-hash" }, + }); + expect(savedGifts).toHaveLength(1); + expect(savedGifts[0]).toMatchObject({ + sender: senderId, + recipient: recipientId, + recipientWallet, + creator: creatorId, + itemType: "book", + itemId, + amount: "15", + status: "pending_signature", + createTxHash: "expected-gift-hash", + }); + expect(savedGifts[0].balanceId).toBeUndefined(); + }); + + it.each([ + { + name: "recipient with no wallet", + recipient: () => ({ _id: recipientId, name: "Recipient", purchasedBooks: [] }), + expected: "Recipient has not connected their Stellar wallet yet", + }, + { + name: "self-gift", + selfGift: true, + expected: "You cannot gift an item to yourself", + }, + ])("rejects $name", async ({ recipient, expected, selfGift }) => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient()); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + recipientUserId: selfGift ? senderId.toString() : recipientId.toString(), + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe(expected); + expect(savedGifts).toHaveLength(0); + }); + + it("rejects gifting an item the recipient already owns", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { + _id: recipientId, + name: "Recipient", + stellarWallet: { publicKey: recipientWallet }, + purchasedBooks: [{ bookId: itemId }], + }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + recipientUserId: recipientId.toString(), + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe("Recipient already owns this book"); + expect(savedGifts).toHaveLength(0); + }); + + it("rejects a duplicate pending gift for the same recipient+item", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { _id: recipientId, name: "Recipient", stellarWallet: { publicKey: recipientWallet }, purchasedBooks: [] }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + const existing = { _id: new mongoose.Types.ObjectId() }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(existing)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), recipientUserId: recipientId.toString() }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toContain("pending gift"); + expect(res.body.giftId).toBe(existing._id.toString()); + }); + + it("submits a gift, stores the REAL balance id (≠ tx hash), and sets status open", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + itemType: "book", + itemId, + amount: "15", + assetCode: "USDC", + status: "pending_signature", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(sender)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + submitTransaction.mockResolvedValue({ hash: "create-tx-hash", ledger: 5, successful: true }); + resolveBalanceId.mockResolvedValue(BALANCE_ID); + + const res = await request(mountGiftApp(senderId)) + .post("/submit") + .send({ giftId: gift._id.toString(), signedXdr: "signed-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ success: true, balanceId: BALANCE_ID, createTxHash: "create-tx-hash" }); + expect(gift.status).toBe("open"); + expect(gift.balanceId).toBe(BALANCE_ID); + expect(gift.balanceId).not.toBe("create-tx-hash"); + expect(validateSignedGiftXdr).toHaveBeenCalled(); + }); + + it("rejects a tampered signed XDR before any DB write", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + itemType: "book", + itemId, + amount: "15", + assetCode: "USDC", + status: "pending_signature", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(), + }; + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(sender)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + validateSignedGiftXdr.mockImplementation(() => { + throw new Error("Signed XDR missing the recipient claimant with before_absolute_time(expiresAt)"); + }); + + const res = await request(mountGiftApp(senderId)) + .post("/submit") + .send({ giftId: gift._id.toString(), signedXdr: "tampered-xdr" }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe("Signed transaction does not match expected gift details"); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(gift.status).toBe("pending_signature"); + }); + + it("builds a claim XDR for the recipient before expiry (trustline-free)", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "open", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + }; + const user = { _id: recipientId, stellarWallet: { publicKey: recipientWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + buildClaimTx.mockResolvedValue({ + xdr: "claim-xdr", + hash: "claim-hash", + networkPassphrase: TESTNET_PASSPHRASE, + includesChangeTrust: true, + }); + + const res = await request(mountGiftApp(recipientId)) + .post(`/${gift._id.toString()}/claim/initialize`) + .send({}); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + action: "claim", + claim: { xdr: "claim-xdr", includesChangeTrust: true }, + }); + }); + + it.each([ + { name: "sender before expiry", userId: () => "SENDER", expected: 403 }, + { name: "recipient after expiry", userId: () => "RECIPIENT", expiresPast: true, expected: 403 }, + { name: "stranger", userId: () => "STRANGER", expected: 403 }, + ])("authorizes claim: $name", async ({ userId, expected, expiresPast }) => { + const ids = { SENDER: senderId, RECIPIENT: recipientId, STRANGER: new mongoose.Types.ObjectId() }; + const actualId = ids[userId()]; + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "open", + expiresAt: new Date(Date.now() + (expiresPast ? -1 : 1) * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: actualId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + + const res = await request(mountGiftApp(actualId)) + .post(`/${gift._id.toString()}/claim/initialize`) + .send({}); + + expect(res.statusCode).toBe(expected); + expect(buildClaimTx).not.toHaveBeenCalled(); + }); + + it("grants access to the RECIPIENT (never the sender) on a successful claim", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "course", + itemId, + status: "open", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: recipientId, stellarWallet: { publicKey: recipientWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + submitTransaction.mockResolvedValue({ hash: "claim-tx-hash", ledger: 9, successful: true }); + verifyTransaction.mockResolvedValue({ + exists: true, + successful: true, + operations: [{ type: "claim_claimable_balance", balance_id: BALANCE_ID }], + }); + + const res = await request(mountGiftApp(recipientId)) + .post(`/${gift._id.toString()}/claim/submit`) + .send({ signedXdr: "signed-claim-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body.action).toBe("claimed"); + expect(gift.status).toBe("claimed"); + expect(gift.claimTxHash).toBe("claim-tx-hash"); + // Access lands on the RECIPIENT, not the sender. + expect(grantItemAccess).toHaveBeenCalledWith({ + buyerId: recipientId, + itemType: "course", + itemId, + }); + expect(grantItemAccess.mock.calls[0][0].buyerId).not.toBe(senderId); + }); + + it("lets the sender reclaim after expiry without granting access", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "expired", + expiresAt: new Date(Date.now() - 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + submitTransaction.mockResolvedValue({ hash: "reclaim-tx-hash", ledger: 10, successful: true }); + verifyTransaction.mockResolvedValue({ + exists: true, + successful: true, + operations: [{ type: "claim_claimable_balance", balance_id: BALANCE_ID }], + }); + + const res = await request(mountGiftApp(senderId)) + .post(`/${gift._id.toString()}/claim/submit`) + .send({ signedXdr: "signed-reclaim-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body.action).toBe("reclaimed"); + expect(gift.status).toBe("reclaimed"); + expect(grantItemAccess).not.toHaveBeenCalled(); + }); +}); + +describe("purchase-flow fallback to claimable balance", () => { + const buyerId = new mongoose.Types.ObjectId(); + const itemId = new mongoose.Types.ObjectId(); + const buyerWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const mountPaymentApp = () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: buyerId }; + next(); + }); + app.post("/initialize", initializePayment); + return app; + }; + + const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), + }); + + beforeEach(() => { + jest.restoreAllMocks(); + jest.spyOn(mongoose, "startSession").mockResolvedValue(makeSession()); + }); + + it("returns { fallback: 'claimable_balance' } when the creator has no wallet", async () => { + const buyer = { _id: buyerId, stellarWallet: { publicKey: buyerWallet } }; + // creator has no stellarWallet and PLATFORM_COLLECT_ENABLED is off + const book = { _id: itemId, title: "Book", price: 15, author: { _id: new mongoose.Types.ObjectId(), name: "Creator" } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountPaymentApp()) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), buyerWallet }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + success: false, + fallback: "claimable_balance", + message: "Creator has not connected their Stellar wallet yet", + }); + }); +}); diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 00000000..227ace9d --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,109 @@ +import { jest } from "@jest/globals"; +import { createHealthHandler, ping } from "../src/controllers/healthController.js"; + +const createResponse = () => { + const res = { + status: jest.fn(), + json: jest.fn(), + send: jest.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + res.send.mockReturnValue(res); + return res; +}; + +describe("Health endpoints", () => { + it("returns healthy readiness metadata when critical dependencies are ready", () => { + const handler = createHealthHandler({ + getMongoReadyState: () => 1, + getRedisReady: () => true, + getUptime: () => 42.5, + getEnvironment: () => "test", + }); + const res = createResponse(); + + handler({}, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: "All critical dependencies are ready", + data: { + status: "healthy", + timestamp: expect.any(String), + uptime: 42.5, + environment: "test", + dependencies: { + mongodb: { + status: "up", + state: "connected", + }, + redis: { + status: "up", + }, + }, + }, + }); + }); + + it.each([ + { + name: "MongoDB", + mongoReadyState: 0, + redisReady: true, + expectedMongoState: "disconnected", + }, + { + name: "Redis", + mongoReadyState: 1, + redisReady: false, + expectedMongoState: "connected", + }, + ])( + "returns unavailable readiness when $name is down", + ({ mongoReadyState, redisReady, expectedMongoState }) => { + const handler = createHealthHandler({ + getMongoReadyState: () => mongoReadyState, + getRedisReady: () => redisReady, + getUptime: () => 10, + getEnvironment: () => "test", + }); + const res = createResponse(); + + handler({}, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "One or more critical dependencies are unavailable", + data: { + status: "unhealthy", + timestamp: expect.any(String), + uptime: 10, + environment: "test", + dependencies: { + mongodb: { + status: mongoReadyState === 1 ? "up" : "down", + state: expectedMongoState, + }, + redis: { + status: redisReady ? "up" : "down", + }, + }, + }, + }) + ); + } + ); + + it("keeps ping independent from dependency probes", () => { + const res = createResponse(); + + ping({}, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith("pong"); + }); +}); diff --git a/test/helpers/testAuth.js b/test/helpers/testAuth.js index 8507eea3..66175d0d 100644 --- a/test/helpers/testAuth.js +++ b/test/helpers/testAuth.js @@ -1,5 +1,5 @@ import request from "supertest"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; import User from "../../src/models/User.js"; // Registration is email-verification-first, so POST /api/auth/register no longer @@ -16,12 +16,14 @@ export async function seedUserAndLogin(app, overrides = {}) { }; const hashedPassword = await bcrypt.hash(creds.password, 12); + const { name, email, role, password, ...extraFields } = creds; const user = await User.create({ - name: creds.name, - email: creds.email, + name, + email, password: hashedPassword, - role: creds.role, + role, isVerified: true, + ...extraFields, }); const res = await request(app) diff --git a/test/idempotency.test.js b/test/idempotency.test.js new file mode 100644 index 00000000..35ad1a86 --- /dev/null +++ b/test/idempotency.test.js @@ -0,0 +1,299 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; + +jest.setTimeout(60000); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + resolveAsset: jest.fn(), + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn().mockReturnValue(null), + buildSep7Uri: jest.fn().mockReturnValue("web+stellar:pay?mock"), + isValidPublicKey: jest.fn().mockReturnValue(true), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn().mockResolvedValue({ ok: true }), + buildPaymentTransaction: jest.fn().mockResolvedValue({ + xdr: "mock_xdr_string", + networkPassphrase: "Test SDF Network ; September 2015", + network: "testnet", + hash: "mock_hash_12345", + }), + buildReversePaymentTransaction: jest.fn(), + submitTransaction: jest.fn().mockResolvedValue({ hash: "mock_tx_hash_123" }), + verifyTransaction: jest.fn(), + verifyPaymentOperations: jest.fn().mockResolvedValue({ ok: true }), + validateSignedPaymentXdr: jest.fn().mockReturnValue({ valid: true }), + hasTrustline: jest.fn().mockResolvedValue(true), + hasUsdcTrustline: jest.fn().mockResolvedValue(true), + getExplorerUrl: jest.fn((hash) => `https://stellar.expert/tx/${hash}`), + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + DONATION_WALLET_PUBLIC_KEY: "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", + DEFAULT_ASSET_CODE: "USDC", +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue: jest.fn().mockResolvedValue(undefined), +})); + +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; +const IdempotencyKey = (await import("../src/models/IdempotencyKey.js")).default; +const protect = (await import("../src/middlewares/authMiddleware.js")).protect; +const idempotencyMiddleware = (await import("../src/middlewares/idempotency.js")).idempotency; +const paymentRoutes = (await import("../src/routes/stellar/paymentRoutes.js")).default; +const donationRoutes = (await import("../src/routes/stellar/donationRoutes.js")).default; +const payoutRoutes = (await import("../src/routes/payoutRoutes.js")).default; + +const JWT_SECRET = process.env.JWT_SECRET || "test_secret_key_32_characters_long_for_testing"; + +describe("Request-Level Idempotency Layer (#93)", () => { + let mongoServer; + let user, book, token; + let app; + let mockConcurrencyHandler; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + process.env.DONATION_WALLET_PUBLIC_KEY = + "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K"; + + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); + + await User.createCollection(); + await Book.createCollection(); + await Transaction.createCollection(); + await IdempotencyKey.createCollection(); + await IdempotencyKey.syncIndexes(); + }); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await IdempotencyKey.deleteMany({}); + await Transaction.deleteMany({}); + await User.deleteMany({}); + await Book.deleteMany({}); + + user = await User.create({ + name: "Test Buyer", + username: "testbuyer", + email: "buyer@example.com", + password: "Password123!", + role: "student", + stellarWallet: { + publicKey: "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K", + }, + }); + + token = jwt.sign({ userId: user._id, role: "student" }, JWT_SECRET, { + expiresIn: "1h", + }); + + book = await Book.create({ + title: "Test Book for Idempotency", + author: user._id, + description: "Test Description for Idempotency Book", + image: "https://cloudinary.com/test.jpg", + fileUrl: "https://cloudinary.com/test.pdf", + price: 10, + currency: "USDC", + }); + + mockConcurrencyHandler = jest.fn((req, res) => { + setTimeout(() => res.status(200).json({ success: true, transactionId: "mock_tx_id" }), 30); + }); + + app = express(); + app.use(express.json()); + app.post("/test-concurrency", protect, idempotencyMiddleware({ required: true }), mockConcurrencyHandler); + app.use("/api/stellar/payment", paymentRoutes); + app.use("/api/stellar/donation", donationRoutes); + app.use("/api/payouts", payoutRoutes); + app.use(errorHandler); + }); + + it("proves the concurrency lock via unique-index insert on concurrent same-key requests", async () => { + const key = "concurrency-key-12345"; + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const [res1, res2] = await Promise.all([ + request(app) + .post("/test-concurrency") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload), + request(app) + .post("/test-concurrency") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload), + ]); + + const winnerRes = res1.status === 200 ? res1 : res2; + const loserRes = res1.status === 409 ? res1 : res2; + + expect(winnerRes.status).toBe(200); + expect(loserRes.status).toBe(409); + expect(winnerRes.body.success).toBe(true); + expect(loserRes.body.message).toMatch(/currently in progress/i); + expect(mockConcurrencyHandler).toHaveBeenCalledTimes(1); + }); + + it("replays a completed key returning identical status + body without creating second Transaction", async () => { + const key = "replay-key-99999"; + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const firstRes = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(firstRes.status).toBe(200); + expect(firstRes.body.success).toBe(true); + const originalTxId = firstRes.body.transactionId; + + const replayRes = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(replayRes.status).toBe(200); + expect(replayRes.body).toEqual(firstRes.body); + expect(replayRes.body.transactionId).toBe(originalTxId); + + const txCount = await Transaction.countDocuments({ buyer: user._id }); + expect(txCount).toBe(1); + }); + + it("returns 422 Unprocessable Entity when same key arrives with a different body payload", async () => { + const key = "mismatch-key-55555"; + const payload1 = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + const payload2 = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: "GOTHERWALLET1234567890123456789012345678901234567890", + }; + + const res1 = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload1); + expect(res1.status).toBe(200); + + const res2 = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload2); + + expect(res2.status).toBe(422); + expect(res2.body.message).toMatch(/payload mismatch/i); + }); + + it("allows missing key requests to proceed normally according to policy", async () => { + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const res = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .send(payload); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("provides idempotency protection to donation routes", async () => { + const key = "donation-key-77777"; + const payload = { + amount: "50", + publicKey: user.stellarWallet.publicKey, + }; + + const firstRes = await request(app) + .post("/api/stellar/donation/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(firstRes.status).toBe(200); + + const replayRes = await request(app) + .post("/api/stellar/donation/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(replayRes.status).toBe(200); + expect(replayRes.body).toEqual(firstRes.body); + + const txCount = await Transaction.countDocuments({ buyer: user._id }); + expect(txCount).toBe(1); + }); + + it("verifies idempotency keys expire via TTL", async () => { + const indexes = IdempotencyKey.schema.indexes(); + const ttlIndex = indexes.find( + (idx) => idx[0].createdAt === 1 && idx[1] && idx[1].expireAfterSeconds === 86400 + ); + expect(ttlIndex).toBeDefined(); + }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); +}); + diff --git a/test/jest.setup.js b/test/jest.setup.js index d63283c9..4aedf275 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -26,13 +26,18 @@ for (const variable of [ delete process.env[variable]; } -for (const variable of ["MONGO_URI", "JWT_SECRET", "PORT"]) { - if (!process.env[variable]) { - throw new Error(`${variable} must be set when running tests`); - } -} +// The app is deliberately importable without a database. Individual +// integration suites opt into MongoMemoryServer or the CI Mongo service. +process.env.JWT_SECRET = + process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; +process.env.PORT = process.env.PORT || "5000"; +process.env.CLOUDINARY_CLOUD_NAME = + process.env.CLOUDINARY_CLOUD_NAME || "test_cloud"; +process.env.CLOUDINARY_API_KEY = + process.env.CLOUDINARY_API_KEY || "test_key"; +process.env.CLOUDINARY_API_SECRET = + process.env.CLOUDINARY_API_SECRET || "test_secret_that_should_not_leak"; if (typeof jest !== "undefined") { jest.setTimeout(60000); } - diff --git a/test/notification.test.js b/test/notification.test.js index 1f319f85..28b27314 100644 --- a/test/notification.test.js +++ b/test/notification.test.js @@ -19,6 +19,15 @@ describe("Notification System & Event Wiring", () => { let mongoServer; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_notification`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); await mongoose.connect(mongoUri); diff --git a/test/ownershipAuthz.test.js b/test/ownershipAuthz.test.js new file mode 100644 index 00000000..091492ed --- /dev/null +++ b/test/ownershipAuthz.test.js @@ -0,0 +1,338 @@ +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Space from "../src/models/Space.js"; +import AuditLog from "../src/models/AuditLog.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../src/middlewares/authorize.js"; + +// Mirrors test/authRoles.test.js: a mini express app + MongoMemoryServer with an +// injected req.user, mounting a single guard followed by a stub handler that +// returns 200 when the guard calls next(). Denials flow through the global +// errorHandler and surface as 403/404. + +// Build an app that injects `user`, runs `guard`, and returns 200 if it passes. +const buildApp = (user, method, path, guard) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = user; + next(); + }); + app[method](path, guard, (req, res) => + res.status(200).json({ + ok: true, + resourceId: req.resource?._id, + reviewId: req.review?._id, + }) + ); + app.use(errorHandler); + return app; +}; + +// Poll for a fire-and-forget audit row (recordAudit schedules the write async). +const waitForAudit = async (query, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const row = await AuditLog.findOne(query); + if (row) return row; + await new Promise((r) => setTimeout(r, 25)); + } + return null; +}; + +describe("Resource-Ownership Authorization Layer", () => { + let mongoServer; + let ownerMentor, otherMentor, studentUser, adminUser, reviewerUser; + let book, course, space, bookReviewId, courseReviewId; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Course.deleteMany({}); + await Space.deleteMany({}); + // AuditLog is append-only (model hooks block deleteMany); clear via the + // raw collection so each test starts with a clean audit trail. + await mongoose.connection.collection("auditlogs").deleteMany({}); + + ownerMentor = await User.create({ + name: "Owner Mentor", + email: "owner_mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + otherMentor = await User.create({ + name: "Other Mentor", + email: "other_mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + studentUser = await User.create({ + name: "Student", + email: "student@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + adminUser = await User.create({ + name: "Admin", + email: "admin@example.com", + password: "Qx7#vLmp92Zt", + role: "admin", + }); + reviewerUser = await User.create({ + name: "Reviewer", + email: "reviewer@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + book = await Book.create({ + title: "Owned Book", + description: "Desc", + category: "Tech", + price: 10, + author: ownerMentor._id, + image: "https://example.com/thumb.jpg", + fileUrl: "https://example.com/file.pdf", + reviews: [{ user: reviewerUser._id, comment: "Nice", rating: 5 }], + }); + bookReviewId = book.reviews[0]._id; + + course = await Course.create({ + title: "Owned Course", + description: "Desc", + category: "Tech", + price: 10, + createdBy: ownerMentor._id, + reviews: [{ user: reviewerUser._id, comment: "Great", rating: 4 }], + }); + courseReviewId = course.reviews[0]._id; + + space = await Space.create({ + title: "Owned Space", + description: "Desc", + category: "Tech", + host: ownerMentor._id, + price: 0, + eventDate: new Date(), + eventTime: "10:00", + duration: 60, + }); + }); + + const missingId = () => new mongoose.Types.ObjectId(); + + // ── Top-level resource ownership (Book / Course / Space) ─────────────────── + describe.each([ + { + label: "Book DELETE", + method: "delete", + path: "/books/:id", + guard: () => + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), + url: () => `/books/${book._id}`, + missingUrl: () => `/books/${missingId()}`, + targetId: () => String(book._id), + resourceType: "Book", + }, + { + label: "Course PUT", + method: "put", + path: "/courses/:id", + guard: () => + authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }), + url: () => `/courses/${course._id}`, + missingUrl: () => `/courses/${missingId()}`, + targetId: () => String(course._id), + resourceType: "Course", + }, + { + label: "Space PUT (update)", + method: "put", + path: "/spaces/update/:id", + guard: () => + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + url: () => `/spaces/update/${space._id}`, + missingUrl: () => `/spaces/update/${missingId()}`, + targetId: () => String(space._id), + resourceType: "Space", + }, + { + label: "Space DELETE", + method: "delete", + path: "/spaces/:id", + guard: () => + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + url: () => `/spaces/${space._id}`, + missingUrl: () => `/spaces/${missingId()}`, + targetId: () => String(space._id), + resourceType: "Space", + }, + ])("$label", ({ method, path, guard, url, missingUrl, targetId, resourceType }) => { + it("allows the resource owner (2xx)", async () => { + const app = buildApp(ownerMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it("allows an admin (2xx)", async () => { + const app = buildApp(adminUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("rejects a non-owner mentor (403) and audits the denial", async () => { + const app = buildApp(otherMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + + const row = await waitForAudit({ + action: "authz.ownership.denied", + actor: otherMentor._id, + targetId: targetId(), + }); + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe(resourceType); + expect(String(row.actor)).toBe(String(otherMentor._id)); + }); + + it("rejects a non-owner student (403)", async () => { + const app = buildApp(studentUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + }); + + it("returns 404 for a non-existent resource", async () => { + const app = buildApp(ownerMentor, method, path, guard()); + const res = await request(app)[method](missingUrl()); + expect(res.status).toBe(404); + }); + }); + + // ── Review subdocument ownership (Book / Course, id-scoped) ──────────────── + describe.each([ + { + label: "Book review (id-scoped)", + method: "put", + path: "/books/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Book }), + url: () => `/books/${book._id}/reviews/${bookReviewId}`, + missingUrl: () => `/books/${missingId()}/reviews/${bookReviewId}`, + missingReviewUrl: () => `/books/${book._id}/reviews/${missingId()}`, + targetId: () => String(bookReviewId), + }, + { + label: "Book review DELETE (id-scoped)", + method: "delete", + path: "/books/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Book }), + url: () => `/books/${book._id}/reviews/${bookReviewId}`, + missingUrl: () => `/books/${missingId()}/reviews/${bookReviewId}`, + missingReviewUrl: () => `/books/${book._id}/reviews/${missingId()}`, + targetId: () => String(bookReviewId), + }, + { + label: "Course review (id-scoped)", + method: "put", + path: "/courses/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Course }), + url: () => `/courses/${course._id}/reviews/${courseReviewId}`, + missingUrl: () => `/courses/${missingId()}/reviews/${courseReviewId}`, + missingReviewUrl: () => `/courses/${course._id}/reviews/${missingId()}`, + targetId: () => String(courseReviewId), + }, + { + label: "Course review DELETE (id-scoped)", + method: "delete", + path: "/courses/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Course }), + url: () => `/courses/${course._id}/reviews/${courseReviewId}`, + missingUrl: () => `/courses/${missingId()}/reviews/${courseReviewId}`, + missingReviewUrl: () => `/courses/${course._id}/reviews/${missingId()}`, + targetId: () => String(courseReviewId), + }, + ])("$label", ({ method, path, guard, url, missingUrl, missingReviewUrl, targetId }) => { + it("allows the review owner (2xx)", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("allows an admin (2xx)", async () => { + const app = buildApp(adminUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("rejects a non-owner mentor (403) and audits the denial", async () => { + const app = buildApp(otherMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + + const row = await waitForAudit({ + action: "authz.ownership.denied", + actor: otherMentor._id, + targetId: targetId(), + }); + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe("Review"); + }); + + it("rejects a non-owner student (403)", async () => { + const app = buildApp(studentUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + }); + + it("returns 404 for a non-existent parent item", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](missingUrl()); + expect(res.status).toBe(404); + }); + + it("returns 404 for a non-existent review id", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](missingReviewUrl()); + expect(res.status).toBe(404); + }); + }); + + // ── Review subdocument ownership (self-scoped: no :reviewId) ─────────────── + describe("Review (self-scoped) ownership", () => { + it("allows the caller to act on their own review (2xx)", async () => { + const app = buildApp(reviewerUser, "put", "/books/:id/reviews", authorizeReviewOwnership({ model: Book })); + const res = await request(app).put(`/books/${book._id}/reviews`); + expect(res.status).toBe(200); + expect(String(res.body.reviewId)).toBe(String(bookReviewId)); + }); + + it("returns 404 when the caller has no review of their own", async () => { + const app = buildApp(otherMentor, "delete", "/courses/:id/reviews", authorizeReviewOwnership({ model: Course })); + const res = await request(app).delete(`/courses/${course._id}/reviews`); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/test/passwordReset.test.js b/test/passwordReset.test.js index ec0d389c..944b9013 100644 --- a/test/passwordReset.test.js +++ b/test/passwordReset.test.js @@ -1,12 +1,14 @@ import { jest } from "@jest/globals"; import request from "supertest"; import mongoose from "mongoose"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; +import axios from "axios"; import app from "../app.js"; import User from "../src/models/User.js"; import PendingUser from "../src/models/PendingUser.js"; import Session from "../src/models/Session.js"; import logger from "../src/config/logger.js"; +import { testOutbox } from "../services/emails/sendMail.js"; const testUser = { name: "Reset User", @@ -23,8 +25,12 @@ describe("Password Reset Flow", () => { let loggerInfoSpy; beforeAll(() => { - // Capture the OTP code from the [EMAIL LOG] fallback (SMTP is unset in tests) + // Capture log output so we can assert OTPs/tokens never reach the logger. loggerInfoSpy = jest.spyOn(logger, "info"); + jest.spyOn(logger, "warn"); + jest.spyOn(logger, "error"); + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); // Mock User methods jest.spyOn(User, "findOne").mockImplementation((query) => { @@ -104,6 +110,7 @@ describe("Password Reset Flow", () => { beforeEach(() => { usersStore = []; sessionsStore = []; + testOutbox.length = 0; if (loggerInfoSpy) loggerInfoSpy.mockClear(); }); @@ -112,17 +119,24 @@ describe("Password Reset Flow", () => { }); const getSentOtp = () => { - // Registration now also sends a verification email, so multiple [EMAIL LOG] - // entries exist. Pick the most recent one that actually carries an OTP span. - const otpLog = loggerInfoSpy.mock.calls - .map((call) => call[0]) - .filter((msg) => typeof msg === "string" && msg.includes("[EMAIL LOG]")) + // sendMail captures rendered emails in its in-memory testOutbox (never in + // logs). Registration also sends a verification email, so pick the most + // recent outbox entry that actually carries an OTP span. + const otpMail = [...testOutbox] .reverse() - .find((msg) => /#166534;">(\d+)<\/span>/.test(msg)); - const match = otpLog && otpLog.match(/#166534;">(\d+)<\/span>/); + .find((mail) => /#166534;">(\d+)<\/span>/.test(mail.html)); + const match = otpMail && otpMail.html.match(/#166534;">(\d+)<\/span>/); return match ? match[1] : null; }; + const capturedLogText = () => + ["info", "warn", "error"] + .flatMap((method) => logger[method].mock.calls || []) + .map((call) => + call.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + ) + .join("\n"); + it("should request password reset without exposing OTP in response body and include success: true", async () => { await request(app).post("/api/auth/register").send(testUser); @@ -277,4 +291,27 @@ describe("Password Reset Flow", () => { expect(reuseRes.body.success).toBe(false); expect(reuseRes.body.message).toContain("Invalid or expired OTP"); }); + + it("never leaks the OTP or verification token into log output", async () => { + await request(app).post("/api/auth/register").send(testUser); + + // Registration renders a verification email carrying a token link; the + // reset request renders an OTP email. Both must stay out of the logs. + const verificationMail = testOutbox.find((m) => m.template === "verification"); + const tokenMatch = verificationMail && verificationMail.html.match(/token=([a-f0-9]{64})/); + expect(tokenMatch).not.toBeNull(); + + await request(app) + .post("/api/auth/request-password-reset") + .send({ email: testUser.email }); + + const otp = getSentOtp(); + expect(otp).toBeDefined(); + + const logs = capturedLogText(); + expect(logs).not.toContain(otp); + expect(logs).not.toContain(tokenMatch[1]); + expect(logs).not.toContain("token="); + expect(logs).not.toContain(" `https://stellar.expert/tx/${hash}`); +const recordSaleEarnings = jest.fn(); +const enqueue = jest.fn(); +const grantItemAccess = jest.fn(); + +// Mirrors every named export of stellarService.js (see the comment in +// stellarPaymentController.test.js for why the full surface is needed). +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit, + buildSep7Uri, + isValidPublicKey: jest.fn(), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment, + buildPaymentTransaction, + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr: jest.fn(), + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: TESTNET_PASSPHRASE, + DONATION_WALLET_PUBLIC_KEY: "", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +// grantItemAccess is what must NOT run twice on a replayed submit — mock it +// so the test can count invocations. +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); + +const { initializePayment, submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + select: jest.fn(() => query), + sort: jest.fn(() => query), + skip: jest.fn(() => query), + limit: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), +}); + +const mountPaymentApp = (userId) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post("/initialize", initializePayment); + app.post("/submit", submitPayment); + return app; +}; + +// A real, valid Stellar destination (the SDK validates the key format when +// building the operation). +const DESTINATION = StellarSdk.Keypair.random().publicKey(); + +// Build a real, signed USDC payment transaction (testnet passphrase) so the +// controller can parse it and derive a deterministic on-chain hash. +const buildSignedXdr = ({ amount = "15" } = {}) => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1"); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: TESTNET_PASSPHRASE, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination: DESTINATION, + asset: new StellarSdk.Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"), + amount, + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-BOOK-1234")) + .setTimeout(300) + .build(); + tx.sign(source); + return { xdr: tx.toXDR(), hash: tx.hash().toString("hex") }; +}; + +describe("Stellar payment idempotency", () => { + let buyerId; + let creatorId; + let itemId; + let buyerWallet; + let creatorWallet; + let session; + let savedTransactions; + + beforeEach(() => { + jest.restoreAllMocks(); + buildPaymentTransaction.mockReset(); + buildSep7Uri.mockReset(); + calculateFeeSplit.mockReset().mockReturnValue(null); + preflightPayment.mockReset().mockResolvedValue({ ok: true }); + submitTransaction.mockReset(); + verifyPaymentOperations.mockReset(); + getExplorerUrl.mockClear(); + recordSaleEarnings.mockReset(); + enqueue.mockReset().mockResolvedValue(undefined); + grantItemAccess.mockReset().mockResolvedValue(undefined); + + buyerId = new mongoose.Types.ObjectId(); + creatorId = new mongoose.Types.ObjectId(); + itemId = new mongoose.Types.ObjectId(); + buyerWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + creatorWallet = "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI"; + session = makeSession(); + savedTransactions = []; + + jest.spyOn(mongoose, "startSession").mockResolvedValue(session); + jest.spyOn(Transaction.prototype, "save").mockImplementation(function () { + savedTransactions.push(this); + return Promise.resolve(this); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the existing pending record instead of creating a duplicate on re-initialize", async () => { + const buyer = { + _id: buyerId, + stellarWallet: { publicKey: buyerWallet }, + purchasedBooks: [], + }; + const creator = { + _id: creatorId, + name: "Educator", + stellarWallet: { publicKey: creatorWallet }, + }; + const book = { + _id: itemId, + title: "Paid Book", + price: 15, + author: creator, + }; + const existingTx = { + _id: new mongoose.Types.ObjectId(), + unsignedXdr: "unsigned-xdr-from-first-init", + expectedHash: "expected-hash", + }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(existingTx)); + + const res = await request(mountPaymentApp(buyerId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + buyerWallet, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + alreadyPending: true, + transactionId: existingTx._id.toString(), + payment: { + xdr: "unsigned-xdr-from-first-init", + networkPassphrase: TESTNET_PASSPHRASE, + expectedHash: "expected-hash", + }, + }); + // No XDR was built and no new document was saved. + expect(buildPaymentTransaction).not.toHaveBeenCalled(); + expect(savedTransactions).toHaveLength(0); + expect(session.abortTransaction).toHaveBeenCalled(); + }); + + it("replays the original success response on a duplicate submit of the same signed XDR", async () => { + const signed = buildSignedXdr(); + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creator: creatorId, + creatorWallet, + itemType: "book", + itemId, + itemTitle: "Paid Book", + amount: "15", + currency: "USDC", + status: "pending", + memo: "DNB-BOOK-1234", + save: jest.fn(() => Promise.resolve()), + }; + + // findOne is called for both the confirmed-by-hash lookup and the pending + // lookup; the same object is returned so the second submit sees it already + // confirmed (status flipped by the first submit). + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: signed.hash, + ledger: 77, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: true }); + + const app = mountPaymentApp(buyerId); + + // First submit — normal confirmation. + const first = await request(app) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + expect(first.statusCode).toBe(200); + expect(first.body).toMatchObject({ + success: true, + message: "Payment successful!", + }); + expect(tx.status).toBe("confirmed"); + expect(tx.stellarTxHash).toBe(signed.hash); + + // Second submit — same XDR, must be recognized as already processed. + const second = await request(app) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + expect(second.statusCode).toBe(200); + expect(second.body).toMatchObject({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + hash: signed.hash, + itemTitle: "Paid Book", + amount: "15", + }, + }); + + // Access is granted exactly once across both submits. + expect(grantItemAccess).toHaveBeenCalledTimes(1); + expect(recordSaleEarnings).toHaveBeenCalledTimes(1); + }); + + it("treats a concurrent duplicate (unique-index E11000) as already processed", async () => { + const signed = buildSignedXdr(); + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creator: creatorId, + creatorWallet, + itemType: "book", + itemId, + itemTitle: "Paid Book", + amount: "15", + currency: "USDC", + status: "pending", + memo: "DNB-BOOK-1234", + // The "confirmed" save must fail with a duplicate-key error to + // simulate the race where a concurrent request already wrote the same + // on-chain hash (the unique index on stellarTxHash is the backstop). + save: jest.fn(function () { + if (this.status === "confirmed") { + const err = new Error("E11000 duplicate key"); + err.code = 11000; + return Promise.reject(err); + } + return Promise.resolve(this); + }), + }; + const confirmedTx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + stellarTxHash: signed.hash, + stellarLedger: 88, + itemTitle: "Paid Book", + amount: "15", + }; + + // findOne calls: confirmed-by-hash lookup (null), pending lookup (tx), + // then the backstop lookup after E11000 (confirmedTx). + jest + .spyOn(Transaction, "findOne") + .mockReturnValueOnce(makeQuery(null)) + .mockReturnValueOnce(makeQuery(tx)) + .mockReturnValueOnce(makeQuery(confirmedTx)); + + submitTransaction.mockResolvedValue({ + hash: signed.hash, + ledger: 88, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const res = await request(mountPaymentApp(buyerId)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { hash: signed.hash }, + }); + // The session was rolled back before any earnings/access were recorded. + expect(session.abortTransaction).toHaveBeenCalled(); + expect(recordSaleEarnings).not.toHaveBeenCalled(); + expect(grantItemAccess).not.toHaveBeenCalled(); + }); +}); + +describe("paymentLimiter (per-user rate limit on payment routes)", () => { + let paymentLimiter; + + beforeAll(async () => { + // Tighten the limit BEFORE security.js is imported so the limiter is + // constructed with the smaller max. + process.env.RATE_LIMIT_PAYMENT_MAX = "3"; + const security = await import("../src/middlewares/security.js"); + paymentLimiter = security.paymentLimiter; + }); + + afterAll(() => { + delete process.env.RATE_LIMIT_PAYMENT_MAX; + }); + + it("returns 429 once a single user exceeds the per-user budget", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: "user-abc" }; + next(); + }); + app.use("/api/stellar/payment", paymentLimiter); + app.post("/api/stellar/payment/initialize", (req, res) => + res.status(200).json({ success: true }) + ); + + const statuses = []; + for (let i = 0; i < 4; i++) { + const res = await request(app) + .post("/api/stellar/payment/initialize") + .send({ itemType: "book", itemId: "x" }); + statuses.push(res.statusCode); + } + + expect(statuses).toEqual([200, 200, 200, 429]); + }); +}); diff --git a/test/reconciliation.test.js b/test/reconciliation.test.js index 02e5fa55..b901ef99 100644 --- a/test/reconciliation.test.js +++ b/test/reconciliation.test.js @@ -42,6 +42,15 @@ describe("Payment Reconciliation Service", () => { let buyer, author, admin, book, course; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_reconciliation`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); await mongoose.connect(mongoServer.getUri()); }, 30000); @@ -236,6 +245,9 @@ describe("Payment Reconciliation Service", () => { const updated = await Transaction.findById(tx._id); expect(updated.status).toBe("confirmed"); expect(updated.confirmedAt).toBeDefined(); + // Terminal state — the reconciliation confirm path must leave the row + // without an expiry so the TTL reaper can never delete it. + expect(updated.expiresAt).toBeUndefined(); expect(mockRecordSaleEarnings).toHaveBeenCalled(); }); diff --git a/test/refund.test.js b/test/refund.test.js index e54f7eaa..ae54c6f8 100644 --- a/test/refund.test.js +++ b/test/refund.test.js @@ -8,6 +8,7 @@ import express from "express"; import request from "supertest"; import jwt from "jsonwebtoken"; import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; import * as StellarSdk from "@stellar/stellar-sdk"; import User from "../src/models/User.js"; import Book from "../src/models/Book.js"; @@ -16,17 +17,19 @@ import Transaction from "../src/models/Transaction.js"; import Refund from "../src/models/Refund.js"; import paymentRoutes from "../src/routes/stellar/paymentRoutes.js"; import { server } from "../src/services/stellar/stellarService.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; jest.setTimeout(60000); const app = express(); app.use(express.json()); app.use("/api/stellar/payment", paymentRoutes); +app.use(errorHandler); const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; -const generateToken = (userId, role = "student") => { - return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +const generateToken = (userId, role = "student", is2FAVerified = true) => { + return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" }); }; describe("Non-Custodial Refund & Dispute Flow (#62)", () => { @@ -35,12 +38,22 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { let buyerToken, educatorToken, otherToken, adminToken; let confirmedTx; let course; + let mongoServer; beforeAll(async () => { - const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/dnb-backend-test"; - - if (mongoose.connection.readyState === 0) { - await mongoose.connect(uri); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_refund`, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); } // Mock Horizon Server @@ -88,6 +101,9 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { if (mongoose.connection.readyState !== 0) { await mongoose.disconnect(); } + if (mongoServer) { + await mongoServer.stop(); + } }); beforeEach(async () => { @@ -132,12 +148,13 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { email: "admin@example.com", password: "Qx7#vLmp92Zt", role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, }); - buyerToken = generateToken(buyer._id, "student"); - educatorToken = generateToken(educator._id, "tutor"); - otherToken = generateToken(otherUser._id, "student"); - adminToken = generateToken(adminUser._id, "admin"); + buyerToken = generateToken(buyer._id, "student", true); + educatorToken = generateToken(educator._id, "tutor", true); + otherToken = generateToken(otherUser._id, "student", true); + adminToken = generateToken(adminUser._id, "admin", true); // Create a purchased course and enroll buyer course = await Course.create({ @@ -381,4 +398,13 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { expect(res.status).toBe(403); }); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); diff --git a/test/requestValidation.test.js b/test/requestValidation.test.js new file mode 100644 index 00000000..80025b7e --- /dev/null +++ b/test/requestValidation.test.js @@ -0,0 +1,221 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; +import logger from "../src/config/logger.js"; + +const controller = (name) => + jest.fn((_req, res) => res.status(200).json({ handler: name })); + +const authHandlers = { + registerUser: controller("register"), + loginUser: controller("login"), + refreshSession: controller("refresh"), + getSessions: controller("sessions"), + revokeSession: controller("revokeSession"), + revokeAllOtherSessions: controller("revokeAllOtherSessions"), + logoutUser: controller("logout"), + requestPasswordReset: controller("requestPasswordReset"), + resetPassword: controller("resetPassword"), + changePassword: controller("changePassword"), + verifyEmail: controller("verifyEmail"), + resendVerification: controller("resendVerification"), + setup2FA: controller("setup2FA"), + verify2FA: controller("verify2FA"), + disable2FA: controller("disable2FA"), +}; + +const paymentHandlers = { + initializePayment: controller("initialize"), + submitPayment: controller("submit"), + getQuote: controller("quote"), + getPaymentPreflight: controller("preflight"), + getTransactionHistory: controller("history"), + getTransaction: controller("transaction"), + cancelTransaction: controller("cancel"), + sponsorshipStatus: controller("sponsorshipStatus"), +}; + +const refundHandlers = { + requestRefund: controller("requestRefund"), + buildRefundXdr: controller("buildRefundXdr"), + submitRefund: controller("submitRefund"), + rejectRefund: controller("rejectRefund"), + escalateDispute: controller("escalateDispute"), + arbitrateDispute: controller("arbitrateDispute"), +}; + +const walletHandlers = { + connectWallet: controller("connect"), + disconnectWallet: controller("disconnect"), + getWalletBalance: controller("balance"), + getMyWallet: controller("me"), + checkUserWallet: controller("check"), +}; + +jest.unstable_mockModule("../src/controllers/authController.js", () => authHandlers); +jest.unstable_mockModule("../src/controllers/stellar/sep10Controller.js", () => ({ + getStellarChallenge: controller("stellarChallenge"), + verifyStellarChallenge: controller("stellarVerify"), +})); +jest.unstable_mockModule("../src/controllers/stellar/paymentController.js", () => paymentHandlers); +jest.unstable_mockModule("../src/controllers/stellar/refundController.js", () => refundHandlers); +jest.unstable_mockModule("../src/controllers/stellar/reconciliationController.js", () => ({ + reconciliationStatus: controller("reconciliationStatus"), +})); +jest.unstable_mockModule("../src/controllers/stellar/walletController.js", () => walletHandlers); +jest.unstable_mockModule("../src/middlewares/authMiddleware.js", () => ({ + protect: (req, _res, next) => { + req.user = { _id: new mongoose.Types.ObjectId(), role: "student" }; + next(); + }, + authorizeRoles: () => (_req, _res, next) => next(), +})); +jest.unstable_mockModule("../src/middlewares/security.js", () => { + const passThrough = (_req, _res, next) => next(); + return { + refreshLimiter: passThrough, + twoFactorLimiter: passThrough, + emailAuthLimiter: passThrough, + captchaGate: () => passThrough, + }; +}); +jest.unstable_mockModule("../src/middlewares/idempotency.js", () => ({ + idempotency: () => (_req, _res, next) => next(), +})); + +const authRoutes = (await import("../src/routes/authRoutes.js")).default; +const paymentRoutes = (await import("../src/routes/stellar/paymentRoutes.js")).default; +const walletRoutes = (await import("../src/routes/stellar/walletRoutes.js")).default; + +const mount = (path, router) => { + const app = express(); + app.use(express.json()); + app.use(path, router); + app.use(errorHandler); + return app; +}; + +const expectValidationError = (res, expectedErrors) => { + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + success: false, + status: "fail", + message: "Validation failed", + data: null, + }); + expect(res.body.errors).toEqual( + expect.arrayContaining( + expectedErrors.map(([field, message]) => ({ field, message })) + ) + ); +}; + +describe("Request validation", () => { + beforeEach(() => { + Object.values(authHandlers) + .concat(Object.values(paymentHandlers), Object.values(walletHandlers)) + .forEach((handler) => handler.mockClear()); + }); + + it("rejects malformed registration data with field-level errors", async () => { + const res = await request(mount("/auth", authRoutes)) + .post("/auth/register") + .send({ + name: " ", + email: "not-an-email", + password: "short", + role: "moderator", + }); + + expectValidationError(res, [ + ["name", "Name is required"], + ["email", "Email must be a valid email address"], + ["password", "Password must be at least 8 characters"], + ["role", "Role must be one of: student, mentor, admin"], + ]); + expect(authHandlers.registerUser).not.toHaveBeenCalled(); + }); + + it("normalizes valid registration email addresses", async () => { + const res = await request(mount("/auth", authRoutes)) + .post("/auth/register") + .send({ + name: "Test User", + email: "USER@EXAMPLE.COM", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + expect(res.status).toBe(200); + expect(authHandlers.registerUser).toHaveBeenCalledTimes(1); + expect(authHandlers.registerUser.mock.calls[0][0].body.email).toBe( + "user@example.com" + ); + }); + + it("rejects malformed login data before the controller runs", async () => { + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + const res = await request(mount("/auth", authRoutes)) + .post("/auth/login?password=query-secret") + .send({ email: "invalid", password: " " }); + + expectValidationError(res, [ + ["email", "Email must be a valid email address"], + ["password", "Password is required"], + ]); + expect(JSON.stringify(warnSpy.mock.calls)).toContain("/auth/login"); + expect(JSON.stringify(warnSpy.mock.calls)).not.toContain("query-secret"); + expect(authHandlers.loginUser).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("rejects invalid payment initialization fields before database access", async () => { + const res = await request(mount("/payment", paymentRoutes)) + .post("/payment/initialize") + .send({ + itemType: "video", + itemId: "not-an-object-id", + buyerWallet: "not-a-stellar-key", + }); + + expectValidationError(res, [ + ["itemType", "itemType must be one of: book, course"], + ["itemId", "itemId must be a valid Mongo ObjectId"], + ["buyerWallet", "buyerWallet must be a valid Stellar public key"], + ]); + expect(paymentHandlers.initializePayment).not.toHaveBeenCalled(); + }); + + it("rejects invalid transaction IDs and signed XDR before submission", async () => { + const res = await request(mount("/payment", paymentRoutes)) + .post("/payment/submit") + .send({ + transactionId: "not-an-object-id", + signedXdr: "not-an-xdr", + }); + + expectValidationError(res, [ + ["transactionId", "transactionId must be a valid Mongo ObjectId"], + ["signedXdr", "signedXdr must be a well-formed Stellar transaction XDR"], + ]); + expect(paymentHandlers.submitPayment).not.toHaveBeenCalled(); + }); + + it("rejects missing and malformed wallet public keys", async () => { + const missing = await request(mount("/wallet", walletRoutes)) + .post("/wallet/connect") + .send({}); + expectValidationError(missing, [["publicKey", "publicKey is required"]]); + + const malformed = await request(mount("/wallet", walletRoutes)) + .post("/wallet/connect") + .send({ publicKey: "not-a-stellar-key" }); + expectValidationError(malformed, [ + ["publicKey", "publicKey must be a valid Stellar public key"], + ]); + + expect(walletHandlers.connectWallet).not.toHaveBeenCalled(); + }); +}); diff --git a/test/reviews.test.js b/test/reviews.test.js index 69091870..aeccd396 100644 --- a/test/reviews.test.js +++ b/test/reviews.test.js @@ -9,22 +9,38 @@ import { computeReviewStats } from "../src/utils/reviewStats.js"; const JWT_SECRET = process.env.JWT_SECRET; -const generateToken = (userId) => { - return jwt.sign({ userId }, JWT_SECRET, { expiresIn: "1h" }); +const generateToken = (userId, role = "student", is2FAVerified = true) => { + return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" }); }; +import { MongoMemoryServer } from "mongodb-memory-server"; + describe("Reviews & Ratings API (Course and Book)", () => { let author, enrolledUser, purchaserUser, randomUser, adminUser; let authorToken, enrolledToken, purchaserToken, randomToken, adminToken; let course, book; + let mongoServer; beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_reviews`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_reviews`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); afterAll(async () => { if (mongoose.connection.readyState !== 0) { - await mongoose.connection.close(); + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); } }); @@ -76,8 +92,9 @@ describe("Reviews & Ratings API (Course and Book)", () => { password: "Qx7#vLmp92Zt", avatar: "https://example.com/avatar_admin.png", role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, }); - adminToken = generateToken(adminUser._id); + adminToken = generateToken(adminUser._id, "admin", true); // Create Course course = await Course.create({ diff --git a/test/search.test.js b/test/search.test.js index fb2edb45..e87783b1 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -7,10 +7,33 @@ import User from "../src/models/User.js"; import Space from "../src/models/Space.js"; import Reel from "../src/models/Reel.js"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +let mongoServer; + beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_search`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_search`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + beforeEach(async () => { // Clean db await Course.deleteMany({}); diff --git a/test/serviceAuth.test.js b/test/serviceAuth.test.js new file mode 100644 index 00000000..17d734ac --- /dev/null +++ b/test/serviceAuth.test.js @@ -0,0 +1,233 @@ +import crypto from "crypto"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; + +// ── Independent client-side signer ────────────────────────────────────────── +// Deliberately reimplements the canonical form from docs/service-to-service-auth.md +// (rather than importing the middleware's helper) so this test doubles as proof +// that the dnb-ai client can reproduce the exact signature from the spec. +const WHOAMI_PATH = "/api/internal/ai/whoami"; + +function sha256hex(input) { + return crypto.createHash("sha256").update(input || "").digest("hex"); +} + +function signGet({ path = WHOAMI_PATH, secret, kid, serviceId = "dnb-ai", timestamp, body = "" }) { + const ts = String(timestamp ?? Math.floor(Date.now() / 1000)); + const canonical = ["GET", path, ts, sha256hex(body)].join("\n"); + const signature = crypto.createHmac("sha256", secret).update(canonical).digest("hex"); + return { + "X-Service-Id": serviceId, + "X-Service-Key-Id": kid, + "X-Timestamp": ts, + "X-Signature": signature, + }; +} + +const K1_SECRET = "k1-super-long-random-secret-value-0123456789"; +const K2_SECRET = "k2-super-long-random-secret-value-9876543210"; + +const KEYS_K1 = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +const KEYS_K1_K2_ACTIVE = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, + { kid: "k2", secret: K2_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +const KEYS_K1_RETIRED = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: false }, + { kid: "k2", secret: K2_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +// A key that authenticates but lacks the route's scope. +const KEYS_WRONG_SCOPE = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:write-answers"], active: true }, +]); + +let mongoServer; +const originalKeys = process.env.AI_SERVICE_KEYS; +const originalJobsToken = process.env.JOBS_DASHBOARD_TOKEN; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + // Warm up the auditlogs collection so its indexes are built now (the first + // write to a fresh in-memory collection can take ~700ms). This keeps the + // later fire-and-forget denial write fast enough to observe within the poll. + await AuditLog.create({ action: AUDIT_ACTIONS.AUTH_LOGOUT, status: "success" }); +}, 30000); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + if (originalKeys === undefined) delete process.env.AI_SERVICE_KEYS; + else process.env.AI_SERVICE_KEYS = originalKeys; + if (originalJobsToken === undefined) delete process.env.JOBS_DASHBOARD_TOKEN; + else process.env.JOBS_DASHBOARD_TOKEN = originalJobsToken; +}); + +beforeEach(() => { + process.env.AI_SERVICE_KEYS = KEYS_K1; +}); + +describe("requireServiceAuth via /api/internal/ai/whoami", () => { + it("accepts a valid signed request with a permitted scope and reflects req.service", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.service).toEqual({ + id: "dnb-ai", + kid: "k1", + scopes: ["ai:read-content"], + }); + }); + + it("rejects a request with no signature headers (401)", async () => { + const res = await request(app).get(WHOAMI_PATH); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("rejects a bad/forged signature (401)", async () => { + const headers = signGet({ secret: "the-wrong-secret", kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects an unknown kid (401)", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "does-not-exist" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects a wrong scope with 403", async () => { + process.env.AI_SERVICE_KEYS = KEYS_WRONG_SCOPE; + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(403); + }); + + it("rejects a replayed (stale-timestamp) request with 401", async () => { + const stale = Math.floor(Date.now() / 1000) - 600; // outside ±300s window + const headers = signGet({ secret: K1_SECRET, kid: "k1", timestamp: stale }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects a future-dated timestamp with 401", async () => { + const future = Math.floor(Date.now() / 1000) + 600; + const headers = signGet({ secret: K1_SECRET, kid: "k1", timestamp: future }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("does not throw on a length-mismatched signature (constant-time safe → 401)", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + headers["X-Signature"] = "abc123"; // shorter than a real 64-char hex digest + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); // 401, never a 500 from timingSafeEqual throwing + }); +}); + +describe("key rotation without downtime", () => { + it("accepts two active kids simultaneously, then rejects the retired one while the other still works", async () => { + // Both k1 and k2 active → both valid. + process.env.AI_SERVICE_KEYS = KEYS_K1_K2_ACTIVE; + + const r1 = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K1_SECRET, kid: "k1" })); + expect(r1.status).toBe(200); + expect(r1.body.service.kid).toBe("k1"); + + const r2 = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K2_SECRET, kid: "k2" })); + expect(r2.status).toBe(200); + expect(r2.body.service.kid).toBe("k2"); + + // Retire k1 (active:false), keep k2. + process.env.AI_SERVICE_KEYS = KEYS_K1_RETIRED; + + const r1Retired = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K1_SECRET, kid: "k1" })); + expect(r1Retired.status).toBe(401); + + const r2Still = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K2_SECRET, kid: "k2" })); + expect(r2Still.status).toBe(200); + }); +}); + +describe("audit trail for denied S2S attempts", () => { + it("writes a service_auth.denied AuditLog row (status failure) on denial", async () => { + // Use a distinctive kid so we assert on THIS request's audit row, not one + // left by an earlier denial test. AuditLog is append-only (no deleteMany). + process.env.AI_SERVICE_KEYS = JSON.stringify([ + { kid: "audit-kid", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, + ]); + + const headers = signGet({ secret: "wrong-secret", kid: "audit-kid" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + + // recordAudit is fire-and-forget (microtask) — poll briefly for the row. + let row = null; + for (let i = 0; i < 40 && !row; i++) { + row = await AuditLog.findOne({ + action: "service_auth.denied", + "metadata.kid": "audit-kid", + }); + if (!row) await new Promise((r) => setTimeout(r, 50)); + } + + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe("Service"); + expect(row.metadata?.kid).toBe("audit-kid"); + expect(row.metadata?.scope).toBe("ai:read-content"); + }); +}); + +describe("/admin/jobs timing-safe token comparison", () => { + const TOKEN = "jobs-dashboard-token-abcdefghijklmnop"; + + beforeEach(() => { + process.env.JOBS_DASHBOARD_TOKEN = TOKEN; + }); + + it("allows the correct bearer token", async () => { + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", `Bearer ${TOKEN}`) + .set("Accept", "application/json"); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("rejects a wrong token of equal length with 401", async () => { + const wrong = "Bearer " + "x".repeat(TOKEN.length); + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", wrong) + .set("Accept", "application/json"); + expect(res.status).toBe(401); + }); + + it("does not throw on a length-mismatched token (constant-time safe → 401)", async () => { + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", "Bearer short") + .set("Accept", "application/json"); + expect(res.status).toBe(401); // not a 500 + }); +}); diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js index cee52db8..6f2e82d4 100644 --- a/test/stellarPaymentController.test.js +++ b/test/stellarPaymentController.test.js @@ -23,6 +23,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ STROOPS_PER_UNIT: 10000000n, toStroops: jest.fn(), fromStroops: jest.fn(), + resolveAsset: jest.fn(), applySlippage: jest.fn(), findPaymentPaths: jest.fn(), buildPathPaymentTransaction: jest.fn(), @@ -39,6 +40,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ submitTransaction, verifyTransaction: jest.fn(), verifyPaymentOperations, + validateSignedPaymentXdr: jest.fn(), hasUsdcTrustline: jest.fn(), getExplorerUrl, getAccountExplorerUrl: jest.fn(), @@ -201,7 +203,9 @@ describe("Stellar payment controller", () => { creator: creatorId, creatorWallet, status: "pending", - stellarTxHash: "expected-hash", + // #18: the pre-computed hash is stored as expectedHash at init for later + // XDR validation; stellarTxHash is only set after actual submission. + expectedHash: "expected-hash", }); expect(session.commitTransaction).toHaveBeenCalledTimes(1); expect(session.abortTransaction).not.toHaveBeenCalled(); @@ -331,6 +335,9 @@ describe("Stellar payment controller", () => { { session } ); expect(tx.status).toBe("confirmed"); + // Terminal state — the submit confirm path must leave the row without an + // expiry so the TTL reaper can never delete it. + expect(tx.expiresAt).toBeUndefined(); expect(session.commitTransaction).toHaveBeenCalledTimes(1); expect(session.abortTransaction).not.toHaveBeenCalled(); }); diff --git a/test/ttlTransactionExpiry.test.js b/test/ttlTransactionExpiry.test.js new file mode 100644 index 00000000..9b2738d5 --- /dev/null +++ b/test/ttlTransactionExpiry.test.js @@ -0,0 +1,256 @@ +import { jest } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Transaction from "../src/models/Transaction.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import { fixTtlTransactionExpiry } from "../src/migrations/fixTtlTransactionExpiry.js"; + +const TERMINAL_STATUSES = ["confirmed", "failed", "expired", "refunded", "disputed"]; + +const makeKey = (prefix) => { + const p = prefix.padEnd(55, "0").slice(0, 55).toUpperCase(); + return "G" + p; +}; + +describe("Transaction TTL expiry & lifecycle invariant", () => { + let mongoServer; + let buyer; + let author; + let book; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await Promise.all([ + Transaction.deleteMany({}), + User.deleteMany({}), + Book.deleteMany({}), + ]); + + buyer = await User.create({ + name: "Buyer User", + email: "buyer_ttl@example.com", + password: "Qx7#vLmp92Zt", + stellarWallet: { publicKey: makeKey("BUYER") }, + }); + + author = await User.create({ + name: "Author User", + email: "author_ttl@example.com", + password: "Qx7#vLmp92Zt", + stellarWallet: { publicKey: makeKey("AUTHOR") }, + }); + + book = await Book.create({ + title: "TTL Test Book", + description: "Testing TTL invariants", + category: "Tech", + price: 15, + author: author._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/image.jpg", + fileUrl: "https://example.com/file.pdf", + }); + }); + + const baseFields = () => ({ + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "15", + network: "testnet", + }); + + describe("schema default", () => { + it("assigns a 30-minute expiresAt by default for pending transactions", async () => { + const tx = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "pending-default-hash", + }); + + expect(tx.expiresAt).toBeInstanceOf(Date); + expect(tx.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(tx.expiresAt.getTime()).toBeLessThanOrEqual( + Date.now() + 31 * 60 * 1000 + ); + }); + + it("does NOT assign expiresAt when created directly in a terminal state", async () => { + for (const status of TERMINAL_STATUSES) { + const tx = await Transaction.create({ + ...baseFields(), + status, + stellarTxHash: `terminal-${status}-hash`, + }); + expect(tx.expiresAt).toBeUndefined(); + } + }); + + it("keeps expiresAt for transient submitted/retrying states", async () => { + const pending = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "transient-hash", + }); + + for (const status of ["submitted", "retrying"]) { + pending.status = status; + await pending.save(); + expect(pending.expiresAt).toBeInstanceOf(Date); + } + }); + }); + + describe("pre-save hook (defense in depth)", () => { + it("clears expiresAt when a document transitions to a terminal status", async () => { + const tx = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "transition-hash", + }); + expect(tx.expiresAt).toBeInstanceOf(Date); + + // Simulate a code path that forgets to clear expiresAt — the hook must + // still rescue the row. + for (const status of TERMINAL_STATUSES) { + tx.status = status; + tx.expiresAt = new Date(Date.now() - 1000); // stale, in the past + await tx.save(); + expect(tx.expiresAt).toBeUndefined(); + } + }); + }); + + describe("migration", () => { + it("unsets expiresAt on legacy non-pending rows, keeps pending rows, and is idempotent", async () => { + // Simulate legacy rows that bypass hooks/defaults (as they were written + // before the invariant existed). + await Transaction.collection.insertOne({ + ...baseFields(), + stellarTxHash: "legacy-confirmed-hash", + status: "confirmed", + confirmedAt: new Date(), + expiresAt: new Date(Date.now() - 1000), // already past — reaper candidate + createdAt: new Date(), + updatedAt: new Date(), + }); + await Transaction.collection.insertOne({ + ...baseFields(), + stellarTxHash: "legacy-failed-hash", + status: "failed", + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + }); + const pending = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "legacy-pending-hash", + }); + + const firstRun = await fixTtlTransactionExpiry(); + expect(firstRun.modifiedCount).toBe(2); + + const rescuedConfirmed = await Transaction.findOne({ + stellarTxHash: "legacy-confirmed-hash", + }); + expect(rescuedConfirmed).not.toBeNull(); + expect(rescuedConfirmed.expiresAt).toBeUndefined(); + + const rescuedFailed = await Transaction.findOne({ + stellarTxHash: "legacy-failed-hash", + }); + expect(rescuedFailed.expiresAt).toBeUndefined(); + + const keptPending = await Transaction.findById(pending._id); + expect(keptPending.expiresAt).toBeInstanceOf(Date); + + // Idempotent: a second run touches nothing. + const secondRun = await fixTtlTransactionExpiry(); + expect(secondRun.modifiedCount).toBe(0); + }); + + it("replaces a blanket TTL index with the pending-scoped partial index", async () => { + // Simulate the pre-fix DB state: blanket TTL index, no partial filter. + const collection = Transaction.collection; + const indexes = await collection.indexes(); + const existing = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + if (existing) { + await collection.dropIndex(existing.name); + } + await collection.createIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0 } + ); + + await fixTtlTransactionExpiry(); + + const after = await collection.indexes(); + const ttlIndex = after.find((idx) => idx.key && idx.key.expiresAt === 1); + expect(ttlIndex).toBeDefined(); + expect(ttlIndex.expireAfterSeconds).toBe(0); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + + // Only one expiresAt index should remain after the swap. + const expiresAtIndexes = after.filter( + (idx) => idx.key && idx.key.expiresAt === 1 + ); + expect(expiresAtIndexes).toHaveLength(1); + }); + + it("leaves the already-correct partial index untouched (idempotent index handling)", async () => { + await fixTtlTransactionExpiry(); + + const after = await Transaction.collection.indexes(); + const ttlIndex = after.find((idx) => idx.key && idx.key.expiresAt === 1); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + expect(ttlIndex.expireAfterSeconds).toBe(0); + }); + }); + + describe("TTL eligibility", () => { + it("exposes the pending-scoped partial index spec so the reaper can only match pending rows", async () => { + const indexes = await Transaction.collection.indexes(); + const ttlIndex = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + + expect(ttlIndex).toBeDefined(); + expect(ttlIndex.expireAfterSeconds).toBe(0); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + }); + + it("survives past the original expiresAt: a confirmed row never carries an expiry", async () => { + // The regression this issue guards against: a confirmed transaction with + // an expiresAt in the past is eligible for deletion. With the schema + // default + pre-save hook, a confirmed row cannot even hold an expiry — + // and if legacy data still has one, the migration clears it. + const confirmed = await Transaction.create({ + ...baseFields(), + status: "confirmed", + stellarTxHash: "survival-hash", + }); + expect(confirmed.expiresAt).toBeUndefined(); + + await fixTtlTransactionExpiry(); + const persisted = await Transaction.findOne({ + stellarTxHash: "survival-hash", + }); + expect(persisted).not.toBeNull(); + expect(persisted.expiresAt).toBeUndefined(); + }); + }); +}); diff --git a/test/upload.test.js b/test/upload.test.js index 5ac84119..d803ab23 100644 --- a/test/upload.test.js +++ b/test/upload.test.js @@ -14,8 +14,20 @@ describe("Upload Routes", () => { let testUserId; beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_upload`, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } const { token: authToken, user } = await seedUserAndLogin(app, { name: "Uploader", diff --git a/test/webhooks.test.js b/test/webhooks.test.js new file mode 100644 index 00000000..96816a89 --- /dev/null +++ b/test/webhooks.test.js @@ -0,0 +1,715 @@ +// test/webhooks.test.js +// +// Offline (mocked-axios) suite for the outbound signed webhook system (#45). +// Every acceptance criterion is exercised: HMAC signing/verification, delivery +// row fan-out, backoff → dead-letter → redelivery, atomic claim (no double +// send), auto-disable, ping, transaction-commit-safe emission, the payload +// allowlist, and the admin-gated management API. No outbound network is used — +// the delivery worker's HTTP client is injected. + +// Worker constants are read from env at module load, so tune them BEFORE the +// dynamic import of deliveryWorker below. +process.env.WEBHOOK_MAX_ATTEMPTS = "3"; +process.env.WEBHOOK_BACKOFF_JITTER_MS = "0"; +process.env.WEBHOOK_AUTO_DISABLE_THRESHOLD = "2"; + +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; + +// ── Mocks for the payment controller's Stellar dependencies ───────────────── +// Full surface so the ESM linker can bind every named import in +// paymentController.js even though this suite only drives submitPayment. +const submitTransaction = jest.fn(); +const verifyPaymentOperations = jest.fn(); +const validateSignedPaymentXdr = jest.fn(); +const getExplorerUrl = jest.fn((h) => `https://stellar.expert/tx/${h}`); +const recordSaleEarnings = jest.fn(); +const grantItemAccess = jest.fn(); +const enqueue = jest.fn(); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + buildPaymentTransaction: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + buildSep7Uri: jest.fn(), + calculateFeeSplit: jest.fn(() => null), + preflightPayment: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr, + findPaymentPaths: jest.fn(), + applySlippage: jest.fn(), + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + getExplorerUrl, + USDC: "USDC", + PLATFORM_WALLET_PUBLIC_KEY: "", + // Exports pulled in transitively via feeSponsorService (#30) — mirror them + // so the ESM mock still satisfies every named import in the graph. + toStroops: jest.fn(), + resolveAsset: jest.fn(), + getAccountBalance: jest.fn(), + networkPassphrase: "Test SDF Network ; September 2015", +})); +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +// ── Dynamic imports (after env tuning + mock registration) ────────────────── +const WebhookEndpoint = (await import("../src/models/WebhookEndpoint.js")).default; +const WebhookDelivery = (await import("../src/models/WebhookDelivery.js")).default; +const User = (await import("../src/models/User.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const { signPayload, verifySignature, WEBHOOK_HEADERS } = await import( + "../src/services/webhooks/signing.js" +); +const { generateSecret, encryptSecret } = await import( + "../src/services/webhooks/webhookSecret.js" +); +const { emitEvent, buildEventEnvelope, sanitizeEventData, EVENT_TYPES } = + await import("../src/services/webhooks/webhookService.js"); +const { validateWebhookUrl, isPrivateAddress } = await import( + "../src/services/webhooks/urlGuard.js" +); +const worker = await import("../src/services/webhooks/deliveryWorker.js"); +const { submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const webhookRoutes = (await import("../src/routes/webhookRoutes.js")).default; +const { errorHandler } = await import("../src/middlewares/errorHandler.js"); + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); +}, 60000); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); +}); + +beforeEach(async () => { + await Promise.all([ + WebhookEndpoint.deleteMany({}), + WebhookDelivery.deleteMany({}), + User.deleteMany({}), + Transaction.deleteMany({}), + ]); + jest.clearAllMocks(); +}); + +// ── Helpers ───────────────────────────────────────────────────────────────── +const KNOWN_SECRET = "test-secret-abcdef0123456789"; + +const createEndpointDoc = async (overrides = {}) => { + const owner = overrides.owner || new mongoose.Types.ObjectId(); + return WebhookEndpoint.create({ + url: "https://example.com/hook", + secretEncrypted: encryptSecret(overrides.secret || KNOWN_SECRET), + events: ["*"], + owner, + ...overrides, + secret: undefined, + }); +}; + +const captureClient = () => { + const calls = []; + const post = async (url, body, headers) => { + calls.push({ url, body, headers }); + return { status: 200 }; + }; + return { post, calls }; +}; + +// The controllers emit fire-and-forget (non-blocking), so delivery rows are +// inserted asynchronously after the handler responds. Poll for them to settle. +const waitForDeliveries = async (filter, count, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + if ((await WebhookDelivery.countDocuments(filter)) >= count) return; + await new Promise((r) => setTimeout(r, 25)); + } +}; + +// ──────────────────────────────────────────────────────────────────────────── +describe("HMAC signing", () => { + it("generates a v1= signature that verifies against the documented scheme", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = JSON.stringify({ hello: "world" }); + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + + expect(header.startsWith("v1=")).toBe(true); + // Independently recompute the HMAC the way a consumer would. + const expected = + "v1=" + + crypto + .createHmac("sha256", KNOWN_SECRET) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + expect(header).toBe(expected); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp, rawBody, signatureHeader: header }) + ).toBe(true); + }); + + it("rejects a tampered body", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = JSON.stringify({ amount: "10.00" }); + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + const tampered = JSON.stringify({ amount: "9999.00" }); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp, rawBody: tampered, signatureHeader: header }) + ).toBe(false); + }); + + it("rejects a stale timestamp (> 5 min)", () => { + const stale = (Math.floor(Date.now() / 1000) - 600).toString(); + const rawBody = "{}"; + const header = signPayload({ secret: KNOWN_SECRET, timestamp: stale, rawBody }); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp: stale, rawBody, signatureHeader: header }) + ).toBe(false); + }); + + it("rejects a wrong secret", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = "{}"; + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + expect( + verifySignature({ secret: "other", timestamp, rawBody, signatureHeader: header }) + ).toBe(false); + }); +}); + +describe("Backoff schedule", () => { + it("follows 1m/5m/30m/2h/12h with zero jitter and caps at the last entry", () => { + expect(worker.computeBackoffMs(1)).toBe(60_000); + expect(worker.computeBackoffMs(2)).toBe(5 * 60_000); + expect(worker.computeBackoffMs(3)).toBe(30 * 60_000); + expect(worker.computeBackoffMs(4)).toBe(2 * 60 * 60_000); + expect(worker.computeBackoffMs(5)).toBe(12 * 60 * 60_000); + // Beyond the schedule length it stays at the max. + expect(worker.computeBackoffMs(9)).toBe(12 * 60 * 60_000); + }); +}); + +describe("Payload allowlist", () => { + it("strips non-allowlisted fields (emails, secrets, user docs)", () => { + const data = sanitizeEventData({ + transactionId: "tx1", + amount: "10.00", + email: "leak@example.com", + password: "hunter2", + user: { name: "x", passwordHash: "y" }, + }); + expect(data).toEqual({ transactionId: "tx1", amount: "10.00" }); + expect(data.email).toBeUndefined(); + expect(data.password).toBeUndefined(); + }); + + it("envelope carries eventId/type/createdAt/apiVersion and sanitized data", () => { + const env = buildEventEnvelope(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: "tx2", + email: "nope@example.com", + }); + expect(env.eventId).toEqual(expect.any(String)); + expect(env.type).toBe("payment.confirmed"); + expect(env.createdAt).toEqual(expect.any(String)); + expect(env.apiVersion).toEqual(expect.any(String)); + expect(env.data).toEqual({ transactionId: "tx2" }); + }); +}); + +describe("SSRF url guard", () => { + it("classifies private/loopback/link-local addresses", () => { + expect(isPrivateAddress("127.0.0.1")).toBe(true); + expect(isPrivateAddress("10.1.2.3")).toBe(true); + expect(isPrivateAddress("172.16.0.9")).toBe(true); + expect(isPrivateAddress("192.168.1.1")).toBe(true); + expect(isPrivateAddress("169.254.1.1")).toBe(true); + expect(isPrivateAddress("8.8.8.8")).toBe(false); + }); + + it("rejects non-https and private literal targets at registration", () => { + expect(() => validateWebhookUrl("http://example.com/x")).toThrow(); + expect(() => validateWebhookUrl("https://127.0.0.1/x")).toThrow(); + expect(() => validateWebhookUrl("https://localhost/x")).toThrow(); + expect(() => validateWebhookUrl("https://10.0.0.1/x")).toThrow(); + // A public https target is accepted. + expect(validateWebhookUrl("https://hooks.example.com/x")).toBeInstanceOf(URL); + }); +}); + +describe("emitEvent fan-out", () => { + it("creates one pending delivery per subscribed active endpoint", async () => { + const owner = new mongoose.Types.ObjectId(); + const subscribed = await createEndpointDoc({ owner, events: ["payment.confirmed"] }); + const wildcard = await createEndpointDoc({ owner, events: ["*"] }); + await createEndpointDoc({ owner, events: ["course.enrolled"] }); // not matching + await createEndpointDoc({ owner, events: ["*"], isActive: false }); // inactive + + const { deliveries } = await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: "tx1", + email: "leak@example.com", + }); + + expect(deliveries).toBe(2); + const rows = await WebhookDelivery.find({}).sort({ endpoint: 1 }); + expect(rows).toHaveLength(2); + const endpointIds = rows.map((r) => r.endpoint.toString()).sort(); + expect(endpointIds).toEqual([subscribed._id.toString(), wildcard._id.toString()].sort()); + for (const row of rows) { + expect(row.status).toBe("pending"); + expect(row.eventType).toBe("payment.confirmed"); + expect(row.payload.data.email).toBeUndefined(); + expect(row.payload.data.transactionId).toBe("tx1"); + } + }); + + it("returns without throwing when no endpoints match", async () => { + const res = await emitEvent(EVENT_TYPES.WALLET_CONNECTED, { userId: "u1" }); + expect(res.deliveries).toBe(0); + }); +}); + +describe("Delivery worker: signing over the wire", () => { + it("POSTs a signed body whose HMAC verifies, then marks delivered", async () => { + await createEndpointDoc({ events: ["*"], secret: KNOWN_SECRET }); + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { transactionId: "tx1", amount: "5.00" }); + + const client = captureClient(); + const now = new Date(); + const delivery = await worker.processOne({ post: client.post, now }); + + expect(delivery.status).toBe("delivered"); + expect(client.calls).toHaveLength(1); + const { body, headers } = client.calls[0]; + expect(headers[WEBHOOK_HEADERS.EVENT]).toBe("payment.confirmed"); + expect(headers[WEBHOOK_HEADERS.EVENT_ID]).toEqual(expect.any(String)); + + // Verify exactly the bytes that were sent, using the known plaintext secret. + const ok = verifySignature({ + secret: KNOWN_SECRET, + timestamp: headers[WEBHOOK_HEADERS.TIMESTAMP], + rawBody: body, + signatureHeader: headers[WEBHOOK_HEADERS.SIGNATURE], + }); + expect(ok).toBe(true); + }); +}); + +describe("Delivery worker: retry → dead-letter → redeliver", () => { + const post500 = async () => ({ status: 500 }); + + it("retries on the backoff schedule then dead-letters after max attempts", async () => { + await createEndpointDoc({ events: ["*"] }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + + const t0 = new Date(); + // Attempt 1 → retrying, nextAttemptAt = t0 + 1m + let d = await worker.processOne({ post: post500, now: t0 }); + expect(d.status).toBe("retrying"); + expect(d.attemptCount).toBe(1); + expect(d.nextAttemptAt.getTime()).toBe(t0.getTime() + 60_000); + + // Attempt 2 → retrying, nextAttemptAt = t1 + 5m + const t1 = new Date(t0.getTime() + 60_000); + d = await worker.processOne({ post: post500, now: t1 }); + expect(d.status).toBe("retrying"); + expect(d.attemptCount).toBe(2); + expect(d.nextAttemptAt.getTime()).toBe(t1.getTime() + 5 * 60_000); + + // Attempt 3 → dead (MAX_ATTEMPTS=3 for this suite) + const t2 = new Date(t1.getTime() + 5 * 60_000); + d = await worker.processOne({ post: post500, now: t2 }); + expect(d.status).toBe("dead"); + expect(d.attemptCount).toBe(3); + expect(d.attempts.length).toBe(3); + }); + + it("a dead delivery can be redelivered and then succeeds", async () => { + await createEndpointDoc({ events: ["*"], secret: KNOWN_SECRET }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + + // Drive to dead. + let now = new Date(); + for (let i = 0; i < 5; i++) { + const cur = await WebhookDelivery.findOne({}); + if (cur.status === "dead") break; + now = new Date(now.getTime() + 13 * 60 * 60 * 1000); + await worker.processOne({ post: post500, now }); + } + let d = await WebhookDelivery.findOne({}); + expect(d.status).toBe("dead"); + + // Redeliver: dead → pending, nextAttemptAt = now. + d.status = "pending"; + d.nextAttemptAt = new Date(); + await d.save(); + + const client = captureClient(); + const redelivered = await worker.processOne({ post: client.post, now: new Date() }); + expect(redelivered.status).toBe("delivered"); + expect(client.calls).toHaveLength(1); + }); +}); + +describe("Delivery worker: atomic claim", () => { + it("two concurrent claims never grab the same row (exactly one send)", async () => { + await createEndpointDoc({ events: ["*"] }); + await emitEvent(EVENT_TYPES.PING, { message: "ping" }); + + const now = new Date(); + const [a, b] = await Promise.all([ + worker.claimNextDelivery(now), + worker.claimNextDelivery(now), + ]); + + const claimed = [a, b].filter(Boolean); + expect(claimed).toHaveLength(1); + }); + + it("runDueDeliveries sends each due row exactly once", async () => { + const owner = new mongoose.Types.ObjectId(); + await createEndpointDoc({ owner, events: ["*"] }); + await createEndpointDoc({ owner, events: ["*"] }); + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { transactionId: "tx1" }); + + const client = captureClient(); + const processed = await worker.runDueDeliveries({ post: client.post, now: new Date() }); + expect(processed).toBe(2); + expect(client.calls).toHaveLength(2); + }); +}); + +describe("Delivery worker: auto-disable after sustained failures", () => { + const post500 = async () => ({ status: 500 }); + + it("disables the endpoint after the consecutive-dead threshold", async () => { + const endpoint = await createEndpointDoc({ events: ["*"] }); + + const driveOneToDead = async () => { + let now = new Date(); + for (let i = 0; i < 5; i++) { + const d = await WebhookDelivery.findOne({ status: { $in: ["pending", "retrying"] } }); + if (!d) break; + now = new Date(now.getTime() + 13 * 60 * 60 * 1000); + await worker.processOne({ post: post500, now }); + } + }; + + // Threshold is 2 for this suite: two dead deliveries → disabled. + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + await driveOneToDead(); + let ep = await WebhookEndpoint.findById(endpoint._id); + expect(ep.isActive).toBe(true); + expect(ep.consecutiveFailures).toBe(1); + + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx2" }); + await driveOneToDead(); + ep = await WebhookEndpoint.findById(endpoint._id); + expect(ep.isActive).toBe(false); + expect(ep.consecutiveFailures).toBeGreaterThanOrEqual(2); + expect(ep.disabledReason).toMatch(/consecutive/i); + }); +}); + +// ── submitPayment emitter wiring + commit-safe emission ───────────────────── +const makeRes = () => { + const res = { statusCode: 200 }; + res.status = (c) => { + res.statusCode = c; + return res; + }; + res.json = (b) => { + res.body = b; + return res; + }; + return res; +}; + +const makePendingTransaction = async (buyerId) => + Transaction.create({ + buyer: buyerId, + buyerWallet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + creator: new mongoose.Types.ObjectId(), + creatorWallet: "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI", + itemType: "course", + itemId: new mongoose.Types.ObjectId(), + itemTypeModel: "Course", + itemTitle: "Intro to Tajweed", + amount: "10.00", + currency: "USDC", + network: "testnet", + status: "pending", + }); + +describe("submitPayment emitter wiring", () => { + it("emits payment.confirmed after the txn commits, without blocking the response", async () => { + await createEndpointDoc({ events: ["payment.confirmed"] }); + const buyerId = new mongoose.Types.ObjectId(); + const tx = await makePendingTransaction(buyerId); + + validateSignedPaymentXdr.mockReturnValue(true); + submitTransaction.mockResolvedValue({ hash: "STELLARHASH", ledger: 999 }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const req = { + user: { _id: buyerId }, + body: { transactionId: tx._id.toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-1", + }; + const res = makeRes(); + await submitPayment(req, res); + + expect(res.statusCode).toBe(200); + await waitForDeliveries({ eventType: "payment.confirmed" }, 1); + const rows = await WebhookDelivery.find({ eventType: "payment.confirmed" }); + expect(rows).toHaveLength(1); + expect(rows[0].payload.data.transactionId).toBe(tx._id.toString()); + expect(rows[0].payload.data.stellarTxHash).toBe("STELLARHASH"); + expect(rows[0].status).toBe("pending"); + }); + + it("emits nothing when the txn is rolled back (unknown transaction → 404)", async () => { + await createEndpointDoc({ events: ["*"] }); + const req = { + user: { _id: new mongoose.Types.ObjectId() }, + body: { transactionId: new mongoose.Types.ObjectId().toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-2", + }; + const res = makeRes(); + await submitPayment(req, res); + + expect(res.statusCode).toBe(404); + // Give any (erroneous) fire-and-forget emit a chance to land, then confirm none did. + await new Promise((r) => setTimeout(r, 150)); + expect(await WebhookDelivery.countDocuments({})).toBe(0); + }); + + it("does not block the payment path when the endpoint is unreachable", async () => { + await createEndpointDoc({ events: ["*"], url: "https://unreachable.example.com/hook" }); + const buyerId = new mongoose.Types.ObjectId(); + const tx = await makePendingTransaction(buyerId); + + validateSignedPaymentXdr.mockReturnValue(true); + submitTransaction.mockResolvedValue({ hash: "HASH2", ledger: 1000 }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const req = { + user: { _id: buyerId }, + body: { transactionId: tx._id.toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-3", + }; + const res = makeRes(); + const start = Date.now(); + await submitPayment(req, res); + // The request completes promptly — delivery happens out of band. + expect(Date.now() - start).toBeLessThan(5000); + expect(res.statusCode).toBe(200); + await waitForDeliveries({}, 1); + const rows = await WebhookDelivery.find({}); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("pending"); + }); +}); + +// ── Management API (admin-gated), driven through the real router ──────────── +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use("/api/webhooks", webhookRoutes); + app.use(errorHandler); + return app; +}; + +const makeUser = async (role) => + User.create({ + name: `${role} user`, + email: `${role}_${new mongoose.Types.ObjectId()}@example.com`, + password: "Qx7#vLmp92Zt", + role, + twoFactor: role === "admin" ? { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() } : { enabled: false }, + }); + +const tokenFor = (user) => + jwt.sign( + { + userId: user._id, + role: user.role, + sessionId: "s1", + is2FAVerified: user.role === "admin" ? true : false, + }, + JWT_SECRET, + { + expiresIn: "15m", + } + ); + +describe("Management API", () => { + let app; + let admin; + let adminToken; + + beforeEach(async () => { + app = buildApp(); + admin = await makeUser("admin"); + adminToken = tokenFor(admin); + }); + + it("rejects unauthenticated callers with 401", async () => { + const res = await request(app).get("/api/webhooks"); + expect(res.status).toBe(401); + }); + + it("rejects non-admin callers with 403", async () => { + const student = await makeUser("student"); + const res = await request(app) + .get("/api/webhooks") + .set("Authorization", `Bearer ${tokenFor(student)}`); + expect(res.status).toBe(403); + }); + + it("creates an endpoint and returns the secret exactly once; reads never expose it", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x", events: ["payment.confirmed"] }); + + expect(create.status).toBe(201); + expect(create.body.secret).toEqual(expect.any(String)); + expect(create.body.endpoint.secretEncrypted).toBeUndefined(); + const id = create.body.endpoint._id; + + const read = await request(app) + .get(`/api/webhooks/${id}`) + .set("Authorization", `Bearer ${adminToken}`); + expect(read.status).toBe(200); + expect(read.body.endpoint.secret).toBeUndefined(); + expect(read.body.endpoint.secretEncrypted).toBeUndefined(); + + const list = await request(app) + .get("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`); + expect(list.body.endpoints[0].secretEncrypted).toBeUndefined(); + }); + + it("rejects non-https and private-network URLs at registration", async () => { + const nonHttps = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "http://hooks.example.com/x" }); + expect(nonHttps.status).toBe(400); + + const priv = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://10.0.0.1/x" }); + expect(priv.status).toBe(400); + }); + + it("rotates the secret and returns a new one", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + + const rotate = await request(app) + .post(`/api/webhooks/${id}/rotate-secret`) + .set("Authorization", `Bearer ${adminToken}`); + expect(rotate.status).toBe(200); + expect(rotate.body.secret).toEqual(expect.any(String)); + expect(rotate.body.secret).not.toBe(create.body.secret); + }); + + it("pings an endpoint, creating a signed ping delivery that verifies", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + const secret = create.body.secret; + + const ping = await request(app) + .post(`/api/webhooks/${id}/ping`) + .set("Authorization", `Bearer ${adminToken}`); + expect(ping.status).toBe(202); + + const rows = await WebhookDelivery.find({ eventType: "ping" }); + expect(rows).toHaveLength(1); + + // Deliver it and verify the signature with the created secret. + const client = captureClient(); + await worker.processOne({ post: client.post, now: new Date() }); + expect(client.calls).toHaveLength(1); + const { body, headers } = client.calls[0]; + expect( + verifySignature({ + secret, + timestamp: headers[WEBHOOK_HEADERS.TIMESTAMP], + rawBody: body, + signatureHeader: headers[WEBHOOK_HEADERS.SIGNATURE], + }) + ).toBe(true); + }); + + it("lists deliveries filtered by status and redelivers a dead one", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + + // Seed a dead delivery directly. + const dead = await WebhookDelivery.create({ + endpoint: id, + eventId: crypto.randomUUID(), + eventType: "payment.confirmed", + payload: { data: {} }, + status: "dead", + attemptCount: 3, + nextAttemptAt: new Date(), + }); + + const list = await request(app) + .get(`/api/webhooks/${id}/deliveries?status=dead`) + .set("Authorization", `Bearer ${adminToken}`); + expect(list.status).toBe(200); + expect(list.body.deliveries).toHaveLength(1); + + const redeliver = await request(app) + .post(`/api/webhooks/${id}/deliveries/${dead._id}/redeliver`) + .set("Authorization", `Bearer ${adminToken}`); + expect(redeliver.status).toBe(200); + + const updated = await WebhookDelivery.findById(dead._id); + expect(updated.status).toBe("pending"); + }); +});