diff --git a/.env.example b/.env.example
index 7b8b8cee..63d767fb 100644
--- a/.env.example
+++ b/.env.example
@@ -36,9 +36,22 @@ 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
+# Soroban loyalty points program (contracts/loyalty-points). The contract id
+# (C…55-char StrKey) of the deployed program; the backend only builds
+# unsigned XDR and reads state, so no secret keys belong here.
+LOYALTY_CONTRACT_ID=
+
+# Public Soroban RPC endpoint for contract queries/simulation. Optional —
+# falls back to https://soroban-testnet.stellar.org on testnet; REQUIRED on
+# mainnet where no public default exists.
+# SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
+
# Resilient Horizon Client Configuration (Optional)
# HORIZON_URLS=https://horizon-testnet.stellar.org,https://horizon-testnet.stellar.org (Comma-separated list of Horizon endpoints)
# HORIZON_TIMEOUT_MS=10000 (Request timeout in milliseconds)
@@ -53,6 +66,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 +118,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 +165,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..12312200 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,39 +22,49 @@ import { sanitizeInput } from "./src/middlewares/validate.js";
import {
errorHandler,
notFound,
- handleUnhandledRejection,
- handleUncaughtException,
} from "./src/middlewares/errorHandler.js";
import authRoutes from "./src/routes/authRoutes.js";
import courseRoutes from "./src/routes/courses/courseRoutes.js";
+import courseAnalyticsRoutes from "./src/routes/courses/analyticsRoutes.js";
import reelsRoute from "./src/routes/reelsRoutes.js";
import userRoutes from "./src/routes/userRoutes.js";
import bookRoutes from "./src/routes/books/bookRoutes.js";
import recommendedBooksRoutes from "./src/routes/books/recommendedBooksRoutes.js";
+import readingProgressRoutes from "./src/routes/books/readingProgressRoutes.js";
import spacesRoutes from "./src/routes/spaceRoutes.js";
import emailRoutes from "./src/routes/emailRoutes.js";
import purchaseRoutes from "./src/routes/books/purchaseBookRoutes.js";
import searchRoutes from "./src/routes/searchRoutes.js";
import callRoutes from "./src/routes/callRoutes.js";
import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js";
+import stellarAnalyticsRoutes from "./src/routes/stellar/analyticsRoutes.js";
import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js";
import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js";
+import stellarOnrampRoutes from "./src/routes/stellar/onrampRoutes.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 adminModerationRoutes from "./src/routes/admin/moderationRoutes.js";
+import categoryRoutes from "./src/routes/categoryRoutes.js";
+import readingGroupRoutes from "./src/routes/readingGroupRoutes.js";
+import courseBundleRoutes from "./src/routes/course-bundle.routes.js";
+import certificateRoutes from "./src/routes/certificate.routes.js";
+import badgeRoutes from "./src/routes/badge.routes.js";
+import messagingRoutes from "./src/routes/messaging.routes.js";
+import { healthCheck, ping } from "./src/controllers/healthController.js";
+import databaseHealthRoutes from "./src/routes/health/database.js";
+import databaseMetricsRoutes from "./src/routes/metrics/database.js";
const app = express();
@@ -143,7 +144,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 +175,9 @@ 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);
+app.use("/health/database", databaseHealthRoutes);
// SEP-1 stellar.toml — must be outside /api rate limiter
app.use("/.well-known", wellKnownRoutes);
@@ -185,23 +192,54 @@ app.use("/api/uploads", standardLimiter, uploadRoutes);
app.use("/api/payouts", standardLimiter, payoutRoutes);
// Read-heavy & content routes — generous limiter
+// Creator analytics is mounted before the generic course routes so the static
+// "/analytics" segment is not captured by the courseRoutes "/:id" matcher.
+app.use("/api/courses/analytics", generousLimiter, courseAnalyticsRoutes);
+app.use("/api/course-bundles", generousLimiter, courseBundleRoutes);
+app.use("/api/courses/bundles", generousLimiter, courseBundleRoutes);
+app.use("/api/certificates", generousLimiter, certificateRoutes);
+app.use("/api/badges", generousLimiter, badgeRoutes);
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);
+// Reading progress sync (#203) — resume position, cross-device sync, library %.
+app.use("/api/books", generousLimiter, readingProgressRoutes);
+app.use("/api/books/reading-groups", generousLimiter, readingGroupRoutes);
+app.use("/api/reading-groups", generousLimiter, readingGroupRoutes);
app.use("/api/spaces", generousLimiter, spacesRoutes);
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);
+app.use("/api/stellar/analytics", generousLimiter, stellarAnalyticsRoutes);
+// 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/onramp", generousLimiter, stellarOnrampRoutes);
+app.use("/api/stellar/pledges", generousLimiter, stellarPledgeRoutes);
+app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
+app.use("/api/messaging", generousLimiter, messagingRoutes);
+
+// 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);
+
+// MongoDB connection-pool metrics (Prometheus text format) — see
+// docs/connection-pool-metrics.md for scrape config + Grafana panels.
+app.use("/metrics/database", databaseMetricsRoutes);
// Admin — no rate limit
app.use("/admin/jobs", jobsRoutes);
app.use("/api/admin/audit", auditRoutes);
+app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes);
+app.use("/api/admin/moderation", adminModerationRoutes);
// ======================
// ERROR HANDLING
@@ -209,7 +247,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/loyalty-points/Cargo.toml b/contracts/loyalty-points/Cargo.toml
new file mode 100644
index 00000000..c9308cad
--- /dev/null
+++ b/contracts/loyalty-points/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "loyalty-points"
+version = "0.1.0"
+edition = "2021"
+publish = false
+description = "Deen Bridge loyalty points: earn, redeem and transfer platform reward points on Soroban"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+soroban-sdk = "=27.0.6"
+
+[dev-dependencies]
+soroban-sdk = { version = "=27.0.6", features = ["testutils"] }
diff --git a/contracts/loyalty-points/src/lib.rs b/contracts/loyalty-points/src/lib.rs
new file mode 100644
index 00000000..0a59bc9c
--- /dev/null
+++ b/contracts/loyalty-points/src/lib.rs
@@ -0,0 +1,673 @@
+//! Deen Bridge Loyalty Points — on-chain points system for platform activities.
+//!
+//! Users earn points for purchases, referrals, and milestones. Points live in
+//! an internal ledger (not a SAC token) so the admin keeps full control over
+//! issuance, while earning/redemption/transfers remain fully on-chain and
+//! auditable through indexable events:
+//!
+//! - **Earning**: `earn` mints points per configurable per-activity rules
+//! (points-per-unit-spend for purchases, flat bonuses for referrals and
+//! milestones). Rules are set by the admin via `set_rate`.
+//! - **Redemption**: `redeem` burns points against discounts/rewards; the
+//! discount itself is granted off-chain by the backend after the burn.
+//! - **Transfers**: users can gift points to other users via `transfer`.
+//!
+//! Storage layout mirrors scholarship_escrow: one persistent `State` entry,
+//! per-user `Balance` entries, and per-activity `Rate` entries.
+
+#![no_std]
+
+use soroban_sdk::{
+ contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, Symbol,
+};
+
+/// One whole unit of a 7-decimal Stellar asset (e.g. USDC), used to normalize
+/// purchase spend into whole units before applying the points rate.
+const ASSET_PRECISION: i128 = 10_000_000;
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum Error {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ InvalidAmount = 3,
+ InvalidRate = 4,
+ RateNotSet = 5,
+ InsufficientBalance = 6,
+ ArithmeticOverflow = 7,
+ SameAccount = 8,
+}
+
+/// Platform activities that award loyalty points.
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum Activity {
+ /// Course/book purchase — points scale with the amount spent.
+ Purchase = 0,
+ /// Successful user referral — flat bonus.
+ Referral = 1,
+ /// Platform milestone (e.g. first course completed) — flat bonus.
+ Milestone = 2,
+}
+
+/// Global counters and admin identity.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LoyaltyState {
+ pub admin: Address,
+ pub total_issued: i128,
+ pub total_redeemed: i128,
+}
+
+#[contracttype]
+#[derive(Clone)]
+enum DataKey {
+ State,
+ Balance(Address),
+ Rate(Activity),
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Initialized {
+ #[topic]
+ pub admin: Address,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RateUpdated {
+ #[topic]
+ pub activity: Symbol,
+ pub rate: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Earned {
+ #[topic]
+ pub user: Address,
+ #[topic]
+ pub activity: Symbol,
+ pub earned: i128,
+ pub balance: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Minted {
+ #[topic]
+ pub user: Address,
+ pub amount: i128,
+ pub balance: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Redeemed {
+ #[topic]
+ pub user: Address,
+ pub amount: i128,
+ pub balance: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Transferred {
+ #[topic]
+ pub from: Address,
+ #[topic]
+ pub to: Address,
+ pub amount: i128,
+ pub from_balance: i128,
+ pub to_balance: i128,
+}
+
+#[contract]
+pub struct LoyaltyPoints;
+
+#[contractimpl]
+impl LoyaltyPoints {
+ /// Initialize the program with its admin (the platform issuer account).
+ pub fn init(env: Env, admin: Address) -> Result<(), Error> {
+ if env.storage().persistent().has(&DataKey::State) {
+ return Err(Error::AlreadyInitialized);
+ }
+
+ admin.require_auth();
+
+ let state = LoyaltyState {
+ admin: admin.clone(),
+ total_issued: 0,
+ total_redeemed: 0,
+ };
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ Initialized { admin }.publish(&env);
+
+ Ok(())
+ }
+
+ /// Configure how many points an activity awards.
+ ///
+ /// For [`Activity::Purchase`] the rate is expressed as points per whole
+ /// asset unit of spend (a spend of 5 USDC at rate 100 awards 500 points).
+ /// For referrals and milestones it is the flat bonus per event.
+ pub fn set_rate(env: Env, activity: Activity, rate: i128) -> Result<(), Error> {
+ let mut state = Self::load_state(&env);
+ state.admin.require_auth();
+
+ if rate < 0 {
+ return Err(Error::InvalidRate);
+ }
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Rate(activity.clone()), &rate);
+
+ RateUpdated {
+ activity: Self::activity_symbol(&env, &activity),
+ rate,
+ }
+ .publish(&env);
+
+ Ok(())
+ }
+
+ /// Award points to `user` for completing `activity`.
+ ///
+ /// `spend_amount` is only meaningful for purchases (the amount spent, in
+ /// raw asset units); it is ignored for flat-bonus activities. The user
+ /// authorizes the claim so points cannot be attributed without consent.
+ pub fn earn(
+ env: Env,
+ user: Address,
+ activity: Activity,
+ spend_amount: i128,
+ ) -> Result {
+ let mut state = Self::load_state(&env);
+ Self::ensure_spend_valid(activity, spend_amount)?;
+
+ let rate = env
+ .storage()
+ .persistent()
+ .get::<_, i128>(&DataKey::Rate(activity.clone()))
+ .ok_or(Error::RateNotSet)?;
+ if rate == 0 {
+ return Err(Error::RateNotSet);
+ }
+
+ let earned = match activity {
+ // Points scale with spend: (spend / precision) * rate, floored.
+ Activity::Purchase => spend_amount
+ .checked_mul(rate)
+ .ok_or(Error::ArithmeticOverflow)?
+ / ASSET_PRECISION,
+ _ => rate,
+ };
+
+ user.require_auth();
+
+ let balance_key = DataKey::Balance(user.clone());
+ let balance = env
+ .storage()
+ .persistent()
+ .get(&balance_key)
+ .unwrap_or(0_i128)
+ .checked_add(earned)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ state.total_issued = state
+ .total_issued
+ .checked_add(earned)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ env.storage().persistent().set(&balance_key, &balance);
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ Earned {
+ user: user.clone(),
+ activity: Self::activity_symbol(&env, &activity),
+ earned,
+ balance,
+ }
+ .publish(&env);
+
+ Ok(balance)
+ }
+
+ /// Admin-only issuance for support/reward flows outside the standard
+ /// earning rules (e.g. contest prizes).
+ pub fn mint(env: Env, user: Address, amount: i128) -> Result {
+ let mut state = Self::load_state(&env);
+ state.admin.require_auth();
+
+ if amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+
+ let balance_key = DataKey::Balance(user.clone());
+ let balance = env
+ .storage()
+ .persistent()
+ .get(&balance_key)
+ .unwrap_or(0_i128)
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ state.total_issued = state
+ .total_issued
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ env.storage().persistent().set(&balance_key, &balance);
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ Minted {
+ user: user.clone(),
+ amount,
+ balance,
+ }
+ .publish(&env);
+
+ Ok(balance)
+ }
+
+ /// Burn points as payment for a discount/reward. The reward itself is
+ /// granted by the backend once this redemption is observed on-chain.
+ pub fn redeem(env: Env, user: Address, amount: i128) -> Result {
+ let mut state = Self::load_state(&env);
+
+ if amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+
+ user.require_auth();
+
+ let balance_key = DataKey::Balance(user.clone());
+ let balance = env
+ .storage()
+ .persistent()
+ .get(&balance_key)
+ .unwrap_or(0_i128);
+ if balance < amount {
+ return Err(Error::InsufficientBalance);
+ }
+
+ let new_balance = balance - amount;
+ state.total_redeemed = state
+ .total_redeemed
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ env.storage().persistent().set(&balance_key, &new_balance);
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ Redeemed {
+ user: user.clone(),
+ amount,
+ balance: new_balance,
+ }
+ .publish(&env);
+
+ Ok(new_balance)
+ }
+
+ /// Move points between two users (gifting). The sender authorizes.
+ pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> Result<(), Error> {
+ if amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+ if from == to {
+ return Err(Error::SameAccount);
+ }
+
+ from.require_auth();
+
+ let from_key = DataKey::Balance(from.clone());
+ let from_balance = env
+ .storage()
+ .persistent()
+ .get(&from_key)
+ .unwrap_or(0_i128);
+ if from_balance < amount {
+ return Err(Error::InsufficientBalance);
+ }
+
+ let to_key = DataKey::Balance(to.clone());
+ let to_balance = env
+ .storage()
+ .persistent()
+ .get(&to_key)
+ .unwrap_or(0_i128)
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ env.storage()
+ .persistent()
+ .set(&from_key, &(from_balance - amount));
+ env.storage().persistent().set(&to_key, &to_balance);
+
+ Transferred {
+ from,
+ to,
+ amount,
+ from_balance: from_balance - amount,
+ to_balance,
+ }
+ .publish(&env);
+
+ Ok(())
+ }
+
+ /// Current point balance of `user`.
+ pub fn balance(env: Env, user: Address) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Balance(user))
+ .unwrap_or(0)
+ }
+
+ /// Program state (admin, totals) for transparency dashboards.
+ pub fn state(env: Env) -> LoyaltyState {
+ Self::load_state(&env)
+ }
+
+ /// Configured award rate for `activity`; `None`-equivalent is an error
+ /// path in `try_rate`, here it surfaces as 0 for view simplicity.
+ pub fn rate(env: Env, activity: Activity) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Rate(activity))
+ .unwrap_or(0)
+ }
+
+ fn load_state(env: &Env) -> LoyaltyState {
+ env.storage().persistent().get(&DataKey::State).unwrap()
+ }
+
+ fn ensure_spend_valid(activity: Activity, spend_amount: i128) -> Result<(), Error> {
+ match activity {
+ Activity::Purchase if spend_amount <= 0 => Err(Error::InvalidAmount),
+ _ => Ok(()),
+ }
+ }
+
+ fn activity_symbol(env: &Env, activity: &Activity) -> Symbol {
+ Symbol::new(
+ env,
+ match activity {
+ Activity::Purchase => "purchase",
+ Activity::Referral => "referral",
+ Activity::Milestone => "milestone",
+ },
+ )
+ }
+}
+
+#[cfg(test)]
+mod test {
+ extern crate std;
+
+ use super::*;
+ use soroban_sdk::{
+ testutils::{Address as _, AuthorizedFunction, Events as _},
+ Env, Event, IntoVal, Symbol,
+ };
+
+ struct Context {
+ env: Env,
+ contract_id: Address,
+ admin: Address,
+ user: Address,
+ other: Address,
+ }
+
+ impl Context {
+ fn client(&self) -> LoyaltyPointsClient<'_> {
+ LoyaltyPointsClient::new(&self.env, &self.contract_id)
+ }
+ }
+
+ fn context() -> Context {
+ let env = Env::default();
+ let admin = Address::generate(&env);
+ let user = Address::generate(&env);
+ let other = Address::generate(&env);
+
+ let contract_id = env.register(LoyaltyPoints, ());
+ let client = LoyaltyPointsClient::new(&env, &contract_id);
+
+ env.mock_all_auths();
+ client.init(&admin);
+
+ Context {
+ env,
+ contract_id,
+ admin,
+ user,
+ other,
+ }
+ }
+
+ #[test]
+ fn init_stores_admin_and_emits_event() {
+ let ctx = context();
+ assert_eq!(ctx.client().state().admin, ctx.admin);
+ assert_eq!(ctx.client().state().total_issued, 0);
+ assert_eq!(
+ ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(),
+ &[Initialized {
+ admin: ctx.admin.clone(),
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+ }
+
+ #[test]
+ fn purchase_points_scale_with_spend_and_configured_rate() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Purchase, &100);
+
+ // 5 whole units spent at 100 points/unit → 500 points.
+ assert_eq!(ctx.client().earn(&ctx.user, &Activity::Purchase, &50_000_000), 500);
+ assert_eq!(ctx.client().balance(&ctx.user), 500);
+ assert_eq!(ctx.client().state().total_issued, 500);
+ }
+
+ #[test]
+ fn flat_activities_award_their_bonus_exactly_once_per_claim() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Referral, &250);
+ ctx.client().set_rate(&Activity::Milestone, &1_000);
+
+ assert_eq!(ctx.client().earn(&ctx.user, &Activity::Referral, &0), 250);
+ assert_eq!(ctx.client().earn(&ctx.other, &Activity::Milestone, &0), 1_000);
+ assert_eq!(ctx.client().state().total_issued, 1_250);
+ }
+
+ #[test]
+ fn unconfigured_rates_are_rejected_rather_than_awarding_zero() {
+ let ctx = context();
+ assert_eq!(
+ ctx.client().try_earn(&ctx.user, &Activity::Milestone, &0),
+ Err(Ok(Error::RateNotSet))
+ );
+
+ // Explicitly zeroed rates behave the same way.
+ ctx.client().set_rate(&Activity::Referral, &0);
+ assert_eq!(
+ ctx.client().try_earn(&ctx.user, &Activity::Referral, &0),
+ Err(Ok(Error::RateNotSet))
+ );
+ }
+
+ #[test]
+ fn purchase_requires_positive_spend() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Purchase, &100);
+ assert_eq!(
+ ctx.client().try_earn(&ctx.user, &Activity::Purchase, &0),
+ Err(Ok(Error::InvalidAmount))
+ );
+ }
+
+ #[test]
+ fn redemption_burns_points_and_tracks_totals() {
+ let ctx = context();
+ ctx.client().mint(&ctx.user, &400);
+ assert_eq!(ctx.client().redeem(&ctx.user, &150), 250);
+ assert_eq!(ctx.client().balance(&ctx.user), 250);
+ assert_eq!(ctx.client().state().total_redeemed, 150);
+
+ assert_eq!(
+ ctx.client().try_redeem(&ctx.user, &251),
+ Err(Ok(Error::InsufficientBalance))
+ );
+ }
+
+ #[test]
+ fn transfers_move_balances_between_users() {
+ let ctx = context();
+ ctx.client().mint(&ctx.user, &300);
+ ctx.client().transfer(&ctx.user, &ctx.other, &180);
+
+ assert_eq!(ctx.client().balance(&ctx.user), 120);
+ assert_eq!(ctx.client().balance(&ctx.other), 180);
+ assert_eq!(
+ ctx.client().try_transfer(&ctx.user, &ctx.other, &121),
+ Err(Ok(Error::InsufficientBalance))
+ );
+ }
+
+ #[test]
+ fn transfers_to_self_are_rejected() {
+ let ctx = context();
+ ctx.client().mint(&ctx.user, &300);
+ assert_eq!(
+ ctx.client().try_transfer(&ctx.user, &ctx.user, &10),
+ Err(Ok(Error::SameAccount))
+ );
+ }
+
+ #[test]
+ fn invalid_mints_and_redemptions_are_rejected() {
+ let ctx = context();
+ assert_eq!(
+ ctx.client().try_mint(&ctx.user, &0),
+ Err(Ok(Error::InvalidAmount))
+ );
+ assert_eq!(
+ ctx.client().try_redeem(&ctx.user, &0),
+ Err(Ok(Error::InvalidAmount))
+ );
+ assert_eq!(
+ ctx.client().try_set_rate(&Activity::Purchase, &-1),
+ Err(Ok(Error::InvalidRate))
+ );
+ }
+
+ #[test]
+ fn initialization_cannot_run_twice() {
+ let ctx = context();
+ assert_eq!(
+ ctx.client().try_init(&ctx.admin),
+ Err(Ok(Error::AlreadyInitialized))
+ );
+ }
+
+ #[test]
+ fn earning_requires_user_authorization() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Referral, &250);
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().earn(&ctx.user, &Activity::Referral, &0);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn rate_changes_require_admin_authorization() {
+ let ctx = context();
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().set_rate(&Activity::Purchase, &100);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn redemption_requires_user_authorization() {
+ let ctx = context();
+ ctx.client().mint(&ctx.user, &100);
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().redeem(&ctx.user, &50);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn state_changes_emit_indexable_events() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Purchase, &100);
+
+ assert_eq!(
+ ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(),
+ &[RateUpdated {
+ activity: Symbol::new(&ctx.env, "purchase"),
+ rate: 100,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+
+ ctx.client().earn(&ctx.user, &Activity::Purchase, &30_000_000);
+ assert_eq!(
+ ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(),
+ &[Earned {
+ user: ctx.user.clone(),
+ activity: Symbol::new(&ctx.env, "purchase"),
+ earned: 300,
+ balance: 300,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+
+ ctx.client().redeem(&ctx.user, &100);
+ assert_eq!(
+ ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(),
+ &[Redeemed {
+ user: ctx.user.clone(),
+ amount: 100,
+ balance: 200,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+ }
+
+ #[test]
+ fn auth_tree_contains_admin_and_user_authorizations() {
+ let ctx = context();
+ ctx.client().set_rate(&Activity::Purchase, &100);
+ let rate_auths = ctx.env.auths();
+ assert!(rate_auths.iter().any(|(address, _)| address == &ctx.admin));
+
+ ctx.client().earn(&ctx.user, &Activity::Purchase, &10_000_000);
+ let earn_auths = ctx.env.auths();
+ assert!(earn_auths
+ .iter()
+ .any(|(address, invocation)| address == &ctx.user
+ && invocation.function
+ == AuthorizedFunction::Contract((
+ ctx.contract_id.clone(),
+ Symbol::new(&ctx.env, "earn"),
+ (&ctx.user, &Activity::Purchase, &10_000_000_i128).into_val(&ctx.env),
+ ))));
+ }
+}
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/connection-pool-metrics.md b/docs/connection-pool-metrics.md
new file mode 100644
index 00000000..2e4d4c3e
--- /dev/null
+++ b/docs/connection-pool-metrics.md
@@ -0,0 +1,153 @@
+# MongoDB Connection Pool Metrics
+
+The API exposes MongoDB connection-pool statistics in **Prometheus text
+exposition format** so pool health can be scraped and dashboarded.
+
+- **Endpoint:** `GET /metrics/database`
+- **Content-Type:** `text/plain; version=0.0.4; charset=utf-8`
+- **Collector:** `mongo/monitoring/poolMetrics.js`
+- **Route:** `src/routes/metrics/database.js` (mounted in `app.js`)
+
+The collector subscribes to the MongoDB driver's Connection Monitoring &
+Pooling (CMAP) events on the live `MongoClient` (wired up in
+`src/config/db.js` after `mongoose.connect`). It has no import-time side
+effects and degrades gracefully: if the database is not yet connected the
+endpoint still returns valid, zeroed metrics.
+
+## Exposed metrics
+
+| Metric | Type | Meaning |
+| --- | --- | --- |
+| `mongodb_pool_connections_open` | gauge | Open physical connections (`created − closed`). |
+| `mongodb_pool_connections_in_use` | gauge | Connections currently checked out. |
+| `mongodb_pool_connections_available` | gauge | Idle connections available to borrow. |
+| `mongodb_pool_wait_queue_size` | gauge | Pending checkout requests (wait-queue depth). |
+| `mongodb_pool_max_size` | gauge | Configured `maxPoolSize`. |
+| `mongodb_pool_min_size` | gauge | Configured `minPoolSize`. |
+| `mongodb_connection_ready_state` | gauge | Mongoose `readyState` (0–3), with a `state` label. |
+| `mongodb_pool_connections_created_total` | counter | Physical connections created. |
+| `mongodb_pool_connections_ready_total` | counter | Connections that finished their handshake. |
+| `mongodb_pool_connections_closed_total` | counter | Physical connections closed. |
+| `mongodb_pool_checkouts_started_total` | counter | Checkout attempts started. |
+| `mongodb_pool_checkouts_total` | counter | Successful checkouts. |
+| `mongodb_pool_checkins_total` | counter | Connections returned to the pool. |
+| `mongodb_pool_checkout_failures_total` | counter | Failed checkout attempts. |
+| `mongodb_pool_errors_total` | counter | Pool + connection errors observed. |
+| `mongodb_pool_pools_created_total` | counter | Pools created (one per topology member). |
+| `mongodb_pool_pools_cleared_total` | counter | Pool clear events. |
+
+Every series carries a constant `pool="mongodb"` label.
+
+### Sample output
+
+```text
+# HELP mongodb_pool_connections_open Current number of open connections in the MongoDB pool.
+# TYPE mongodb_pool_connections_open gauge
+mongodb_pool_connections_open{pool="mongodb"} 5
+# HELP mongodb_pool_connections_in_use Connections currently checked out (in use) from the pool.
+# TYPE mongodb_pool_connections_in_use gauge
+mongodb_pool_connections_in_use{pool="mongodb"} 2
+# HELP mongodb_connection_ready_state Mongoose connection readyState (0=disconnected,1=connected,2=connecting,3=disconnecting).
+# TYPE mongodb_connection_ready_state gauge
+mongodb_connection_ready_state{pool="mongodb",state="connected"} 1
+```
+
+## Prometheus scrape configuration
+
+If `METRICS_TOKEN` is set, protect the endpoint the same way as `/metrics`
+by sending `Authorization: Bearer ` (see `authorization` below).
+
+```yaml
+# prometheus.yml
+scrape_configs:
+ - job_name: dnb-backend-db-pool
+ metrics_path: /metrics/database
+ scheme: http
+ static_configs:
+ - targets: ["dnb-backend:5000"]
+ labels:
+ service: dnb-backend
+ # Uncomment if METRICS_TOKEN is configured:
+ # authorization:
+ # type: Bearer
+ # credentials: ""
+```
+
+## Grafana
+
+### Panel: pool saturation (time series)
+
+```json
+{
+ "title": "MongoDB Pool Saturation",
+ "type": "timeseries",
+ "targets": [
+ { "expr": "mongodb_pool_connections_in_use{service=\"dnb-backend\"}", "legendFormat": "in use" },
+ { "expr": "mongodb_pool_connections_available{service=\"dnb-backend\"}", "legendFormat": "available" },
+ { "expr": "mongodb_pool_max_size{service=\"dnb-backend\"}", "legendFormat": "max" }
+ ],
+ "fieldConfig": { "defaults": { "unit": "short", "min": 0 } }
+}
+```
+
+### Panel: checkout wait queue (stat)
+
+```json
+{
+ "title": "DB Pool Wait Queue",
+ "type": "stat",
+ "targets": [
+ { "expr": "mongodb_pool_wait_queue_size{service=\"dnb-backend\"}", "legendFormat": "pending" }
+ ],
+ "options": { "colorMode": "value" },
+ "fieldConfig": {
+ "defaults": {
+ "thresholds": { "steps": [
+ { "color": "green", "value": null },
+ { "color": "orange", "value": 1 },
+ { "color": "red", "value": 5 }
+ ] }
+ }
+ }
+}
+```
+
+### Panel: connection error rate (time series)
+
+```json
+{
+ "title": "DB Pool Errors (rate)",
+ "type": "timeseries",
+ "targets": [
+ { "expr": "rate(mongodb_pool_errors_total{service=\"dnb-backend\"}[5m])", "legendFormat": "errors/s" },
+ { "expr": "rate(mongodb_pool_checkout_failures_total{service=\"dnb-backend\"}[5m])", "legendFormat": "checkout failures/s" }
+ ],
+ "fieldConfig": { "defaults": { "unit": "ops", "min": 0 } }
+}
+```
+
+### Suggested alerts
+
+```yaml
+groups:
+ - name: dnb-backend-db-pool
+ rules:
+ - alert: MongoPoolSaturated
+ expr: mongodb_pool_connections_in_use / mongodb_pool_max_size > 0.9
+ for: 5m
+ labels: { severity: warning }
+ annotations:
+ summary: "MongoDB pool >90% utilised"
+ - alert: MongoPoolWaitQueueBacklog
+ expr: mongodb_pool_wait_queue_size > 0
+ for: 2m
+ labels: { severity: warning }
+ annotations:
+ summary: "Requests are waiting for a DB connection"
+ - alert: MongoDisconnected
+ expr: mongodb_connection_ready_state != 1
+ for: 1m
+ labels: { severity: critical }
+ annotations:
+ summary: "Mongoose connection is not in the connected state"
+```
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/full-text-search.md b/docs/full-text-search.md
new file mode 100644
index 00000000..43194402
--- /dev/null
+++ b/docs/full-text-search.md
@@ -0,0 +1,291 @@
+# Full-Text Search Configuration Guide
+
+This document explains how to configure MongoDB text indexes and use the full-text search abstraction provided by the `mongo/utils/textSearch.js` utility and the `mongo/mixins/Searchable.js` mixin.
+
+## Overview
+
+The full-text search abstraction provides:
+
+1. **Score-based ranking** — Results are ordered by MongoDB's computed text-match score (`$meta: "textScore"`), not just returned in natural/insertion order.
+
+2. **Composable filters** — Additional filter criteria (e.g. price range, category) can be combined with text search without breaking correct score-based ordering.
+
+3. **Offset-based pagination** — Supports `page`/`limit` pagination alongside score-based sorting.
+
+4. **Reusable interface** — The `Searchable` mixin gives every model the same `.search()` method, eliminating duplicated query-building logic.
+
+## Text Index Configuration
+
+### Basic Text Index
+
+To make a model searchable, you must define a text index on the fields you want to search. Add this to your Mongoose schema definition:
+
+```javascript
+// Simple text index on a single field
+bookSchema.index({ title: "text" });
+
+// Compound text index across multiple fields
+bookSchema.index({ title: "text", description: "text", category: "text" });
+```
+
+### Weighted Text Index
+
+You can assign weights to fields so that matches in more important fields rank higher:
+
+```javascript
+// Title matches are 5x more important than description or category matches
+bookSchema.index(
+ { title: "text", description: "text", category: "text" },
+ { weights: { title: 5 } }
+);
+```
+
+**Current models with weighted text indexes:**
+- `Book`: `{ title: 5, description: 1, category: 1 }`
+- `Course`: `{ title: 5, description: 1, category: 1 }`
+
+### Language Options
+
+MongoDB supports stemming and stop-words for many languages. You can configure these per index:
+
+```javascript
+// English (default)
+userSchema.index(
+ { name: "text", bio: "text", interests: "text" },
+ { default_language: "english" }
+);
+
+// Disable language processing (treat all tokens as literals)
+userSchema.index(
+ { name: "text", bio: "text" },
+ { default_language: "none" }
+);
+
+// Use a specific language override field
+userSchema.index(
+ { name: "text", bio: "text" },
+ { default_language: "english", language_override: "lang" }
+);
+```
+
+**Current models with language options:**
+- `User`: `{ default_language: "none", language_override: "lang" }`
+
+### Partial Index (Performance Optimization)
+
+If you only need to search a subset of documents, use a partial index to reduce index size:
+
+```javascript
+// Only index active books
+bookSchema.index(
+ { title: "text", description: "text" },
+ { partialFilterExpression: { isActive: true } }
+);
+
+// Only index books that have content
+bookSchema.index(
+ { title: "text", description: "text" },
+ { partialFilterExpression: { fileUrl: { $exists: true } } }
+);
+```
+
+### Wildcard Text Index (MongoDB 4.2+)
+
+To search across all string fields:
+
+```javascript
+// Search all string fields (use with caution — may impact performance)
+bookSchema.index({ "$**": "text" });
+```
+
+## Using the Text Search Utility
+
+### Basic Search
+
+```javascript
+import { textSearch } from "../mongo/utils/textSearch.js";
+import Book from "../src/models/Book.js";
+
+const results = await textSearch({
+ model: Book,
+ term: "react patterns",
+ page: 1,
+ limit: 10,
+});
+
+// results.documents — Array of matching books with `score` field
+// results.total — Total matching documents
+// results.page — Current page number
+// results.pages — Total pages
+```
+
+### Search with Filters
+
+```javascript
+const results = await textSearch({
+ model: Book,
+ term: "node.js",
+ filters: {
+ price: { $gte: 0, $lte: 50 },
+ category: "Programming",
+ },
+ page: 1,
+ limit: 20,
+});
+```
+
+### Custom Projection
+
+```javascript
+const results = await textSearch({
+ model: Book,
+ term: "design patterns",
+ projection: { title: 1, price: 1, rating: 1 },
+ page: 1,
+ limit: 10,
+});
+```
+
+### Custom Sort
+
+```javascript
+// Sort by price ascending (score still available in returned documents)
+const results = await textSearch({
+ model: Book,
+ term: "javascript",
+ sort: { price: 1 },
+ page: 1,
+ limit: 10,
+});
+```
+
+## Using the Searchable Mixin
+
+The `Searchable` mixin provides a convenient `.search()` static method on your model:
+
+```javascript
+import { applySearchable } from "../mongo/mixins/Searchable.js";
+
+const bookSchema = new mongoose.Schema({
+ title: String,
+ description: String,
+ category: String,
+ price: Number,
+});
+
+// Define the text index
+bookSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } });
+
+// Apply the mixin (must be called after defining the schema, before compiling the model)
+applySearchable(bookSchema, {
+ defaultFields: ["title", "description", "category", "price"],
+ defaultFilters: { isActive: true },
+});
+
+const Book = mongoose.model("Book", bookSchema);
+
+// Now you can use .search() directly on the model
+const results = await Book.search({
+ term: "react patterns",
+ filters: { price: { $gte: 0 } },
+ page: 1,
+ limit: 10,
+});
+```
+
+### Mixin Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `defaultFields` | `string[]` | Fields to project by default when no explicit projection is provided |
+| `defaultFilters` | `Object` | Filters always applied (merged with caller-supplied filters) |
+
+### Mixin Helper Methods
+
+When you apply the mixin, these static methods are also available:
+
+- `Model._buildSearchFilter(term, filters)` — Build a text-search filter without executing
+- `Model._buildSearchProjection(extraProjection)` — Build a projection with score field
+- `Model._buildSearchSort(customSort)` — Build a sort specification
+
+## Accessing the Text Score
+
+When using either the utility or the mixin, each document in the results includes a `score` field with the text-match relevance score:
+
+```javascript
+const results = await textSearch({ model: Book, term: "react" });
+
+results.documents.forEach((doc) => {
+ console.log(`${doc.title}: score ${doc.score}`);
+});
+```
+
+The score is:
+- Higher for documents that match more terms
+- Higher for documents where matched terms appear in weighted fields (e.g. `title`)
+- Based on term frequency and inverse document frequency (TF-IDF)
+
+## Pagination Notes
+
+### Offset Pagination (page/limit)
+
+The default pagination is offset-based (`page`/`limit`), which is suitable for most search UIs where users navigate to specific page numbers.
+
+**Important:** When paginating with score-based sorting, MongoDB guarantees consistent results within a single query. However, if documents are inserted or deleted between page loads, results may shift slightly. This is acceptable for search use cases.
+
+### Cursor-Based Pagination
+
+For large datasets or infinite scroll, consider using cursor-based pagination from `mongo/utils/cursorPagination.js`. The text search utilities build filters that can be composed with cursor pagination:
+
+```javascript
+import { buildTextFilter, buildTextProjection } from "../mongo/utils/textSearch.js";
+import { paginate } from "../mongo/utils/cursorPagination.js";
+
+const filter = buildTextFilter("react", { price: { $gte: 0 } });
+const projection = buildTextProjection({ title: 1 });
+
+// Use paginate with the text search filter
+const results = await paginate({
+ executor: ({ filter: cursorFilter, sort, limit }) =>
+ Book.find({ $and: [filter, cursorFilter] }, projection)
+ .sort(sort)
+ .limit(limit)
+ .lean(),
+ sortField: "score",
+ sortOrder: -1,
+ limit: 20,
+});
+```
+
+**Note:** Score-based cursor pagination is complex because the score is computed by MongoDB, not stored on the document. The example above uses a hybrid approach. For most applications, offset pagination is sufficient.
+
+## Current Models with Text Indexes
+
+| Model | Fields | Weights | Language |
+|-------|--------|---------|----------|
+| Book | `title`, `description`, `category` | `title: 5` | default (english) |
+| Course | `title`, `description`, `category` | `title: 5` | default (english) |
+| User | `name`, `bio`, `interests` | none | `none` (language_override: `lang`) |
+
+## Testing Text Indexes
+
+When testing with MongoDB Memory Server, you must call `syncIndexes()` after connecting to ensure text indexes are created:
+
+```javascript
+beforeEach(async () => {
+ await Book.deleteMany({});
+ await Book.syncIndexes(); // Ensures text index exists
+});
+```
+
+## Common Pitfalls
+
+1. **Only one text index per collection:** MongoDB only allows one text index per collection. If you need to search different field combinations, use a single compound text index.
+
+2. **Text index + regular index conflict:** You cannot have both a text index and a regular index on the same field combination. Remove redundant regular indexes.
+
+3. **Case sensitivity:** Text search is case-insensitive by default. Don't add case-insensitive regex filters alongside text search.
+
+4. **Short search terms:** MongoDB requires at least 3 characters for `$text` search. The existing `searchService.js` falls back to regex for shorter terms.
+
+5. **Performance:** Text indexes can be large. Use partial indexes and field projections to minimize memory usage.
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/mongo/base/BaseRepository.js b/mongo/base/BaseRepository.js
new file mode 100644
index 00000000..440b416b
--- /dev/null
+++ b/mongo/base/BaseRepository.js
@@ -0,0 +1,735 @@
+/**
+ * @module mongo/base/BaseRepository
+ * @description Abstract base repository encapsulating common Mongoose CRUD
+ * operations, offset- and cursor-based pagination, and standardized,
+ * typed error handling. Model-specific repositories should extend this class
+ * instead of talking to Mongoose models directly.
+ */
+
+import mongoose from "mongoose";
+import logger from "../../src/config/logger.js";
+
+/**
+ * Default number of documents returned by pagination methods.
+ * @constant {number}
+ */
+const DEFAULT_LIMIT = 20;
+
+/**
+ * Upper bound applied to any caller-supplied `limit` so a single request
+ * can never page the entire collection into memory.
+ * @constant {number}
+ */
+const MAX_LIMIT = 100;
+
+/**
+ * Queries slower than this many milliseconds are logged as warnings.
+ * @constant {number}
+ */
+const SLOW_OP_THRESHOLD_MS = 250;
+
+/* -------------------------------------------------------------------------- */
+/* Error types */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Base class for all errors thrown by {@link BaseRepository}. Lets callers
+ * branch on a stable machine-readable `code` instead of parsing messages.
+ * All repository errors are operational (expected, recoverable) unless the
+ * underlying cause is unexpected infrastructure failure.
+ */
+export class RepositoryError extends Error {
+ /**
+ * @param {string} code - Stable error code (e.g. `"NOT_FOUND"`).
+ * @param {string} message - Human-readable description.
+ * @param {object} [options]
+ * @param {number} [options.statusCode=500] - HTTP status callers should map to.
+ * @param {Error} [options.cause] - Original error, preserved for debugging.
+ * @param {*} [options.details] - Extra structured context (e.g. invalid fields).
+ */
+ constructor(code, message, { statusCode = 500, cause, details } = {}) {
+ super(message);
+ this.name = new.target.name;
+ this.code = code;
+ this.statusCode = statusCode;
+ this.isOperational = true;
+ if (cause) this.cause = cause;
+ if (details !== undefined) this.details = details;
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+/** Thrown when a document matching an id/filter does not exist. Maps to HTTP 404. */
+export class DocumentNotFoundError extends RepositoryError {
+ /** @param {string} message @param {object} [options] */
+ constructor(message, options) {
+ super("NOT_FOUND", message, { statusCode: 404, ...options });
+ }
+}
+
+/** Thrown for malformed input: empty filters, bad ids/cursors, unsupported options. Maps to HTTP 400. */
+export class RepositoryValidationError extends RepositoryError {
+ /** @param {string} message @param {object} [options] */
+ constructor(message, options) {
+ super("VALIDATION_FAILED", message, { statusCode: 400, ...options });
+ }
+}
+
+/** Thrown when a write violates a unique index (Mongo duplicate key `11000`). Maps to HTTP 409. */
+export class DuplicateKeyError extends RepositoryError {
+ /** @param {string} message @param {object} [options] */
+ constructor(message, options) {
+ super("DUPLICATE_KEY", message, { statusCode: 409, ...options });
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+/* Helpers */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Clamp a caller-supplied limit into `[1, MAX_LIMIT]`.
+ * @param {*} value - Raw limit value (may be undefined / string).
+ * @returns {{limit: number, clamped: boolean}} Resolved limit and whether it was adjusted.
+ */
+function resolveLimit(value) {
+ let limit = parseInt(value ?? DEFAULT_LIMIT, 10);
+ const clamped =
+ Number.isNaN(limit) || limit < 1 || limit > MAX_LIMIT;
+ if (Number.isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT;
+ if (limit > MAX_LIMIT) limit = MAX_LIMIT;
+ return { limit, clamped };
+}
+
+/**
+ * Determine whether a plain-object filter is effectively empty.
+ * `$and`/`$or` operators count as non-empty.
+ * @param {*} filter
+ * @returns {boolean}
+ */
+function isEmptyFilter(filter) {
+ if (!filter || typeof filter !== "object") return true;
+ return Object.keys(filter).length === 0;
+}
+
+/**
+ * Build a Mongoose query projection option from a `select` param.
+ * Accepts `"name email"` or `{ name: 1 }`.
+ * @param {string|object|undefined} select
+ * @returns {object|undefined}
+ */
+function selectOption(select) {
+ if (!select) return undefined;
+ return typeof select === "string" ? { [select]: 1 } : select;
+}
+
+/* -------------------------------------------------------------------------- */
+/* BaseRepository */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Abstract data-access base class for a single Mongoose model.
+ *
+ * Subclasses only need to pass their model to the constructor; every common
+ * read/write concern (CRUD, pagination, sessions, error normalization,
+ * slow-query logging) is inherited. Soft deletes are opt-in per call via
+ * `{ soft: true }` and are only available for models that declare a
+ * soft-delete field (`isDeleted` or `deletedAt`) in their schema — otherwise
+ * `delete()` performs a hard delete.
+ *
+ * Every method accepts an optional `session` (mongoose.ClientSession) so
+ * repositories compose cleanly inside `model.db.transaction()`.
+ *
+ * @example
+ * // mongo/repositories/CategoryRepository.js
+ * import BaseRepository from "../base/BaseRepository.js";
+ * import Category from "../../src/models/Category.js";
+ *
+ * export class CategoryRepository extends BaseRepository {
+ * constructor() {
+ * super(Category);
+ * }
+ *
+ * /** @returns {Promise} active categories ordered for menus *\/
+ * async findActiveForMenu(options = {}) {
+ * return this.findMany(
+ * { isActive: true },
+ * { sort: { order: 1, name: 1 }, select: "name slug icon", lean: true, ...options }
+ * );
+ * }
+ * }
+ *
+ * // usage inside a service:
+ * const categoryRepo = new CategoryRepository();
+ * const created = await categoryRepo.create({ name: "Zakat", slug: "zakat" });
+ * const page = await categoryRepo.paginate({ isActive: true }, { page: 2, limit: 10 });
+ * const feed = await categoryRepo.paginateCursor({}, { cursor: "NjZh...", limit: 25 });
+ */
+export default class BaseRepository {
+ /**
+ * @param {import("mongoose").Model} model - Compiled Mongoose model this repository operates on.
+ * @throws {RepositoryValidationError} If `model` is missing or not a Mongoose model.
+ */
+ constructor(model) {
+ if (!model || !(model.prototype instanceof mongoose.Model)) {
+ throw new RepositoryValidationError(
+ `${this.constructor.name}: a compiled mongoose model is required`
+ );
+ }
+
+ /**
+ * The wrapped Mongoose model.
+ * @type {import("mongoose").Model}
+ * @protected
+ */
+ this.model = model;
+
+ /**
+ * Scoped pino logger; every entry is tagged with the concrete class name.
+ * @type {import("pino").Logger}
+ * @protected
+ */
+ this.logger = logger.child({ repository: this.constructor.name });
+
+ /**
+ * Whether this model's schema supports soft deletes.
+ * @type {"isDeleted"|"deletedAt"|null}
+ * @protected
+ */
+ this.softDeleteField = this.model.schema.path("deletedAt")
+ ? "deletedAt"
+ : this.model.schema.path("isDeleted")
+ ? "isDeleted"
+ : null;
+ }
+
+ /* ---------------------------------------------------------------------- */
+ /* Writes */
+ /* ---------------------------------------------------------------------- */
+
+ /**
+ * Create and persist a new document.
+ *
+ * @param {object} data - Attributes for the new document.
+ * @param {object} [options]
+ * @param {import("mongoose").ClientSession} [options.session] - Transaction session.
+ * @param {boolean} [options.validateBeforeSave=true] - Run schema validators.
+ * @returns {Promise} The saved document.
+ * @throws {DuplicateKeyError} If a unique index is violated (Mongo code 11000).
+ * @throws {RepositoryValidationError} If schema validation fails.
+ * @example
+ * const user = await userRepo.create({ name: "Aisha", email: "a@example.com" });
+ */
+ async create(data, options = {}) {
+ const { session, validateBeforeSave = true } = options;
+ return this._run("create", async () => {
+ try {
+ return await this.model.create([data], {
+ session,
+ validateBeforeSave,
+ }).then(([doc]) => doc);
+ } catch (err) {
+ throw this._normalizeError(err, "create");
+ }
+ });
+ }
+
+ /**
+ * Update one document by id **or** arbitrary filter.
+ *
+ * Passing a bare ObjectId/string targets `_id`; passing an object is used as
+ * the filter directly (an empty filter throws rather than updating blindly).
+ *
+ * @param {string|import("mongoose").Types.ObjectId|object} idOrFilter - Document id or filter object.
+ * @param {object} data - Update payload (`$set` is applied implicitly).
+ * @param {object} [options]
+ * @param {boolean} [options.new=true] - Return the updated document.
+ * @param {boolean} [options.runValidators=true] - Validate update payload against schema.
+ * @param {string|object} [options.select] - Projection of the returned document.
+ * @param {(Array|object|string)} [options.populate] - Paths to populate.
+ * @param {boolean} [options.upsert=false] - Insert when no document matches.
+ * @param {import("mongoose").ClientSession} [options.session] - Transaction session.
+ * @returns {Promise} Updated document, or `null` with upsert disabled and nothing matched.
+ * @throws {DocumentNotFoundError} When `throwIfNotFound` semantics requested and nothing matched.
+ * @throws {RepositoryValidationError} On empty filter or failed validation.
+ * @throws {DuplicateKeyError} On unique-index violation.
+ * @example
+ * await pledgeRepo.update(pledgeId, { status: "paid" }, { session });
+ */
+ async update(idOrFilter, data, options = {}) {
+ const {
+ new: returnNew = true,
+ runValidators = true,
+ upsert = false,
+ select,
+ populate,
+ session,
+ } = options;
+
+ return this._run("update", async () => {
+ const filter = this._resolveFilter(idOrFilter);
+ try {
+ const doc = await this.model
+ .findOneAndUpdate(filter, { $set: data }, { new: returnNew, runValidators, upsert, session })
+ .select(selectOption(select))
+ .populate(populate);
+ if (!doc && !upsert) {
+ this.logger.warn({ filter: this._safe(filter) }, "update matched no documents");
+ }
+ return doc;
+ } catch (err) {
+ throw this._normalizeError(err, "update");
+ }
+ });
+ }
+
+ /**
+ * Delete one document by id **or** filter.
+ *
+ * Hard delete is the default. Pass `{ soft: true }` to flag the document
+ * instead — supported only when the schema declares an `isDeleted` or
+ * `deletedAt` path (see {@link BaseRepository#softDeleteField}). Extend
+ * subclasses for richer retention rules (archival, cascades).
+ *
+ * @param {string|import("mongoose").Types.ObjectId|object} idOrFilter - Document id or filter object.
+ * @param {object} [options]
+ * @param {boolean} [options.soft=false] - Soft delete instead of removing.
+ * @param {import("mongoose").ClientSession} [options.session] - Transaction session.
+ * @returns {Promise<{acknowledged: boolean, deletedCount: number, softDeleted: boolean}>} Outcome summary.
+ * @throws {DocumentNotFoundError} When nothing matched.
+ * @throws {RepositoryValidationError} On empty filter, or `soft: true` for a model without a soft-delete field.
+ * @example
+ * await notificationRepo.delete(notificationId); // hard delete
+ * await auditLogRepo.delete(auditId, { soft: true }); // flags isDeleted/deletedAt if present
+ */
+ async delete(idOrFilter, options = {}) {
+ const { soft = false, session } = options;
+
+ return this._run("delete", async () => {
+ const filter = this._resolveFilter(idOrFilter);
+
+ if (soft) {
+ if (!this.softDeleteField) {
+ throw new RepositoryValidationError(
+ `${this.model.modelName} does not declare an isDeleted/deletedAt field; soft delete unsupported`
+ );
+ }
+ const now = new Date();
+ const patch =
+ this.softDeleteField === "isDeleted"
+ ? { $set: { isDeleted: true } }
+ : { $set: { deletedAt: now } };
+ const res = await this.model.updateOne(filter, patch, { session });
+ if (res.matchedCount === 0) {
+ throw new DocumentNotFoundError(`${this.model.modelName} not found`, { details: { filter: this._safe(filter) } });
+ }
+ return { acknowledged: res.acknowledged, deletedCount: res.modifiedCount, softDeleted: true };
+ }
+
+ const doc = await this.model.findOneAndDelete(filter, { session });
+ if (!doc) {
+ throw new DocumentNotFoundError(`${this.model.modelName} not found`, { details: { filter: this._safe(filter) } });
+ }
+ return { acknowledged: true, deletedCount: 1, softDeleted: false };
+ });
+ }
+
+ /* ---------------------------------------------------------------------- */
+ /* Reads */
+ /* ---------------------------------------------------------------------- */
+
+ /**
+ * Fetch a document by its primary key.
+ *
+ * @param {string|import("mongoose").Types.ObjectId|null} id - Value castable to ObjectId.
+ * @param {object} [options]
+ * @param {string|object} [options.select] - Projection ("name email" or { name: 1 }).
+ * @param {(Array|object|string)} [options.populate] - Paths to populate.
+ * @param {boolean} [options.lean=false] - Return a plain JS object instead of a hydrated document.
+ * @param {import("mongoose").ClientSession} [options.session] - Transaction session.
+ * @returns {Promise} The document, or `null` when absent/malformed id.
+ * @example
+ * const course = await courseRepo.findById(id, { populate: "educator", lean: true });
+ * if (!course) throw new APIError("Course not found", 404);
+ */
+ async findById(id, options = {}) {
+ const { select, populate, lean = false, session } = options;
+
+ return this._run("findById", async () => {
+ if (!id || !mongoose.Types.ObjectId.isValid(String(id))) return null;
+ return this.model
+ .findById(id)
+ .select(selectOption(select))
+ .populate(populate)
+ .lean(lean)
+ .session(session ?? null);
+ });
+ }
+
+ /**
+ * Fetch a single document matching an arbitrary filter.
+ *
+ * @param {object} filter - Mongo filter; must be non-empty.
+ * @param {object} [options] - Same shape as {@link BaseRepository#findById}.
+ * @param {string|object} [options.select]
+ * @param {(Array|object|string)} [options.populate]
+ * @param {boolean} [options.lean=false]
+ * @param {import("mongoose").ClientSession} [options.session]
+ * @returns {Promise} Matching document or `null`.
+ * @throws {RepositoryValidationError} When `filter` is empty (would match an arbitrary document).
+ * @example
+ * const user = await userRepo.findOne({ email }, { lean: true });
+ */
+ async findOne(filter, options = {}) {
+ const { select, populate, lean = false, session } = options;
+
+ return this._run("findOne", async () => {
+ if (isEmptyFilter(filter)) {
+ throw new RepositoryValidationError("findOne requires a non-empty filter");
+ }
+ try {
+ return await this.model
+ .findOne(filter)
+ .select(selectOption(select))
+ .populate(populate)
+ .lean(lean)
+ .session(session ?? null);
+ } catch (err) {
+ throw this._normalizeError(err, "findOne");
+ }
+ });
+ }
+
+ /**
+ * Fetch many documents matching a filter, with optional sorting, paging and projection.
+ *
+ * @param {object} [filter={}] - Mongo filter; `{}` returns all documents.
+ * @param {object} [options]
+ * @param {object|string} [options.sort] - e.g. `{ createdAt: -1 }`.
+ * @param {number} [options.limit] - Max docs to return.
+ * @param {number} [options.skip] - Docs to skip (offset paging).
+ * @param {string|object} [options.select]
+ * @param {(Array|object|string)} [options.populate]
+ * @param {boolean} [options.lean=false]
+ * @param {import("mongoose").ClientSession} [options.session]
+ * @returns {Promise>} Matching documents (possibly empty).
+ * @example
+ * const recent = await transactionRepo.findMany({ status: "pending" }, { sort: { createdAt: -1 }, limit: 50, lean: true });
+ */
+ async findMany(filter = {}, options = {}) {
+ const { sort, limit, skip, select, populate, lean = false, session } = options;
+
+ return this._run("findMany", async () => {
+ try {
+ let query = this.model.find(filter).lean(lean).session(session ?? null);
+ if (sort) query = query.sort(sort);
+ if (Number.isFinite(limit)) query = query.limit(limit);
+ if (Number.isFinite(skip)) query = query.skip(skip);
+ if (select) query = query.select(typeof select === "string" ? select : selectOption(select));
+ if (populate) query = query.populate(populate);
+ return await query;
+ } catch (err) {
+ throw this._normalizeError(err, "findMany");
+ }
+ });
+ }
+
+ /**
+ * Count documents matching a filter.
+ *
+ * @param {object} [filter={}] - Mongo filter.
+ * @param {object} [options]
+ * @param {number} [options.limit] - Cap the counted documents (countDocuments arg).
+ * @param {import("mongoose").ClientSession} [options.session] - Transaction session.
+ * @returns {Promise} Number of matching documents.
+ */
+ async count(filter = {}, options = {}) {
+ const { limit, session } = options;
+ return this._run("count", () =>
+ this.model.countDocuments(filter, { limit, session })
+ );
+ }
+
+ /* ---------------------------------------------------------------------- */
+ /* Pagination */
+ /* ---------------------------------------------------------------------- */
+
+ /**
+ * Offset-based pagination for classic numbered UIs.
+ *
+ * Limits are clamped to `[1, 100]`. Sorting defaults to `_id` descending
+ * (stable even without a `createdAt` index). Deep offsets scan skipped
+ * documents server-side — prefer {@link BaseRepository#paginateCursor}
+ * for unbounded feeds.
+ *
+ * @param {object} [filter={}] - Mongo filter.
+ * @param {object} [options]
+ * @param {number} [options.page=1] - 1-based page number.
+ * @param {number} [options.offset] - Explicit skip; overrides computed `page` offset when provided.
+ * @param {number} [options.limit=20] - Page size (clamped to 100).
+ * @param {string} [options.sortBy="_id"] - Field to sort by.
+ * @param {("asc"|"desc")} [options.order="desc"] - Sort direction.
+ * @param {string|object} [options.select]
+ * @param {(Array|object|string)} [options.populate]
+ * @param {boolean} [options.lean=false]
+ * @param {import("mongoose").ClientSession} [options.session]
+ * @returns {Promise<{data: Array
`;
- 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/db.js b/src/config/db.js
index 499fb3e6..25ab2f10 100644
--- a/src/config/db.js
+++ b/src/config/db.js
@@ -1,5 +1,6 @@
import mongoose from "mongoose";
import logger from "./logger.js";
+import poolMetrics from "../../mongo/monitoring/poolMetrics.js";
const SLOW_QUERY_MS = parseInt(process.env.SLOW_QUERY_MS || "200", 10);
@@ -62,6 +63,14 @@ const connectDB = async () => {
logger.info(`Database: ${mongoose.connection.name}`);
logger.info(`Host: ${mongoose.connection.host}`);
+ // Instrument the live connection pool (CMAP events). Best-effort: never
+ // let metrics wiring break a successful DB connection.
+ try {
+ poolMetrics.attach(mongoose.connection);
+ } catch (metricsErr) {
+ logger.warn(metricsErr, "Failed to attach pool metrics collector");
+ }
+
mongoose.connection.on("error", (err) => {
logger.error(err, "MongoDB connection error");
});
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/analytics/analyticsController.js b/src/controllers/analytics/analyticsController.js
index 378f7b74..6454b4a5 100644
--- a/src/controllers/analytics/analyticsController.js
+++ b/src/controllers/analytics/analyticsController.js
@@ -2,6 +2,8 @@ import mongoose from "mongoose";
import logger from "../../config/logger.js";
import Course from "../../models/Course.js";
import CourseProgress from "../../models/CourseProgress.js";
+import certificateService from "../../services/certificate.service.js";
+import badgeService from "../../services/badge.service.js";
const toObjectId = (value) => {
if (!value) return null;
diff --git a/src/controllers/analytics/courseAnalyticsController.js b/src/controllers/analytics/courseAnalyticsController.js
new file mode 100644
index 00000000..1458e7f3
--- /dev/null
+++ b/src/controllers/analytics/courseAnalyticsController.js
@@ -0,0 +1,107 @@
+// controllers/analytics/courseAnalyticsController.js
+//
+// Creator-facing HTTP handlers for course analytics. Ownership of a single
+// course is enforced upstream by the authorizeOwnership middleware (which loads
+// the course as req.resource); the overview handler is implicitly scoped to the
+// authenticated creator. All handlers are read-only.
+
+import mongoose from "mongoose";
+import { catchAsync, APIError } from "../../middlewares/errorHandler.js";
+import {
+ getCourseAnalytics,
+ getCreatorOverview,
+} from "../../services/analytics/courseAnalyticsService.js";
+import { analyticsToCsv } from "../../utils/analyticsCalculator.js";
+
+/**
+ * Extract and lightly validate the optional startDate/endDate query params.
+ *
+ * @param {import("express").Request} req
+ * @returns {{startDate?: string, endDate?: string}}
+ */
+const readDateRange = (req) => {
+ const { startDate, endDate } = req.query;
+ return { startDate, endDate };
+};
+
+/**
+ * GET /api/courses/analytics/:courseId
+ *
+ * Full analytics for a single course. Requires the caller to own the course
+ * (enforced by authorizeOwnership, which attaches req.resource).
+ */
+export const getCourseAnalyticsHandler = catchAsync(async (req, res, next) => {
+ const { courseId } = req.params;
+ if (!mongoose.Types.ObjectId.isValid(courseId)) {
+ return next(new APIError("Invalid course id", 400));
+ }
+
+ const analytics = await getCourseAnalytics(courseId, {
+ ...readDateRange(req),
+ courseDoc: req.resource, // provided by authorizeOwnership
+ });
+
+ if (!analytics) {
+ return next(new APIError("Course not found", 404));
+ }
+
+ res.status(200).json({ success: true, analytics });
+});
+
+/**
+ * GET /api/courses/analytics/:courseId/export?format=csv
+ *
+ * Export a single course's analytics. Defaults to CSV; `format=json` returns
+ * the raw payload. (PDF is intentionally out of scope for this endpoint.)
+ */
+export const exportCourseAnalyticsHandler = catchAsync(async (req, res, next) => {
+ const { courseId } = req.params;
+ if (!mongoose.Types.ObjectId.isValid(courseId)) {
+ return next(new APIError("Invalid course id", 400));
+ }
+
+ const format = String(req.query.format || "csv").toLowerCase();
+
+ const analytics = await getCourseAnalytics(courseId, {
+ ...readDateRange(req),
+ courseDoc: req.resource,
+ });
+
+ if (!analytics) {
+ return next(new APIError("Course not found", 404));
+ }
+
+ if (format === "json") {
+ return res.status(200).json({ success: true, analytics });
+ }
+
+ if (format !== "csv") {
+ return next(
+ new APIError("Unsupported export format. Use 'csv' or 'json'.", 400)
+ );
+ }
+
+ const csv = analyticsToCsv(analytics);
+ const filename = `course-${analytics.courseId}-analytics.csv`;
+
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
+ res.status(200).send(csv);
+});
+
+/**
+ * GET /api/courses/analytics/overview
+ *
+ * Portfolio-level analytics across every course owned by the authenticated
+ * creator, with an aggregate roll-up.
+ */
+export const getCreatorOverviewHandler = catchAsync(async (req, res) => {
+ const overview = await getCreatorOverview(req.user._id, readDateRange(req));
+ res.status(200).json({ success: true, overview });
+});
+
+export default {
+ getCourseAnalyticsHandler,
+ exportCourseAnalyticsHandler,
+ getCreatorOverviewHandler,
+};
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/badge.controller.js b/src/controllers/badge.controller.js
new file mode 100644
index 00000000..2f19a3ce
--- /dev/null
+++ b/src/controllers/badge.controller.js
@@ -0,0 +1,57 @@
+import badgeService from "../services/badge.service.js";
+
+export const getUserBadgesController = async (req, res) => {
+ try {
+ const userId = req.params.userId || req.user?._id;
+ if (!userId) {
+ return res.status(400).json({
+ success: false,
+ message: "User ID is required",
+ });
+ }
+
+ const userBadges = await badgeService.getUserBadges(userId);
+ res.status(200).json({
+ success: true,
+ count: userBadges.length,
+ data: userBadges,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getAllBadgesController = async (req, res) => {
+ try {
+ const badges = await badgeService.getAllBadges();
+ res.status(200).json({
+ success: true,
+ count: badges.length,
+ data: badges,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const checkBadgesController = async (req, res) => {
+ try {
+ const newlyAwarded = await badgeService.checkAndAwardBadges(req.user._id);
+ res.status(200).json({
+ success: true,
+ newlyAwardedCount: newlyAwarded.length,
+ data: newlyAwarded,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
diff --git a/src/controllers/badge.controller.ts b/src/controllers/badge.controller.ts
new file mode 100644
index 00000000..2f19a3ce
--- /dev/null
+++ b/src/controllers/badge.controller.ts
@@ -0,0 +1,57 @@
+import badgeService from "../services/badge.service.js";
+
+export const getUserBadgesController = async (req, res) => {
+ try {
+ const userId = req.params.userId || req.user?._id;
+ if (!userId) {
+ return res.status(400).json({
+ success: false,
+ message: "User ID is required",
+ });
+ }
+
+ const userBadges = await badgeService.getUserBadges(userId);
+ res.status(200).json({
+ success: true,
+ count: userBadges.length,
+ data: userBadges,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getAllBadgesController = async (req, res) => {
+ try {
+ const badges = await badgeService.getAllBadges();
+ res.status(200).json({
+ success: true,
+ count: badges.length,
+ data: badges,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const checkBadgesController = async (req, res) => {
+ try {
+ const newlyAwarded = await badgeService.checkAndAwardBadges(req.user._id);
+ res.status(200).json({
+ success: true,
+ newlyAwardedCount: newlyAwarded.length,
+ data: newlyAwarded,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
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/books/readingProgressController.js b/src/controllers/books/readingProgressController.js
new file mode 100644
index 00000000..bc702ff4
--- /dev/null
+++ b/src/controllers/books/readingProgressController.js
@@ -0,0 +1,61 @@
+import readingProgressService from "../../services/reading-progress.service.js";
+
+/**
+ * PUT /api/books/:bookId/progress
+ * Create or update the reader's progress for a book (upsert per user + book).
+ */
+export const updateReadingProgress = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const userId = req.user._id;
+ const { page, totalPages, percentage, lastPosition, device } = req.body;
+
+ const progress = await readingProgressService.upsertProgress({
+ userId,
+ bookId,
+ page,
+ totalPages,
+ percentage,
+ lastPosition,
+ device,
+ });
+
+ res.status(200).json({ success: true, progress });
+ } catch (error) {
+ const status = error.message === "Book not found" ? 404 : 400;
+ res.status(status).json({ success: false, message: error.message });
+ }
+};
+
+/**
+ * GET /api/books/:bookId/progress
+ * Resume: return the last stored position for this user + book.
+ */
+export const getReadingProgress = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const userId = req.user._id;
+
+ const progress = await readingProgressService.getProgress({ userId, bookId });
+
+ res.status(200).json({ success: true, progress: progress || null });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+/**
+ * GET /api/books/library/progress
+ * The user's reading library with a progress percentage per book.
+ */
+export const getReadingLibrary = async (req, res) => {
+ try {
+ const userId = req.user._id;
+
+ const library = await readingProgressService.getLibraryWithProgress({ userId });
+
+ res.status(200).json({ success: true, library });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
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/certificate.controller.js b/src/controllers/certificate.controller.js
new file mode 100644
index 00000000..40c4b96a
--- /dev/null
+++ b/src/controllers/certificate.controller.js
@@ -0,0 +1,78 @@
+import certificateService from "../services/certificate.service.js";
+
+export const generateCertificateController = async (req, res) => {
+ try {
+ const { courseId } = req.body;
+ const userId = req.user._id;
+
+ if (!courseId) {
+ return res.status(400).json({
+ success: false,
+ message: "courseId is required",
+ });
+ }
+
+ const certificate = await certificateService.generateCertificate({ userId, courseId });
+ res.status(201).json({
+ success: true,
+ data: certificate,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getCertificateByIdController = async (req, res) => {
+ try {
+ const certificate = await certificateService.getCertificateById(req.params.id);
+ res.status(200).json({
+ success: true,
+ data: certificate,
+ });
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getUserCertificatesController = async (req, res) => {
+ try {
+ const userId = req.params.userId || req.user._id;
+ const certificates = await certificateService.getUserCertificates(userId);
+ res.status(200).json({
+ success: true,
+ count: certificates.length,
+ data: certificates,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const downloadCertificateController = async (req, res) => {
+ try {
+ const certificate = await certificateService.getCertificateById(req.params.id);
+ const pdfBuffer = await certificateService.generatePDFBuffer(certificate);
+
+ res.setHeader("Content-Type", "application/pdf");
+ res.setHeader(
+ "Content-Disposition",
+ `attachment; filename="${certificate.certificateId}.pdf"`
+ );
+ res.setHeader("Content-Length", pdfBuffer.length);
+ res.send(pdfBuffer);
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
diff --git a/src/controllers/certificate.controller.ts b/src/controllers/certificate.controller.ts
new file mode 100644
index 00000000..40c4b96a
--- /dev/null
+++ b/src/controllers/certificate.controller.ts
@@ -0,0 +1,78 @@
+import certificateService from "../services/certificate.service.js";
+
+export const generateCertificateController = async (req, res) => {
+ try {
+ const { courseId } = req.body;
+ const userId = req.user._id;
+
+ if (!courseId) {
+ return res.status(400).json({
+ success: false,
+ message: "courseId is required",
+ });
+ }
+
+ const certificate = await certificateService.generateCertificate({ userId, courseId });
+ res.status(201).json({
+ success: true,
+ data: certificate,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getCertificateByIdController = async (req, res) => {
+ try {
+ const certificate = await certificateService.getCertificateById(req.params.id);
+ res.status(200).json({
+ success: true,
+ data: certificate,
+ });
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getUserCertificatesController = async (req, res) => {
+ try {
+ const userId = req.params.userId || req.user._id;
+ const certificates = await certificateService.getUserCertificates(userId);
+ res.status(200).json({
+ success: true,
+ count: certificates.length,
+ data: certificates,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const downloadCertificateController = async (req, res) => {
+ try {
+ const certificate = await certificateService.getCertificateById(req.params.id);
+ const pdfBuffer = await certificateService.generatePDFBuffer(certificate);
+
+ res.setHeader("Content-Type", "application/pdf");
+ res.setHeader(
+ "Content-Disposition",
+ `attachment; filename="${certificate.certificateId}.pdf"`
+ );
+ res.setHeader("Content-Length", pdfBuffer.length);
+ res.send(pdfBuffer);
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
diff --git a/src/controllers/course-bundle.controller.js b/src/controllers/course-bundle.controller.js
new file mode 100644
index 00000000..4033f0d2
--- /dev/null
+++ b/src/controllers/course-bundle.controller.js
@@ -0,0 +1,129 @@
+import courseBundleService from "../services/course-bundle.service.js";
+
+export const createBundle = async (req, res) => {
+ try {
+ const { title, description, courses, price, currency } = req.body;
+ const createdBy = req.user._id;
+
+ if (!title || !description || !courses || price === undefined) {
+ return res.status(400).json({
+ success: false,
+ message: "Title, description, courses array, and price are required",
+ });
+ }
+
+ const bundle = await courseBundleService.createBundle({
+ title,
+ description,
+ courses,
+ price: Number(price),
+ currency,
+ createdBy,
+ });
+
+ res.status(201).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundles = async (req, res) => {
+ try {
+ const bundles = await courseBundleService.getBundles(req.query);
+ res.status(200).json({
+ success: true,
+ count: bundles.length,
+ data: bundles,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundleById = async (req, res) => {
+ try {
+ const bundle = await courseBundleService.getBundleById(req.params.id);
+ res.status(200).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundlesByCourse = async (req, res) => {
+ try {
+ const bundles = await courseBundleService.getBundlesByCourse(req.params.courseId);
+ res.status(200).json({
+ success: true,
+ count: bundles.length,
+ data: bundles,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const updateBundle = async (req, res) => {
+ try {
+ const bundle = await courseBundleService.updateBundle(
+ req.params.id,
+ req.body,
+ req.user._id,
+ req.user.role
+ );
+ res.status(200).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const deleteBundle = async (req, res) => {
+ try {
+ const result = await courseBundleService.deleteBundle(
+ req.params.id,
+ req.user._id,
+ req.user.role
+ );
+ res.status(200).json(result);
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const purchaseBundle = async (req, res) => {
+ try {
+ const result = await courseBundleService.purchaseBundle(req.params.id, req.user._id);
+ res.status(200).json(result);
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
diff --git a/src/controllers/course-bundle.controller.ts b/src/controllers/course-bundle.controller.ts
new file mode 100644
index 00000000..4033f0d2
--- /dev/null
+++ b/src/controllers/course-bundle.controller.ts
@@ -0,0 +1,129 @@
+import courseBundleService from "../services/course-bundle.service.js";
+
+export const createBundle = async (req, res) => {
+ try {
+ const { title, description, courses, price, currency } = req.body;
+ const createdBy = req.user._id;
+
+ if (!title || !description || !courses || price === undefined) {
+ return res.status(400).json({
+ success: false,
+ message: "Title, description, courses array, and price are required",
+ });
+ }
+
+ const bundle = await courseBundleService.createBundle({
+ title,
+ description,
+ courses,
+ price: Number(price),
+ currency,
+ createdBy,
+ });
+
+ res.status(201).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundles = async (req, res) => {
+ try {
+ const bundles = await courseBundleService.getBundles(req.query);
+ res.status(200).json({
+ success: true,
+ count: bundles.length,
+ data: bundles,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundleById = async (req, res) => {
+ try {
+ const bundle = await courseBundleService.getBundleById(req.params.id);
+ res.status(200).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const getBundlesByCourse = async (req, res) => {
+ try {
+ const bundles = await courseBundleService.getBundlesByCourse(req.params.courseId);
+ res.status(200).json({
+ success: true,
+ count: bundles.length,
+ data: bundles,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const updateBundle = async (req, res) => {
+ try {
+ const bundle = await courseBundleService.updateBundle(
+ req.params.id,
+ req.body,
+ req.user._id,
+ req.user.role
+ );
+ res.status(200).json({
+ success: true,
+ data: bundle,
+ });
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const deleteBundle = async (req, res) => {
+ try {
+ const result = await courseBundleService.deleteBundle(
+ req.params.id,
+ req.user._id,
+ req.user.role
+ );
+ res.status(200).json(result);
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
+
+export const purchaseBundle = async (req, res) => {
+ try {
+ const result = await courseBundleService.purchaseBundle(req.params.id, req.user._id);
+ res.status(200).json(result);
+ } catch (error) {
+ res.status(400).json({
+ success: false,
+ message: error.message,
+ });
+ }
+};
diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js
index 65780cc8..2d926065 100644
--- a/src/controllers/courses/courseController.js
+++ b/src/controllers/courses/courseController.js
@@ -1,9 +1,39 @@
import Course from "../../models/Course.js";
+import CourseProgress from "../../models/CourseProgress.js";
import mongoose from "mongoose";
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";
+
+/**
+ * Normalize + defensively validate an optional prerequisites array of course
+ * ObjectIds. Returns { ids } on success or { error } (string) on failure.
+ * `selfId` (optional) guards against a course listing itself.
+ */
+const normalizePrerequisites = (prerequisites, selfId) => {
+ if (prerequisites === undefined) return { ids: undefined };
+ if (!Array.isArray(prerequisites)) {
+ return { error: "prerequisites must be an array" };
+ }
+ const ids = [];
+ for (const p of prerequisites) {
+ if (!mongoose.Types.ObjectId.isValid(p)) {
+ return { error: "Each prerequisite must be a valid Mongo ObjectId" };
+ }
+ if (selfId && String(p) === String(selfId)) {
+ return { error: "A course cannot list itself as a prerequisite" };
+ }
+ ids.push(p);
+ }
+ return { ids };
+};
/**
* Create a new course
@@ -11,7 +41,8 @@ import { createNewCourseNotification } from "../notificationController.js";
* Backend receives URLs instead of file buffers
*/
export const createCourse = catchAsync(async (req, res, next) => {
- const { title, description, category, price, thumbnail, video } = req.body;
+ const { title, description, category, price, thumbnail, video, prerequisites } =
+ req.body;
logger.info(`Creating course: ${title} by user: ${req.user._id}`);
@@ -22,15 +53,27 @@ export const createCourse = catchAsync(async (req, res, next) => {
);
}
+ const { ids: prerequisiteIds, error: prerequisiteError } =
+ normalizePrerequisites(prerequisites);
+ if (prerequisiteError) {
+ return next(new APIError(prerequisiteError, 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
video: video || null, // URL from frontend
+ ...(prerequisiteIds !== undefined && { prerequisites: prerequisiteIds }),
});
logger.info(`✅ Course created successfully: ${course._id} - ${title}`);
@@ -48,9 +91,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"
);
@@ -63,12 +112,20 @@ export const getCourses = async (_req, res) => {
// 📘 Get a single course
export const getCourseById = async (req, res) => {
try {
- const course = await Course.findById(req.params.id).populate("createdBy", "name avatar bio");
+ const course = await Course.findById(req.params.id)
+ .populate("createdBy", "name avatar bio")
+ .populate("prerequisites", "title thumbnail");
if (!course)
return res
.status(404)
.json({ success: false, message: "Course not found" });
+ // Track a course view for creator analytics (fire-and-forget so a failed
+ // metric write never blocks or fails the detail response).
+ Course.updateOne({ _id: course._id }, { $inc: { views: 1 } }).catch((err) =>
+ logger.error("Failed to increment course view count:", err)
+ );
+
res.status(200).json({ success: true, course });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
@@ -129,6 +186,36 @@ export const enrollInCourse = async (req, res) => {
.json({ success: false, message: "Already enrolled" });
}
+ // Prerequisite gate: the learner must have COMPLETED every prerequisite
+ // course (a CourseProgress doc with completedAt set, or percentComplete>=100)
+ // before they can enroll in this (advanced) course.
+ if (Array.isArray(course.prerequisites) && course.prerequisites.length > 0) {
+ const completed = await CourseProgress.find({
+ user: req.user._id,
+ course: { $in: course.prerequisites },
+ $or: [
+ { completedAt: { $ne: null } },
+ { percentComplete: { $gte: 100 } },
+ ],
+ }).select("course");
+
+ const completedIds = new Set(completed.map((p) => p.course.toString()));
+ const missingIds = course.prerequisites.filter(
+ (p) => !completedIds.has(p.toString())
+ );
+
+ if (missingIds.length > 0) {
+ const missingCourses = await Course.find({
+ _id: { $in: missingIds },
+ }).select("title");
+ const titles = missingCourses.map((c) => c.title).join(", ");
+ return res.status(400).json({
+ success: false,
+ message: `Complete these prerequisites first: ${titles}`,
+ });
+ }
+ }
+
// Add user to course's enrolledUsers
course.enrolledUsers.push(req.user._id);
await course.save();
@@ -149,6 +236,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({
@@ -164,34 +257,44 @@ export const enrollInCourse = async (req, res) => {
// 📝 Edit/Update a course
export const updateCourse = catchAsync(async (req, res, next) => {
- const { title, description, category, price, thumbnail, video } = req.body;
+ const { title, description, category, price, thumbnail, video, prerequisites } =
+ req.body;
const courseId = req.params.id;
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)
- );
+ const { ids: prerequisiteIds, error: prerequisiteError } =
+ normalizePrerequisites(prerequisites, courseId);
+ if (prerequisiteError) {
+ return next(new APIError(prerequisiteError, 400));
}
// 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
if (thumbnail) course.thumbnail = thumbnail;
if (video) course.video = video;
+ // Replace prerequisites only when explicitly provided (keeps update PATCH-like).
+ if (prerequisiteIds !== undefined) course.prerequisites = prerequisiteIds;
+
await course.save();
logger.info(`✅ Course updated successfully: ${courseId}`);
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/highlight.controller.js b/src/controllers/highlight.controller.js
new file mode 100644
index 00000000..2f93b22d
--- /dev/null
+++ b/src/controllers/highlight.controller.js
@@ -0,0 +1,168 @@
+import highlightService from "../services/highlight.service.js";
+
+export const createHighlight = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const { text, color, pageNumber, passage, cfiRange } = req.body;
+ const userId = req.user._id;
+
+ if (!text || text.trim() === "") {
+ return res.status(400).json({ success: false, message: "Highlight text is required" });
+ }
+
+ const highlight = await highlightService.createHighlight({
+ userId,
+ bookId,
+ text,
+ color,
+ pageNumber,
+ passage,
+ cfiRange,
+ });
+
+ res.status(201).json({ success: true, highlight });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getHighlights = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const userId = req.user._id;
+
+ const highlights = await highlightService.getHighlights({ userId, bookId });
+ res.status(200).json({ success: true, highlights });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const deleteHighlight = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user._id;
+
+ const highlight = await highlightService.deleteHighlight({ userId, highlightId: id });
+ res.status(200).json({ success: true, message: "Highlight deleted", highlight });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const createNote = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const { highlightId, content, pageNumber, passage } = req.body;
+ const userId = req.user._id;
+
+ if (!content || content.trim() === "") {
+ return res.status(400).json({ success: false, message: "Note content is required" });
+ }
+
+ const note = await highlightService.createNote({
+ userId,
+ bookId,
+ highlightId,
+ content,
+ pageNumber,
+ passage,
+ });
+
+ res.status(201).json({ success: true, note });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getNotes = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const userId = req.user._id;
+
+ const notes = await highlightService.getNotes({ userId, bookId });
+ res.status(200).json({ success: true, notes });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const deleteNote = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user._id;
+
+ const note = await highlightService.deleteNote({ userId, noteId: id });
+ res.status(200).json({ success: true, message: "Note deleted", note });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getHighlightsAndNotes = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const userId = req.user._id;
+
+ const result = await highlightService.getHighlightsAndNotes({ userId, bookId });
+ res.status(200).json({ success: true, ...result });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const searchHighlightsAndNotes = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const query = req.query.q || req.query.query || "";
+ const userId = req.user._id;
+
+ const results = await highlightService.searchHighlightsAndNotes({
+ userId,
+ bookId,
+ query,
+ });
+
+ res.status(200).json({ success: true, results });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const exportHighlights = async (req, res) => {
+ try {
+ const { bookId } = req.params;
+ const format = req.query.format || "text";
+ const userId = req.user._id;
+
+ const exportData = await highlightService.exportHighlights({
+ userId,
+ bookId,
+ format,
+ });
+
+ if (format === "pdf") {
+ res.setHeader("Content-Type", exportData.mimeType);
+ res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`);
+ return res.status(200).send(exportData.content);
+ }
+
+ res.setHeader("Content-Type", exportData.mimeType);
+ res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`);
+ return res.status(200).send(exportData.content);
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createHighlight,
+ getHighlights,
+ deleteHighlight,
+ createNote,
+ getNotes,
+ deleteNote,
+ getHighlightsAndNotes,
+ searchHighlightsAndNotes,
+ exportHighlights,
+};
diff --git a/src/controllers/highlight.controller.ts b/src/controllers/highlight.controller.ts
new file mode 100644
index 00000000..13401b87
--- /dev/null
+++ b/src/controllers/highlight.controller.ts
@@ -0,0 +1,169 @@
+import { Request, Response } from "express";
+import highlightService from "../services/highlight.service.ts";
+
+export const createHighlight = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const { text, color, pageNumber, passage, cfiRange } = req.body;
+ const userId = (req as any).user._id;
+
+ if (!text || text.trim() === "") {
+ return res.status(400).json({ success: false, message: "Highlight text is required" });
+ }
+
+ const highlight = await highlightService.createHighlight({
+ userId,
+ bookId,
+ text,
+ color,
+ pageNumber,
+ passage,
+ cfiRange,
+ });
+
+ res.status(201).json({ success: true, highlight });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getHighlights = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const userId = (req as any).user._id;
+
+ const highlights = await highlightService.getHighlights({ userId, bookId });
+ res.status(200).json({ success: true, highlights });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const deleteHighlight = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const userId = (req as any).user._id;
+
+ const highlight = await highlightService.deleteHighlight({ userId, highlightId: id });
+ res.status(200).json({ success: true, message: "Highlight deleted", highlight });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const createNote = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const { highlightId, content, pageNumber, passage } = req.body;
+ const userId = (req as any).user._id;
+
+ if (!content || content.trim() === "") {
+ return res.status(400).json({ success: false, message: "Note content is required" });
+ }
+
+ const note = await highlightService.createNote({
+ userId,
+ bookId,
+ highlightId,
+ content,
+ pageNumber,
+ passage,
+ });
+
+ res.status(201).json({ success: true, note });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getNotes = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const userId = (req as any).user._id;
+
+ const notes = await highlightService.getNotes({ userId, bookId });
+ res.status(200).json({ success: true, notes });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const deleteNote = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const userId = (req as any).user._id;
+
+ const note = await highlightService.deleteNote({ userId, noteId: id });
+ res.status(200).json({ success: true, message: "Note deleted", note });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getHighlightsAndNotes = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const userId = (req as any).user._id;
+
+ const result = await highlightService.getHighlightsAndNotes({ userId, bookId });
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const searchHighlightsAndNotes = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const query = (req.query.q || req.query.query || "") as string;
+ const userId = (req as any).user._id;
+
+ const results = await highlightService.searchHighlightsAndNotes({
+ userId,
+ bookId,
+ query,
+ });
+
+ res.status(200).json({ success: true, results });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const exportHighlights = async (req: Request, res: Response) => {
+ try {
+ const { bookId } = req.params;
+ const format = (req.query.format as string) || "text";
+ const userId = (req as any).user._id;
+
+ const exportData = await highlightService.exportHighlights({
+ userId,
+ bookId,
+ format,
+ });
+
+ if (format === "pdf") {
+ res.setHeader("Content-Type", exportData.mimeType);
+ res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`);
+ return res.status(200).send(exportData.content);
+ }
+
+ res.setHeader("Content-Type", exportData.mimeType);
+ res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`);
+ return res.status(200).send(exportData.content);
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createHighlight,
+ getHighlights,
+ deleteHighlight,
+ createNote,
+ getNotes,
+ deleteNote,
+ getHighlightsAndNotes,
+ searchHighlightsAndNotes,
+ exportHighlights,
+};
diff --git a/src/controllers/messaging.controller.ts b/src/controllers/messaging.controller.ts
new file mode 100644
index 00000000..674be8df
--- /dev/null
+++ b/src/controllers/messaging.controller.ts
@@ -0,0 +1,95 @@
+import { Request, Response } from "express";
+import messagingService from "../services/messaging.service.ts";
+
+export const getOrCreateConversation = async (req: Request, res: Response) => {
+ try {
+ const { userId } = req.params;
+ const currentUserId = (req as any).user._id;
+
+ const conversation = await messagingService.getOrCreateConversation(
+ currentUserId.toString(),
+ userId
+ );
+
+ res.status(200).json({ success: true, conversation });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getConversations = async (req: Request, res: Response) => {
+ try {
+ const userId = (req as any).user._id;
+
+ const conversations = await messagingService.getConversations(
+ userId.toString()
+ );
+
+ res.status(200).json({ success: true, conversations });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getMessages = async (req: Request, res: Response) => {
+ try {
+ const { conversationId } = req.params;
+ const userId = (req as any).user._id;
+ const page = parseInt(req.query.page as string) || 1;
+ const limit = parseInt(req.query.limit as string) || 30;
+
+ const result = await messagingService.getMessages({
+ conversationId,
+ userId: userId.toString(),
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const sendMessage = async (req: Request, res: Response) => {
+ try {
+ const { conversationId } = req.params;
+ const { text, image } = req.body;
+ const userId = (req as any).user._id;
+
+ const message = await messagingService.sendMessage({
+ conversationId,
+ senderId: userId.toString(),
+ text,
+ image,
+ });
+
+ res.status(201).json({ success: true, message });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const markAsRead = async (req: Request, res: Response) => {
+ try {
+ const { conversationId } = req.params;
+ const userId = (req as any).user._id;
+
+ const result = await messagingService.markAsRead({
+ conversationId,
+ userId: userId.toString(),
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ getOrCreateConversation,
+ getConversations,
+ getMessages,
+ sendMessage,
+ markAsRead,
+};
diff --git a/src/controllers/moderation.controller.js b/src/controllers/moderation.controller.js
new file mode 100644
index 00000000..66dbaecc
--- /dev/null
+++ b/src/controllers/moderation.controller.js
@@ -0,0 +1,96 @@
+import moderationService from "../services/moderation.service.js";
+
+export const flagReel = async (req, res) => {
+ try {
+ const { reelId } = req.params;
+ const { reason, details } = req.body;
+ const reporterId = req.user._id;
+
+ if (!reason || reason.trim() === "") {
+ return res.status(400).json({ success: false, message: "Reason for flagging is required" });
+ }
+
+ const flag = await moderationService.flagReel({
+ reelId,
+ reporterId,
+ reason,
+ details,
+ });
+
+ res.status(201).json({ success: true, flag, message: "Reel flagged for moderation" });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getModerationQueue = async (req, res) => {
+ try {
+ const status = req.query.status;
+ const page = parseInt(req.query.page, 10) || 1;
+ const limit = parseInt(req.query.limit, 10) || 20;
+
+ const result = await moderationService.getModerationQueue({
+ status,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const processModerationAction = async (req, res) => {
+ try {
+ const { flagId } = req.params;
+ const { reelId, action, notes } = req.body;
+ const adminId = req.user._id;
+
+ if (!action || !["approve", "reject", "remove"].includes(action)) {
+ return res.status(400).json({
+ success: false,
+ message: "Valid action ('approve', 'reject', 'remove') is required",
+ });
+ }
+
+ const result = await moderationService.processModerationAction({
+ flagId,
+ reelId,
+ adminId,
+ action,
+ notes,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getModerationHistory = async (req, res) => {
+ try {
+ const reelId = req.query.reelId;
+ const adminId = req.query.adminId;
+ const page = parseInt(req.query.page, 10) || 1;
+ const limit = parseInt(req.query.limit, 10) || 20;
+
+ const result = await moderationService.getModerationHistory({
+ reelId,
+ adminId,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ flagReel,
+ getModerationQueue,
+ processModerationAction,
+ getModerationHistory,
+};
diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts
new file mode 100644
index 00000000..72ffac15
--- /dev/null
+++ b/src/controllers/moderation.controller.ts
@@ -0,0 +1,97 @@
+import { Request, Response } from "express";
+import moderationService from "../services/moderation.service.ts";
+
+export const flagReel = async (req: Request, res: Response) => {
+ try {
+ const { reelId } = req.params;
+ const { reason, details } = req.body;
+ const reporterId = (req as any).user._id;
+
+ if (!reason || reason.trim() === "") {
+ return res.status(400).json({ success: false, message: "Reason for flagging is required" });
+ }
+
+ const flag = await moderationService.flagReel({
+ reelId,
+ reporterId,
+ reason,
+ details,
+ });
+
+ res.status(201).json({ success: true, flag, message: "Reel flagged for moderation" });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getModerationQueue = async (req: Request, res: Response) => {
+ try {
+ const status = req.query.status as string;
+ const page = parseInt(req.query.page as string, 10) || 1;
+ const limit = parseInt(req.query.limit as string, 10) || 20;
+
+ const result = await moderationService.getModerationQueue({
+ status,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const processModerationAction = async (req: Request, res: Response) => {
+ try {
+ const { flagId } = req.params;
+ const { reelId, action, notes } = req.body;
+ const adminId = (req as any).user._id;
+
+ if (!action || !["approve", "reject", "remove"].includes(action)) {
+ return res.status(400).json({
+ success: false,
+ message: "Valid action ('approve', 'reject', 'remove') is required",
+ });
+ }
+
+ const result = await moderationService.processModerationAction({
+ flagId,
+ reelId,
+ adminId,
+ action,
+ notes,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getModerationHistory = async (req: Request, res: Response) => {
+ try {
+ const reelId = req.query.reelId as string;
+ const adminId = req.query.adminId as string;
+ const page = parseInt(req.query.page as string, 10) || 1;
+ const limit = parseInt(req.query.limit as string, 10) || 20;
+
+ const result = await moderationService.getModerationHistory({
+ reelId,
+ adminId,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ flagReel,
+ getModerationQueue,
+ processModerationAction,
+ getModerationHistory,
+};
diff --git a/src/controllers/reading-group.controller.js b/src/controllers/reading-group.controller.js
new file mode 100644
index 00000000..f92a580c
--- /dev/null
+++ b/src/controllers/reading-group.controller.js
@@ -0,0 +1,186 @@
+import readingGroupService from "../services/reading-group.service.js";
+
+export const createGroup = async (req, res) => {
+ try {
+ const { name, description, bookId, privacy, chaptersPerWeek, readingSchedule } = req.body;
+ const creatorId = req.user._id;
+
+ if (!name || name.trim() === "") {
+ return res.status(400).json({ success: false, message: "Group name is required" });
+ }
+ if (!bookId) {
+ return res.status(400).json({ success: false, message: "bookId is required" });
+ }
+
+ const group = await readingGroupService.createGroup({
+ name,
+ description,
+ bookId,
+ creatorId,
+ privacy,
+ chaptersPerWeek,
+ readingSchedule,
+ });
+
+ res.status(201).json({ success: true, group });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getGroups = async (req, res) => {
+ try {
+ const bookId = req.query.bookId;
+ const privacy = req.query.privacy;
+ const search = req.query.search || req.query.q;
+ const page = parseInt(req.query.page, 10) || 1;
+ const limit = parseInt(req.query.limit, 10) || 20;
+
+ const result = await readingGroupService.getGroups({
+ bookId,
+ privacy,
+ search,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getGroupDetails = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user?._id;
+
+ const details = await readingGroupService.getGroupDetails(id, userId);
+ res.status(200).json({ success: true, ...details });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const joinGroup = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user._id;
+
+ const membership = await readingGroupService.joinGroup(id, userId);
+ res.status(200).json({ success: true, membership });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const inviteMember = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { targetUserId } = req.body;
+ const adminId = req.user._id;
+
+ if (!targetUserId) {
+ return res.status(400).json({ success: false, message: "targetUserId is required" });
+ }
+
+ const membership = await readingGroupService.inviteMember(id, adminId, targetUserId);
+ res.status(200).json({ success: true, membership, message: "Invitation sent" });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const updateSchedule = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { readingSchedule, chaptersPerWeek } = req.body;
+ const adminId = req.user._id;
+
+ const group = await readingGroupService.updateSchedule(id, adminId, readingSchedule, chaptersPerWeek);
+ res.status(200).json({ success: true, group });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const addDiscussionPost = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { chapter, content } = req.body;
+ const userId = req.user._id;
+
+ if (chapter === undefined || chapter === null) {
+ return res.status(400).json({ success: false, message: "Chapter is required" });
+ }
+ if (!content || content.trim() === "") {
+ return res.status(400).json({ success: false, message: "Content is required" });
+ }
+
+ const discussions = await readingGroupService.addDiscussionPost({
+ groupId: id,
+ chapter: Number(chapter),
+ userId,
+ content,
+ });
+
+ res.status(201).json({ success: true, discussions });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getDiscussions = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const chapter = req.query.chapter ? parseInt(req.query.chapter, 10) : undefined;
+
+ const discussions = await readingGroupService.getDiscussions(id, chapter);
+ res.status(200).json({ success: true, discussions });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const updateMemberProgress = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { currentChapter, currentProgressPercent } = req.body;
+ const userId = req.user._id;
+
+ const member = await readingGroupService.updateMemberProgress({
+ groupId: id,
+ userId,
+ currentChapter,
+ currentProgressPercent,
+ });
+
+ res.status(200).json({ success: true, member });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getMemberProgressDashboard = async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const dashboard = await readingGroupService.getMemberProgressDashboard(id);
+ res.status(200).json({ success: true, ...dashboard });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createGroup,
+ getGroups,
+ getGroupDetails,
+ joinGroup,
+ inviteMember,
+ updateSchedule,
+ addDiscussionPost,
+ getDiscussions,
+ updateMemberProgress,
+ getMemberProgressDashboard,
+};
diff --git a/src/controllers/reading-group.controller.ts b/src/controllers/reading-group.controller.ts
new file mode 100644
index 00000000..76491100
--- /dev/null
+++ b/src/controllers/reading-group.controller.ts
@@ -0,0 +1,187 @@
+import { Request, Response } from "express";
+import readingGroupService from "../services/reading-group.service.ts";
+
+export const createGroup = async (req: Request, res: Response) => {
+ try {
+ const { name, description, bookId, privacy, chaptersPerWeek, readingSchedule } = req.body;
+ const creatorId = (req as any).user._id;
+
+ if (!name || name.trim() === "") {
+ return res.status(400).json({ success: false, message: "Group name is required" });
+ }
+ if (!bookId) {
+ return res.status(400).json({ success: false, message: "bookId is required" });
+ }
+
+ const group = await readingGroupService.createGroup({
+ name,
+ description,
+ bookId,
+ creatorId,
+ privacy,
+ chaptersPerWeek,
+ readingSchedule,
+ });
+
+ res.status(201).json({ success: true, group });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getGroups = async (req: Request, res: Response) => {
+ try {
+ const bookId = req.query.bookId as string;
+ const privacy = req.query.privacy as string;
+ const search = (req.query.search || req.query.q) as string;
+ const page = parseInt(req.query.page as string, 10) || 1;
+ const limit = parseInt(req.query.limit as string, 10) || 20;
+
+ const result = await readingGroupService.getGroups({
+ bookId,
+ privacy,
+ search,
+ page,
+ limit,
+ });
+
+ res.status(200).json({ success: true, ...result });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getGroupDetails = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const userId = (req as any).user?._id;
+
+ const details = await readingGroupService.getGroupDetails(id, userId);
+ res.status(200).json({ success: true, ...details });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const joinGroup = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const userId = (req as any).user._id;
+
+ const membership = await readingGroupService.joinGroup(id, userId);
+ res.status(200).json({ success: true, membership });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const inviteMember = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const { targetUserId } = req.body;
+ const adminId = (req as any).user._id;
+
+ if (!targetUserId) {
+ return res.status(400).json({ success: false, message: "targetUserId is required" });
+ }
+
+ const membership = await readingGroupService.inviteMember(id, adminId, targetUserId);
+ res.status(200).json({ success: true, membership, message: "Invitation sent" });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const updateSchedule = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const { readingSchedule, chaptersPerWeek } = req.body;
+ const adminId = (req as any).user._id;
+
+ const group = await readingGroupService.updateSchedule(id, adminId, readingSchedule, chaptersPerWeek);
+ res.status(200).json({ success: true, group });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const addDiscussionPost = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const { chapter, content } = req.body;
+ const userId = (req as any).user._id;
+
+ if (chapter === undefined || chapter === null) {
+ return res.status(400).json({ success: false, message: "Chapter is required" });
+ }
+ if (!content || content.trim() === "") {
+ return res.status(400).json({ success: false, message: "Content is required" });
+ }
+
+ const discussions = await readingGroupService.addDiscussionPost({
+ groupId: id,
+ chapter: Number(chapter),
+ userId,
+ content,
+ });
+
+ res.status(201).json({ success: true, discussions });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getDiscussions = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const chapter = req.query.chapter ? parseInt(req.query.chapter as string, 10) : undefined;
+
+ const discussions = await readingGroupService.getDiscussions(id, chapter);
+ res.status(200).json({ success: true, discussions });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const updateMemberProgress = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+ const { currentChapter, currentProgressPercent } = req.body;
+ const userId = (req as any).user._id;
+
+ const member = await readingGroupService.updateMemberProgress({
+ groupId: id,
+ userId,
+ currentChapter,
+ currentProgressPercent,
+ });
+
+ res.status(200).json({ success: true, member });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getMemberProgressDashboard = async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params;
+
+ const dashboard = await readingGroupService.getMemberProgressDashboard(id);
+ res.status(200).json({ success: true, ...dashboard });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createGroup,
+ getGroups,
+ getGroupDetails,
+ joinGroup,
+ inviteMember,
+ updateSchedule,
+ addDiscussionPost,
+ getDiscussions,
+ updateMemberProgress,
+ getMemberProgressDashboard,
+};
diff --git a/src/controllers/reelController.js b/src/controllers/reelController.js
index bb9ab31f..25984a8f 100644
--- a/src/controllers/reelController.js
+++ b/src/controllers/reelController.js
@@ -2,6 +2,10 @@ import mongoose from "mongoose";
import Reel from "../models/Reel.js";
import cloudinary from "../utils/cloudinary.js";
import logger from "../config/logger.js";
+import {
+ createReelDerivative,
+ listReelDerivatives,
+} from "../services/reelDuetService.js";
const uploadBufferToCloudinary = (buffer, options) =>
new Promise((resolve, reject) => {
@@ -48,12 +52,18 @@ const formatReelResponse = (reel, viewerId) => {
duration: reel.duration,
createdAt: reel.createdAt,
updatedAt: reel.updatedAt,
+ originalReelId: reel.originalReelId || null,
+ duetType: reel.duetType || null,
+ stitchClip: reel.stitchClip || null,
+ composition: reel.composition || null,
stats: {
likes: likeSet.size,
loves: loveSet.size,
comments: reel.comments?.length || 0,
shares: reel.shareCount || 0,
views: reel.viewCount || 0,
+ duets: reel.duetCount || 0,
+ stitches: reel.stitchCount || 0,
},
viewerState: viewerKey
? {
@@ -425,3 +435,98 @@ export const registerReelView = async (req, res) => {
res.status(500).json({ success: false, message: error.message });
}
};
+
+// ======================
+// DUET / STITCH
+// ======================
+
+// Create a duet/stitch response video linked to the original reel (:id).
+// A `duet` is displayed side-by-side with the original; a `stitch` prepends a
+// clip of the original before the response plays.
+export const createReelDuet = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { description, category, tags, type, stitchStart, stitchEnd } =
+ req.body;
+ const userId = req.user?._id;
+
+ if (!req.file?.buffer) {
+ return res
+ .status(400)
+ .json({ success: false, message: "Video file is required" });
+ }
+
+ const uploadResult = await uploadBufferToCloudinary(req.file.buffer, {
+ resource_type: "video",
+ folder: "dnb/reels/duets",
+ });
+
+ const clip =
+ type === "stitch"
+ ? { start: Number(stitchStart), end: Number(stitchEnd) }
+ : undefined;
+
+ const derivative = await createReelDerivative({
+ originalReelId: id,
+ type,
+ userId,
+ description,
+ category,
+ tags: normalizeTags(tags),
+ video: uploadResult.secure_url,
+ videoPublicId: uploadResult.public_id,
+ thumbnail: uploadResult.thumbnail_url || uploadResult.secure_url,
+ duration: uploadResult.duration,
+ clip,
+ });
+
+ const populatedReel = await derivative.populate("createdBy", "name avatar");
+
+ res.status(201).json({
+ success: true,
+ reel: formatReelResponse(populatedReel.toObject(), userId),
+ });
+ } catch (error) {
+ logger.error("Error creating reel duet/stitch:", error);
+ res
+ .status(error.statusCode || 500)
+ .json({ success: false, message: error.message });
+ }
+};
+
+// Browse all duets/stitches for a given reel (:id). Optional ?type=duet|stitch.
+export const getReelDerivatives = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { type } = req.query;
+ const viewerId = req.user?._id;
+
+ const original = await Reel.findById(id).select("_id duetCount stitchCount");
+ if (!original) {
+ return res
+ .status(404)
+ .json({ success: false, message: "Reel not found" });
+ }
+
+ const { items, page, limit, total, hasMore } = await listReelDerivatives(
+ id,
+ { page: req.query.page, limit: req.query.limit, type }
+ );
+
+ res.status(200).json({
+ success: true,
+ page,
+ limit,
+ total,
+ hasMore,
+ counts: {
+ duets: original.duetCount || 0,
+ stitches: original.stitchCount || 0,
+ },
+ reels: items.map((reel) => formatReelResponse(reel, viewerId)),
+ });
+ } catch (error) {
+ logger.error("Error fetching reel duets/stitches:", error);
+ res.status(500).json({ success: false, message: error.message });
+ }
+};
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/space-poll.controller.js b/src/controllers/space-poll.controller.js
new file mode 100644
index 00000000..a79df448
--- /dev/null
+++ b/src/controllers/space-poll.controller.js
@@ -0,0 +1,110 @@
+import spacePollService from "../services/space-poll.service.js";
+
+export const createPoll = async (req, res) => {
+ try {
+ const { spaceId } = req.params;
+ const { question, options } = req.body;
+ const hostId = req.user._id;
+
+ const poll = await spacePollService.createPoll({
+ spaceId,
+ hostId,
+ question,
+ options,
+ });
+
+ res.status(201).json({ success: true, poll });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getSpacePolls = async (req, res) => {
+ try {
+ const { spaceId } = req.params;
+ const userId = req.user?._id;
+
+ const polls = await spacePollService.getSpacePolls(spaceId, userId);
+ res.status(200).json({ success: true, polls });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getPollResults = async (req, res) => {
+ try {
+ const { pollId } = req.params;
+ const userId = req.user?._id;
+
+ const poll = await spacePollService.getPollResults(pollId, userId);
+ res.status(200).json({ success: true, poll });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const voteInPoll = async (req, res) => {
+ try {
+ const { pollId } = req.params;
+ const { optionIndex } = req.body;
+ const userId = req.user._id;
+
+ if (optionIndex === undefined || optionIndex === null) {
+ return res.status(400).json({ success: false, message: "optionIndex is required" });
+ }
+
+ const updatedPoll = await spacePollService.voteInPoll({
+ pollId,
+ userId,
+ optionIndex: Number(optionIndex),
+ });
+
+ res.status(200).json({ success: true, poll: updatedPoll });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const closePoll = async (req, res) => {
+ try {
+ const { pollId } = req.params;
+ const hostId = req.user._id;
+
+ const closedPoll = await spacePollService.closePoll({
+ pollId,
+ hostId,
+ });
+
+ res.status(200).json({ success: true, poll: closedPoll });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const exportPollResults = async (req, res) => {
+ try {
+ const { pollId } = req.params;
+ const format = req.query.format;
+
+ const exportData = await spacePollService.exportPollResults(pollId);
+
+ if (format === "csv") {
+ res.setHeader("Content-Type", "text/csv");
+ res.setHeader("Content-Disposition", `attachment; filename="poll-${pollId}-results.csv"`);
+ return res.status(200).send(exportData.csvData);
+ }
+
+ res.status(200).json({ success: true, export: exportData });
+ } catch (error) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createPoll,
+ getSpacePolls,
+ getPollResults,
+ voteInPoll,
+ closePoll,
+ exportPollResults,
+};
diff --git a/src/controllers/space-poll.controller.ts b/src/controllers/space-poll.controller.ts
new file mode 100644
index 00000000..28d4585b
--- /dev/null
+++ b/src/controllers/space-poll.controller.ts
@@ -0,0 +1,111 @@
+import { Request, Response } from "express";
+import spacePollService from "../services/space-poll.service.ts";
+
+export const createPoll = async (req: Request, res: Response) => {
+ try {
+ const { spaceId } = req.params;
+ const { question, options } = req.body;
+ const hostId = (req as any).user._id;
+
+ const poll = await spacePollService.createPoll({
+ spaceId,
+ hostId,
+ question,
+ options,
+ });
+
+ res.status(201).json({ success: true, poll });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getSpacePolls = async (req: Request, res: Response) => {
+ try {
+ const { spaceId } = req.params;
+ const userId = (req as any).user?._id;
+
+ const polls = await spacePollService.getSpacePolls(spaceId, userId);
+ res.status(200).json({ success: true, polls });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const getPollResults = async (req: Request, res: Response) => {
+ try {
+ const { pollId } = req.params;
+ const userId = (req as any).user?._id;
+
+ const poll = await spacePollService.getPollResults(pollId, userId);
+ res.status(200).json({ success: true, poll });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const voteInPoll = async (req: Request, res: Response) => {
+ try {
+ const { pollId } = req.params;
+ const { optionIndex } = req.body;
+ const userId = (req as any).user._id;
+
+ if (optionIndex === undefined || optionIndex === null) {
+ return res.status(400).json({ success: false, message: "optionIndex is required" });
+ }
+
+ const updatedPoll = await spacePollService.voteInPoll({
+ pollId,
+ userId,
+ optionIndex: Number(optionIndex),
+ });
+
+ res.status(200).json({ success: true, poll: updatedPoll });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const closePoll = async (req: Request, res: Response) => {
+ try {
+ const { pollId } = req.params;
+ const hostId = (req as any).user._id;
+
+ const closedPoll = await spacePollService.closePoll({
+ pollId,
+ hostId,
+ });
+
+ res.status(200).json({ success: true, poll: closedPoll });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export const exportPollResults = async (req: Request, res: Response) => {
+ try {
+ const { pollId } = req.params;
+ const format = req.query.format as string;
+
+ const exportData = await spacePollService.exportPollResults(pollId);
+
+ if (format === "csv") {
+ res.setHeader("Content-Type", "text/csv");
+ res.setHeader("Content-Disposition", `attachment; filename="poll-${pollId}-results.csv"`);
+ return res.status(200).send(exportData.csvData);
+ }
+
+ res.status(200).json({ success: true, export: exportData });
+ } catch (error: any) {
+ res.status(400).json({ success: false, message: error.message });
+ }
+};
+
+export default {
+ createPoll,
+ getSpacePolls,
+ getPollResults,
+ voteInPoll,
+ closePoll,
+ exportPollResults,
+};
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/analyticsController.js b/src/controllers/stellar/analyticsController.js
new file mode 100644
index 00000000..cc389d18
--- /dev/null
+++ b/src/controllers/stellar/analyticsController.js
@@ -0,0 +1,114 @@
+// controllers/stellar/analyticsController.js
+import {
+ getPaymentAnalytics,
+ getSummaryAnalytics,
+ getTimeSeriesAnalytics,
+ DEFAULT_PERIOD,
+} from "../../services/stellar/analyticsService.js";
+import logger from "../../config/logger.js";
+
+/**
+ * Analytics controllers for the payment dashboard.
+ *
+ * Each handler translates validated query parameters into service filters and
+ * returns aggregated statistics. Validation of the query shape is performed by
+ * the route's express-validator chain before these run.
+ *
+ * @module controllers/stellar/analyticsController
+ */
+
+/**
+ * Pull whitelisted analytics filters off the request query.
+ * @param {import("express").Request} req Express request.
+ * @returns {import("../../services/stellar/analyticsService.js").AnalyticsFilters}
+ */
+const extractFilters = (req) => {
+ const { status, type, currency, buyerId, creatorId, startDate, endDate } =
+ req.query;
+ return { status, type, currency, buyerId, creatorId, startDate, endDate };
+};
+
+/**
+ * GET /api/stellar/analytics
+ * Combined per-asset summary plus a time series bucketed by `period`.
+ * @param {import("express").Request} req Express request.
+ * @param {import("express").Response} res Express response.
+ */
+export const getAnalyticsOverview = async (req, res) => {
+ try {
+ const period = req.query.period || DEFAULT_PERIOD;
+ const filters = extractFilters(req);
+
+ const data = await getPaymentAnalytics({ period, ...filters });
+
+ res.status(200).json({
+ success: true,
+ ...data,
+ });
+ } catch (error) {
+ logger.error("Get analytics overview error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch payment analytics",
+ });
+ }
+};
+
+/**
+ * GET /api/stellar/analytics/summary
+ * Per-asset totals (volume, count, average) with no time bucketing.
+ * @param {import("express").Request} req Express request.
+ * @param {import("express").Response} res Express response.
+ */
+export const getAnalyticsSummary = async (req, res) => {
+ try {
+ const filters = extractFilters(req);
+ const summary = await getSummaryAnalytics(filters);
+
+ res.status(200).json({
+ success: true,
+ filters,
+ summary,
+ });
+ } catch (error) {
+ logger.error("Get analytics summary error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch payment analytics summary",
+ });
+ }
+};
+
+/**
+ * GET /api/stellar/analytics/timeseries
+ * Statistics bucketed by `period` (day|week|month|year) and asset.
+ * @param {import("express").Request} req Express request.
+ * @param {import("express").Response} res Express response.
+ */
+export const getAnalyticsTimeSeries = async (req, res) => {
+ try {
+ const period = req.query.period || DEFAULT_PERIOD;
+ const filters = extractFilters(req);
+
+ const series = await getTimeSeriesAnalytics({ period, ...filters });
+
+ res.status(200).json({
+ success: true,
+ period,
+ filters,
+ series,
+ });
+ } catch (error) {
+ logger.error("Get analytics time series error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch payment analytics time series",
+ });
+ }
+};
+
+export default {
+ getAnalyticsOverview,
+ getAnalyticsSummary,
+ getAnalyticsTimeSeries,
+};
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/onrampController.js b/src/controllers/stellar/onrampController.js
new file mode 100644
index 00000000..89dd0cc4
--- /dev/null
+++ b/src/controllers/stellar/onrampController.js
@@ -0,0 +1,220 @@
+// controllers/stellar/onrampController.js
+import OnrampTransaction from "../../models/OnrampTransaction.js";
+import {
+ buildWidgetUrl,
+ isOnrampConfigured,
+ verifyWebhookSignature,
+ mapProviderStatus,
+} from "../../services/stellar/onrampService.js";
+import logger from "../../config/logger.js";
+
+/**
+ * Create a fiat on-ramp widget session for the authenticated user.
+ *
+ * Persists a `created` OnrampTransaction linked to the user, then returns a
+ * signed MoonPay widget URL with the user's wallet address pre-filled. The
+ * record id is passed to MoonPay as `externalTransactionId` so later webhooks
+ * can be matched back to the originating record.
+ *
+ * POST /api/stellar/onramp/session
+ *
+ * @param {import("express").Request} req Authenticated request (`req.user`).
+ * @param {import("express").Response} res
+ * @returns {Promise}
+ */
+export const createOnrampSession = async (req, res) => {
+ try {
+ if (!isOnrampConfigured()) {
+ return res.status(503).json({
+ success: false,
+ message: "Fiat on-ramp is not configured",
+ });
+ }
+
+ const userId = req.user._id;
+ const { walletAddress, cryptoCurrency, fiatCurrency, fiatAmount, redirectUrl } =
+ req.body;
+
+ if (!walletAddress) {
+ return res.status(400).json({
+ success: false,
+ message: "walletAddress is required",
+ });
+ }
+
+ // Create the tracking record first so its id can be embedded in the widget
+ // URL as the provider's externalTransactionId.
+ const record = new OnrampTransaction({
+ user: userId,
+ walletAddress,
+ provider: "moonpay",
+ status: "created",
+ cryptoCurrency: cryptoCurrency ? cryptoCurrency.toLowerCase() : undefined,
+ fiatCurrency: fiatCurrency ? fiatCurrency.toLowerCase() : undefined,
+ fiatAmount: fiatAmount !== undefined ? String(fiatAmount) : undefined,
+ });
+
+ let widget;
+ try {
+ widget = buildWidgetUrl({
+ walletAddress,
+ cryptoCurrency,
+ baseCurrencyCode: fiatCurrency,
+ baseCurrencyAmount: fiatAmount,
+ externalTransactionId: record._id.toString(),
+ email: req.user.email,
+ redirectUrl,
+ });
+ } catch (buildError) {
+ // Invalid wallet / unconfigured — surface without persisting the record.
+ return res.status(buildError.statusCode || 400).json({
+ success: false,
+ message: buildError.message,
+ });
+ }
+
+ record.cryptoCurrency = widget.cryptoCurrency;
+ await record.save();
+
+ logger.info(`On-ramp session created: ${record._id} for user ${userId}`);
+
+ res.status(201).json({
+ success: true,
+ onrampId: record._id,
+ provider: "moonpay",
+ widgetUrl: widget.url,
+ status: record.status,
+ });
+ } catch (error) {
+ logger.error("Create on-ramp session error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to create on-ramp session",
+ error: process.env.NODE_ENV === "development" ? error.message : undefined,
+ });
+ }
+};
+
+/**
+ * List the authenticated user's on-ramp transactions, most recent first.
+ *
+ * GET /api/stellar/onramp/transactions
+ *
+ * @param {import("express").Request} req Authenticated request (`req.user`).
+ * @param {import("express").Response} res
+ * @returns {Promise}
+ */
+export const getOnrampTransactions = async (req, res) => {
+ try {
+ const userId = req.user._id;
+ const transactions = await OnrampTransaction.find({ user: userId })
+ .sort({ createdAt: -1 })
+ .limit(50)
+ .select(
+ "provider status providerStatus cryptoCurrency fiatCurrency fiatAmount cryptoAmount cryptoTransactionHash walletAddress createdAt completedAt"
+ );
+
+ res.status(200).json({
+ success: true,
+ count: transactions.length,
+ transactions,
+ });
+ } catch (error) {
+ logger.error("Get on-ramp transactions error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch on-ramp transactions",
+ });
+ }
+};
+
+/**
+ * Handle inbound MoonPay webhooks for on-ramp status updates.
+ *
+ * Public endpoint: authenticity is established by verifying the provider HMAC
+ * signature over the raw request body (`req.rawBody`, captured globally in
+ * app.js). The matching OnrampTransaction is located by `externalTransactionId`
+ * (our record id) or the provider transaction id, then updated in place.
+ *
+ * POST /api/stellar/onramp/webhook
+ *
+ * @param {import("express").Request} req Public request; `req.rawBody` is the
+ * exact bytes of the body used for signature verification.
+ * @param {import("express").Response} res
+ * @returns {Promise}
+ */
+export const handleWebhook = async (req, res) => {
+ try {
+ const signatureHeader =
+ req.get("Moonpay-Signature-V2") || req.get("moonpay-signature-v2");
+
+ const valid = verifyWebhookSignature(req.rawBody, signatureHeader);
+ if (!valid) {
+ logger.warn("Rejected on-ramp webhook: invalid signature");
+ return res.status(401).json({
+ success: false,
+ message: "Invalid webhook signature",
+ });
+ }
+
+ const data = req.body?.data || {};
+ const providerStatus = data.status;
+ const externalTransactionId = data.externalTransactionId;
+ const providerTransactionId = data.id;
+
+ // Locate the originating record: prefer our own id, fall back to the
+ // provider id for events that omit externalTransactionId.
+ let record = null;
+ if (externalTransactionId) {
+ record = await OnrampTransaction.findById(externalTransactionId).catch(
+ () => null
+ );
+ }
+ if (!record && providerTransactionId) {
+ record = await OnrampTransaction.findOne({ providerTransactionId });
+ }
+
+ if (!record) {
+ // Acknowledge to stop provider retries; nothing to update on our side.
+ logger.warn(
+ `On-ramp webhook for unknown transaction (external=${externalTransactionId}, provider=${providerTransactionId})`
+ );
+ return res.status(200).json({ success: true, matched: false });
+ }
+
+ if (providerTransactionId) record.providerTransactionId = providerTransactionId;
+ if (providerStatus) {
+ record.providerStatus = providerStatus;
+ record.status = mapProviderStatus(providerStatus);
+ }
+ if (data.cryptoTransactionId) {
+ record.cryptoTransactionHash = data.cryptoTransactionId;
+ }
+ if (data.quoteCurrencyAmount !== undefined && data.quoteCurrencyAmount !== null) {
+ record.cryptoAmount = String(data.quoteCurrencyAmount);
+ }
+ if (data.baseCurrencyAmount !== undefined && data.baseCurrencyAmount !== null) {
+ record.fiatAmount = String(data.baseCurrencyAmount);
+ }
+ if (data.failureReason) {
+ record.failureReason = data.failureReason;
+ }
+ if (record.status === "completed" && !record.completedAt) {
+ record.completedAt = new Date();
+ }
+
+ await record.save();
+
+ logger.info(
+ `On-ramp webhook applied: ${record._id} -> ${record.status} (${providerStatus})`
+ );
+
+ res.status(200).json({ success: true, matched: true });
+ } catch (error) {
+ logger.error("On-ramp webhook error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to process on-ramp webhook",
+ });
+ }
+};
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/userController.js b/src/controllers/userController.js
index f462e1aa..49c54400 100644
--- a/src/controllers/userController.js
+++ b/src/controllers/userController.js
@@ -6,6 +6,7 @@ import logger from "../config/logger.js";
import { validateMagicBytes } from "../utils/fileValidation.js";
import CourseProgress from "../models/CourseProgress.js";
import { createFollowNotification, createUnfollowNotification } from "./notificationController.js";
+import badgeService from "../services/badge.service.js";
const PUBLIC_FIELDS = "name avatar bio role interests gender age country language";
@@ -130,9 +131,18 @@ export const getUser = async (req, res) => {
message: "User not found",
});
}
+
+ const userObj = typeof user.toObject === "function" ? user.toObject() : { ...user };
+ try {
+ const badges = await badgeService.getUserBadges(user._id);
+ userObj.badges = badges;
+ } catch (_err) {
+ userObj.badges = [];
+ }
+
res.status(200).json({
success: true,
- user,
+ user: userObj,
});
} catch (error) {
logger.error("Get user error:", 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..eee7b6cd 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
},
@@ -31,6 +32,11 @@ const courseSchema = new mongoose.Schema(
default: "USDC",
enum: getSupportedCodes(),
},
+ views: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
rating: {
type: Number,
default: 0,
@@ -73,6 +79,11 @@ const courseSchema = new mongoose.Schema(
required: true,
},
enrolledUsers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }],
+ // Courses a learner must COMPLETE before they can enroll in this course.
+ prerequisites: {
+ type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Course" }],
+ default: [],
+ },
sections: [
{
title: String,
@@ -95,4 +106,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/OnrampTransaction.js b/src/models/OnrampTransaction.js
new file mode 100644
index 00000000..08782aa2
--- /dev/null
+++ b/src/models/OnrampTransaction.js
@@ -0,0 +1,103 @@
+// models/OnrampTransaction.js
+import mongoose from "mongoose";
+
+/**
+ * Internal on-ramp lifecycle statuses.
+ *
+ * These are provider-agnostic. Raw provider statuses (e.g. MoonPay's
+ * `waitingPayment`, `pending`, `completed`, `failed`) are normalized into this
+ * set by {@link module:services/stellar/onrampService.mapProviderStatus} and the
+ * original provider value is preserved in `providerStatus` for audit.
+ *
+ * @constant {string[]}
+ */
+export const ONRAMP_STATUSES = [
+ "created",
+ "pending",
+ "completed",
+ "failed",
+];
+
+/**
+ * Tracks a fiat-to-crypto on-ramp purchase initiated through a third-party
+ * provider (MoonPay). Each record links a widget session to the user account
+ * that started it and is updated as provider webhooks report status changes.
+ *
+ * A dedicated collection is used (rather than the shared `Transaction` model)
+ * because on-ramp purchases are settled entirely by the provider and do not
+ * carry the buyer/creator/item shape of on-chain marketplace transactions.
+ */
+const onrampTransactionSchema = new mongoose.Schema(
+ {
+ // User account that initiated the on-ramp purchase.
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ // Stellar public key the purchased asset is delivered to (pre-filled in
+ // the widget URL).
+ walletAddress: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ // On-ramp provider used for this session.
+ provider: {
+ type: String,
+ enum: ["moonpay"],
+ default: "moonpay",
+ index: true,
+ },
+ // Provider-side transaction identifier, populated from webhook payloads.
+ // Sparse because it is unknown until the first webhook arrives.
+ providerTransactionId: {
+ type: String,
+ sparse: true,
+ index: true,
+ },
+ // Normalized lifecycle status (see ONRAMP_STATUSES).
+ status: {
+ type: String,
+ enum: ONRAMP_STATUSES,
+ default: "created",
+ index: true,
+ },
+ // Raw provider status string, kept verbatim for audit/debugging.
+ providerStatus: {
+ type: String,
+ },
+ // Crypto asset the user is buying (e.g. "usdc", "xlm").
+ cryptoCurrency: {
+ type: String,
+ },
+ // Fiat currency used to pay (e.g. "usd", "eur").
+ fiatCurrency: {
+ type: String,
+ },
+ // Fiat amount charged. Stored as a string to preserve precision.
+ fiatAmount: {
+ type: String,
+ },
+ // Crypto amount delivered, once known. Stored as a string for precision.
+ cryptoAmount: {
+ type: String,
+ },
+ // On-chain settlement hash reported by the provider when the crypto is sent.
+ cryptoTransactionHash: {
+ type: String,
+ },
+ // Reason recorded when the provider reports a failed purchase.
+ failureReason: {
+ type: String,
+ },
+ completedAt: Date,
+ },
+ { timestamps: true }
+);
+
+// Common lookup: a user's on-ramp history, most recent first.
+onrampTransactionSchema.index({ user: 1, createdAt: -1 });
+
+export default mongoose.model("OnrampTransaction", onrampTransactionSchema);
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/ReadingProgress.js b/src/models/ReadingProgress.js
new file mode 100644
index 00000000..f88f5a32
--- /dev/null
+++ b/src/models/ReadingProgress.js
@@ -0,0 +1,75 @@
+import mongoose from "mongoose";
+
+/**
+ * ReadingProgress
+ *
+ * Stores a single reading-progress record per user + book combination so that
+ * progress can be resumed and synced across a user's devices. Mirrors the
+ * CourseProgress model style: ObjectId refs, a unique compound index and
+ * mongoose timestamps (createdAt / updatedAt).
+ */
+const readingProgressSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ // Current page the reader is on (0-based / 1-based is up to the client).
+ page: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
+ // Total number of pages, when the client knows it. Used to derive a
+ // percentage on the fly if the client only reports the page.
+ totalPages: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
+ // Completion percentage, 0-100.
+ percentage: {
+ type: Number,
+ default: 0,
+ min: 0,
+ max: 100,
+ },
+ // Opaque resume token (e.g. an EPUB CFI or PDF locator) so the reader can
+ // resume from the exact last position, not just the page number.
+ lastPosition: {
+ type: String,
+ default: "",
+ },
+ // Identifier of the device that last wrote progress. Lets a client ignore
+ // echoes of its own updates when syncing across devices.
+ device: {
+ type: String,
+ default: "",
+ },
+ // Monotonic version bumped on every update. A client can poll the library
+ // endpoint and compare versions (or updatedAt) to detect changes made on
+ // another device without needing a live socket connection.
+ version: {
+ type: Number,
+ default: 0,
+ },
+ completedAt: {
+ type: Date,
+ default: null,
+ },
+ },
+ { timestamps: true }
+);
+
+// One progress record per user + book combination.
+readingProgressSchema.index({ user: 1, book: 1 }, { unique: true });
+
+export default mongoose.model("ReadingProgress", readingProgressSchema);
diff --git a/src/models/Reel.js b/src/models/Reel.js
index cb696516..faef2831 100644
--- a/src/models/Reel.js
+++ b/src/models/Reel.js
@@ -15,6 +15,7 @@ const commentSchema = new Schema(
const reelSchema = new Schema(
{
+ title: { type: String, trim: true },
description: { type: String, required: true, trim: true, maxlength: 2000 },
category: { type: String, trim: true },
tags: [{ type: String, trim: true }],
@@ -22,11 +23,40 @@ const reelSchema = new Schema(
videoPublicId: { type: String },
thumbnail: { type: String },
duration: { type: Number },
+ status: { type: String, enum: ["active", "pending", "removed"], default: "active" },
+ isRemoved: { type: Boolean, default: false },
createdBy: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
},
+ // Duet / stitch linkage. A derivative reel is a response video linked to an
+ // original reel. `originalReelId` is null for a normal (top-level) reel.
+ originalReelId: {
+ type: Schema.Types.ObjectId,
+ ref: "Reel",
+ default: null,
+ },
+ // `duet` = response shown side-by-side with the original.
+ // `stitch` = play a portion of the original, then the user's response.
+ duetType: {
+ type: String,
+ enum: ["duet", "stitch"],
+ default: null,
+ },
+ // For a stitch, the [start, end] portion (in seconds) of the original that
+ // is prepended before the response video plays.
+ stitchClip: {
+ start: { type: Number, min: 0 },
+ end: { type: Number, min: 0 },
+ },
+ // Compositing descriptor produced by utils/videoCompositor.js. Records the
+ // compositing intent/metadata; actual frame compositing is delegated to the
+ // media pipeline.
+ composition: { type: Schema.Types.Mixed, default: null },
+ // Derivative counts surfaced on the original reel.
+ duetCount: { type: Number, default: 0 },
+ stitchCount: { type: Number, default: 0 },
likes: [{ type: Schema.Types.ObjectId, ref: "User" }],
loves: [{ type: Schema.Types.ObjectId, ref: "User" }],
comments: [commentSchema],
@@ -38,6 +68,8 @@ const reelSchema = new Schema(
reelSchema.index({ createdAt: -1 });
reelSchema.index({ description: "text" });
+// Browse all duets/stitches for a given reel, newest first.
+reelSchema.index({ originalReelId: 1, createdAt: -1 });
reelSchema.virtual("likeCount").get(function () {
return this.likes?.length || 0;
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..23f9cb22 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -7,10 +7,24 @@ 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 +128,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 +173,43 @@ 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;
+ }
+ 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/models/badge.model.js b/src/models/badge.model.js
new file mode 100644
index 00000000..4c41de18
--- /dev/null
+++ b/src/models/badge.model.js
@@ -0,0 +1,41 @@
+import mongoose from "mongoose";
+
+const badgeSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ },
+ category: {
+ type: String,
+ default: "milestone",
+ },
+ criteriaType: {
+ type: String,
+ required: true,
+ enum: ["courses_completed", "category_completed", "custom"],
+ },
+ threshold: {
+ type: Number,
+ default: 1,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("Badge", badgeSchema);
diff --git a/src/models/badge.model.ts b/src/models/badge.model.ts
new file mode 100644
index 00000000..4c41de18
--- /dev/null
+++ b/src/models/badge.model.ts
@@ -0,0 +1,41 @@
+import mongoose from "mongoose";
+
+const badgeSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ },
+ category: {
+ type: String,
+ default: "milestone",
+ },
+ criteriaType: {
+ type: String,
+ required: true,
+ enum: ["courses_completed", "category_completed", "custom"],
+ },
+ threshold: {
+ type: Number,
+ default: 1,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("Badge", badgeSchema);
diff --git a/src/models/certificate.model.js b/src/models/certificate.model.js
new file mode 100644
index 00000000..fa3aac2b
--- /dev/null
+++ b/src/models/certificate.model.js
@@ -0,0 +1,53 @@
+import mongoose from "mongoose";
+
+const certificateSchema = new mongoose.Schema(
+ {
+ certificateId: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ course: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Course",
+ required: true,
+ index: true,
+ },
+ learnerName: {
+ type: String,
+ required: true,
+ },
+ courseTitle: {
+ type: String,
+ required: true,
+ },
+ completionDate: {
+ type: Date,
+ default: Date.now,
+ },
+ instructorName: {
+ type: String,
+ default: "DeenBridge Instructor",
+ },
+ instructorSignature: {
+ type: String,
+ default: "DeenBridge Academy",
+ },
+ certificateUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ { timestamps: true }
+);
+
+certificateSchema.index({ user: 1, course: 1 }, { unique: true });
+
+export default mongoose.model("Certificate", certificateSchema);
diff --git a/src/models/certificate.model.ts b/src/models/certificate.model.ts
new file mode 100644
index 00000000..fa3aac2b
--- /dev/null
+++ b/src/models/certificate.model.ts
@@ -0,0 +1,53 @@
+import mongoose from "mongoose";
+
+const certificateSchema = new mongoose.Schema(
+ {
+ certificateId: {
+ type: String,
+ required: true,
+ unique: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ course: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Course",
+ required: true,
+ index: true,
+ },
+ learnerName: {
+ type: String,
+ required: true,
+ },
+ courseTitle: {
+ type: String,
+ required: true,
+ },
+ completionDate: {
+ type: Date,
+ default: Date.now,
+ },
+ instructorName: {
+ type: String,
+ default: "DeenBridge Instructor",
+ },
+ instructorSignature: {
+ type: String,
+ default: "DeenBridge Academy",
+ },
+ certificateUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ { timestamps: true }
+);
+
+certificateSchema.index({ user: 1, course: 1 }, { unique: true });
+
+export default mongoose.model("Certificate", certificateSchema);
diff --git a/src/models/content-flag.model.js b/src/models/content-flag.model.js
new file mode 100644
index 00000000..26bb977a
--- /dev/null
+++ b/src/models/content-flag.model.js
@@ -0,0 +1,40 @@
+import mongoose from "mongoose";
+
+const contentFlagSchema = new mongoose.Schema(
+ {
+ reel: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Reel",
+ required: true,
+ index: true,
+ },
+ reporter: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ index: true,
+ },
+ reason: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ details: {
+ type: String,
+ trim: true,
+ },
+ status: {
+ type: String,
+ enum: ["pending", "approved", "rejected", "removed"],
+ default: "pending",
+ index: true,
+ },
+ isAutoFlagged: {
+ type: Boolean,
+ default: false,
+ },
+ flaggedKeywords: [{ type: String }],
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("ContentFlag", contentFlagSchema);
diff --git a/src/models/content-flag.model.ts b/src/models/content-flag.model.ts
new file mode 100644
index 00000000..26bb977a
--- /dev/null
+++ b/src/models/content-flag.model.ts
@@ -0,0 +1,40 @@
+import mongoose from "mongoose";
+
+const contentFlagSchema = new mongoose.Schema(
+ {
+ reel: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Reel",
+ required: true,
+ index: true,
+ },
+ reporter: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ index: true,
+ },
+ reason: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ details: {
+ type: String,
+ trim: true,
+ },
+ status: {
+ type: String,
+ enum: ["pending", "approved", "rejected", "removed"],
+ default: "pending",
+ index: true,
+ },
+ isAutoFlagged: {
+ type: Boolean,
+ default: false,
+ },
+ flaggedKeywords: [{ type: String }],
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("ContentFlag", contentFlagSchema);
diff --git a/src/models/conversation.model.ts b/src/models/conversation.model.ts
new file mode 100644
index 00000000..c84b119f
--- /dev/null
+++ b/src/models/conversation.model.ts
@@ -0,0 +1,34 @@
+import mongoose from "mongoose";
+
+const conversationSchema = new mongoose.Schema(
+ {
+ participants: {
+ type: [
+ {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ },
+ ],
+ required: true,
+ validate: [
+ (val: any[]) => val && val.length === 2,
+ "A conversation must have exactly 2 participants",
+ ],
+ },
+ lastMessage: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Message",
+ },
+ lastMessageAt: {
+ type: Date,
+ default: Date.now,
+ index: true,
+ },
+ },
+ { timestamps: true }
+);
+
+conversationSchema.index({ participants: 1 });
+conversationSchema.index({ lastMessageAt: -1 });
+
+export default mongoose.model("Conversation", conversationSchema);
diff --git a/src/models/course-bundle.model.js b/src/models/course-bundle.model.js
new file mode 100644
index 00000000..d874329d
--- /dev/null
+++ b/src/models/course-bundle.model.js
@@ -0,0 +1,55 @@
+import mongoose from "mongoose";
+
+const courseBundleSchema = new mongoose.Schema(
+ {
+ title: {
+ type: String,
+ required: [true, "Title is required"],
+ trim: true,
+ },
+ description: {
+ type: String,
+ required: [true, "Description is required"],
+ },
+ courses: [
+ {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Course",
+ required: true,
+ },
+ ],
+ price: {
+ type: Number,
+ required: [true, "Price is required"],
+ min: [0, "Price cannot be negative"],
+ },
+ currency: {
+ type: String,
+ default: "USDC",
+ },
+ originalPrice: {
+ type: Number,
+ default: 0,
+ },
+ discountPercentage: {
+ type: Number,
+ default: 0,
+ },
+ createdBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ isActive: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ { timestamps: true }
+);
+
+courseBundleSchema.index({ createdBy: 1 });
+courseBundleSchema.index({ courses: 1 });
+courseBundleSchema.index({ title: "text", description: "text" });
+
+export default mongoose.model("CourseBundle", courseBundleSchema);
diff --git a/src/models/course-bundle.model.ts b/src/models/course-bundle.model.ts
new file mode 100644
index 00000000..d874329d
--- /dev/null
+++ b/src/models/course-bundle.model.ts
@@ -0,0 +1,55 @@
+import mongoose from "mongoose";
+
+const courseBundleSchema = new mongoose.Schema(
+ {
+ title: {
+ type: String,
+ required: [true, "Title is required"],
+ trim: true,
+ },
+ description: {
+ type: String,
+ required: [true, "Description is required"],
+ },
+ courses: [
+ {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Course",
+ required: true,
+ },
+ ],
+ price: {
+ type: Number,
+ required: [true, "Price is required"],
+ min: [0, "Price cannot be negative"],
+ },
+ currency: {
+ type: String,
+ default: "USDC",
+ },
+ originalPrice: {
+ type: Number,
+ default: 0,
+ },
+ discountPercentage: {
+ type: Number,
+ default: 0,
+ },
+ createdBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ isActive: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ { timestamps: true }
+);
+
+courseBundleSchema.index({ createdBy: 1 });
+courseBundleSchema.index({ courses: 1 });
+courseBundleSchema.index({ title: "text", description: "text" });
+
+export default mongoose.model("CourseBundle", courseBundleSchema);
diff --git a/src/models/highlight.model.js b/src/models/highlight.model.js
new file mode 100644
index 00000000..9943bf98
--- /dev/null
+++ b/src/models/highlight.model.js
@@ -0,0 +1,43 @@
+import mongoose from "mongoose";
+
+const highlightSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ color: {
+ type: String,
+ enum: ["yellow", "blue", "green", "pink", "purple", "orange"],
+ default: "yellow",
+ },
+ pageNumber: {
+ type: Number,
+ },
+ passage: {
+ type: String,
+ },
+ cfiRange: {
+ type: String,
+ },
+ },
+ { timestamps: true }
+);
+
+highlightSchema.index({ user: 1, book: 1 });
+highlightSchema.index({ text: "text", passage: "text" });
+
+export default mongoose.model("Highlight", highlightSchema);
diff --git a/src/models/highlight.model.ts b/src/models/highlight.model.ts
new file mode 100644
index 00000000..9943bf98
--- /dev/null
+++ b/src/models/highlight.model.ts
@@ -0,0 +1,43 @@
+import mongoose from "mongoose";
+
+const highlightSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ color: {
+ type: String,
+ enum: ["yellow", "blue", "green", "pink", "purple", "orange"],
+ default: "yellow",
+ },
+ pageNumber: {
+ type: Number,
+ },
+ passage: {
+ type: String,
+ },
+ cfiRange: {
+ type: String,
+ },
+ },
+ { timestamps: true }
+);
+
+highlightSchema.index({ user: 1, book: 1 });
+highlightSchema.index({ text: "text", passage: "text" });
+
+export default mongoose.model("Highlight", highlightSchema);
diff --git a/src/models/message.model.ts b/src/models/message.model.ts
new file mode 100644
index 00000000..6d448666
--- /dev/null
+++ b/src/models/message.model.ts
@@ -0,0 +1,39 @@
+import mongoose from "mongoose";
+
+const messageSchema = new mongoose.Schema(
+ {
+ conversation: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Conversation",
+ required: true,
+ index: true,
+ },
+ sender: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ text: {
+ type: String,
+ trim: true,
+ default: "",
+ },
+ image: {
+ type: String,
+ },
+ readBy: {
+ type: [
+ {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ },
+ ],
+ default: [],
+ },
+ },
+ { timestamps: true }
+);
+
+messageSchema.index({ conversation: 1, createdAt: -1 });
+
+export default mongoose.model("Message", messageSchema);
diff --git a/src/models/moderation-action.model.js b/src/models/moderation-action.model.js
new file mode 100644
index 00000000..387cfa9b
--- /dev/null
+++ b/src/models/moderation-action.model.js
@@ -0,0 +1,35 @@
+import mongoose from "mongoose";
+
+const moderationActionSchema = new mongoose.Schema(
+ {
+ flag: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "ContentFlag",
+ index: true,
+ },
+ reel: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Reel",
+ required: true,
+ index: true,
+ },
+ admin: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ action: {
+ type: String,
+ enum: ["approve", "reject", "remove"],
+ required: true,
+ },
+ notes: {
+ type: String,
+ trim: true,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("ModerationAction", moderationActionSchema);
diff --git a/src/models/moderation-action.model.ts b/src/models/moderation-action.model.ts
new file mode 100644
index 00000000..387cfa9b
--- /dev/null
+++ b/src/models/moderation-action.model.ts
@@ -0,0 +1,35 @@
+import mongoose from "mongoose";
+
+const moderationActionSchema = new mongoose.Schema(
+ {
+ flag: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "ContentFlag",
+ index: true,
+ },
+ reel: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Reel",
+ required: true,
+ index: true,
+ },
+ admin: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ action: {
+ type: String,
+ enum: ["approve", "reject", "remove"],
+ required: true,
+ },
+ notes: {
+ type: String,
+ trim: true,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("ModerationAction", moderationActionSchema);
diff --git a/src/models/note.model.js b/src/models/note.model.js
new file mode 100644
index 00000000..1ea9f2ab
--- /dev/null
+++ b/src/models/note.model.js
@@ -0,0 +1,40 @@
+import mongoose from "mongoose";
+
+const noteSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ highlight: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Highlight",
+ index: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ pageNumber: {
+ type: Number,
+ },
+ passage: {
+ type: String,
+ },
+ },
+ { timestamps: true }
+);
+
+noteSchema.index({ user: 1, book: 1 });
+noteSchema.index({ content: "text", passage: "text" });
+
+export default mongoose.model("Note", noteSchema);
diff --git a/src/models/note.model.ts b/src/models/note.model.ts
new file mode 100644
index 00000000..1ea9f2ab
--- /dev/null
+++ b/src/models/note.model.ts
@@ -0,0 +1,40 @@
+import mongoose from "mongoose";
+
+const noteSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ highlight: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Highlight",
+ index: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ pageNumber: {
+ type: Number,
+ },
+ passage: {
+ type: String,
+ },
+ },
+ { timestamps: true }
+);
+
+noteSchema.index({ user: 1, book: 1 });
+noteSchema.index({ content: "text", passage: "text" });
+
+export default mongoose.model("Note", noteSchema);
diff --git a/src/models/poll-vote.model.js b/src/models/poll-vote.model.js
new file mode 100644
index 00000000..42b8be09
--- /dev/null
+++ b/src/models/poll-vote.model.js
@@ -0,0 +1,33 @@
+import mongoose from "mongoose";
+
+const pollVoteSchema = new mongoose.Schema(
+ {
+ poll: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "SpacePoll",
+ required: true,
+ index: true,
+ },
+ space: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Space",
+ required: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ optionIndex: {
+ type: Number,
+ required: true,
+ },
+ },
+ { timestamps: true }
+);
+
+pollVoteSchema.index({ poll: 1, user: 1 }, { unique: true });
+
+export default mongoose.model("PollVote", pollVoteSchema);
diff --git a/src/models/poll-vote.model.ts b/src/models/poll-vote.model.ts
new file mode 100644
index 00000000..42b8be09
--- /dev/null
+++ b/src/models/poll-vote.model.ts
@@ -0,0 +1,33 @@
+import mongoose from "mongoose";
+
+const pollVoteSchema = new mongoose.Schema(
+ {
+ poll: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "SpacePoll",
+ required: true,
+ index: true,
+ },
+ space: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Space",
+ required: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ optionIndex: {
+ type: Number,
+ required: true,
+ },
+ },
+ { timestamps: true }
+);
+
+pollVoteSchema.index({ poll: 1, user: 1 }, { unique: true });
+
+export default mongoose.model("PollVote", pollVoteSchema);
diff --git a/src/models/reading-group-member.model.js b/src/models/reading-group-member.model.js
new file mode 100644
index 00000000..47fb30e6
--- /dev/null
+++ b/src/models/reading-group-member.model.js
@@ -0,0 +1,45 @@
+import mongoose from "mongoose";
+
+const readingGroupMemberSchema = new mongoose.Schema(
+ {
+ group: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "ReadingGroup",
+ required: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ role: {
+ type: String,
+ enum: ["admin", "member"],
+ default: "member",
+ },
+ status: {
+ type: String,
+ enum: ["active", "invited", "pending"],
+ default: "active",
+ },
+ currentChapter: {
+ type: Number,
+ default: 1,
+ },
+ currentProgressPercent: {
+ type: Number,
+ default: 0,
+ },
+ lastReadDate: {
+ type: Date,
+ default: Date.now,
+ },
+ },
+ { timestamps: true }
+);
+
+readingGroupMemberSchema.index({ group: 1, user: 1 }, { unique: true });
+
+export default mongoose.model("ReadingGroupMember", readingGroupMemberSchema);
diff --git a/src/models/reading-group-member.model.ts b/src/models/reading-group-member.model.ts
new file mode 100644
index 00000000..47fb30e6
--- /dev/null
+++ b/src/models/reading-group-member.model.ts
@@ -0,0 +1,45 @@
+import mongoose from "mongoose";
+
+const readingGroupMemberSchema = new mongoose.Schema(
+ {
+ group: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "ReadingGroup",
+ required: true,
+ index: true,
+ },
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ role: {
+ type: String,
+ enum: ["admin", "member"],
+ default: "member",
+ },
+ status: {
+ type: String,
+ enum: ["active", "invited", "pending"],
+ default: "active",
+ },
+ currentChapter: {
+ type: Number,
+ default: 1,
+ },
+ currentProgressPercent: {
+ type: Number,
+ default: 0,
+ },
+ lastReadDate: {
+ type: Date,
+ default: Date.now,
+ },
+ },
+ { timestamps: true }
+);
+
+readingGroupMemberSchema.index({ group: 1, user: 1 }, { unique: true });
+
+export default mongoose.model("ReadingGroupMember", readingGroupMemberSchema);
diff --git a/src/models/reading-group.model.js b/src/models/reading-group.model.js
new file mode 100644
index 00000000..f2274dc0
--- /dev/null
+++ b/src/models/reading-group.model.js
@@ -0,0 +1,63 @@
+import mongoose from "mongoose";
+
+const scheduleItemSchema = new mongoose.Schema(
+ {
+ chapter: { type: Number, required: true },
+ title: { type: String, default: "" },
+ targetPages: { type: String, default: "" },
+ startDate: { type: Date },
+ endDate: { type: Date },
+ },
+ { _id: false }
+);
+
+const discussionPostSchema = new mongoose.Schema(
+ {
+ chapter: { type: Number, required: true },
+ user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
+ content: { type: String, required: true, trim: true },
+ createdAt: { type: Date, default: Date.now },
+ }
+);
+
+const readingGroupSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ description: {
+ type: String,
+ trim: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ creator: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ privacy: {
+ type: String,
+ enum: ["public", "private"],
+ default: "public",
+ },
+ chaptersPerWeek: {
+ type: Number,
+ default: 1,
+ },
+ readingSchedule: [scheduleItemSchema],
+ discussions: [discussionPostSchema],
+ },
+ { timestamps: true }
+);
+
+readingGroupSchema.index({ name: "text", description: "text" });
+
+export default mongoose.model("ReadingGroup", readingGroupSchema);
diff --git a/src/models/reading-group.model.ts b/src/models/reading-group.model.ts
new file mode 100644
index 00000000..f2274dc0
--- /dev/null
+++ b/src/models/reading-group.model.ts
@@ -0,0 +1,63 @@
+import mongoose from "mongoose";
+
+const scheduleItemSchema = new mongoose.Schema(
+ {
+ chapter: { type: Number, required: true },
+ title: { type: String, default: "" },
+ targetPages: { type: String, default: "" },
+ startDate: { type: Date },
+ endDate: { type: Date },
+ },
+ { _id: false }
+);
+
+const discussionPostSchema = new mongoose.Schema(
+ {
+ chapter: { type: Number, required: true },
+ user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
+ content: { type: String, required: true, trim: true },
+ createdAt: { type: Date, default: Date.now },
+ }
+);
+
+const readingGroupSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ description: {
+ type: String,
+ trim: true,
+ },
+ book: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Book",
+ required: true,
+ index: true,
+ },
+ creator: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ privacy: {
+ type: String,
+ enum: ["public", "private"],
+ default: "public",
+ },
+ chaptersPerWeek: {
+ type: Number,
+ default: 1,
+ },
+ readingSchedule: [scheduleItemSchema],
+ discussions: [discussionPostSchema],
+ },
+ { timestamps: true }
+);
+
+readingGroupSchema.index({ name: "text", description: "text" });
+
+export default mongoose.model("ReadingGroup", readingGroupSchema);
diff --git a/src/models/space-poll.model.js b/src/models/space-poll.model.js
new file mode 100644
index 00000000..5b950f89
--- /dev/null
+++ b/src/models/space-poll.model.js
@@ -0,0 +1,50 @@
+import mongoose from "mongoose";
+
+const pollOptionSchema = new mongoose.Schema(
+ {
+ optionIndex: { type: Number, required: true },
+ text: { type: String, required: true, trim: true },
+ },
+ { _id: false }
+);
+
+const spacePollSchema = new mongoose.Schema(
+ {
+ space: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Space",
+ required: true,
+ index: true,
+ },
+ creator: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ question: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ options: {
+ type: [pollOptionSchema],
+ required: true,
+ validate: [
+ (opts) => opts && opts.length >= 2,
+ "Poll must have at least 2 options",
+ ],
+ },
+ status: {
+ type: String,
+ enum: ["active", "closed"],
+ default: "active",
+ index: true,
+ },
+ closedAt: {
+ type: Date,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("SpacePoll", spacePollSchema);
diff --git a/src/models/space-poll.model.ts b/src/models/space-poll.model.ts
new file mode 100644
index 00000000..be3878fa
--- /dev/null
+++ b/src/models/space-poll.model.ts
@@ -0,0 +1,50 @@
+import mongoose from "mongoose";
+
+const pollOptionSchema = new mongoose.Schema(
+ {
+ optionIndex: { type: Number, required: true },
+ text: { type: String, required: true, trim: true },
+ },
+ { _id: false }
+);
+
+const spacePollSchema = new mongoose.Schema(
+ {
+ space: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Space",
+ required: true,
+ index: true,
+ },
+ creator: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ question: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ options: {
+ type: [pollOptionSchema],
+ required: true,
+ validate: [
+ (opts: any[]) => opts && opts.length >= 2,
+ "Poll must have at least 2 options",
+ ],
+ },
+ status: {
+ type: String,
+ enum: ["active", "closed"],
+ default: "active",
+ index: true,
+ },
+ closedAt: {
+ type: Date,
+ },
+ },
+ { timestamps: true }
+);
+
+export default mongoose.model("SpacePoll", spacePollSchema);
diff --git a/src/models/user-badge.model.js b/src/models/user-badge.model.js
new file mode 100644
index 00000000..3ca92997
--- /dev/null
+++ b/src/models/user-badge.model.js
@@ -0,0 +1,30 @@
+import mongoose from "mongoose";
+
+const userBadgeSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ badge: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Badge",
+ required: true,
+ index: true,
+ },
+ awardedAt: {
+ type: Date,
+ default: Date.now,
+ },
+ metadata: {
+ type: mongoose.Schema.Types.Mixed,
+ },
+ },
+ { timestamps: true }
+);
+
+userBadgeSchema.index({ user: 1, badge: 1 }, { unique: true });
+
+export default mongoose.model("UserBadge", userBadgeSchema);
diff --git a/src/models/user-badge.model.ts b/src/models/user-badge.model.ts
new file mode 100644
index 00000000..3ca92997
--- /dev/null
+++ b/src/models/user-badge.model.ts
@@ -0,0 +1,30 @@
+import mongoose from "mongoose";
+
+const userBadgeSchema = new mongoose.Schema(
+ {
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ badge: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Badge",
+ required: true,
+ index: true,
+ },
+ awardedAt: {
+ type: Date,
+ default: Date.now,
+ },
+ metadata: {
+ type: mongoose.Schema.Types.Mixed,
+ },
+ },
+ { timestamps: true }
+);
+
+userBadgeSchema.index({ user: 1, badge: 1 }, { unique: true });
+
+export default mongoose.model("UserBadge", userBadgeSchema);
diff --git a/src/mongo/utils/healthCheck.js b/src/mongo/utils/healthCheck.js
new file mode 100644
index 00000000..4f2debd2
--- /dev/null
+++ b/src/mongo/utils/healthCheck.js
@@ -0,0 +1,2 @@
+export * from "../../../mongo/utils/healthCheck.js";
+export { checkDatabaseHealth as default } from "../../../mongo/utils/healthCheck.js";
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/admin/moderationRoutes.js b/src/routes/admin/moderationRoutes.js
new file mode 100644
index 00000000..3df96284
--- /dev/null
+++ b/src/routes/admin/moderationRoutes.js
@@ -0,0 +1,19 @@
+import express from "express";
+import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js";
+import {
+ getModerationQueue,
+ processModerationAction,
+ getModerationHistory,
+} from "../../controllers/moderation.controller.js";
+
+const router = express.Router();
+
+router.use(protect);
+router.use(authorizeRoles("admin"));
+
+router.get("/queue", getModerationQueue);
+router.post("/action", processModerationAction);
+router.post("/:flagId/action", processModerationAction);
+router.get("/history", getModerationHistory);
+
+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/badge.routes.js b/src/routes/badge.routes.js
new file mode 100644
index 00000000..26592deb
--- /dev/null
+++ b/src/routes/badge.routes.js
@@ -0,0 +1,19 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ getUserBadgesController,
+ getAllBadgesController,
+ checkBadgesController,
+} from "../controllers/badge.controller.js";
+
+const router = express.Router();
+
+// Public routes
+router.get("/", getAllBadgesController);
+router.get("/user/:userId", getUserBadgesController);
+
+// Protected routes
+router.get("/my-badges", protect, getUserBadgesController);
+router.post("/evaluate", protect, checkBadgesController);
+
+export default router;
diff --git a/src/routes/badge.routes.ts b/src/routes/badge.routes.ts
new file mode 100644
index 00000000..26592deb
--- /dev/null
+++ b/src/routes/badge.routes.ts
@@ -0,0 +1,19 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ getUserBadgesController,
+ getAllBadgesController,
+ checkBadgesController,
+} from "../controllers/badge.controller.js";
+
+const router = express.Router();
+
+// Public routes
+router.get("/", getAllBadgesController);
+router.get("/user/:userId", getUserBadgesController);
+
+// Protected routes
+router.get("/my-badges", protect, getUserBadgesController);
+router.post("/evaluate", protect, checkBadgesController);
+
+export default router;
diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js
index 56b3a833..b3d9b60d 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,39 +105,70 @@ 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
);
+import {
+ createHighlight,
+ getHighlights,
+ deleteHighlight,
+ createNote,
+ getNotes,
+ deleteNote,
+ getHighlightsAndNotes,
+ searchHighlightsAndNotes,
+ exportHighlights,
+} from "../../controllers/highlight.controller.js";
+
+// Highlights & Notes Endpoints (placed before dynamic :id routes where needed)
+router.get("/highlights-notes/search", protect, searchHighlightsAndNotes);
+router.delete("/highlights/:id", protect, deleteHighlight);
+router.delete("/notes/:id", protect, deleteNote);
+
+router.post("/:bookId/highlights", protect, createHighlight);
+router.get("/:bookId/highlights", protect, getHighlights);
+router.post("/:bookId/notes", protect, createNote);
+router.get("/:bookId/notes", protect, getNotes);
+router.get("/:bookId/highlights-notes", protect, getHighlightsAndNotes);
+router.get("/:bookId/highlights-notes/search", protect, searchHighlightsAndNotes);
+router.get("/:bookId/highlights/export", protect, exportHighlights);
+
export default router;
diff --git a/src/routes/books/readingProgressRoutes.js b/src/routes/books/readingProgressRoutes.js
new file mode 100644
index 00000000..a6c29d54
--- /dev/null
+++ b/src/routes/books/readingProgressRoutes.js
@@ -0,0 +1,38 @@
+import express from "express";
+import {
+ updateReadingProgress,
+ getReadingProgress,
+ getReadingLibrary,
+} from "../../controllers/books/readingProgressController.js";
+import { protect } from "../../middlewares/authMiddleware.js";
+import { validate } from "../../middlewares/validate.js";
+import {
+ updateReadingProgressValidation,
+ readingProgressBookIdValidation,
+} from "../../validators/readingProgressValidators.js";
+
+const router = express.Router();
+
+// Reading library augmented with progress %. Declared before the dynamic
+// ":bookId" route so the static "library" segment is not captured as a bookId.
+router.get("/library/progress", protect, getReadingLibrary);
+
+// Update progress as the user reads (upsert per user + book).
+router.put(
+ "/:bookId/progress",
+ protect,
+ updateReadingProgressValidation,
+ validate,
+ updateReadingProgress
+);
+
+// Resume: fetch the last stored position for this user + book.
+router.get(
+ "/:bookId/progress",
+ protect,
+ readingProgressBookIdValidation,
+ validate,
+ getReadingProgress
+);
+
+export default router;
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/certificate.routes.js b/src/routes/certificate.routes.js
new file mode 100644
index 00000000..9ad85ead
--- /dev/null
+++ b/src/routes/certificate.routes.js
@@ -0,0 +1,21 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ generateCertificateController,
+ getCertificateByIdController,
+ getUserCertificatesController,
+ downloadCertificateController,
+} from "../controllers/certificate.controller.js";
+
+const router = express.Router();
+
+// Download & lookup routes (public/protected)
+router.get("/:id/download", downloadCertificateController);
+router.get("/user", protect, getUserCertificatesController);
+router.get("/user/:userId", protect, getUserCertificatesController);
+router.get("/:id", getCertificateByIdController);
+
+// Protected mutation route
+router.post("/generate", protect, generateCertificateController);
+
+export default router;
diff --git a/src/routes/certificate.routes.ts b/src/routes/certificate.routes.ts
new file mode 100644
index 00000000..9ad85ead
--- /dev/null
+++ b/src/routes/certificate.routes.ts
@@ -0,0 +1,21 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ generateCertificateController,
+ getCertificateByIdController,
+ getUserCertificatesController,
+ downloadCertificateController,
+} from "../controllers/certificate.controller.js";
+
+const router = express.Router();
+
+// Download & lookup routes (public/protected)
+router.get("/:id/download", downloadCertificateController);
+router.get("/user", protect, getUserCertificatesController);
+router.get("/user/:userId", protect, getUserCertificatesController);
+router.get("/:id", getCertificateByIdController);
+
+// Protected mutation route
+router.post("/generate", protect, generateCertificateController);
+
+export default router;
diff --git a/src/routes/course-bundle.routes.js b/src/routes/course-bundle.routes.js
new file mode 100644
index 00000000..3fe96478
--- /dev/null
+++ b/src/routes/course-bundle.routes.js
@@ -0,0 +1,26 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ createBundle,
+ getBundles,
+ getBundleById,
+ getBundlesByCourse,
+ updateBundle,
+ deleteBundle,
+ purchaseBundle,
+} from "../controllers/course-bundle.controller.js";
+
+const router = express.Router();
+
+// Public routes
+router.get("/", getBundles);
+router.get("/course/:courseId", getBundlesByCourse);
+router.get("/:id", getBundleById);
+
+// Protected routes
+router.post("/", protect, createBundle);
+router.put("/:id", protect, updateBundle);
+router.delete("/:id", protect, deleteBundle);
+router.post("/:id/purchase", protect, purchaseBundle);
+
+export default router;
diff --git a/src/routes/course-bundle.routes.ts b/src/routes/course-bundle.routes.ts
new file mode 100644
index 00000000..3fe96478
--- /dev/null
+++ b/src/routes/course-bundle.routes.ts
@@ -0,0 +1,26 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ createBundle,
+ getBundles,
+ getBundleById,
+ getBundlesByCourse,
+ updateBundle,
+ deleteBundle,
+ purchaseBundle,
+} from "../controllers/course-bundle.controller.js";
+
+const router = express.Router();
+
+// Public routes
+router.get("/", getBundles);
+router.get("/course/:courseId", getBundlesByCourse);
+router.get("/:id", getBundleById);
+
+// Protected routes
+router.post("/", protect, createBundle);
+router.put("/:id", protect, updateBundle);
+router.delete("/:id", protect, deleteBundle);
+router.post("/:id/purchase", protect, purchaseBundle);
+
+export default router;
diff --git a/src/routes/courses/analyticsRoutes.js b/src/routes/courses/analyticsRoutes.js
new file mode 100644
index 00000000..6d3b7d04
--- /dev/null
+++ b/src/routes/courses/analyticsRoutes.js
@@ -0,0 +1,44 @@
+// routes/courses/analyticsRoutes.js
+//
+// Creator course-analytics endpoints. Mounted at /api/courses/analytics in
+// app.js BEFORE the generic course routes so the static "analytics" segment is
+// not swallowed by the courseRoutes "/:id" matcher.
+//
+// All endpoints require authentication (protect). Per-course endpoints
+// additionally require ownership of the target course (authorizeOwnership),
+// so only a course's creator (or an admin) can read its analytics.
+
+import express from "express";
+import { protect } from "../../middlewares/authMiddleware.js";
+import { authorizeOwnership } from "../../middlewares/authorize.js";
+import Course from "../../models/Course.js";
+import {
+ getCourseAnalyticsHandler,
+ exportCourseAnalyticsHandler,
+ getCreatorOverviewHandler,
+} from "../../controllers/analytics/courseAnalyticsController.js";
+
+const router = express.Router();
+
+// Ownership guard bound to the :courseId route param.
+const requireCourseOwnership = authorizeOwnership({
+ model: Course,
+ ownerField: "createdBy",
+ resourceType: "Course",
+ idParam: "courseId",
+});
+
+// Portfolio overview across all of the creator's courses.
+// Declared before "/:courseId" so the static path wins.
+router.get("/overview", protect, getCreatorOverviewHandler);
+
+// Single-course analytics + CSV export (creator-owned).
+router.get("/:courseId", protect, requireCourseOwnership, getCourseAnalyticsHandler);
+router.get(
+ "/:courseId/export",
+ protect,
+ requireCourseOwnership,
+ exportCourseAnalyticsHandler
+);
+
+export default router;
diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js
index 57c46a1b..9b0f05cd 100644
--- a/src/routes/courses/courseRoutes.js
+++ b/src/routes/courses/courseRoutes.js
@@ -22,17 +22,25 @@ import {
getCourseProgress,
updateCourseProgress,
} from "../../controllers/analytics/analyticsController.js";
-import { protect } from "../../middlewares/authMiddleware.js";
+import { getBundlesByCourse } from "../../controllers/course-bundle.controller.js";
+import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js";
+import {
+ authorizeOwnership,
+ authorizeReviewOwnership,
+} from "../../middlewares/authorize.js";
+import Course from "../../models/Course.js";
import {
cacheMiddleware,
invalidateCacheMiddleware,
} from "../../middlewares/cache.js";
+import { validate } from "../../middlewares/validate.js";
+import { prerequisitesValidation } from "../../validators/requestValidators.js";
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}`;
@@ -60,6 +68,7 @@ router.post("/:id/progress", protect, updateCourseProgress);
// Review listing route
router.get("/:id/reviews", getCourseReviews);
+router.get("/:courseId/bundles", getBundlesByCourse);
// Dynamic routes - cached for 15 minutes
router.get(
@@ -72,6 +81,9 @@ router.get(
router.post(
"/",
protect,
+ requireVerifiedEducator,
+ prerequisitesValidation,
+ validate,
invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]),
createCourse
);
@@ -90,45 +102,53 @@ 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" }),
+ prerequisitesValidation,
+ validate,
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/health/database.js b/src/routes/health/database.js
new file mode 100644
index 00000000..b300dece
--- /dev/null
+++ b/src/routes/health/database.js
@@ -0,0 +1,30 @@
+import express from "express";
+import { checkDatabaseHealth } from "../../../mongo/utils/healthCheck.js";
+
+const router = express.Router();
+
+router.get("/", async (req, res) => {
+ try {
+ const result = await checkDatabaseHealth();
+ const statusCode = result.healthy ? 200 : 503;
+ return res.status(statusCode).json({
+ success: result.healthy,
+ message: result.healthy
+ ? "Database connection is healthy"
+ : "Database connection is unhealthy",
+ data: result,
+ });
+ } catch (error) {
+ return res.status(503).json({
+ success: false,
+ message: "Database health check failed",
+ data: {
+ healthy: false,
+ status: "unhealthy",
+ error: error.message,
+ },
+ });
+ }
+});
+
+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/messaging.routes.js b/src/routes/messaging.routes.js
new file mode 100644
index 00000000..921e12f5
--- /dev/null
+++ b/src/routes/messaging.routes.js
@@ -0,0 +1,19 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ getOrCreateConversation,
+ getConversations,
+ getMessages,
+ sendMessage,
+ markAsRead,
+} from "../controllers/messaging.controller.js";
+
+const router = express.Router();
+
+router.get("/conversations", protect, getConversations);
+router.post("/conversations/:userId", protect, getOrCreateConversation);
+router.get("/conversations/:conversationId/messages", protect, getMessages);
+router.post("/conversations/:conversationId/messages", protect, sendMessage);
+router.post("/conversations/:conversationId/read", protect, markAsRead);
+
+export default router;
diff --git a/src/routes/metrics/database.js b/src/routes/metrics/database.js
new file mode 100644
index 00000000..54f23536
--- /dev/null
+++ b/src/routes/metrics/database.js
@@ -0,0 +1,36 @@
+import express from "express";
+import mongoose from "mongoose";
+import poolMetrics from "../../../mongo/monitoring/poolMetrics.js";
+
+const router = express.Router();
+
+/**
+ * GET /metrics/database
+ * ---------------------------------------------------------------------------
+ * Exposes MongoDB connection-pool statistics in Prometheus text exposition
+ * format (v0.0.4). The collector is normally wired up at boot in
+ * `src/config/db.js`; as a safety net this handler lazily attaches the
+ * listeners if a live connection exists but has not yet been instrumented
+ * (e.g. when the process connected after this route was first imported).
+ *
+ * The endpoint never fails on a missing/partial connection — it returns valid
+ * zeroed metrics so a Prometheus scrape always succeeds.
+ */
+router.get("/", (req, res) => {
+ try {
+ if (
+ !poolMetrics.isAttached() &&
+ mongoose.connection &&
+ mongoose.connection.readyState !== 0
+ ) {
+ poolMetrics.attach(mongoose.connection);
+ }
+ } catch {
+ // Never let instrumentation wiring break the scrape.
+ }
+
+ res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
+ res.status(200).send(poolMetrics.render());
+});
+
+export default router;
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/readingGroupRoutes.js b/src/routes/readingGroupRoutes.js
new file mode 100644
index 00000000..a29b2345
--- /dev/null
+++ b/src/routes/readingGroupRoutes.js
@@ -0,0 +1,29 @@
+import express from "express";
+import { protect } from "../middlewares/authMiddleware.js";
+import {
+ createGroup,
+ getGroups,
+ getGroupDetails,
+ joinGroup,
+ inviteMember,
+ updateSchedule,
+ addDiscussionPost,
+ getDiscussions,
+ updateMemberProgress,
+ getMemberProgressDashboard,
+} from "../controllers/reading-group.controller.js";
+
+const router = express.Router();
+
+router.post("/", protect, createGroup);
+router.get("/", protect, getGroups);
+router.get("/:id", protect, getGroupDetails);
+router.post("/:id/join", protect, joinGroup);
+router.post("/:id/invite", protect, inviteMember);
+router.put("/:id/schedule", protect, updateSchedule);
+router.post("/:id/discussions", protect, addDiscussionPost);
+router.get("/:id/discussions", protect, getDiscussions);
+router.put("/:id/progress", protect, updateMemberProgress);
+router.get("/:id/dashboard", protect, getMemberProgressDashboard);
+
+export default router;
diff --git a/src/routes/reelsRoutes.js b/src/routes/reelsRoutes.js
index 7bb5ebed..e0b3c013 100644
--- a/src/routes/reelsRoutes.js
+++ b/src/routes/reelsRoutes.js
@@ -9,9 +9,16 @@ import {
deleteReelComment,
registerReelShare,
registerReelView,
+ createReelDuet,
+ getReelDerivatives,
} from "../controllers/reelController.js";
import { protect } from "../middlewares/authMiddleware.js";
import upload from "../middlewares/upload.js";
+import { validate } from "../middlewares/validate.js";
+import {
+ createReelDuetValidation,
+ listReelDuetsValidation,
+} from "../validators/reelValidators.js";
const router = express.Router();
@@ -25,4 +32,26 @@ router.delete("/:id/comments/:commentId", protect, deleteReelComment);
router.post("/:id/share", protect, registerReelShare);
router.post("/:id/view", protect, registerReelView);
+// Duet / stitch: create a response video linked to the original reel and
+// browse all duets/stitches for a given reel.
+router.post(
+ "/:id/duet",
+ protect,
+ upload.single("video"),
+ createReelDuetValidation,
+ validate,
+ createReelDuet
+);
+router.get(
+ "/:id/duets",
+ protect,
+ listReelDuetsValidation,
+ validate,
+ getReelDerivatives
+);
+
+import { flagReel } from "../controllers/moderation.controller.js";
+
+router.post("/:reelId/flag", protect, flagReel);
+
export default router;
\ No newline at end of file
diff --git a/src/routes/spaceRoutes.js b/src/routes/spaceRoutes.js
index 92f08b71..21a68474 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,8 +79,26 @@ router.put(
router.delete(
"/:id",
protect,
+ authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }),
invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`, `${CACHE_KEYS.EDUCATORS}*`]),
deleteSpace
);
+import {
+ createPoll,
+ getSpacePolls,
+ getPollResults,
+ voteInPoll,
+ closePoll,
+ exportPollResults,
+} from "../controllers/space-poll.controller.js";
+
+// Poll Endpoints
+router.post("/:spaceId/polls", protect, createPoll);
+router.get("/:spaceId/polls", protect, getSpacePolls);
+router.get("/polls/:pollId", protect, getPollResults);
+router.post("/polls/:pollId/vote", protect, voteInPoll);
+router.patch("/polls/:pollId/close", protect, closePoll);
+router.get("/polls/:pollId/export", protect, exportPollResults);
+
export default router;
diff --git a/src/routes/stellar/analyticsRoutes.js b/src/routes/stellar/analyticsRoutes.js
new file mode 100644
index 00000000..285914ce
--- /dev/null
+++ b/src/routes/stellar/analyticsRoutes.js
@@ -0,0 +1,41 @@
+// routes/stellar/analyticsRoutes.js
+import express from "express";
+import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js";
+import { validate } from "../../middlewares/validate.js";
+import {
+ getAnalyticsOverview,
+ getAnalyticsSummary,
+ getAnalyticsTimeSeries,
+} from "../../controllers/stellar/analyticsController.js";
+import {
+ analyticsTimeSeriesValidation,
+ analyticsSummaryValidation,
+} from "../../validators/stellarAnalyticsValidators.js";
+
+const router = express.Router();
+
+// Payment analytics is an admin dashboard surface: require auth (mirroring the
+// other stellar routes) plus the admin role.
+router.use(protect);
+router.use(authorizeRoles("admin"));
+
+// Combined summary + time series in one call.
+router.get("/", analyticsTimeSeriesValidation, validate, getAnalyticsOverview);
+
+// Per-asset totals only.
+router.get(
+ "/summary",
+ analyticsSummaryValidation,
+ validate,
+ getAnalyticsSummary
+);
+
+// Time series bucketed by day|week|month|year.
+router.get(
+ "/timeseries",
+ analyticsTimeSeriesValidation,
+ validate,
+ getAnalyticsTimeSeries
+);
+
+export default router;
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/onrampRoutes.js b/src/routes/stellar/onrampRoutes.js
new file mode 100644
index 00000000..0a9648dc
--- /dev/null
+++ b/src/routes/stellar/onrampRoutes.js
@@ -0,0 +1,20 @@
+// routes/stellar/onrampRoutes.js
+import express from "express";
+import { protect } from "../../middlewares/authMiddleware.js";
+import {
+ createOnrampSession,
+ getOnrampTransactions,
+ handleWebhook,
+} from "../../controllers/stellar/onrampController.js";
+
+const router = express.Router();
+
+// Public webhook — authenticity is established by provider HMAC signature
+// verification inside the handler (over req.rawBody), not by auth middleware.
+router.post("/webhook", handleWebhook);
+
+// Protected routes (require authentication)
+router.post("/session", protect, createOnrampSession);
+router.get("/transactions", protect, getOnrampTransactions);
+
+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/userRoutes.js b/src/routes/userRoutes.js
index 08797257..e832658c 100644
--- a/src/routes/userRoutes.js
+++ b/src/routes/userRoutes.js
@@ -17,6 +17,7 @@ import {
getLearningDashboard,
} from "../controllers/userController.js";
import { searchAll } from "../controllers/searchController.js";
+import { getUserBadgesController } from "../controllers/badge.controller.js";
import {
cacheMiddleware,
invalidateCacheMiddleware,
@@ -58,6 +59,9 @@ router.put(
updateUser
);
+// Fetch user badges
+router.get("/:userId/badges", protect, getUserBadgesController);
+
// Get user by ID - cached for 10 minutes
router.get(
"/:id",
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/analytics/courseAnalyticsService.js b/src/services/analytics/courseAnalyticsService.js
new file mode 100644
index 00000000..27df23ee
--- /dev/null
+++ b/src/services/analytics/courseAnalyticsService.js
@@ -0,0 +1,269 @@
+// services/analytics/courseAnalyticsService.js
+//
+// Data-access + orchestration layer for creator course analytics. Pulls the
+// raw signals from the existing collections (Course, CourseProgress,
+// Transaction, User.purchasedCourses) and hands them to the pure calculators
+// in utils/analyticsCalculator.js to produce the metrics returned by the
+// creator analytics endpoints.
+//
+// Nothing here mutates state — these are read-only aggregations.
+
+import mongoose from "mongoose";
+import Course from "../../models/Course.js";
+import CourseProgress from "../../models/CourseProgress.js";
+import Transaction from "../../models/Transaction.js";
+import User from "../../models/User.js";
+import {
+ computeCompletionRate,
+ computeConversionRate,
+ computeEngagement,
+ computeDropOff,
+ sumRevenue,
+} from "../../utils/analyticsCalculator.js";
+
+/**
+ * Normalise a raw date-range input into concrete Date objects.
+ *
+ * @param {string|Date} [startDate] - Inclusive lower bound (ISO string or Date).
+ * @param {string|Date} [endDate] - Inclusive upper bound (ISO string or Date).
+ * @returns {{start: Date|null, end: Date|null}} Parsed bounds (null when absent/invalid).
+ */
+export const parseDateRange = (startDate, endDate) => {
+ const toDate = (value) => {
+ if (!value) return null;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? null : d;
+ };
+ return { start: toDate(startDate), end: toDate(endDate) };
+};
+
+/**
+ * Build a Mongo range filter fragment for a timestamp field.
+ *
+ * @param {Date|null} start - Inclusive lower bound.
+ * @param {Date|null} end - Inclusive upper bound.
+ * @returns {object} A `{ $gte, $lte }` object, or `{}` when both bounds are null.
+ */
+const rangeFilter = (start, end) => {
+ const filter = {};
+ if (start) filter.$gte = start;
+ if (end) filter.$lte = end;
+ return filter;
+};
+
+/**
+ * Flatten a course's ordered sections/lessons into a single ordered lesson list.
+ *
+ * @param {object} course - A Course document (lean or hydrated).
+ * @returns {Array<{lessonId: string, title: string}>} Ordered lessons.
+ */
+export const flattenLessons = (course) => {
+ const sections = [...(course.sections || [])].sort(
+ (a, b) => (a.order ?? 0) - (b.order ?? 0)
+ );
+ const lessons = [];
+ for (const section of sections) {
+ const ordered = [...(section.lessons || [])].sort(
+ (a, b) => (a.order ?? 0) - (b.order ?? 0)
+ );
+ for (const lesson of ordered) {
+ if (!lesson?._id) continue;
+ lessons.push({
+ lessonId: String(lesson._id),
+ title: lesson.title || section.title || "Lesson",
+ });
+ }
+ }
+ return lessons;
+};
+
+/**
+ * Count enrollments for a course within an optional date range, using the
+ * per-user `purchasedCourses.purchaseDate` (covers both free enrollments and
+ * paid purchases, each of which records a purchaseDate).
+ *
+ * @param {mongoose.Types.ObjectId} courseId - Course id.
+ * @param {Date|null} start - Inclusive lower bound.
+ * @param {Date|null} end - Inclusive upper bound.
+ * @returns {Promise} Enrollment count in the window.
+ */
+const countEnrollmentsInRange = async (courseId, start, end) => {
+ const range = rangeFilter(start, end);
+ const hasRange = Object.keys(range).length > 0;
+
+ const match = { "purchasedCourses.courseId": courseId };
+ const unwoundMatch = { "purchasedCourses.courseId": courseId };
+ if (hasRange) unwoundMatch["purchasedCourses.purchaseDate"] = range;
+
+ const result = await User.aggregate([
+ { $match: match },
+ { $unwind: "$purchasedCourses" },
+ { $match: unwoundMatch },
+ { $count: "count" },
+ ]);
+
+ return result[0]?.count || 0;
+};
+
+/**
+ * Compute the full analytics payload for a single course.
+ *
+ * @param {string|mongoose.Types.ObjectId} courseId - Course id.
+ * @param {object} [options]
+ * @param {string|Date} [options.startDate] - Inclusive range start.
+ * @param {string|Date} [options.endDate] - Inclusive range end.
+ * @param {object} [options.courseDoc] - Pre-loaded Course doc to avoid a re-fetch
+ * (e.g. `req.resource` from the ownership middleware).
+ * @returns {Promise} Analytics object, or null when the course is missing.
+ */
+export const getCourseAnalytics = async (courseId, options = {}) => {
+ const { startDate, endDate, courseDoc } = options;
+ const objectId = new mongoose.Types.ObjectId(courseId);
+ const { start, end } = parseDateRange(startDate, endDate);
+
+ const course =
+ courseDoc || (await Course.findById(objectId).lean());
+ if (!course) return null;
+
+ const completionRange = rangeFilter(start, end);
+ const hasRange = Object.keys(completionRange).length > 0;
+
+ // Completions in-range: progress docs with a completedAt inside the window.
+ const completionFilter = { course: objectId, completedAt: { $ne: null } };
+ if (hasRange) completionFilter.completedAt = { ...completionRange, $ne: null };
+
+ const revenueFilter = {
+ creator: course.createdBy,
+ itemType: "course",
+ itemId: objectId,
+ status: "confirmed",
+ };
+ if (hasRange) revenueFilter.createdAt = completionRange;
+
+ const [progressDocs, completions, enrollmentsInRange, transactions] =
+ await Promise.all([
+ CourseProgress.find({ course: objectId }).lean(),
+ CourseProgress.countDocuments(completionFilter),
+ countEnrollmentsInRange(objectId, start, end),
+ Transaction.find(revenueFilter).select("amount currency createdAt").lean(),
+ ]);
+
+ const enrollmentsTotal = Array.isArray(course.enrolledUsers)
+ ? course.enrolledUsers.length
+ : 0;
+ const enrollments = hasRange ? enrollmentsInRange : enrollmentsTotal;
+
+ const lessons = flattenLessons(course);
+ const engagement = computeEngagement(progressDocs, start);
+ const dropOff = computeDropOff(lessons, progressDocs);
+ const revenue = sumRevenue(transactions);
+
+ return {
+ courseId: String(course._id),
+ title: course.title,
+ createdBy: String(course.createdBy),
+ range: {
+ startDate: start ? start.toISOString() : null,
+ endDate: end ? end.toISOString() : null,
+ },
+ metrics: {
+ views: course.views || 0,
+ enrollments,
+ enrollmentsTotal,
+ completions,
+ completionRate: computeCompletionRate(completions, enrollments),
+ conversionRate: computeConversionRate(enrollments, course.views || 0),
+ revenue,
+ engagement,
+ dropOff,
+ totalLessons: lessons.length,
+ },
+ };
+};
+
+/**
+ * Aggregate a lightweight analytics overview across every course owned by a
+ * creator, plus a portfolio-level roll-up.
+ *
+ * @param {string|mongoose.Types.ObjectId} creatorId - The creator's user id.
+ * @param {object} [options]
+ * @param {string|Date} [options.startDate] - Inclusive range start.
+ * @param {string|Date} [options.endDate] - Inclusive range end.
+ * @returns {Promise<{creatorId: string, range: object, totals: object, courses: object[]}>}
+ */
+export const getCreatorOverview = async (creatorId, options = {}) => {
+ const { startDate, endDate } = options;
+ const objectId = new mongoose.Types.ObjectId(creatorId);
+ const { start, end } = parseDateRange(startDate, endDate);
+
+ const courses = await Course.find({ createdBy: objectId }).lean();
+
+ const perCourse = await Promise.all(
+ courses.map((courseDoc) =>
+ getCourseAnalytics(courseDoc._id, {
+ startDate,
+ endDate,
+ courseDoc,
+ })
+ )
+ );
+
+ const totals = {
+ courses: courses.length,
+ views: 0,
+ enrollments: 0,
+ completions: 0,
+ revenueByCurrency: {},
+ };
+
+ const summaries = [];
+ for (const analytics of perCourse) {
+ if (!analytics) continue;
+ const m = analytics.metrics;
+ totals.views += m.views;
+ totals.enrollments += m.enrollments;
+ totals.completions += m.completions;
+ for (const [currency, amount] of Object.entries(
+ m.revenue.revenueByCurrency
+ )) {
+ totals.revenueByCurrency[currency] =
+ (totals.revenueByCurrency[currency] || 0) + amount;
+ }
+ summaries.push({
+ courseId: analytics.courseId,
+ title: analytics.title,
+ views: m.views,
+ enrollments: m.enrollments,
+ completions: m.completions,
+ completionRate: m.completionRate,
+ conversionRate: m.conversionRate,
+ revenue: m.revenue.grossByCurrency,
+ });
+ }
+
+ totals.completionRate = computeCompletionRate(
+ totals.completions,
+ totals.enrollments
+ );
+ totals.conversionRate = computeConversionRate(
+ totals.enrollments,
+ totals.views
+ );
+
+ return {
+ creatorId: String(objectId),
+ range: {
+ startDate: start ? start.toISOString() : null,
+ endDate: end ? end.toISOString() : null,
+ },
+ totals,
+ courses: summaries,
+ };
+};
+
+export default {
+ parseDateRange,
+ flattenLessons,
+ getCourseAnalytics,
+ getCreatorOverview,
+};
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/badge.service.js b/src/services/badge.service.js
new file mode 100644
index 00000000..ea90cd63
--- /dev/null
+++ b/src/services/badge.service.js
@@ -0,0 +1,87 @@
+import Badge from "../models/badge.model.js";
+import UserBadge from "../models/user-badge.model.js";
+import CourseProgress from "../models/CourseProgress.js";
+import { DEFAULT_BADGES } from "../utils/badge-criteria.js";
+
+export class BadgeService {
+ async seedDefaultBadges() {
+ for (const b of DEFAULT_BADGES) {
+ await Badge.findOneAndUpdate(
+ { slug: b.slug },
+ { $setOnInsert: b },
+ { upsert: true, new: true }
+ );
+ }
+ }
+
+ async checkAndAwardBadges(userId) {
+ await this.seedDefaultBadges();
+
+ const completedProgresses = await CourseProgress.find({
+ user: userId,
+ $or: [{ percentComplete: 100 }, { completedAt: { $ne: null } }],
+ }).populate("course", "category");
+
+ const completedCount = completedProgresses.length;
+
+ const categoryCounts = {};
+ for (const cp of completedProgresses) {
+ if (cp.course && cp.course.category) {
+ const cat = cp.course.category;
+ categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
+ }
+ }
+
+ const maxCategoryCount = Math.max(0, ...Object.values(categoryCounts));
+ const badges = await Badge.find();
+ const newlyAwarded = [];
+
+ for (const badge of badges) {
+ let isEligible = false;
+
+ if (badge.criteriaType === "courses_completed") {
+ isEligible = completedCount >= badge.threshold;
+ } else if (badge.criteriaType === "category_completed") {
+ isEligible = maxCategoryCount >= badge.threshold;
+ }
+
+ if (isEligible) {
+ const existing = await UserBadge.findOne({ user: userId, badge: badge._id });
+ if (!existing) {
+ try {
+ const userBadge = await UserBadge.create({
+ user: userId,
+ badge: badge._id,
+ metadata: {
+ completedCount,
+ maxCategoryCount,
+ },
+ });
+ const populated = await UserBadge.findById(userBadge._id).populate("badge");
+ newlyAwarded.push(populated);
+ } catch (err) {
+ if (!err.message?.includes("E11000") && err.code !== 11000) {
+ throw err;
+ }
+ }
+ }
+ }
+ }
+
+ return newlyAwarded;
+ }
+
+ async getUserBadges(userId) {
+ await this.checkAndAwardBadges(userId);
+ return await UserBadge.find({ user: userId })
+ .populate("badge")
+ .sort({ awardedAt: -1 });
+ }
+
+ async getAllBadges() {
+ await this.seedDefaultBadges();
+ return await Badge.find().sort({ threshold: 1 });
+ }
+}
+
+export default new BadgeService();
diff --git a/src/services/badge.service.ts b/src/services/badge.service.ts
new file mode 100644
index 00000000..869921fb
--- /dev/null
+++ b/src/services/badge.service.ts
@@ -0,0 +1,101 @@
+import Badge from "../models/badge.model.js";
+import UserBadge from "../models/user-badge.model.js";
+import CourseProgress from "../models/CourseProgress.js";
+import { DEFAULT_BADGES } from "../utils/badge-criteria.js";
+
+export class BadgeService {
+ /**
+ * Initializes default system badges if they do not exist.
+ */
+ async seedDefaultBadges() {
+ for (const b of DEFAULT_BADGES) {
+ await Badge.findOneAndUpdate(
+ { slug: b.slug },
+ { $setOnInsert: b },
+ { upsert: true, new: true }
+ );
+ }
+ }
+
+ /**
+ * Evaluates user milestone achievements and awards badges automatically.
+ */
+ async checkAndAwardBadges(userId) {
+ await this.seedDefaultBadges();
+
+ const completedProgresses = await CourseProgress.find({
+ user: userId,
+ $or: [{ percentComplete: 100 }, { completedAt: { $ne: null } }],
+ }).populate("course", "category");
+
+ const completedCount = completedProgresses.length;
+
+ // Count category completion totals
+ const categoryCounts = {};
+ for (const cp of completedProgresses) {
+ if (cp.course && cp.course.category) {
+ const cat = cp.course.category;
+ categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
+ }
+ }
+
+ const maxCategoryCount = Math.max(0, ...Object.values(categoryCounts));
+ const badges = await Badge.find();
+ const newlyAwarded = [];
+
+ for (const badge of badges) {
+ let isEligible = false;
+
+ if (badge.criteriaType === "courses_completed") {
+ isEligible = completedCount >= badge.threshold;
+ } else if (badge.criteriaType === "category_completed") {
+ isEligible = maxCategoryCount >= badge.threshold;
+ }
+
+ if (isEligible) {
+ const existing = await UserBadge.findOne({ user: userId, badge: badge._id });
+ if (!existing) {
+ try {
+ const userBadge = await UserBadge.create({
+ user: userId,
+ badge: badge._id,
+ metadata: {
+ completedCount,
+ maxCategoryCount,
+ },
+ });
+ const populated = await UserBadge.findById(userBadge._id).populate("badge");
+ newlyAwarded.push(populated);
+ } catch (err) {
+ // Ignore duplicate key race condition errors
+ if (!err.message?.includes("E11000") && err.code !== 11000) {
+ throw err;
+ }
+ }
+ }
+ }
+ }
+
+ return newlyAwarded;
+ }
+
+ /**
+ * Fetches all badges earned by a user.
+ */
+ async getUserBadges(userId) {
+ await this.checkAndAwardBadges(userId);
+ return await UserBadge.find({ user: userId })
+ .populate("badge")
+ .sort({ awardedAt: -1 });
+ }
+
+ /**
+ * Fetches all badge definitions.
+ */
+ async getAllBadges() {
+ await this.seedDefaultBadges();
+ return await Badge.find().sort({ threshold: 1 });
+ }
+}
+
+export default new BadgeService();
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/certificate.service.js b/src/services/certificate.service.js
new file mode 100644
index 00000000..05dfbafd
--- /dev/null
+++ b/src/services/certificate.service.js
@@ -0,0 +1,87 @@
+import crypto from "crypto";
+import Certificate from "../models/certificate.model.js";
+import CourseProgress from "../models/CourseProgress.js";
+import Course from "../models/Course.js";
+import User from "../models/User.js";
+import { generateCertificatePDF } from "../templates/certificate.template.js";
+
+export class CertificateService {
+ generateCertificateId() {
+ const timestamp = Date.now().toString(36).toUpperCase();
+ const randomHex = crypto.randomBytes(3).toString("hex").toUpperCase();
+ return `CERT-${timestamp}-${randomHex}`;
+ }
+
+ async generateCertificate({ userId, courseId }) {
+ const existing = await Certificate.findOne({ user: userId, course: courseId });
+ if (existing) {
+ return existing;
+ }
+
+ const progress = await CourseProgress.findOne({ user: userId, course: courseId });
+ if (!progress || (progress.percentComplete < 100 && !progress.completedAt)) {
+ throw new Error("Course has not been completed yet");
+ }
+
+ const [user, course] = await Promise.all([
+ User.findById(userId),
+ Course.findById(courseId).populate("createdBy", "name"),
+ ]);
+
+ if (!user) throw new Error("User not found");
+ if (!course) throw new Error("Course not found");
+
+ const certificateId = this.generateCertificateId();
+ const certificateUrl = `/api/certificates/${certificateId}/download`;
+
+ const certificate = await Certificate.create({
+ certificateId,
+ user: userId,
+ course: courseId,
+ learnerName: user.name,
+ courseTitle: course.title,
+ completionDate: progress.completedAt || new Date(),
+ instructorName: course.createdBy?.name || "DeenBridge Instructor",
+ instructorSignature: "DeenBridge Verification",
+ certificateUrl,
+ });
+
+ return certificate;
+ }
+
+ async getCertificateById(idOrCertificateId) {
+ const isObjectId = /^[0-9a-fA-F]{24}$/.test(idOrCertificateId);
+ const filter = isObjectId
+ ? { $or: [{ _id: idOrCertificateId }, { certificateId: idOrCertificateId }] }
+ : { certificateId: idOrCertificateId };
+
+ const certificate = await Certificate.findOne(filter)
+ .populate("user", "name email avatar")
+ .populate("course", "title category thumbnail");
+
+ if (!certificate) {
+ throw new Error("Certificate not found");
+ }
+
+ return certificate;
+ }
+
+ async getUserCertificates(userId) {
+ return await Certificate.find({ user: userId })
+ .populate("course", "title category thumbnail rating")
+ .sort({ createdAt: -1 });
+ }
+
+ async generatePDFBuffer(certificate) {
+ return await generateCertificatePDF({
+ learnerName: certificate.learnerName,
+ courseTitle: certificate.courseTitle,
+ completionDate: certificate.completionDate,
+ certificateId: certificate.certificateId,
+ instructorName: certificate.instructorName,
+ instructorSignature: certificate.instructorSignature,
+ });
+ }
+}
+
+export default new CertificateService();
diff --git a/src/services/certificate.service.ts b/src/services/certificate.service.ts
new file mode 100644
index 00000000..9454f376
--- /dev/null
+++ b/src/services/certificate.service.ts
@@ -0,0 +1,93 @@
+import crypto from "crypto";
+import Certificate from "../models/certificate.model.js";
+import CourseProgress from "../models/CourseProgress.js";
+import Course from "../models/Course.js";
+import User from "../models/User.js";
+import { generateCertificatePDF } from "../templates/certificate.template.js";
+
+export class CertificateService {
+ /**
+ * Generates a unique certificate ID.
+ */
+ generateCertificateId() {
+ const timestamp = Date.now().toString(36).toUpperCase();
+ const randomHex = crypto.randomBytes(3).toString("hex").toUpperCase();
+ return `CERT-${timestamp}-${randomHex}`;
+ }
+
+ /**
+ * Issue / generate certificate upon course completion.
+ */
+ async generateCertificate({ userId, courseId }) {
+ const existing = await Certificate.findOne({ user: userId, course: courseId });
+ if (existing) {
+ return existing;
+ }
+
+ const progress = await CourseProgress.findOne({ user: userId, course: courseId });
+ if (!progress || (progress.percentComplete < 100 && !progress.completedAt)) {
+ throw new Error("Course has not been completed yet");
+ }
+
+ const [user, course] = await Promise.all([
+ User.findById(userId),
+ Course.findById(courseId).populate("createdBy", "name"),
+ ]);
+
+ if (!user) throw new Error("User not found");
+ if (!course) throw new Error("Course not found");
+
+ const certificateId = this.generateCertificateId();
+ const certificateUrl = `/api/certificates/${certificateId}/download`;
+
+ const certificate = await Certificate.create({
+ certificateId,
+ user: userId,
+ course: courseId,
+ learnerName: user.name,
+ courseTitle: course.title,
+ completionDate: progress.completedAt || new Date(),
+ instructorName: course.createdBy?.name || "DeenBridge Instructor",
+ instructorSignature: "DeenBridge Verification",
+ certificateUrl,
+ });
+
+ return certificate;
+ }
+
+ async getCertificateById(idOrCertificateId) {
+ const isObjectId = /^[0-9a-fA-F]{24}$/.test(idOrCertificateId);
+ const filter = isObjectId
+ ? { $or: [{ _id: idOrCertificateId }, { certificateId: idOrCertificateId }] }
+ : { certificateId: idOrCertificateId };
+
+ const certificate = await Certificate.findOne(filter)
+ .populate("user", "name email avatar")
+ .populate("course", "title category thumbnail");
+
+ if (!certificate) {
+ throw new Error("Certificate not found");
+ }
+
+ return certificate;
+ }
+
+ async getUserCertificates(userId) {
+ return await Certificate.find({ user: userId })
+ .populate("course", "title category thumbnail rating")
+ .sort({ createdAt: -1 });
+ }
+
+ async generatePDFBuffer(certificate) {
+ return await generateCertificatePDF({
+ learnerName: certificate.learnerName,
+ courseTitle: certificate.courseTitle,
+ completionDate: certificate.completionDate,
+ certificateId: certificate.certificateId,
+ instructorName: certificate.instructorName,
+ instructorSignature: certificate.instructorSignature,
+ });
+ }
+}
+
+export default new CertificateService();
diff --git a/src/services/course-bundle.service.js b/src/services/course-bundle.service.js
new file mode 100644
index 00000000..cff73e18
--- /dev/null
+++ b/src/services/course-bundle.service.js
@@ -0,0 +1,180 @@
+import CourseBundle from "../models/course-bundle.model.js";
+import Course from "../models/Course.js";
+import User from "../models/User.js";
+import CourseProgress from "../models/CourseProgress.js";
+
+export class CourseBundleService {
+ calculateDiscount(courses, bundlePrice) {
+ const originalPrice = courses.reduce((sum, course) => sum + (course.price || 0), 0);
+ let discountPercentage = 0;
+ if (originalPrice > 0 && bundlePrice < originalPrice) {
+ discountPercentage = Math.round(((originalPrice - bundlePrice) / originalPrice) * 100 * 100) / 100;
+ }
+ return { originalPrice, discountPercentage };
+ }
+
+ async createBundle({ title, description, courses: courseIds, price, currency = "USDC", createdBy }) {
+ if (!courseIds || !Array.isArray(courseIds) || courseIds.length === 0) {
+ throw new Error("A bundle must contain at least one course");
+ }
+
+ const fetchedCourses = await Course.find({ _id: { $in: courseIds } });
+ if (fetchedCourses.length !== courseIds.length) {
+ throw new Error("One or more specified courses do not exist");
+ }
+
+ const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, price);
+
+ const bundle = await CourseBundle.create({
+ title,
+ description,
+ courses: courseIds,
+ price,
+ currency,
+ originalPrice,
+ discountPercentage,
+ createdBy,
+ });
+
+ return await CourseBundle.findById(bundle._id)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async getBundles(query = {}) {
+ const filter = { isActive: true };
+ if (query.courseId) {
+ filter.courses = query.courseId;
+ }
+ if (query.createdBy) {
+ filter.createdBy = query.createdBy;
+ }
+ if (query.search) {
+ filter.$text = { $search: query.search };
+ }
+
+ return await CourseBundle.find(filter)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar")
+ .sort({ createdAt: -1 });
+ }
+
+ async getBundleById(bundleId) {
+ const bundle = await CourseBundle.findById(bundleId)
+ .populate("courses", "title description price thumbnail category rating createdBy")
+ .populate("createdBy", "name email avatar");
+
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+ return bundle;
+ }
+
+ async getBundlesByCourse(courseId) {
+ return await CourseBundle.find({ courses: courseId, isActive: true })
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async updateBundle(bundleId, updateData, userId, userRole) {
+ const bundle = await CourseBundle.findById(bundleId);
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+
+ if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) {
+ throw new Error("Not authorized to update this bundle");
+ }
+
+ if (updateData.courses || updateData.price !== undefined) {
+ const courseIds = updateData.courses || bundle.courses;
+ const bundlePrice = updateData.price !== undefined ? updateData.price : bundle.price;
+
+ const fetchedCourses = await Course.find({ _id: { $in: courseIds } });
+ if (fetchedCourses.length !== courseIds.length) {
+ throw new Error("One or more specified courses do not exist");
+ }
+
+ const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, bundlePrice);
+ updateData.originalPrice = originalPrice;
+ updateData.discountPercentage = discountPercentage;
+ }
+
+ Object.assign(bundle, updateData);
+ await bundle.save();
+
+ return await CourseBundle.findById(bundle._id)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async deleteBundle(bundleId, userId, userRole) {
+ const bundle = await CourseBundle.findById(bundleId);
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+
+ if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) {
+ throw new Error("Not authorized to delete this bundle");
+ }
+
+ bundle.isActive = false;
+ await bundle.save();
+ return { success: true, message: "Bundle deactivated successfully" };
+ }
+
+ async purchaseBundle(bundleId, userId) {
+ const bundle = await CourseBundle.findById(bundleId).populate("courses");
+ if (!bundle || !bundle.isActive) {
+ throw new Error("Course bundle not found or inactive");
+ }
+
+ const user = await User.findById(userId);
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ let newlyEnrolledCount = 0;
+
+ for (const course of bundle.courses) {
+ const courseIdStr = course._id.toString();
+ const alreadyPurchased = user.purchasedCourses.some(
+ (pc) => pc.courseId && pc.courseId.toString() === courseIdStr
+ );
+
+ if (!alreadyPurchased) {
+ user.purchasedCourses.push({
+ courseId: course._id,
+ purchaseDate: new Date(),
+ });
+ user.stat.coursesEnrolled = (user.stat.coursesEnrolled || 0) + 1;
+ newlyEnrolledCount++;
+ }
+
+ const isEnrolledInCourse = course.enrolledUsers.some(
+ (uId) => uId.toString() === userId.toString()
+ );
+ if (!isEnrolledInCourse) {
+ course.enrolledUsers.push(userId);
+ await course.save();
+ }
+
+ await CourseProgress.findOneAndUpdate(
+ { user: userId, course: course._id },
+ { $setOnInsert: { user: userId, course: course._id, percentComplete: 0 } },
+ { upsert: true, new: true }
+ );
+ }
+
+ await user.save();
+
+ return {
+ success: true,
+ message: `Enrolled successfully in bundle '${bundle.title}'`,
+ bundle,
+ newlyEnrolledCount,
+ };
+ }
+}
+
+export default new CourseBundleService();
diff --git a/src/services/course-bundle.service.ts b/src/services/course-bundle.service.ts
new file mode 100644
index 00000000..fcc220b0
--- /dev/null
+++ b/src/services/course-bundle.service.ts
@@ -0,0 +1,183 @@
+import CourseBundle from "../models/course-bundle.model.js";
+import Course from "../models/Course.js";
+import User from "../models/User.js";
+import CourseProgress from "../models/CourseProgress.js";
+
+export class CourseBundleService {
+ /**
+ * Calculates original total price and discount percentage.
+ */
+ calculateDiscount(courses, bundlePrice) {
+ const originalPrice = courses.reduce((sum, course) => sum + (course.price || 0), 0);
+ let discountPercentage = 0;
+ if (originalPrice > 0 && bundlePrice < originalPrice) {
+ discountPercentage = Math.round(((originalPrice - bundlePrice) / originalPrice) * 100 * 100) / 100;
+ }
+ return { originalPrice, discountPercentage };
+ }
+
+ async createBundle({ title, description, courses: courseIds, price, currency = "USDC", createdBy }) {
+ if (!courseIds || !Array.isArray(courseIds) || courseIds.length === 0) {
+ throw new Error("A bundle must contain at least one course");
+ }
+
+ const fetchedCourses = await Course.find({ _id: { $in: courseIds } });
+ if (fetchedCourses.length !== courseIds.length) {
+ throw new Error("One or more specified courses do not exist");
+ }
+
+ const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, price);
+
+ const bundle = await CourseBundle.create({
+ title,
+ description,
+ courses: courseIds,
+ price,
+ currency,
+ originalPrice,
+ discountPercentage,
+ createdBy,
+ });
+
+ return await CourseBundle.findById(bundle._id)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async getBundles(query = {}) {
+ const filter = { isActive: true };
+ if (query.courseId) {
+ filter.courses = query.courseId;
+ }
+ if (query.createdBy) {
+ filter.createdBy = query.createdBy;
+ }
+ if (query.search) {
+ filter.$text = { $search: query.search };
+ }
+
+ return await CourseBundle.find(filter)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar")
+ .sort({ createdAt: -1 });
+ }
+
+ async getBundleById(bundleId) {
+ const bundle = await CourseBundle.findById(bundleId)
+ .populate("courses", "title description price thumbnail category rating createdBy")
+ .populate("createdBy", "name email avatar");
+
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+ return bundle;
+ }
+
+ async getBundlesByCourse(courseId) {
+ return await CourseBundle.find({ courses: courseId, isActive: true })
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async updateBundle(bundleId, updateData, userId, userRole) {
+ const bundle = await CourseBundle.findById(bundleId);
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+
+ if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) {
+ throw new Error("Not authorized to update this bundle");
+ }
+
+ if (updateData.courses || updateData.price !== undefined) {
+ const courseIds = updateData.courses || bundle.courses;
+ const bundlePrice = updateData.price !== undefined ? updateData.price : bundle.price;
+
+ const fetchedCourses = await Course.find({ _id: { $in: courseIds } });
+ if (fetchedCourses.length !== courseIds.length) {
+ throw new Error("One or more specified courses do not exist");
+ }
+
+ const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, bundlePrice);
+ updateData.originalPrice = originalPrice;
+ updateData.discountPercentage = discountPercentage;
+ }
+
+ Object.assign(bundle, updateData);
+ await bundle.save();
+
+ return await CourseBundle.findById(bundle._id)
+ .populate("courses", "title description price thumbnail category rating")
+ .populate("createdBy", "name email avatar");
+ }
+
+ async deleteBundle(bundleId, userId, userRole) {
+ const bundle = await CourseBundle.findById(bundleId);
+ if (!bundle) {
+ throw new Error("Course bundle not found");
+ }
+
+ if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) {
+ throw new Error("Not authorized to delete this bundle");
+ }
+
+ bundle.isActive = false;
+ await bundle.save();
+ return { success: true, message: "Bundle deactivated successfully" };
+ }
+
+ async purchaseBundle(bundleId, userId) {
+ const bundle = await CourseBundle.findById(bundleId).populate("courses");
+ if (!bundle || !bundle.isActive) {
+ throw new Error("Course bundle not found or inactive");
+ }
+
+ const user = await User.findById(userId);
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ let newlyEnrolledCount = 0;
+
+ for (const course of bundle.courses) {
+ const courseIdStr = course._id.toString();
+ const alreadyPurchased = user.purchasedCourses.some(
+ (pc) => pc.courseId && pc.courseId.toString() === courseIdStr
+ );
+
+ if (!alreadyPurchased) {
+ user.purchasedCourses.push({
+ courseId: course._id,
+ purchaseDate: new Date(),
+ });
+ user.stat.coursesEnrolled = (user.stat.coursesEnrolled || 0) + 1;
+ newlyEnrolledCount++;
+ }
+
+ const isEnrolledInCourse = course.enrolledUsers.some(
+ (uId) => uId.toString() === userId.toString()
+ );
+ if (!isEnrolledInCourse) {
+ course.enrolledUsers.push(userId);
+ await course.save();
+ }
+
+ await CourseProgress.findOneAndUpdate(
+ { user: userId, course: course._id },
+ { $setOnInsert: { user: userId, course: course._id, percentComplete: 0 } },
+ { upsert: true, new: true }
+ );
+ }
+
+ await user.save();
+
+ return {
+ success: true,
+ message: `Enrolled successfully in bundle '${bundle.title}'`,
+ bundle,
+ newlyEnrolledCount,
+ };
+ }
+}
+
+export default new CourseBundleService();
diff --git a/src/services/highlight.service.js b/src/services/highlight.service.js
new file mode 100644
index 00000000..50a8502f
--- /dev/null
+++ b/src/services/highlight.service.js
@@ -0,0 +1,204 @@
+import Highlight from "../models/highlight.model.js";
+import Note from "../models/note.model.js";
+import Book from "../models/Book.js";
+
+export class HighlightService {
+ async createHighlight({
+ userId,
+ bookId,
+ text,
+ color = "yellow",
+ pageNumber,
+ passage,
+ cfiRange,
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ const highlight = await Highlight.create({
+ user: userId,
+ book: bookId,
+ text,
+ color,
+ pageNumber,
+ passage,
+ cfiRange,
+ });
+
+ return highlight;
+ }
+
+ async getHighlights({ userId, bookId }) {
+ const query = { user: userId };
+ if (bookId) {
+ query.book = bookId;
+ }
+ return Highlight.find(query).sort({ createdAt: -1 });
+ }
+
+ async deleteHighlight({ userId, highlightId }) {
+ const highlight = await Highlight.findOneAndDelete({ _id: highlightId, user: userId });
+ if (!highlight) {
+ throw new Error("Highlight not found or unauthorized");
+ }
+ await Note.deleteMany({ highlight: highlightId, user: userId });
+ return highlight;
+ }
+
+ async createNote({
+ userId,
+ bookId,
+ highlightId,
+ content,
+ pageNumber,
+ passage,
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ if (highlightId) {
+ const highlight = await Highlight.findById(highlightId);
+ if (!highlight) {
+ throw new Error("Target highlight not found");
+ }
+ }
+
+ const note = await Note.create({
+ user: userId,
+ book: bookId,
+ highlight: highlightId || undefined,
+ content,
+ pageNumber,
+ passage,
+ });
+
+ return note;
+ }
+
+ async getNotes({ userId, bookId }) {
+ const query = { user: userId };
+ if (bookId) {
+ query.book = bookId;
+ }
+ return Note.find(query).populate("highlight").sort({ createdAt: -1 });
+ }
+
+ async deleteNote({ userId, noteId }) {
+ const note = await Note.findOneAndDelete({ _id: noteId, user: userId });
+ if (!note) {
+ throw new Error("Note not found or unauthorized");
+ }
+ return note;
+ }
+
+ async getHighlightsAndNotes({ userId, bookId }) {
+ const [highlights, notes] = await Promise.all([
+ this.getHighlights({ userId, bookId }),
+ this.getNotes({ userId, bookId }),
+ ]);
+
+ return {
+ bookId,
+ highlights,
+ notes,
+ };
+ }
+
+ async searchHighlightsAndNotes({
+ userId,
+ bookId,
+ query,
+ }) {
+ if (!query || query.trim() === "") {
+ return { highlights: [], notes: [] };
+ }
+
+ const regex = new RegExp(query, "i");
+ const filter = { user: userId };
+ if (bookId) {
+ filter.book = bookId;
+ }
+
+ const [highlights, notes] = await Promise.all([
+ Highlight.find({
+ ...filter,
+ $or: [{ text: regex }, { passage: regex }],
+ }).sort({ createdAt: -1 }),
+ Note.find({
+ ...filter,
+ $or: [{ content: regex }, { passage: regex }],
+ })
+ .populate("highlight")
+ .sort({ createdAt: -1 }),
+ ]);
+
+ return { highlights, notes };
+ }
+
+ async exportHighlights({
+ userId,
+ bookId,
+ format = "text",
+ }) {
+ const book = await Book.findById(bookId);
+ const { highlights, notes } = await this.getHighlightsAndNotes({ userId, bookId });
+
+ const title = book ? book.title : "Book Highlights & Notes";
+ const author = book ? book.author : "Unknown Author";
+
+ if (format === "pdf") {
+ const pdfHeader = `%PDF-1.4\n1 0 obj << /Title (${title}) /Author (${author}) >> endobj\n`;
+ let pdfContent = `HIGHLIGHTS & NOTES FOR: ${title} by ${author}\n\n`;
+ pdfContent += `--- HIGHLIGHTS (${highlights.length}) ---\n`;
+ highlights.forEach((h, idx) => {
+ pdfContent += `${idx + 1}. [${h.color.toUpperCase()}] Page ${h.pageNumber || "N/A"}: "${h.text}"\n`;
+ });
+ pdfContent += `\n--- NOTES (${notes.length}) ---\n`;
+ notes.forEach((n, idx) => {
+ pdfContent += `${idx + 1}. Page ${n.pageNumber || "N/A"}: ${n.content}\n`;
+ });
+
+ return {
+ format: "pdf",
+ mimeType: "application/pdf",
+ filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.pdf`,
+ content: pdfHeader + Buffer.from(pdfContent).toString("utf8"),
+ rawText: pdfContent,
+ };
+ }
+
+ let textExport = `=========================================\n`;
+ textExport += `BOOK: ${title}\n`;
+ textExport += `AUTHOR: ${author}\n`;
+ textExport += `EXPORTED: ${new Date().toISOString()}\n`;
+ textExport += `=========================================\n\n`;
+
+ textExport += `HIGHLIGHTS (${highlights.length})\n`;
+ textExport += `-----------------------------------------\n`;
+ highlights.forEach((h, i) => {
+ textExport += `${i + 1}. Color: ${h.color} | Page: ${h.pageNumber || "N/A"}\n`;
+ textExport += ` "${h.text}"\n\n`;
+ });
+
+ textExport += `NOTES (${notes.length})\n`;
+ textExport += `-----------------------------------------\n`;
+ notes.forEach((n, i) => {
+ textExport += `${i + 1}. Page: ${n.pageNumber || "N/A"}\n`;
+ textExport += ` Note: ${n.content}\n\n`;
+ });
+
+ return {
+ format: "text",
+ mimeType: "text/plain",
+ filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.txt`,
+ content: textExport,
+ };
+ }
+}
+
+export const highlightService = new HighlightService();
+export default highlightService;
diff --git a/src/services/highlight.service.ts b/src/services/highlight.service.ts
new file mode 100644
index 00000000..805913b7
--- /dev/null
+++ b/src/services/highlight.service.ts
@@ -0,0 +1,257 @@
+import Highlight from "../models/highlight.model.ts";
+import Note from "../models/note.model.ts";
+import Book from "../models/Book.js";
+
+export class HighlightService {
+ /**
+ * Save a new text highlight for a book.
+ */
+ async createHighlight({
+ userId,
+ bookId,
+ text,
+ color = "yellow",
+ pageNumber,
+ passage,
+ cfiRange,
+ }: {
+ userId: string;
+ bookId: string;
+ text: string;
+ color?: string;
+ pageNumber?: number;
+ passage?: string;
+ cfiRange?: string;
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ const highlight = await Highlight.create({
+ user: userId,
+ book: bookId,
+ text,
+ color,
+ pageNumber,
+ passage,
+ cfiRange,
+ });
+
+ return highlight;
+ }
+
+ /**
+ * Get all highlights for a book by a specific user.
+ */
+ async getHighlights({ userId, bookId }: { userId: string; bookId?: string }) {
+ const query: any = { user: userId };
+ if (bookId) {
+ query.book = bookId;
+ }
+ return Highlight.find(query).sort({ createdAt: -1 });
+ }
+
+ /**
+ * Delete a highlight owned by user.
+ */
+ async deleteHighlight({ userId, highlightId }: { userId: string; highlightId: string }) {
+ const highlight = await Highlight.findOneAndDelete({ _id: highlightId, user: userId });
+ if (!highlight) {
+ throw new Error("Highlight not found or unauthorized");
+ }
+ // Delete associated notes if any
+ await Note.deleteMany({ highlight: highlightId, user: userId });
+ return highlight;
+ }
+
+ /**
+ * Add a note to a specific passage, page, or highlight.
+ */
+ async createNote({
+ userId,
+ bookId,
+ highlightId,
+ content,
+ pageNumber,
+ passage,
+ }: {
+ userId: string;
+ bookId: string;
+ highlightId?: string;
+ content: string;
+ pageNumber?: number;
+ passage?: string;
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ if (highlightId) {
+ const highlight = await Highlight.findById(highlightId);
+ if (!highlight) {
+ throw new Error("Target highlight not found");
+ }
+ }
+
+ const note = await Note.create({
+ user: userId,
+ book: bookId,
+ highlight: highlightId || undefined,
+ content,
+ pageNumber,
+ passage,
+ });
+
+ return note;
+ }
+
+ /**
+ * Get all notes for a book by user.
+ */
+ async getNotes({ userId, bookId }: { userId: string; bookId?: string }) {
+ const query: any = { user: userId };
+ if (bookId) {
+ query.book = bookId;
+ }
+ return Note.find(query).populate("highlight").sort({ createdAt: -1 });
+ }
+
+ /**
+ * Delete a note owned by user.
+ */
+ async deleteNote({ userId, noteId }: { userId: string; noteId: string }) {
+ const note = await Note.findOneAndDelete({ _id: noteId, user: userId });
+ if (!note) {
+ throw new Error("Note not found or unauthorized");
+ }
+ return note;
+ }
+
+ /**
+ * View all highlights and notes for a book.
+ */
+ async getHighlightsAndNotes({ userId, bookId }: { userId: string; bookId: string }) {
+ const [highlights, notes] = await Promise.all([
+ this.getHighlights({ userId, bookId }),
+ this.getNotes({ userId, bookId }),
+ ]);
+
+ return {
+ bookId,
+ highlights,
+ notes,
+ };
+ }
+
+ /**
+ * Search through highlights and notes.
+ */
+ async searchHighlightsAndNotes({
+ userId,
+ bookId,
+ query,
+ }: {
+ userId: string;
+ bookId?: string;
+ query: string;
+ }) {
+ if (!query || query.trim() === "") {
+ return { highlights: [], notes: [] };
+ }
+
+ const regex = new RegExp(query, "i");
+ const filter: any = { user: userId };
+ if (bookId) {
+ filter.book = bookId;
+ }
+
+ const [highlights, notes] = await Promise.all([
+ Highlight.find({
+ ...filter,
+ $or: [{ text: regex }, { passage: regex }],
+ }).sort({ createdAt: -1 }),
+ Note.find({
+ ...filter,
+ $or: [{ content: regex }, { passage: regex }],
+ })
+ .populate("highlight")
+ .sort({ createdAt: -1 }),
+ ]);
+
+ return { highlights, notes };
+ }
+
+ /**
+ * Export highlights and notes as text or PDF formatted structure.
+ */
+ async exportHighlights({
+ userId,
+ bookId,
+ format = "text",
+ }: {
+ userId: string;
+ bookId: string;
+ format?: string;
+ }) {
+ const book = await Book.findById(bookId);
+ const { highlights, notes } = await this.getHighlightsAndNotes({ userId, bookId });
+
+ const title = book ? book.title : "Book Highlights & Notes";
+ const author = book ? book.author : "Unknown Author";
+
+ if (format === "pdf") {
+ // Build PDF document format string / buffer structure
+ const pdfHeader = `%PDF-1.4\n1 0 obj << /Title (${title}) /Author (${author}) >> endobj\n`;
+ let pdfContent = `HIGHLIGHTS & NOTES FOR: ${title} by ${author}\n\n`;
+ pdfContent += `--- HIGHLIGHTS (${highlights.length}) ---\n`;
+ highlights.forEach((h: any, idx: number) => {
+ pdfContent += `${idx + 1}. [${h.color.toUpperCase()}] Page ${h.pageNumber || "N/A"}: "${h.text}"\n`;
+ });
+ pdfContent += `\n--- NOTES (${notes.length}) ---\n`;
+ notes.forEach((n: any, idx: number) => {
+ pdfContent += `${idx + 1}. Page ${n.pageNumber || "N/A"}: ${n.content}\n`;
+ });
+
+ return {
+ format: "pdf",
+ mimeType: "application/pdf",
+ filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.pdf`,
+ content: pdfHeader + Buffer.from(pdfContent).toString("utf8"),
+ rawText: pdfContent,
+ };
+ }
+
+ // Default text format
+ let textExport = `=========================================\n`;
+ textExport += `BOOK: ${title}\n`;
+ textExport += `AUTHOR: ${author}\n`;
+ textExport += `EXPORTED: ${new Date().toISOString()}\n`;
+ textExport += `=========================================\n\n`;
+
+ textExport += `HIGHLIGHTS (${highlights.length})\n`;
+ textExport += `-----------------------------------------\n`;
+ highlights.forEach((h: any, i: number) => {
+ textExport += `${i + 1}. Color: ${h.color} | Page: ${h.pageNumber || "N/A"}\n`;
+ textExport += ` "${h.text}"\n\n`;
+ });
+
+ textExport += `NOTES (${notes.length})\n`;
+ textExport += `-----------------------------------------\n`;
+ notes.forEach((n: any, i: number) => {
+ textExport += `${i + 1}. Page: ${n.pageNumber || "N/A"}\n`;
+ textExport += ` Note: ${n.content}\n\n`;
+ });
+
+ return {
+ format: "text",
+ mimeType: "text/plain",
+ filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.txt`,
+ content: textExport,
+ };
+ }
+}
+
+export const highlightService = new HighlightService();
+export default highlightService;
diff --git a/src/services/messaging.service.ts b/src/services/messaging.service.ts
new file mode 100644
index 00000000..6dd0999b
--- /dev/null
+++ b/src/services/messaging.service.ts
@@ -0,0 +1,131 @@
+import Conversation from "../models/conversation.model.ts";
+import Message from "../models/message.model.ts";
+
+export class MessagingService {
+ async getOrCreateConversation(userId1: string, userId2: string) {
+ const sorted = [userId1, userId2].sort();
+
+ let conversation = await Conversation.findOne({
+ participants: { $all: sorted, $size: 2 },
+ });
+
+ if (!conversation) {
+ conversation = await Conversation.create({
+ participants: sorted,
+ });
+ }
+
+ return conversation.populate("participants", "name email avatar");
+ }
+
+ async sendMessage({
+ conversationId,
+ senderId,
+ text,
+ image,
+ }: {
+ conversationId: string;
+ senderId: string;
+ text?: string;
+ image?: string;
+ }) {
+ const conversation = await Conversation.findById(conversationId);
+ if (!conversation) {
+ throw new Error("Conversation not found");
+ }
+
+ if (!conversation.participants.some((p: any) => p.toString() === senderId)) {
+ throw new Error("You are not a participant in this conversation");
+ }
+
+ const message = await Message.create({
+ conversation: conversationId,
+ sender: senderId,
+ text: text || "",
+ image: image || undefined,
+ readBy: [senderId],
+ });
+
+ conversation.lastMessage = message._id as any;
+ conversation.lastMessageAt = new Date();
+ await conversation.save();
+
+ return message.populate([
+ { path: "sender", select: "name email avatar" },
+ { path: "readBy", select: "name email avatar" },
+ ]);
+ }
+
+ async getConversations(userId: string) {
+ const conversations = await Conversation.find({
+ participants: userId,
+ })
+ .populate("participants", "name email avatar")
+ .populate("lastMessage")
+ .sort({ lastMessageAt: -1 });
+
+ return conversations;
+ }
+
+ async getMessages({
+ conversationId,
+ userId,
+ page = 1,
+ limit = 30,
+ }: {
+ conversationId: string;
+ userId: string;
+ page?: number;
+ limit?: number;
+ }) {
+ const conversation = await Conversation.findById(conversationId);
+ if (!conversation) {
+ throw new Error("Conversation not found");
+ }
+
+ if (!conversation.participants.some((p: any) => p.toString() === userId)) {
+ throw new Error("You are not a participant in this conversation");
+ }
+
+ const skip = (page - 1) * limit;
+ const messages = await Message.find({ conversation: conversationId })
+ .populate("sender", "name email avatar")
+ .populate("readBy", "name email avatar")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit);
+
+ const total = await Message.countDocuments({ conversation: conversationId });
+
+ return {
+ messages,
+ pagination: {
+ page,
+ limit,
+ total,
+ pages: Math.ceil(total / limit),
+ },
+ };
+ }
+
+ async markAsRead({
+ conversationId,
+ userId,
+ }: {
+ conversationId: string;
+ userId: string;
+ }) {
+ await Message.updateMany(
+ {
+ conversation: conversationId,
+ readBy: { $ne: userId },
+ },
+ { $addToSet: { readBy: userId } }
+ );
+
+ return { success: true };
+ }
+}
+
+export const messagingService = new MessagingService();
+export default messagingService;
diff --git a/src/services/moderation.service.js b/src/services/moderation.service.js
new file mode 100644
index 00000000..fb16a858
--- /dev/null
+++ b/src/services/moderation.service.js
@@ -0,0 +1,214 @@
+import ContentFlag from "../models/content-flag.model.js";
+import ModerationAction from "../models/moderation-action.model.js";
+import Reel from "../models/Reel.js";
+import Notification from "../models/Notification.js";
+
+const DEFAULT_PROHIBITED_KEYWORDS = [
+ "hate",
+ "scam",
+ "violence",
+ "abuse",
+ "explicit",
+ "spam",
+ "illegal",
+ "offensive",
+ "nude",
+ "porn",
+ "gambling",
+ "harassment",
+];
+
+export class ModerationService {
+ checkKeywords(text, keywords = DEFAULT_PROHIBITED_KEYWORDS) {
+ if (!text) return [];
+ const lowerText = text.toLowerCase();
+ return keywords.filter((kw) => {
+ const regex = new RegExp(`\\b${kw}\\b`, "i");
+ return regex.test(lowerText);
+ });
+ }
+
+ async autoFlagReel(reel) {
+ const textToScan = `${reel.title || ""} ${reel.description || ""} ${(reel.tags || []).join(" ")}`;
+ const matchedKeywords = this.checkKeywords(textToScan);
+
+ if (matchedKeywords.length > 0) {
+ const flag = await ContentFlag.create({
+ reel: reel._id,
+ reporter: null,
+ reason: "Auto-flagged keyword filter",
+ details: `Content matched flagged keywords: ${matchedKeywords.join(", ")}`,
+ status: "pending",
+ isAutoFlagged: true,
+ flaggedKeywords: matchedKeywords,
+ });
+
+ return flag;
+ }
+
+ return null;
+ }
+
+ async flagReel({
+ reelId,
+ reporterId,
+ reason,
+ details,
+ }) {
+ const reel = await Reel.findById(reelId);
+ if (!reel) {
+ throw new Error("Reel not found");
+ }
+
+ const flag = await ContentFlag.create({
+ reel: reelId,
+ reporter: reporterId,
+ reason,
+ details,
+ status: "pending",
+ isAutoFlagged: false,
+ });
+
+ return flag;
+ }
+
+ async getModerationQueue({
+ status = "pending",
+ page = 1,
+ limit = 20,
+ }) {
+ const query = {};
+ if (status && status !== "all") {
+ query.status = status;
+ }
+
+ const skip = (page - 1) * limit;
+ const [flags, total] = await Promise.all([
+ ContentFlag.find(query)
+ .populate("reel")
+ .populate("reporter", "name email avatar")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ContentFlag.countDocuments(query),
+ ]);
+
+ return {
+ flags,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+
+ async processModerationAction({
+ flagId,
+ reelId,
+ adminId,
+ action,
+ notes,
+ }) {
+ let flag = null;
+ if (flagId) {
+ flag = await ContentFlag.findById(flagId);
+ } else if (reelId) {
+ flag = await ContentFlag.findOne({ reel: reelId, status: "pending" });
+ }
+
+ const targetReelId = flag ? flag.reel : reelId;
+ const reel = await Reel.findById(targetReelId);
+ if (!reel) {
+ throw new Error("Reel not found");
+ }
+
+ let updatedStatus = "pending";
+ if (action === "approve") {
+ updatedStatus = "approved";
+ } else if (action === "reject") {
+ updatedStatus = "rejected";
+ } else if (action === "remove") {
+ updatedStatus = "removed";
+ reel.status = "removed";
+ reel.isRemoved = true;
+ await reel.save();
+ }
+
+ if (flag) {
+ flag.status = updatedStatus;
+ await flag.save();
+ }
+
+ const moderationAction = await ModerationAction.create({
+ flag: flag ? flag._id : undefined,
+ reel: reel._id,
+ admin: adminId,
+ action,
+ notes,
+ });
+
+ const creatorId = reel.user || reel.author || reel.creator;
+ if (creatorId) {
+ const decisionText =
+ action === "remove"
+ ? "has been removed due to community guideline violations."
+ : action === "approve"
+ ? "has been reviewed and approved."
+ : "flag review has been resolved.";
+
+ await Notification.create({
+ recipient: creatorId,
+ sender: adminId,
+ type: "system",
+ title: "Reel Moderation Notice",
+ message: `Your reel "${reel.title || "Content"}" ${decisionText}`,
+ data: { reelId: reel._id },
+ priority: action === "remove" ? "high" : "medium",
+ }).catch(() => {});
+ }
+
+ return {
+ flag,
+ moderationAction,
+ reel,
+ };
+ }
+
+ async getModerationHistory({
+ reelId,
+ adminId,
+ page = 1,
+ limit = 20,
+ }) {
+ const query = {};
+ if (reelId) query.reel = reelId;
+ if (adminId) query.admin = adminId;
+
+ const skip = (page - 1) * limit;
+ const [actions, total] = await Promise.all([
+ ModerationAction.find(query)
+ .populate("reel")
+ .populate("admin", "name email avatar")
+ .populate("flag")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ModerationAction.countDocuments(query),
+ ]);
+
+ return {
+ actions,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+}
+
+export const moderationService = new ModerationService();
+export default moderationService;
diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts
new file mode 100644
index 00000000..b0306435
--- /dev/null
+++ b/src/services/moderation.service.ts
@@ -0,0 +1,256 @@
+import ContentFlag from "../models/content-flag.model.ts";
+import ModerationAction from "../models/moderation-action.model.ts";
+import Reel from "../models/Reel.js";
+import Notification from "../models/Notification.js";
+
+const DEFAULT_PROHIBITED_KEYWORDS = [
+ "hate",
+ "scam",
+ "violence",
+ "abuse",
+ "explicit",
+ "spam",
+ "illegal",
+ "offensive",
+ "nude",
+ "porn",
+ "gambling",
+ "harassment",
+];
+
+export class ModerationService {
+ /**
+ * Check text content for prohibited keywords.
+ */
+ checkKeywords(text: string, keywords: string[] = DEFAULT_PROHIBITED_KEYWORDS): string[] {
+ if (!text) return [];
+ const lowerText = text.toLowerCase();
+ return keywords.filter((kw) => {
+ const regex = new RegExp(`\\b${kw}\\b`, "i");
+ return regex.test(lowerText);
+ });
+ }
+
+ /**
+ * Automatically flag a reel if it contains inappropriate keyword content.
+ */
+ async autoFlagReel(reel: any) {
+ const textToScan = `${reel.title || ""} ${reel.description || ""} ${(reel.tags || []).join(" ")}`;
+ const matchedKeywords = this.checkKeywords(textToScan);
+
+ if (matchedKeywords.length > 0) {
+ const flag = await ContentFlag.create({
+ reel: reel._id,
+ reporter: null,
+ reason: "Auto-flagged keyword filter",
+ details: `Content matched flagged keywords: ${matchedKeywords.join(", ")}`,
+ status: "pending",
+ isAutoFlagged: true,
+ flaggedKeywords: matchedKeywords,
+ });
+
+ return flag;
+ }
+
+ return null;
+ }
+
+ /**
+ * User flags a reel for inappropriate content.
+ */
+ async flagReel({
+ reelId,
+ reporterId,
+ reason,
+ details,
+ }: {
+ reelId: string;
+ reporterId: string;
+ reason: string;
+ details?: string;
+ }) {
+ const reel = await Reel.findById(reelId);
+ if (!reel) {
+ throw new Error("Reel not found");
+ }
+
+ const flag = await ContentFlag.create({
+ reel: reelId,
+ reporter: reporterId,
+ reason,
+ details,
+ status: "pending",
+ isAutoFlagged: false,
+ });
+
+ return flag;
+ }
+
+ /**
+ * Fetch admin moderation queue for flagged reels.
+ */
+ async getModerationQueue({
+ status = "pending",
+ page = 1,
+ limit = 20,
+ }: {
+ status?: string;
+ page?: number;
+ limit?: number;
+ }) {
+ const query: any = {};
+ if (status && status !== "all") {
+ query.status = status;
+ }
+
+ const skip = (page - 1) * limit;
+ const [flags, total] = await Promise.all([
+ ContentFlag.find(query)
+ .populate("reel")
+ .populate("reporter", "name email avatar")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ContentFlag.countDocuments(query),
+ ]);
+
+ return {
+ flags,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+
+ /**
+ * Process a moderation action (approve, reject, remove) on a flagged reel.
+ */
+ async processModerationAction({
+ flagId,
+ reelId,
+ adminId,
+ action,
+ notes,
+ }: {
+ flagId?: string;
+ reelId?: string;
+ adminId: string;
+ action: "approve" | "reject" | "remove";
+ notes?: string;
+ }) {
+ let flag: any = null;
+ if (flagId) {
+ flag = await ContentFlag.findById(flagId);
+ } else if (reelId) {
+ flag = await ContentFlag.findOne({ reel: reelId, status: "pending" });
+ }
+
+ const targetReelId = flag ? flag.reel : reelId;
+ const reel = await Reel.findById(targetReelId);
+ if (!reel) {
+ throw new Error("Reel not found");
+ }
+
+ let updatedStatus = "pending";
+ if (action === "approve") {
+ updatedStatus = "approved";
+ } else if (action === "reject") {
+ updatedStatus = "rejected";
+ } else if (action === "remove") {
+ updatedStatus = "removed";
+ // Perform content removal (mark reel removed / inactive or delete)
+ reel.status = "removed";
+ reel.isRemoved = true;
+ await reel.save();
+ }
+
+ if (flag) {
+ flag.status = updatedStatus;
+ await flag.save();
+ }
+
+ const moderationAction = await ModerationAction.create({
+ flag: flag ? flag._id : undefined,
+ reel: reel._id,
+ admin: adminId,
+ action,
+ notes,
+ });
+
+ // Notify content creator of moderation decision
+ const creatorId = reel.user || reel.author || reel.creator;
+ if (creatorId) {
+ const decisionText =
+ action === "remove"
+ ? "has been removed due to community guideline violations."
+ : action === "approve"
+ ? "has been reviewed and approved."
+ : "flag review has been resolved.";
+
+ await Notification.create({
+ recipient: creatorId,
+ sender: adminId,
+ type: "system",
+ title: "Reel Moderation Notice",
+ message: `Your reel "${reel.title || "Content"}" ${decisionText}`,
+ data: { reelId: reel._id },
+ priority: action === "remove" ? "high" : "medium",
+ }).catch((err) => {
+ // Notification creation logging fallback
+ });
+ }
+
+ return {
+ flag,
+ moderationAction,
+ reel,
+ };
+ }
+
+ /**
+ * Retrieve moderation history and audit log.
+ */
+ async getModerationHistory({
+ reelId,
+ adminId,
+ page = 1,
+ limit = 20,
+ }: {
+ reelId?: string;
+ adminId?: string;
+ page?: number;
+ limit?: number;
+ }) {
+ const query: any = {};
+ if (reelId) query.reel = reelId;
+ if (adminId) query.admin = adminId;
+
+ const skip = (page - 1) * limit;
+ const [actions, total] = await Promise.all([
+ ModerationAction.find(query)
+ .populate("reel")
+ .populate("admin", "name email avatar")
+ .populate("flag")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ModerationAction.countDocuments(query),
+ ]);
+
+ return {
+ actions,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+}
+
+export const moderationService = new ModerationService();
+export default moderationService;
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/reading-group.service.js b/src/services/reading-group.service.js
new file mode 100644
index 00000000..0b44b00c
--- /dev/null
+++ b/src/services/reading-group.service.js
@@ -0,0 +1,280 @@
+import ReadingGroup from "../models/reading-group.model.js";
+import ReadingGroupMember from "../models/reading-group-member.model.js";
+import Book from "../models/Book.js";
+import Notification from "../models/Notification.js";
+
+export class ReadingGroupService {
+ async createGroup({
+ name,
+ description,
+ bookId,
+ creatorId,
+ privacy = "public",
+ chaptersPerWeek = 1,
+ readingSchedule = [],
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ const group = await ReadingGroup.create({
+ name,
+ description,
+ book: bookId,
+ creator: creatorId,
+ privacy,
+ chaptersPerWeek,
+ readingSchedule,
+ });
+
+ await ReadingGroupMember.create({
+ group: group._id,
+ user: creatorId,
+ role: "admin",
+ status: "active",
+ });
+
+ return group;
+ }
+
+ async getGroups({
+ bookId,
+ privacy,
+ search,
+ page = 1,
+ limit = 20,
+ }) {
+ const query = {};
+ if (bookId) query.book = bookId;
+ if (privacy) query.privacy = privacy;
+ if (search && search.trim() !== "") {
+ const regex = new RegExp(search, "i");
+ query.$or = [{ name: regex }, { description: regex }];
+ }
+
+ const skip = (page - 1) * limit;
+ const [groups, total] = await Promise.all([
+ ReadingGroup.find(query)
+ .populate("book", "title author thumbnail")
+ .populate("creator", "name email avatar")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ReadingGroup.countDocuments(query),
+ ]);
+
+ return {
+ groups,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+
+ async getGroupDetails(groupId, userId) {
+ const group = await ReadingGroup.findById(groupId)
+ .populate("book", "title author thumbnail totalPages chapters")
+ .populate("creator", "name email avatar");
+
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const membersCount = await ReadingGroupMember.countDocuments({ group: group._id, status: "active" });
+
+ let userMembership = null;
+ if (userId) {
+ userMembership = await ReadingGroupMember.findOne({ group: group._id, user: userId });
+ }
+
+ return {
+ group,
+ membersCount,
+ userMembership,
+ };
+ }
+
+ async joinGroup(groupId, userId) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const status = group.privacy === "private" ? "pending" : "active";
+
+ const membership = await ReadingGroupMember.findOneAndUpdate(
+ { group: groupId, user: userId },
+ {
+ role: "member",
+ status,
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ return membership;
+ }
+
+ async inviteMember(groupId, adminId, targetUserId) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" });
+ const isCreator = group.creator.toString() === adminId.toString();
+
+ if (!adminMember && !isCreator) {
+ throw new Error("Only group admins can invite members");
+ }
+
+ const membership = await ReadingGroupMember.findOneAndUpdate(
+ { group: groupId, user: targetUserId },
+ {
+ role: "member",
+ status: "invited",
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ await Notification.create({
+ recipient: targetUserId,
+ sender: adminId,
+ type: "system",
+ title: "Reading Group Invitation",
+ message: `You have been invited to join the reading group "${group.name}".`,
+ data: { bookId: group.book },
+ priority: "medium",
+ }).catch(() => {});
+
+ return membership;
+ }
+
+ async updateSchedule(
+ groupId,
+ adminId,
+ readingSchedule,
+ chaptersPerWeek
+ ) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" });
+ const isCreator = group.creator.toString() === adminId.toString();
+
+ if (!adminMember && !isCreator) {
+ throw new Error("Only group admins can update the reading schedule");
+ }
+
+ if (readingSchedule) {
+ group.readingSchedule = readingSchedule;
+ }
+ if (chaptersPerWeek !== undefined) {
+ group.chaptersPerWeek = chaptersPerWeek;
+ }
+
+ await group.save();
+ return group;
+ }
+
+ async addDiscussionPost({
+ groupId,
+ chapter,
+ userId,
+ content,
+ }) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const member = await ReadingGroupMember.findOne({ group: groupId, user: userId, status: "active" });
+ if (!member) {
+ throw new Error("You must be an active member of this group to post discussions");
+ }
+
+ const post = {
+ chapter,
+ user: userId,
+ content,
+ createdAt: new Date(),
+ };
+
+ group.discussions.push(post);
+ await group.save();
+
+ return group.discussions;
+ }
+
+ async getDiscussions(groupId, chapter) {
+ const group = await ReadingGroup.findById(groupId).populate("discussions.user", "name email avatar");
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ let discussions = group.discussions;
+ if (chapter !== undefined && !isNaN(chapter)) {
+ discussions = discussions.filter((d) => d.chapter === Number(chapter));
+ }
+
+ return discussions;
+ }
+
+ async updateMemberProgress({
+ groupId,
+ userId,
+ currentChapter,
+ currentProgressPercent,
+ }) {
+ const member = await ReadingGroupMember.findOne({ group: groupId, user: userId });
+ if (!member) {
+ throw new Error("Member not found in reading group");
+ }
+
+ if (currentChapter !== undefined) member.currentChapter = currentChapter;
+ if (currentProgressPercent !== undefined) member.currentProgressPercent = currentProgressPercent;
+ member.lastReadDate = new Date();
+
+ await member.save();
+ return member;
+ }
+
+ async getMemberProgressDashboard(groupId) {
+ const group = await ReadingGroup.findById(groupId).populate("book", "title totalPages");
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const members = await ReadingGroupMember.find({ group: groupId, status: "active" })
+ .populate("user", "name email avatar")
+ .sort({ currentProgressPercent: -1, currentChapter: -1 });
+
+ const totalMembers = members.length;
+ const avgProgress =
+ totalMembers > 0
+ ? Number((members.reduce((sum, m) => sum + m.currentProgressPercent, 0) / totalMembers).toFixed(1))
+ : 0;
+
+ return {
+ group: {
+ _id: group._id,
+ name: group.name,
+ book: group.book,
+ chaptersPerWeek: group.chaptersPerWeek,
+ },
+ stats: {
+ totalMembers,
+ avgProgressPercent: avgProgress,
+ },
+ membersProgress: members,
+ };
+ }
+}
+
+export const readingGroupService = new ReadingGroupService();
+export default readingGroupService;
diff --git a/src/services/reading-group.service.ts b/src/services/reading-group.service.ts
new file mode 100644
index 00000000..00ea834a
--- /dev/null
+++ b/src/services/reading-group.service.ts
@@ -0,0 +1,335 @@
+import ReadingGroup from "../models/reading-group.model.ts";
+import ReadingGroupMember from "../models/reading-group-member.model.ts";
+import Book from "../models/Book.js";
+import Notification from "../models/Notification.js";
+
+export class ReadingGroupService {
+ /**
+ * Create a reading group / book club for a specific book.
+ */
+ async createGroup({
+ name,
+ description,
+ bookId,
+ creatorId,
+ privacy = "public",
+ chaptersPerWeek = 1,
+ readingSchedule = [],
+ }: {
+ name: string;
+ description?: string;
+ bookId: string;
+ creatorId: string;
+ privacy?: "public" | "private";
+ chaptersPerWeek?: number;
+ readingSchedule?: Array<{ chapter: number; title?: string; targetPages?: string; startDate?: Date; endDate?: Date }>;
+ }) {
+ const book = await Book.findById(bookId);
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ const group = await ReadingGroup.create({
+ name,
+ description,
+ book: bookId,
+ creator: creatorId,
+ privacy,
+ chaptersPerWeek,
+ readingSchedule,
+ });
+
+ // Automatically add creator as admin member
+ await ReadingGroupMember.create({
+ group: group._id,
+ user: creatorId,
+ role: "admin",
+ status: "active",
+ });
+
+ return group;
+ }
+
+ /**
+ * List reading groups with search & filter options.
+ */
+ async getGroups({
+ bookId,
+ privacy,
+ search,
+ page = 1,
+ limit = 20,
+ }: {
+ bookId?: string;
+ privacy?: string;
+ search?: string;
+ page?: number;
+ limit?: number;
+ }) {
+ const query: any = {};
+ if (bookId) query.book = bookId;
+ if (privacy) query.privacy = privacy;
+ if (search && search.trim() !== "") {
+ const regex = new RegExp(search, "i");
+ query.$or = [{ name: regex }, { description: regex }];
+ }
+
+ const skip = (page - 1) * limit;
+ const [groups, total] = await Promise.all([
+ ReadingGroup.find(query)
+ .populate("book", "title author thumbnail")
+ .populate("creator", "name email avatar")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit),
+ ReadingGroup.countDocuments(query),
+ ]);
+
+ return {
+ groups,
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+ }
+
+ /**
+ * Get detailed info for a reading group.
+ */
+ async getGroupDetails(groupId: string, userId?: string) {
+ const group = await ReadingGroup.findById(groupId)
+ .populate("book", "title author thumbnail totalPages chapters")
+ .populate("creator", "name email avatar");
+
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const membersCount = await ReadingGroupMember.countDocuments({ group: group._id, status: "active" });
+
+ let userMembership = null;
+ if (userId) {
+ userMembership = await ReadingGroupMember.findOne({ group: group._id, user: userId });
+ }
+
+ return {
+ group,
+ membersCount,
+ userMembership,
+ };
+ }
+
+ /**
+ * Join a reading group or submit join request.
+ */
+ async joinGroup(groupId: string, userId: string) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const status = group.privacy === "private" ? "pending" : "active";
+
+ const membership = await ReadingGroupMember.findOneAndUpdate(
+ { group: groupId, user: userId },
+ {
+ role: "member",
+ status,
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ return membership;
+ }
+
+ /**
+ * Invite a user to a reading group (Group admin only).
+ */
+ async inviteMember(groupId: string, adminId: string, targetUserId: string) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" });
+ const isCreator = group.creator.toString() === adminId.toString();
+
+ if (!adminMember && !isCreator) {
+ throw new Error("Only group admins can invite members");
+ }
+
+ const membership = await ReadingGroupMember.findOneAndUpdate(
+ { group: groupId, user: targetUserId },
+ {
+ role: "member",
+ status: "invited",
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ await Notification.create({
+ recipient: targetUserId,
+ sender: adminId,
+ type: "system",
+ title: "Reading Group Invitation",
+ message: `You have been invited to join the reading group "${group.name}".`,
+ data: { bookId: group.book },
+ priority: "medium",
+ }).catch(() => {});
+
+ return membership;
+ }
+
+ /**
+ * Update reading schedule (chapters per week, target dates/pages).
+ */
+ async updateSchedule(
+ groupId: string,
+ adminId: string,
+ readingSchedule: Array<{ chapter: number; title?: string; targetPages?: string; startDate?: Date; endDate?: Date }>,
+ chaptersPerWeek?: number
+ ) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" });
+ const isCreator = group.creator.toString() === adminId.toString();
+
+ if (!adminMember && !isCreator) {
+ throw new Error("Only group admins can update the reading schedule");
+ }
+
+ if (readingSchedule) {
+ group.readingSchedule = readingSchedule as any;
+ }
+ if (chaptersPerWeek !== undefined) {
+ group.chaptersPerWeek = chaptersPerWeek;
+ }
+
+ await group.save();
+ return group;
+ }
+
+ /**
+ * Add a post to a chapter discussion thread.
+ */
+ async addDiscussionPost({
+ groupId,
+ chapter,
+ userId,
+ content,
+ }: {
+ groupId: string;
+ chapter: number;
+ userId: string;
+ content: string;
+ }) {
+ const group = await ReadingGroup.findById(groupId);
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const member = await ReadingGroupMember.findOne({ group: groupId, user: userId, status: "active" });
+ if (!member) {
+ throw new Error("You must be an active member of this group to post discussions");
+ }
+
+ const post = {
+ chapter,
+ user: userId,
+ content,
+ createdAt: new Date(),
+ };
+
+ group.discussions.push(post as any);
+ await group.save();
+
+ return group.discussions;
+ }
+
+ /**
+ * Get discussion threads per chapter.
+ */
+ async getDiscussions(groupId: string, chapter?: number) {
+ const group = await ReadingGroup.findById(groupId).populate("discussions.user", "name email avatar");
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ let discussions = group.discussions;
+ if (chapter !== undefined && !isNaN(chapter)) {
+ discussions = discussions.filter((d: any) => d.chapter === Number(chapter)) as any;
+ }
+
+ return discussions;
+ }
+
+ /**
+ * Update individual member reading progress in group.
+ */
+ async updateMemberProgress({
+ groupId,
+ userId,
+ currentChapter,
+ currentProgressPercent,
+ }: {
+ groupId: string;
+ userId: string;
+ currentChapter?: number;
+ currentProgressPercent?: number;
+ }) {
+ const member = await ReadingGroupMember.findOne({ group: groupId, user: userId });
+ if (!member) {
+ throw new Error("Member not found in reading group");
+ }
+
+ if (currentChapter !== undefined) member.currentChapter = currentChapter;
+ if (currentProgressPercent !== undefined) member.currentProgressPercent = currentProgressPercent;
+ member.lastReadDate = new Date();
+
+ await member.save();
+ return member;
+ }
+
+ /**
+ * Track member progress in group dashboard.
+ */
+ async getMemberProgressDashboard(groupId: string) {
+ const group = await ReadingGroup.findById(groupId).populate("book", "title totalPages");
+ if (!group) {
+ throw new Error("Reading group not found");
+ }
+
+ const members = await ReadingGroupMember.find({ group: groupId, status: "active" })
+ .populate("user", "name email avatar")
+ .sort({ currentProgressPercent: -1, currentChapter: -1 });
+
+ const totalMembers = members.length;
+ const avgProgress =
+ totalMembers > 0
+ ? Number((members.reduce((sum, m) => sum + m.currentProgressPercent, 0) / totalMembers).toFixed(1))
+ : 0;
+
+ return {
+ group: {
+ _id: group._id,
+ name: group.name,
+ book: group.book,
+ chaptersPerWeek: group.chaptersPerWeek,
+ },
+ stats: {
+ totalMembers,
+ avgProgressPercent: avgProgress,
+ },
+ membersProgress: members,
+ };
+ }
+}
+
+export const readingGroupService = new ReadingGroupService();
+export default readingGroupService;
diff --git a/src/services/reading-progress.service.js b/src/services/reading-progress.service.js
new file mode 100644
index 00000000..e13d1261
--- /dev/null
+++ b/src/services/reading-progress.service.js
@@ -0,0 +1,109 @@
+import ReadingProgress from "../models/ReadingProgress.js";
+import Book from "../models/Book.js";
+import { emitProgress } from "../sockets/reading-progress.socket.js";
+
+/**
+ * ReadingProgressService
+ *
+ * Persists one progress record per user + book (upsert), exposes a resume
+ * endpoint and augments a user's book-library listing with progress %. Mirrors
+ * the class-based service style used across the codebase (e.g. HighlightService,
+ * SpacePollService).
+ */
+export class ReadingProgressService {
+ /**
+ * Derive a 0-100 percentage. Prefers an explicit percentage; otherwise
+ * computes it from page / totalPages when both are known.
+ */
+ computePercentage({ percentage, page, totalPages }) {
+ if (percentage !== undefined && percentage !== null) {
+ return Math.min(100, Math.max(0, Number(percentage)));
+ }
+ if (totalPages && totalPages > 0 && page !== undefined && page !== null) {
+ return Math.min(100, Math.max(0, Number(((page / totalPages) * 100).toFixed(2))));
+ }
+ return undefined;
+ }
+
+ /**
+ * Create or update the reading-progress record for a user + book. Upserts so
+ * there is never more than one record per combination, bumps `version` and
+ * (via timestamps) `updatedAt`, then emits a real-time sync event to the
+ * user's other devices when a socket layer is attached.
+ */
+ async upsertProgress({ userId, bookId, page, totalPages, percentage, lastPosition, device }) {
+ const book = await Book.findById(bookId).select("_id");
+ if (!book) {
+ throw new Error("Book not found");
+ }
+
+ const set = {};
+ if (page !== undefined && page !== null) set.page = page;
+ if (totalPages !== undefined && totalPages !== null) set.totalPages = totalPages;
+ if (lastPosition !== undefined && lastPosition !== null) set.lastPosition = lastPosition;
+ if (device !== undefined && device !== null) set.device = device;
+
+ const computed = this.computePercentage({ percentage, page, totalPages });
+ if (computed !== undefined) {
+ set.percentage = computed;
+ if (computed >= 100) {
+ set.completedAt = new Date();
+ }
+ }
+
+ const progress = await ReadingProgress.findOneAndUpdate(
+ { user: userId, book: bookId },
+ { $set: set, $inc: { version: 1 } },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ // Push to the user's other devices (no-op if no socket server is wired).
+ emitProgress(userId, {
+ book: bookId,
+ page: progress.page,
+ percentage: progress.percentage,
+ lastPosition: progress.lastPosition,
+ version: progress.version,
+ updatedAt: progress.updatedAt,
+ device: progress.device,
+ });
+
+ return progress;
+ }
+
+ /**
+ * Get the stored progress for a user + book so the reader can resume from the
+ * last position. Returns null when there is no record yet.
+ */
+ async getProgress({ userId, bookId }) {
+ return ReadingProgress.findOne({ user: userId, book: bookId });
+ }
+
+ /**
+ * Return the user's reading library augmented with progress. Each entry pairs
+ * the populated book with its progress percentage / last position so the
+ * frontend can display a progress bar on the library view.
+ */
+ async getLibraryWithProgress({ userId }) {
+ const records = await ReadingProgress.find({ user: userId })
+ .sort({ updatedAt: -1 })
+ .populate("book", "title author image category price");
+
+ return records
+ // A book may have been deleted after progress was stored.
+ .filter((record) => record.book)
+ .map((record) => ({
+ book: record.book,
+ page: record.page,
+ totalPages: record.totalPages,
+ percentage: record.percentage,
+ lastPosition: record.lastPosition,
+ version: record.version,
+ completedAt: record.completedAt,
+ updatedAt: record.updatedAt,
+ }));
+ }
+}
+
+export const readingProgressService = new ReadingProgressService();
+export default readingProgressService;
diff --git a/src/services/reelDuetService.js b/src/services/reelDuetService.js
new file mode 100644
index 00000000..63736d3c
--- /dev/null
+++ b/src/services/reelDuetService.js
@@ -0,0 +1,148 @@
+// services/reelDuetService.js
+//
+// Business logic for duet/stitch response videos. A derivative reel is a normal
+// reel that additionally links back to an original reel via `originalReelId`
+// and records its `duetType`. Creating one increments the derivative counter on
+// the original so it can be surfaced on the original reel.
+
+import mongoose from "mongoose";
+import Reel from "../models/Reel.js";
+import {
+ isDuetType,
+ buildCompositionDescriptor,
+ normalizeStitchClip,
+} from "../utils/videoCompositor.js";
+
+const httpError = (message, statusCode) =>
+ Object.assign(new Error(message), { statusCode });
+
+/**
+ * Create a duet/stitch response reel linked to an original reel.
+ *
+ * @param {Object} params
+ * @param {string} params.originalReelId
+ * @param {"duet"|"stitch"} params.type
+ * @param {string} params.userId - author of the response
+ * @param {string} params.description
+ * @param {string} [params.category]
+ * @param {string[]} [params.tags]
+ * @param {string} params.video - uploaded response video URL
+ * @param {string} [params.videoPublicId]
+ * @param {string} [params.thumbnail]
+ * @param {number} [params.duration]
+ * @param {Object} [params.clip] - stitch clip range ({ start, end })
+ * @returns {Promise} the created derivative reel
+ */
+export const createReelDerivative = async ({
+ originalReelId,
+ type,
+ userId,
+ description,
+ category,
+ tags,
+ video,
+ videoPublicId,
+ thumbnail,
+ duration,
+ clip,
+}) => {
+ if (!isDuetType(type)) {
+ throw httpError("type must be one of: duet, stitch", 400);
+ }
+ if (!mongoose.Types.ObjectId.isValid(originalReelId)) {
+ throw httpError("A valid original reel id is required", 400);
+ }
+
+ const original = await Reel.findById(originalReelId).select(
+ "_id video duration"
+ );
+ if (!original) {
+ throw httpError("Original reel not found", 404);
+ }
+
+ const stitchClip = type === "stitch" ? normalizeStitchClip(clip) : null;
+ if (type === "stitch" && !stitchClip) {
+ throw httpError(
+ "A stitch requires a valid clip range ({ start, end } in seconds, end > start)",
+ 400
+ );
+ }
+
+ const composition = buildCompositionDescriptor({
+ type,
+ original,
+ response: { video, duration },
+ clip: stitchClip,
+ });
+
+ const derivative = await Reel.create({
+ description,
+ category,
+ tags: Array.isArray(tags) ? tags : [],
+ video,
+ videoPublicId,
+ thumbnail,
+ duration,
+ createdBy: userId,
+ originalReelId: original._id,
+ duetType: type,
+ stitchClip: stitchClip || undefined,
+ composition,
+ });
+
+ // Increment the derivative counter surfaced on the original reel.
+ const counterField = type === "duet" ? "duetCount" : "stitchCount";
+ await Reel.updateOne(
+ { _id: original._id },
+ { $inc: { [counterField]: 1 } }
+ );
+
+ return derivative;
+};
+
+/**
+ * List duet/stitch derivatives for a given reel, paginated (newest first).
+ *
+ * @param {string} originalReelId
+ * @param {Object} [options]
+ * @param {number} [options.page=1]
+ * @param {number} [options.limit=10]
+ * @param {"duet"|"stitch"} [options.type] - optional filter
+ * @returns {Promise<{items: Object[], page: number, limit: number, total: number, hasMore: boolean}>}
+ */
+export const listReelDerivatives = async (
+ originalReelId,
+ { page = 1, limit = 10, type } = {}
+) => {
+ const safePage = Math.max(parseInt(page, 10) || 1, 1);
+ const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
+ const skip = (safePage - 1) * safeLimit;
+
+ const filter = { originalReelId };
+ if (isDuetType(type)) {
+ filter.duetType = type;
+ }
+
+ const [items, total] = await Promise.all([
+ Reel.find(filter)
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(safeLimit)
+ .populate("createdBy", "name avatar")
+ .lean(),
+ Reel.countDocuments(filter),
+ ]);
+
+ return {
+ items,
+ page: safePage,
+ limit: safeLimit,
+ total,
+ hasMore: skip + items.length < total,
+ };
+};
+
+export default {
+ createReelDerivative,
+ listReelDerivatives,
+};
diff --git a/src/services/space-poll.service.js b/src/services/space-poll.service.js
new file mode 100644
index 00000000..cc32aa62
--- /dev/null
+++ b/src/services/space-poll.service.js
@@ -0,0 +1,177 @@
+import SpacePoll from "../models/space-poll.model.js";
+import PollVote from "../models/poll-vote.model.js";
+import Space from "../models/Space.js";
+
+export class SpacePollService {
+ async createPoll({ spaceId, hostId, question, options }) {
+ const space = await Space.findById(spaceId);
+ if (!space) {
+ throw new Error("Space not found");
+ }
+
+ const isHost = space.host.toString() === hostId.toString();
+ if (!isHost) {
+ throw new Error("Only the host can create polls for this space");
+ }
+
+ if (!options || !Array.isArray(options) || options.length < 2) {
+ throw new Error("At least two options are required to create a poll");
+ }
+
+ const formattedOptions = options.map((opt, index) => {
+ if (typeof opt === "string") {
+ return { optionIndex: index, text: opt };
+ }
+ return { optionIndex: index, text: opt.text };
+ });
+
+ const poll = await SpacePoll.create({
+ space: spaceId,
+ creator: hostId,
+ question,
+ options: formattedOptions,
+ status: "active",
+ });
+
+ return this.getPollResults(poll._id.toString());
+ }
+
+ async voteInPoll({ pollId, userId, optionIndex }) {
+ const poll = await SpacePoll.findById(pollId);
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ if (poll.status !== "active") {
+ throw new Error("Poll is closed for voting");
+ }
+
+ const validOption = poll.options.some((o) => o.optionIndex === optionIndex);
+ if (!validOption) {
+ throw new Error("Invalid option selected");
+ }
+
+ await PollVote.findOneAndUpdate(
+ { poll: poll._id, user: userId },
+ { space: poll.space, optionIndex },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ return this.getPollResults(poll._id.toString(), userId);
+ }
+
+ async getPollResults(pollId, userId) {
+ const poll = await SpacePoll.findById(pollId).populate("creator", "name email avatar");
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ const votes = await PollVote.find({ poll: poll._id });
+ const totalVotes = votes.length;
+
+ const voteCounts = {};
+ poll.options.forEach((o) => {
+ voteCounts[o.optionIndex] = 0;
+ });
+
+ votes.forEach((v) => {
+ if (voteCounts[v.optionIndex] !== undefined) {
+ voteCounts[v.optionIndex] += 1;
+ }
+ });
+
+ const results = poll.options.map((o) => {
+ const count = voteCounts[o.optionIndex] || 0;
+ const percentage = totalVotes > 0 ? Number(((count / totalVotes) * 100).toFixed(1)) : 0;
+ return {
+ optionIndex: o.optionIndex,
+ text: o.text,
+ votes: count,
+ percentage,
+ };
+ });
+
+ let userVote = null;
+ if (userId) {
+ const existingVote = votes.find((v) => v.user.toString() === userId.toString());
+ if (existingVote) {
+ userVote = existingVote.optionIndex;
+ }
+ }
+
+ return {
+ _id: poll._id,
+ space: poll.space,
+ creator: poll.creator,
+ question: poll.question,
+ status: poll.status,
+ closedAt: poll.closedAt,
+ createdAt: poll.createdAt,
+ updatedAt: poll.updatedAt,
+ totalVotes,
+ results,
+ userVote,
+ };
+ }
+
+ async getSpacePolls(spaceId, userId) {
+ const polls = await SpacePoll.find({ space: spaceId }).sort({ createdAt: -1 });
+ const pollResults = await Promise.all(
+ polls.map((poll) => this.getPollResults(poll._id.toString(), userId))
+ );
+ return pollResults;
+ }
+
+ async closePoll({ pollId, hostId }) {
+ const poll = await SpacePoll.findById(pollId);
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ const space = await Space.findById(poll.space);
+ const isHost = space && space.host.toString() === hostId.toString();
+ const isCreator = poll.creator.toString() === hostId.toString();
+
+ if (!isHost && !isCreator) {
+ throw new Error("Only the space host or poll creator can close this poll");
+ }
+
+ poll.status = "closed";
+ poll.closedAt = new Date();
+ await poll.save();
+
+ return this.getPollResults(poll._id.toString());
+ }
+
+ async exportPollResults(pollId) {
+ const pollData = await this.getPollResults(pollId);
+ const votes = await PollVote.find({ poll: pollId }).populate("user", "name email");
+
+ const csvHeader = "Option Index,Option Text,Votes,Percentage\n";
+ const csvRows = pollData.results
+ .map((r) => `"${r.optionIndex}","${r.text.replace(/"/g, '""')}",${r.votes},${r.percentage}%`)
+ .join("\n");
+
+ const exportSummary = {
+ pollId: pollData._id,
+ spaceId: pollData.space,
+ question: pollData.question,
+ status: pollData.status,
+ totalVotes: pollData.totalVotes,
+ createdAt: pollData.createdAt,
+ closedAt: pollData.closedAt,
+ results: pollData.results,
+ votes: votes.map((v) => ({
+ user: v.user,
+ optionIndex: v.optionIndex,
+ votedAt: v.createdAt,
+ })),
+ csvData: csvHeader + csvRows,
+ };
+
+ return exportSummary;
+ }
+}
+
+export const spacePollService = new SpacePollService();
+export default spacePollService;
diff --git a/src/services/space-poll.service.ts b/src/services/space-poll.service.ts
new file mode 100644
index 00000000..1a3e9d98
--- /dev/null
+++ b/src/services/space-poll.service.ts
@@ -0,0 +1,204 @@
+import SpacePoll from "../models/space-poll.model.ts";
+import PollVote from "../models/poll-vote.model.ts";
+import Space from "../models/Space.js";
+
+export class SpacePollService {
+ /**
+ * Create a new poll in a space session.
+ */
+ async createPoll({ spaceId, hostId, question, options }: {
+ spaceId: string;
+ hostId: string;
+ question: string;
+ options: string[] | { optionIndex?: number; text: string }[];
+ }) {
+ const space = await Space.findById(spaceId);
+ if (!space) {
+ throw new Error("Space not found");
+ }
+
+ const isHost = space.host.toString() === hostId.toString();
+ if (!isHost) {
+ throw new Error("Only the host can create polls for this space");
+ }
+
+ if (!options || !Array.isArray(options) || options.length < 2) {
+ throw new Error("At least two options are required to create a poll");
+ }
+
+ const formattedOptions = options.map((opt, index) => {
+ if (typeof opt === "string") {
+ return { optionIndex: index, text: opt };
+ }
+ return { optionIndex: index, text: opt.text };
+ });
+
+ const poll = await SpacePoll.create({
+ space: spaceId,
+ creator: hostId,
+ question,
+ options: formattedOptions,
+ status: "active",
+ });
+
+ return this.getPollResults(poll._id.toString());
+ }
+
+ /**
+ * Vote in a poll during a space session.
+ */
+ async voteInPoll({ pollId, userId, optionIndex }: {
+ pollId: string;
+ userId: string;
+ optionIndex: number;
+ }) {
+ const poll = await SpacePoll.findById(pollId);
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ if (poll.status !== "active") {
+ throw new Error("Poll is closed for voting");
+ }
+
+ const validOption = poll.options.some((o: any) => o.optionIndex === optionIndex);
+ if (!validOption) {
+ throw new Error("Invalid option selected");
+ }
+
+ await PollVote.findOneAndUpdate(
+ { poll: poll._id, user: userId },
+ { space: poll.space, optionIndex },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+
+ return this.getPollResults(poll._id.toString(), userId);
+ }
+
+ /**
+ * Get live poll results with counts and percentages.
+ */
+ async getPollResults(pollId: string, userId?: string) {
+ const poll = await SpacePoll.findById(pollId).populate("creator", "name email avatar");
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ const votes = await PollVote.find({ poll: poll._id });
+ const totalVotes = votes.length;
+
+ const voteCounts: Record = {};
+ poll.options.forEach((o: any) => {
+ voteCounts[o.optionIndex] = 0;
+ });
+
+ votes.forEach((v: any) => {
+ if (voteCounts[v.optionIndex] !== undefined) {
+ voteCounts[v.optionIndex] += 1;
+ }
+ });
+
+ const results = poll.options.map((o: any) => {
+ const count = voteCounts[o.optionIndex] || 0;
+ const percentage = totalVotes > 0 ? Number(((count / totalVotes) * 100).toFixed(1)) : 0;
+ return {
+ optionIndex: o.optionIndex,
+ text: o.text,
+ votes: count,
+ percentage,
+ };
+ });
+
+ let userVote: number | null = null;
+ if (userId) {
+ const existingVote = votes.find((v: any) => v.user.toString() === userId.toString());
+ if (existingVote) {
+ userVote = existingVote.optionIndex;
+ }
+ }
+
+ return {
+ _id: poll._id,
+ space: poll.space,
+ creator: poll.creator,
+ question: poll.question,
+ status: poll.status,
+ closedAt: poll.closedAt,
+ createdAt: (poll as any).createdAt,
+ updatedAt: (poll as any).updatedAt,
+ totalVotes,
+ results,
+ userVote,
+ };
+ }
+
+ /**
+ * Get all polls for a space session with live vote results.
+ */
+ async getSpacePolls(spaceId: string, userId?: string) {
+ const polls = await SpacePoll.find({ space: spaceId }).sort({ createdAt: -1 });
+ const pollResults = await Promise.all(
+ polls.map((poll) => this.getPollResults(poll._id.toString(), userId))
+ );
+ return pollResults;
+ }
+
+ /**
+ * Close a poll to stop accepting new votes.
+ */
+ async closePoll({ pollId, hostId }: { pollId: string; hostId: string }) {
+ const poll = await SpacePoll.findById(pollId);
+ if (!poll) {
+ throw new Error("Poll not found");
+ }
+
+ const space = await Space.findById(poll.space);
+ const isHost = space && space.host.toString() === hostId.toString();
+ const isCreator = poll.creator.toString() === hostId.toString();
+
+ if (!isHost && !isCreator) {
+ throw new Error("Only the space host or poll creator can close this poll");
+ }
+
+ poll.status = "closed";
+ poll.closedAt = new Date();
+ await poll.save();
+
+ return this.getPollResults(poll._id.toString());
+ }
+
+ /**
+ * Export poll results in JSON / text / CSV friendly structure.
+ */
+ async exportPollResults(pollId: string) {
+ const pollData = await this.getPollResults(pollId);
+ const votes = await PollVote.find({ poll: pollId }).populate("user", "name email");
+
+ const csvHeader = "Option Index,Option Text,Votes,Percentage\n";
+ const csvRows = pollData.results
+ .map((r) => `"${r.optionIndex}","${r.text.replace(/"/g, '""')}",${r.votes},${r.percentage}%`)
+ .join("\n");
+
+ const exportSummary = {
+ pollId: pollData._id,
+ spaceId: pollData.space,
+ question: pollData.question,
+ status: pollData.status,
+ totalVotes: pollData.totalVotes,
+ createdAt: pollData.createdAt,
+ closedAt: pollData.closedAt,
+ results: pollData.results,
+ votes: votes.map((v: any) => ({
+ user: v.user,
+ optionIndex: v.optionIndex,
+ votedAt: v.createdAt,
+ })),
+ csvData: csvHeader + csvRows,
+ };
+
+ return exportSummary;
+ }
+}
+
+export const spacePollService = new SpacePollService();
+export default spacePollService;
diff --git a/src/services/stellar/analyticsService.js b/src/services/stellar/analyticsService.js
new file mode 100644
index 00000000..f7f3689d
--- /dev/null
+++ b/src/services/stellar/analyticsService.js
@@ -0,0 +1,239 @@
+// services/stellar/analyticsService.js
+import mongoose from "mongoose";
+import Transaction from "../../models/Transaction.js";
+import logger from "../../config/logger.js";
+
+/**
+ * Payment analytics service.
+ *
+ * Aggregates Stellar {@link Transaction} data into dashboard-friendly
+ * statistics using MongoDB aggregation pipelines (never in-application loops),
+ * so it stays efficient over large datasets.
+ *
+ * Monetary values are stored on the model as precision-preserving STRINGS and a
+ * single collection can hold rows in several assets (see `currency`). Sums are
+ * therefore always grouped per `currency`, converted to `Decimal128` inside the
+ * pipeline via `$toDecimal`, and returned as strings so no precision is lost.
+ *
+ * Recommended indexes for the query shapes below (the Transaction model already
+ * ships some of these; do NOT add them here — this is documentation for ops):
+ * - `{ createdAt: 1 }` — every analytics query filters/buckets on `createdAt`.
+ * - `{ status: 1, createdAt: 1 }` — status filter + time bucketing.
+ * - `{ type: 1, status: 1, createdAt: -1 }` — already present on the model.
+ * - `{ currency: 1, createdAt: 1 }` — asset-scoped time series.
+ * - `{ buyer: 1, createdAt: 1 }` / `{ creator: 1, createdAt: 1 }` — per-user.
+ * A compound index that matches the leading equality filters followed by
+ * `createdAt` lets the `$match` stage use an index and the pipeline stream
+ * results instead of collection-scanning.
+ *
+ * @module services/stellar/analyticsService
+ */
+
+/** Time-bucket granularities supported by the analytics endpoints. */
+export const SUPPORTED_PERIODS = ["day", "week", "month", "year"];
+
+/** Default bucket granularity when the caller does not specify one. */
+export const DEFAULT_PERIOD = "month";
+
+/**
+ * Map an analytics period onto the `unit` argument of `$dateTrunc`.
+ * @param {string} period One of {@link SUPPORTED_PERIODS}.
+ * @returns {string} A `$dateTrunc` unit.
+ */
+const dateTruncUnit = (period) =>
+ SUPPORTED_PERIODS.includes(period) ? period : DEFAULT_PERIOD;
+
+/**
+ * @typedef {Object} AnalyticsFilters
+ * @property {string} [status] Restrict to a single transaction status.
+ * @property {string} [type] Restrict to a transaction type ("purchase"|"donation").
+ * @property {string} [currency] Restrict to a single asset code (e.g. "USDC").
+ * @property {string} [buyerId] Restrict to transactions bought by this user id.
+ * @property {string} [creatorId] Restrict to transactions credited to this creator id.
+ * @property {Date|string} [startDate] Inclusive lower bound on `createdAt`.
+ * @property {Date|string} [endDate] Inclusive upper bound on `createdAt`.
+ */
+
+/**
+ * Build the `$match` stage from validated, already-sanitized filters.
+ *
+ * Only whitelisted fields are consulted, so untrusted query input cannot inject
+ * operators into the pipeline.
+ *
+ * @param {AnalyticsFilters} [filters={}] Filter selection.
+ * @returns {Object} A MongoDB match expression.
+ */
+export const buildMatchStage = (filters = {}) => {
+ const match = {};
+
+ if (filters.status) match.status = filters.status;
+ if (filters.type) match.type = filters.type;
+ if (filters.currency) match.currency = filters.currency;
+
+ if (filters.buyerId && mongoose.Types.ObjectId.isValid(filters.buyerId)) {
+ match.buyer = new mongoose.Types.ObjectId(filters.buyerId);
+ }
+ if (filters.creatorId && mongoose.Types.ObjectId.isValid(filters.creatorId)) {
+ match.creator = new mongoose.Types.ObjectId(filters.creatorId);
+ }
+
+ if (filters.startDate || filters.endDate) {
+ match.createdAt = {};
+ if (filters.startDate) match.createdAt.$gte = new Date(filters.startDate);
+ if (filters.endDate) match.createdAt.$lte = new Date(filters.endDate);
+ }
+
+ return match;
+};
+
+/**
+ * Aggregate payment statistics bucketed by time period and asset.
+ *
+ * Each returned bucket carries: `totalVolume` (summed amount as a string),
+ * `transactionCount`, and `averageAmount` (as a string) for a single
+ * `(period, currency)` pair.
+ *
+ * @param {AnalyticsFilters & { period?: string }} [options={}] Bucketing period
+ * plus any filters.
+ * @returns {Promise>} One entry per (period, currency), sorted oldest-first.
+ */
+export const getTimeSeriesAnalytics = async (options = {}) => {
+ const { period, ...filters } = options;
+ const unit = dateTruncUnit(period);
+ const match = buildMatchStage(filters);
+
+ const pipeline = [
+ { $match: match },
+ {
+ $addFields: {
+ // Amounts are stored as strings; coerce to Decimal128 so sums/averages
+ // keep full precision. Malformed values fall back to 0.
+ amountDecimal: {
+ $convert: { input: "$amount", to: "decimal", onError: 0, onNull: 0 },
+ },
+ },
+ },
+ {
+ $group: {
+ _id: {
+ periodStart: { $dateTrunc: { date: "$createdAt", unit } },
+ currency: { $ifNull: ["$currency", "USDC"] },
+ },
+ totalVolume: { $sum: "$amountDecimal" },
+ transactionCount: { $sum: 1 },
+ averageAmount: { $avg: "$amountDecimal" },
+ },
+ },
+ {
+ $project: {
+ _id: 0,
+ period: unit,
+ periodStart: "$_id.periodStart",
+ currency: "$_id.currency",
+ // Return monetary figures as strings to preserve precision on the wire.
+ totalVolume: { $toString: "$totalVolume" },
+ transactionCount: 1,
+ averageAmount: { $toString: { $ifNull: ["$averageAmount", 0] } },
+ },
+ },
+ { $sort: { periodStart: 1, currency: 1 } },
+ ];
+
+ return Transaction.aggregate(pipeline);
+};
+
+/**
+ * Aggregate overall payment statistics (no time bucketing), grouped by asset.
+ *
+ * @param {AnalyticsFilters} [filters={}] Filter selection.
+ * @returns {Promise>} One entry per asset, highest transaction count first.
+ */
+export const getSummaryAnalytics = async (filters = {}) => {
+ const match = buildMatchStage(filters);
+
+ const pipeline = [
+ { $match: match },
+ {
+ $addFields: {
+ amountDecimal: {
+ $convert: { input: "$amount", to: "decimal", onError: 0, onNull: 0 },
+ },
+ },
+ },
+ {
+ $group: {
+ _id: { $ifNull: ["$currency", "USDC"] },
+ totalVolume: { $sum: "$amountDecimal" },
+ transactionCount: { $sum: 1 },
+ averageAmount: { $avg: "$amountDecimal" },
+ },
+ },
+ {
+ $project: {
+ _id: 0,
+ currency: "$_id",
+ totalVolume: { $toString: "$totalVolume" },
+ transactionCount: 1,
+ averageAmount: { $toString: { $ifNull: ["$averageAmount", 0] } },
+ },
+ },
+ { $sort: { transactionCount: -1, currency: 1 } },
+ ];
+
+ return Transaction.aggregate(pipeline);
+};
+
+/**
+ * Convenience wrapper returning both the per-asset summary and the time series
+ * in a single call, so a dashboard can populate headline totals and a chart
+ * from one request.
+ *
+ * @param {AnalyticsFilters & { period?: string }} [options={}] Period + filters.
+ * @returns {Promise<{
+ * period: string,
+ * filters: AnalyticsFilters,
+ * summary: Array,
+ * series: Array
+ * }>}
+ */
+export const getPaymentAnalytics = async (options = {}) => {
+ const { period = DEFAULT_PERIOD, ...filters } = options;
+
+ try {
+ const [summary, series] = await Promise.all([
+ getSummaryAnalytics(filters),
+ getTimeSeriesAnalytics({ period, ...filters }),
+ ]);
+
+ return {
+ period: dateTruncUnit(period),
+ filters,
+ summary,
+ series,
+ };
+ } catch (error) {
+ logger.error("Payment analytics aggregation error:", error);
+ throw error;
+ }
+};
+
+export default {
+ SUPPORTED_PERIODS,
+ DEFAULT_PERIOD,
+ buildMatchStage,
+ getTimeSeriesAnalytics,
+ getSummaryAnalytics,
+ getPaymentAnalytics,
+};
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/loyaltyService.js b/src/services/stellar/loyaltyService.js
new file mode 100644
index 00000000..292a2cb3
--- /dev/null
+++ b/src/services/stellar/loyaltyService.js
@@ -0,0 +1,310 @@
+// services/stellar/loyaltyService.js
+//
+// Backend bridge to the Deen Bridge loyalty-points Soroban contract
+// (contracts/loyalty-points). Users earn points for platform activities
+// (course/book purchases, referrals, milestones), redeem them for discounts,
+// and can transfer them to other users.
+//
+// Consistent with the rest of this module's Stellar services, all signing
+// stays client-side: this service BUILDS UNSIGNED transaction XDR that the
+// frontend signs and submits, performs read-only queries against Soroban RPC,
+// and never holds keys. Server-signed flows (admin mint/rate changes) are an
+// explicit extension point marked below.
+//
+// Contract surface (see contracts/loyalty-points/src/lib.rs):
+// init(admin) / set_rate(activity, rate) — admin setup
+// earn(user, activity, spend_amount) -> balance — claim activity points
+// mint(user, amount) — admin issuance
+// redeem(user, amount) -> balance — burn for rewards
+// transfer(from, to, amount) — user-to-user gifting
+// balance(user) / rate(activity) / state() — views
+
+import * as StellarSdk from "@stellar/stellar-sdk";
+import logger from "../../config/logger.js";
+import { resolveStellarNetwork } from "../../config/stellar.js";
+
+/** Env var holding the deployed loyalty contract id (C…55-char StrKey). */
+export const LOYALTY_CONTRACT_ENV = "LOYALTY_CONTRACT_ID";
+
+/** Default public Soroban RPC endpoints per network. */
+const SOROBAN_RPC_DEFAULTS = {
+ testnet: "https://soroban-testnet.stellar.org",
+ // No fixed public mainnet RPC — operators must configure one explicitly.
+ mainnet: null,
+};
+
+/** Allowed activity discriminants, mirroring the contract's Activity enum. */
+export const LOYALTY_ACTIVITIES = Object.freeze({
+ PURCHASE: "Purchase",
+ REFERRAL: "Referral",
+ MILESTONE: "Milestone",
+});
+
+/**
+ * Resolve the configured network using the shared Stellar config.
+ * @returns {string} "testnet" | "mainnet"
+ */
+export const resolveLoyaltyNetwork = () => resolveStellarNetwork();
+
+/**
+ * Lazily-built singleton RPC client for the active network.
+ *
+ * @returns {{server: StellarSdk.rpc.Server, networkPassphrase: string}}
+ */
+let cachedRpc;
+export const loyaltyRpc = () => {
+ if (cachedRpc) return cachedRpc;
+
+ const network = resolveLoyaltyNetwork();
+ const url =
+ process.env.SOROBAN_RPC_URL || SOROBAN_RPC_DEFAULTS[network] || null;
+ if (!url) {
+ throw new Error(
+ `SOROBAN_RPC_URL must be configured for the "${network}" network`
+ );
+ }
+
+ const passphrase =
+ StellarSdk.Networks[network.toUpperCase()] ??
+ (() => {
+ throw new Error(`Unknown Stellar network "${network}"`);
+ })();
+
+ cachedRpc = {
+ server: new StellarSdk.rpc.Server(url, {
+ allowHttp: url.startsWith("http://"),
+ }),
+ networkPassphrase: passphrase,
+ };
+ return cachedRpc;
+};
+
+/**
+ * The deployed loyalty contract handle.
+ *
+ * @returns {StellarSdk.Contract}
+ */
+export const loyaltyContract = () => {
+ const contractId = process.env[LOYALTY_CONTRACT_ENV];
+ if (!contractId) {
+ throw new Error(
+ `${LOYALTY_CONTRACT_ENV} is not configured — deploy contracts/loyalty-points and record its id`
+ );
+ }
+ return new StellarSdk.Contract(contractId);
+};
+
+/* ------------------------------------------------------------------ */
+/* Encoding helpers */
+/* ------------------------------------------------------------------ */
+
+/** @returns {StellarSdk.xdr.ScVal} */
+const addressScVal = (publicKey) =>
+ StellarSdk.Address.fromString(publicKey).toScVal();
+
+/** @returns {StellarSdk.xdr.ScVal} */
+const i128ScVal = (value) =>
+ StellarSdk.nativeToScVal(BigInt(value), { type: "i128" });
+
+/** Activity enum values encode as their variant symbol on-chain. */
+const activityScVal = (activity) => {
+ if (!Object.values(LOYALTY_ACTIVITIES).includes(activity)) {
+ throw new Error(
+ `Unknown loyalty activity "${activity}" — expected one of ${Object.values(
+ LOYALTY_ACTIVITIES
+ ).join(", ")}`
+ );
+ }
+ return StellarSdk.xdr.ScVal.scvSymbol(activity);
+};
+
+/* ------------------------------------------------------------------ */
+/* Unsigned transaction builders (client signs & submits) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Shared builder: assemble an unsigned Soroban invoke for the loyalty
+ * contract. The returned XDR must be signed by `sourcePublicKey`'s keypair
+ * and submitted via a wallet or the platform's submission flow.
+ *
+ * @param {string} sourcePublicKey Signer's G… address (sequence source).
+ * @param {(contract: StellarSdk.Contract) => StellarSdk.xdr.Operation} buildOp
+ * @param {{memo?: string}} [options]
+ * @returns {Promise<{xdr: string, contractId: string, networkPassphrase: string}>}
+ */
+const buildInvokeTx = async (sourcePublicKey, buildOp, options = {}) => {
+ const { server, networkPassphrase } = loyaltyRpc();
+ const contract = loyaltyContract();
+
+ const account = await server.getAccount(sourcePublicKey);
+ const tx = new StellarSdk.TransactionBuilder(account, {
+ fee: StellarSdk.BASE_FEE,
+ networkPassphrase,
+ memo: options.memo ? StellarSdk.Memo.text(options.memo) : undefined,
+ })
+ .addOperation(buildOp(contract))
+ .setTimeout(180)
+ .build();
+
+ logger.debug(
+ { sourcePublicKey, contractId: contract.contractId() },
+ "Built unsigned loyalty contract invocation"
+ );
+
+ return {
+ xdr: tx.toXDR(),
+ contractId: contract.contractId(),
+ networkPassphrase,
+ };
+};
+
+/**
+ * Build an unsigned `earn(user, activity, spend_amount)` invocation so a user
+ * can claim points for a completed platform activity.
+ *
+ * @param {{sourcePublicKey: string, activity: string, spendAmount?: number|string}} params
+ * `spendAmount` is required for purchases (raw 7-decimal asset units) and
+ * ignored for flat-bonus activities.
+ */
+export const buildEarnPointsTx = ({ sourcePublicKey, activity, spendAmount = 0 }) =>
+ buildInvokeTx(sourcePublicKey, (contract) =>
+ contract.call(
+ "earn",
+ addressScVal(sourcePublicKey),
+ activityScVal(activity),
+ i128ScVal(spendAmount)
+ )
+ );
+
+/**
+ * Build an unsigned `redeem(user, amount)` invocation that burns points
+ * against a discount/reward.
+ *
+ * @param {{sourcePublicKey: string, amount: number|string}} params
+ */
+export const buildRedeemPointsTx = ({ sourcePublicKey, amount }) =>
+ buildInvokeTx(
+ sourcePublicKey,
+ (contract) =>
+ contract.call("redeem", addressScVal(sourcePublicKey), i128ScVal(amount)),
+ { memo: "DeenBridge Rewards" }
+ );
+
+/**
+ * Build an unsigned `transfer(from, to, amount)` invocation for gifting.
+ *
+ * @param {{sourcePublicKey: string, destinationPublicKey: string, amount: number|string}} params
+ */
+export const buildTransferPointsTx = ({
+ sourcePublicKey,
+ destinationPublicKey,
+ amount,
+}) =>
+ buildInvokeTx(
+ sourcePublicKey,
+ (contract) =>
+ contract.call(
+ "transfer",
+ addressScVal(sourcePublicKey),
+ addressScVal(destinationPublicKey),
+ i128ScVal(amount)
+ ),
+ { memo: "DeenBridge Points Gift" }
+ );
+
+/**
+ * ADMIN FLOW (extension point): build an unsigned `set_rate` invocation.
+ * Today the admin signs off-platform; wire a secured key management flow
+ * before ever exposing this through a route.
+ *
+ * @param {{adminPublicKey: string, activity: string, rate: number|string}} params
+ */
+export const buildSetRateTx = ({ adminPublicKey, activity, rate }) =>
+ buildInvokeTx(adminPublicKey, (contract) =>
+ contract.call("set_rate", activityScVal(activity), i128ScVal(rate))
+ );
+
+/* ------------------------------------------------------------------ */
+/* Read-only queries */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Simulate a read-only contract call without submitting a transaction.
+ * Simulation requires a funded sequence source, so callers pass any funded
+ * G… address they control (typically the querying user's own public key).
+ *
+ * @param {{sourcePublicKey: string, method: string, args?: StellarSdk.xdr.ScVal[]}} params
+ * @returns {Promise} raw return value
+ */
+const queryContract = async ({ sourcePublicKey, method, args = [] }) => {
+ if (!sourcePublicKey) {
+ throw new Error(
+ `A funded sourcePublicKey is required to query "${method}" from the loyalty contract`
+ );
+ }
+
+ const { server, networkPassphrase } = loyaltyRpc();
+ const account = await server.getAccount(sourcePublicKey);
+ const tx = new StellarSdk.TransactionBuilder(account, {
+ fee: StellarSdk.BASE_FEE,
+ networkPassphrase,
+ })
+ .addOperation(loyaltyContract().call(method, ...args))
+ .setTimeout(60)
+ .build();
+
+ const simulation = await server.simulateTransaction(tx);
+ if (simulation.error || !simulation.result?.retval) {
+ throw new Error(
+ `Loyalty query "${method}" failed: ${simulation.error ?? "no result"}`
+ );
+ }
+ return simulation.result.retval;
+};
+
+/**
+ * On-chain point balance for a user.
+ *
+ * @param {{publicKey: string, sourcePublicKey?: string}} params
+ * `sourcePublicKey` defaults to `publicKey` when it is itself funded.
+ * @returns {Promise}
+ */
+export const getLoyaltyBalance = async ({ publicKey, sourcePublicKey }) => {
+ const retval = await queryContract({
+ sourcePublicKey: sourcePublicKey ?? publicKey,
+ method: "balance",
+ args: [addressScVal(publicKey)],
+ });
+ return StellarSdk.scValToNative(retval);
+};
+
+/**
+ * Configured award rate for an activity (0 when never set).
+ *
+ * @param {{activity: string, sourcePublicKey: string}} params
+ * @returns {Promise}
+ */
+export const getLoyaltyRate = async ({ activity, sourcePublicKey }) => {
+ const retval = await queryContract({
+ sourcePublicKey,
+ method: "rate",
+ args: [activityScVal(activity)],
+ });
+ return StellarSdk.scValToNative(retval);
+};
+
+/**
+ * Program totals/admin — mirrors the contract's LoyaltyState struct.
+ *
+ * @param {{sourcePublicKey: string}} params
+ * @returns {Promise<{admin: string, totalIssued: bigint, totalRedeemed: bigint}>}
+ */
+export const getLoyaltyState = async ({ sourcePublicKey }) => {
+ const retval = await queryContract({ sourcePublicKey, method: "state" });
+ const native = StellarSdk.scValToNative(retval);
+ return {
+ admin: String(native.admin),
+ totalIssued: BigInt(native.total_issued ?? 0),
+ totalRedeemed: BigInt(native.total_redeemed ?? 0),
+ };
+};
diff --git a/src/services/stellar/onrampService.js b/src/services/stellar/onrampService.js
new file mode 100644
index 00000000..5d418210
--- /dev/null
+++ b/src/services/stellar/onrampService.js
@@ -0,0 +1,235 @@
+// services/stellar/onrampService.js
+import crypto from "crypto";
+import logger from "../../config/logger.js";
+import { isValidPublicKey } from "./stellarService.js";
+
+/**
+ * Fiat on-ramp integration (MoonPay).
+ *
+ * Provides the widget-URL builder (with the MoonPay `signature` HMAC), webhook
+ * signature verification, and provider→internal status mapping used by the
+ * on-ramp controller. All provider credentials are read lazily from the
+ * environment so the module imports cleanly even when nothing is configured.
+ *
+ * Required environment variables:
+ * - MOONPAY_API_KEY Publishable key embedded in the widget URL.
+ * - MOONPAY_SECRET_KEY Secret used to sign widget URLs (server-side only).
+ * - MOONPAY_WEBHOOK_KEY Secret used to verify inbound webhook signatures.
+ * Falls back to MOONPAY_SECRET_KEY when unset.
+ * Optional:
+ * - MOONPAY_WIDGET_URL Override the widget base URL.
+ * - MOONPAY_DEFAULT_CRYPTO_CODE Default crypto currency code (default "usdc").
+ *
+ * @module services/stellar/onrampService
+ */
+
+const DEFAULT_LIVE_WIDGET_URL = "https://buy.moonpay.com";
+const DEFAULT_SANDBOX_WIDGET_URL = "https://buy-sandbox.moonpay.com";
+const DEFAULT_CRYPTO_CODE = "usdc";
+
+/**
+ * Read the current MoonPay configuration from the environment.
+ *
+ * Read on each call (rather than cached at import time) so tests and deploys
+ * can set the variables after the module is first loaded.
+ *
+ * @returns {{apiKey: string|undefined, secretKey: string|undefined,
+ * webhookKey: string|undefined, widgetBaseUrl: string,
+ * defaultCryptoCode: string}} Resolved config.
+ */
+export const getMoonpayConfig = () => {
+ const apiKey = process.env.MOONPAY_API_KEY;
+ // A live publishable key is prefixed "pk_live_"; anything else (or unset)
+ // uses the sandbox widget host so test keys never point at production.
+ const isLive = typeof apiKey === "string" && apiKey.startsWith("pk_live_");
+ const widgetBaseUrl =
+ process.env.MOONPAY_WIDGET_URL ||
+ (isLive ? DEFAULT_LIVE_WIDGET_URL : DEFAULT_SANDBOX_WIDGET_URL);
+
+ return {
+ apiKey,
+ secretKey: process.env.MOONPAY_SECRET_KEY,
+ webhookKey: process.env.MOONPAY_WEBHOOK_KEY || process.env.MOONPAY_SECRET_KEY,
+ widgetBaseUrl,
+ defaultCryptoCode:
+ process.env.MOONPAY_DEFAULT_CRYPTO_CODE || DEFAULT_CRYPTO_CODE,
+ };
+};
+
+/**
+ * Whether the on-ramp is usable (both the publishable key and signing secret
+ * are present). Used by the controller to return 503 when unconfigured.
+ *
+ * @returns {boolean} True when widget URLs can be signed and served.
+ */
+export const isOnrampConfigured = () => {
+ const { apiKey, secretKey } = getMoonpayConfig();
+ return Boolean(apiKey && secretKey);
+};
+
+/**
+ * Build a signed MoonPay buy-widget URL with the user's wallet pre-filled.
+ *
+ * The MoonPay `signature` is a base64-encoded HMAC-SHA256 of the full URL query
+ * string (including the leading "?") using the secret key. It is appended as
+ * the final `signature` query parameter.
+ *
+ * @param {object} params
+ * @param {string} params.walletAddress Stellar public key to deliver crypto to.
+ * @param {string} [params.cryptoCurrency] Crypto currency code (default from env).
+ * @param {string} [params.baseCurrencyCode] Fiat currency code (e.g. "usd").
+ * @param {number|string} [params.baseCurrencyAmount] Fiat amount to prefill.
+ * @param {string} [params.externalTransactionId] Our transaction id, echoed back
+ * in webhooks so a provider event can be linked to the originating record.
+ * @param {string} [params.email] Customer email to prefill in the widget.
+ * @param {string} [params.redirectUrl] URL MoonPay redirects to on completion.
+ * @returns {{url: string, cryptoCurrency: string}} Signed widget URL and the
+ * resolved crypto currency code.
+ * @throws {Error} With `statusCode` set when unconfigured or inputs are invalid.
+ */
+export const buildWidgetUrl = ({
+ walletAddress,
+ cryptoCurrency,
+ baseCurrencyCode,
+ baseCurrencyAmount,
+ externalTransactionId,
+ email,
+ redirectUrl,
+} = {}) => {
+ const { apiKey, secretKey, widgetBaseUrl, defaultCryptoCode } =
+ getMoonpayConfig();
+
+ if (!apiKey || !secretKey) {
+ const error = new Error(
+ "Fiat on-ramp is not available right now. Please try again later."
+ );
+ error.statusCode = 503;
+ throw error;
+ }
+ if (!walletAddress || !isValidPublicKey(walletAddress)) {
+ const error = new Error("Invalid Stellar wallet address");
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const currencyCode = (cryptoCurrency || defaultCryptoCode).toLowerCase();
+
+ const url = new URL(widgetBaseUrl);
+ url.searchParams.append("apiKey", apiKey);
+ url.searchParams.append("currencyCode", currencyCode);
+ url.searchParams.append("walletAddress", walletAddress);
+
+ if (baseCurrencyCode) {
+ url.searchParams.append("baseCurrencyCode", baseCurrencyCode.toLowerCase());
+ }
+ if (baseCurrencyAmount !== undefined && baseCurrencyAmount !== null) {
+ url.searchParams.append("baseCurrencyAmount", String(baseCurrencyAmount));
+ }
+ if (externalTransactionId) {
+ url.searchParams.append("externalTransactionId", String(externalTransactionId));
+ }
+ if (email) {
+ url.searchParams.append("email", email);
+ }
+ if (redirectUrl) {
+ url.searchParams.append("redirectURL", redirectUrl);
+ }
+
+ // MoonPay signs the query string including the leading "?".
+ const signature = crypto
+ .createHmac("sha256", secretKey)
+ .update(url.search)
+ .digest("base64");
+ url.searchParams.append("signature", signature);
+
+ return { url: url.toString(), cryptoCurrency: currencyCode };
+};
+
+/**
+ * Constant-time comparison of two signature strings of possibly differing
+ * length (returns false instead of throwing on a length mismatch).
+ *
+ * @param {string} a First value.
+ * @param {string} b Second value.
+ * @returns {boolean} True when equal.
+ */
+const safeEqual = (a, b) => {
+ const bufA = Buffer.from(String(a));
+ const bufB = Buffer.from(String(b));
+ if (bufA.length !== bufB.length) return false;
+ return crypto.timingSafeEqual(bufA, bufB);
+};
+
+/**
+ * Verify a MoonPay webhook signature against the raw request body.
+ *
+ * MoonPay sends the `Moonpay-Signature-V2` header formatted as
+ * `t=,s=`, where the signed payload is
+ * `.` HMAC-SHA256 hex-digested with the webhook key. A bare
+ * hex signature (HMAC of the raw body alone) is also accepted as a fallback.
+ *
+ * @param {Buffer|string} rawBody Exact bytes of the request body.
+ * @param {string} signatureHeader Value of the signature header.
+ * @returns {boolean} True when the signature is valid and the webhook key is set.
+ */
+export const verifyWebhookSignature = (rawBody, signatureHeader) => {
+ const { webhookKey } = getMoonpayConfig();
+ if (!webhookKey || !signatureHeader || rawBody === undefined || rawBody === null) {
+ return false;
+ }
+
+ const payload = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : String(rawBody);
+
+ // Parse the "t=...,s=..." structured header when present.
+ let timestamp;
+ let signature;
+ for (const part of String(signatureHeader).split(",")) {
+ const [key, value] = part.split("=");
+ if (key && value !== undefined) {
+ const trimmedKey = key.trim();
+ if (trimmedKey === "t") timestamp = value.trim();
+ else if (trimmedKey === "s") signature = value.trim();
+ }
+ }
+
+ if (timestamp && signature) {
+ const expected = crypto
+ .createHmac("sha256", webhookKey)
+ .update(`${timestamp}.${payload}`)
+ .digest("hex");
+ return safeEqual(expected, signature);
+ }
+
+ // Fallback: header is a bare signature over the raw body.
+ const bare = String(signatureHeader).trim();
+ const expectedBody = crypto
+ .createHmac("sha256", webhookKey)
+ .update(payload)
+ .digest("hex");
+ return safeEqual(expectedBody, bare);
+};
+
+/**
+ * Map a raw MoonPay transaction status to an internal ONRAMP_STATUS.
+ *
+ * MoonPay states: `waitingPayment`, `pending`, `waitingAuthorization`,
+ * `completed`, `failed`.
+ *
+ * @param {string} providerStatus Raw provider status.
+ * @returns {string} One of the internal ONRAMP_STATUSES values.
+ */
+export const mapProviderStatus = (providerStatus) => {
+ switch (providerStatus) {
+ case "completed":
+ return "completed";
+ case "failed":
+ return "failed";
+ case "waitingPayment":
+ case "pending":
+ case "waitingAuthorization":
+ return "pending";
+ default:
+ logger.warn(`Unknown MoonPay on-ramp status: ${providerStatus}`);
+ return "pending";
+ }
+};
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/messaging.socket.ts b/src/sockets/messaging.socket.ts
new file mode 100644
index 00000000..2aeb2de3
--- /dev/null
+++ b/src/sockets/messaging.socket.ts
@@ -0,0 +1,76 @@
+import { Server, Socket } from "socket.io";
+import messagingService from "../services/messaging.service.ts";
+import logger from "../config/logger.js";
+
+export const initMessagingSocket = (io: Server) => {
+ const messagingNamespace = io.of("/messaging");
+
+ messagingNamespace.on("connection", (socket: Socket) => {
+ logger.info(`Socket connected to /messaging: ${socket.id}`);
+
+ socket.on("join_conversation", (conversationId: string) => {
+ const room = `dm_${conversationId}`;
+ socket.join(room);
+ logger.info(`Socket ${socket.id} joined room ${room}`);
+ });
+
+ socket.on("leave_conversation", (conversationId: string) => {
+ const room = `dm_${conversationId}`;
+ socket.leave(room);
+ logger.info(`Socket ${socket.id} left room ${room}`);
+ });
+
+ socket.on(
+ "send_message",
+ async (data: {
+ conversationId: string;
+ senderId: string;
+ text?: string;
+ image?: string;
+ }) => {
+ try {
+ const message = await messagingService.sendMessage(data);
+ const room = `dm_${data.conversationId}`;
+ messagingNamespace.to(room).emit("new_message", message);
+ } catch (error: any) {
+ socket.emit("message_error", { message: error.message });
+ }
+ }
+ );
+
+ socket.on(
+ "typing",
+ (data: { conversationId: string; userId: string; isTyping: boolean }) => {
+ const room = `dm_${data.conversationId}`;
+ socket.to(room).emit("user_typing", data);
+ }
+ );
+
+ socket.on(
+ "mark_read",
+ async (data: { conversationId: string; userId: string }) => {
+ try {
+ await messagingService.markAsRead({
+ conversationId: data.conversationId,
+ userId: data.userId,
+ });
+ const room = `dm_${data.conversationId}`;
+ messagingNamespace.to(room).emit("messages_read", {
+ conversationId: data.conversationId,
+ userId: data.userId,
+ });
+ } catch (error: any) {
+ socket.emit("message_error", { message: error.message });
+ }
+ }
+ );
+
+ socket.on("disconnect", () => {
+ logger.info(`Socket disconnected from /messaging: ${socket.id}`);
+ });
+ });
+
+ return messagingNamespace;
+};
+
+export default initMessagingSocket;
diff --git a/src/sockets/reading-progress.socket.js b/src/sockets/reading-progress.socket.js
new file mode 100644
index 00000000..c88f3caf
--- /dev/null
+++ b/src/sockets/reading-progress.socket.js
@@ -0,0 +1,68 @@
+import logger from "../config/logger.js";
+
+/**
+ * Reading-progress real-time sync.
+ *
+ * Follows the same convention as space-poll.socket.js: a dedicated namespace
+ * with per-user rooms. When a socket.io server is attached (see
+ * initReadingProgressSocket), progress updates written through the service are
+ * pushed to every other device the same user has connected, so reading
+ * position stays in sync in real time.
+ *
+ * If no socket.io server is wired in, emitProgress() is a safe no-op and the
+ * REST endpoints + the `version`/`updatedAt` fields on the library listing act
+ * as a poll-based fallback. Importing this module has NO side effects.
+ */
+
+let ioRef = null;
+
+const roomFor = (userId) => `reading_progress_${userId}`;
+
+export const initReadingProgressSocket = (io) => {
+ ioRef = io;
+ const progressNamespace = io.of("/reading-progress");
+
+ progressNamespace.on("connection", (socket) => {
+ logger.info(`Socket connected to /reading-progress: ${socket.id}`);
+
+ socket.on("join_reading_progress_room", (userId) => {
+ const room = roomFor(userId);
+ socket.join(room);
+ logger.info(`Socket ${socket.id} joined room ${room}`);
+ });
+
+ socket.on("leave_reading_progress_room", (userId) => {
+ const room = roomFor(userId);
+ socket.leave(room);
+ logger.info(`Socket ${socket.id} left room ${room}`);
+ });
+
+ socket.on("disconnect", () => {
+ logger.info(`Socket disconnected from /reading-progress: ${socket.id}`);
+ });
+ });
+
+ return progressNamespace;
+};
+
+/**
+ * emitProgress — seam used by the service layer to push a progress update to a
+ * user's other devices. No-op (returns false) until a socket.io server is
+ * attached via initReadingProgressSocket, so it is safe to call unconditionally
+ * and never throws during CI / health-check boots.
+ */
+export const emitProgress = (userId, progress) => {
+ if (!ioRef || !userId) return false;
+ try {
+ ioRef
+ .of("/reading-progress")
+ .to(roomFor(userId))
+ .emit("reading_progress_updated", progress);
+ return true;
+ } catch (error) {
+ logger.error("Failed to emit reading progress update:", error);
+ return false;
+ }
+};
+
+export default initReadingProgressSocket;
diff --git a/src/sockets/space-poll.socket.js b/src/sockets/space-poll.socket.js
new file mode 100644
index 00000000..f7663f0e
--- /dev/null
+++ b/src/sockets/space-poll.socket.js
@@ -0,0 +1,60 @@
+import spacePollService from "../services/space-poll.service.js";
+import logger from "../config/logger.js";
+
+export const initSpacePollSocket = (io) => {
+ const pollNamespace = io.of("/space-polls");
+
+ pollNamespace.on("connection", (socket) => {
+ logger.info(`Socket connected to /space-polls: ${socket.id}`);
+
+ socket.on("join_space_poll_room", (spaceId) => {
+ const room = `space_poll_${spaceId}`;
+ socket.join(room);
+ logger.info(`Socket ${socket.id} joined room ${room}`);
+ });
+
+ socket.on("leave_space_poll_room", (spaceId) => {
+ const room = `space_poll_${spaceId}`;
+ socket.leave(room);
+ logger.info(`Socket ${socket.id} left room ${room}`);
+ });
+
+ socket.on("create_poll", async (data) => {
+ try {
+ const poll = await spacePollService.createPoll(data);
+ const room = `space_poll_${data.spaceId}`;
+ pollNamespace.to(room).emit("poll_created", poll);
+ } catch (error) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("cast_vote", async (data) => {
+ try {
+ const updatedPoll = await spacePollService.voteInPoll(data);
+ const room = `space_poll_${updatedPoll.space}`;
+ pollNamespace.to(room).emit("poll_results_updated", updatedPoll);
+ } catch (error) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("close_poll", async (data) => {
+ try {
+ const closedPoll = await spacePollService.closePoll(data);
+ const room = `space_poll_${closedPoll.space}`;
+ pollNamespace.to(room).emit("poll_closed", closedPoll);
+ } catch (error) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("disconnect", () => {
+ logger.info(`Socket disconnected from /space-polls: ${socket.id}`);
+ });
+ });
+
+ return pollNamespace;
+};
+
+export default initSpacePollSocket;
diff --git a/src/sockets/space-poll.socket.ts b/src/sockets/space-poll.socket.ts
new file mode 100644
index 00000000..bbad66d7
--- /dev/null
+++ b/src/sockets/space-poll.socket.ts
@@ -0,0 +1,61 @@
+import { Server, Socket } from "socket.io";
+import spacePollService from "../services/space-poll.service.ts";
+import logger from "../config/logger.js";
+
+export const initSpacePollSocket = (io: Server) => {
+ const pollNamespace = io.of("/space-polls");
+
+ pollNamespace.on("connection", (socket: Socket) => {
+ logger.info(`Socket connected to /space-polls: ${socket.id}`);
+
+ socket.on("join_space_poll_room", (spaceId: string) => {
+ const room = `space_poll_${spaceId}`;
+ socket.join(room);
+ logger.info(`Socket ${socket.id} joined room ${room}`);
+ });
+
+ socket.on("leave_space_poll_room", (spaceId: string) => {
+ const room = `space_poll_${spaceId}`;
+ socket.leave(room);
+ logger.info(`Socket ${socket.id} left room ${room}`);
+ });
+
+ socket.on("create_poll", async (data: { spaceId: string; hostId: string; question: string; options: string[] }) => {
+ try {
+ const poll = await spacePollService.createPoll(data);
+ const room = `space_poll_${data.spaceId}`;
+ pollNamespace.to(room).emit("poll_created", poll);
+ } catch (error: any) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("cast_vote", async (data: { pollId: string; userId: string; optionIndex: number }) => {
+ try {
+ const updatedPoll = await spacePollService.voteInPoll(data);
+ const room = `space_poll_${updatedPoll.space}`;
+ pollNamespace.to(room).emit("poll_results_updated", updatedPoll);
+ } catch (error: any) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("close_poll", async (data: { pollId: string; hostId: string }) => {
+ try {
+ const closedPoll = await spacePollService.closePoll(data);
+ const room = `space_poll_${closedPoll.space}`;
+ pollNamespace.to(room).emit("poll_closed", closedPoll);
+ } catch (error: any) {
+ socket.emit("poll_error", { message: error.message });
+ }
+ });
+
+ socket.on("disconnect", () => {
+ logger.info(`Socket disconnected from /space-polls: ${socket.id}`);
+ });
+ });
+
+ return pollNamespace;
+};
+
+export default initSpacePollSocket;
diff --git a/src/templates/certificate.template.js b/src/templates/certificate.template.js
new file mode 100644
index 00000000..0fd33620
--- /dev/null
+++ b/src/templates/certificate.template.js
@@ -0,0 +1,139 @@
+import PDFDocument from "pdfkit";
+
+export function generateCertificatePDF(data) {
+ return new Promise((resolve, reject) => {
+ try {
+ const doc = new PDFDocument({
+ layout: "landscape",
+ size: "A4",
+ margin: 40,
+ });
+
+ const buffers = [];
+ doc.on("data", (chunk) => buffers.push(chunk));
+ doc.on("end", () => resolve(Buffer.concat(buffers)));
+ doc.on("error", (err) => reject(err));
+
+ const width = doc.page.width;
+ const height = doc.page.height;
+ const contentWidth = width - 80;
+
+ // Outer Border (Dark navy blue)
+ doc
+ .lineWidth(6)
+ .strokeColor("#1E3A8A")
+ .rect(20, 20, width - 40, height - 40)
+ .stroke();
+
+ // Inner Decorative Border (Gold/amber accent)
+ doc
+ .lineWidth(2)
+ .strokeColor("#D97706")
+ .rect(30, 30, width - 60, height - 60)
+ .stroke();
+
+ // Header Brand
+ doc
+ .fillColor("#1E3A8A")
+ .fontSize(22)
+ .text("DEENBRIDGE ACADEMY", 40, 75, { width: contentWidth, align: "center" });
+
+ // Title
+ doc
+ .fillColor("#111827")
+ .fontSize(32)
+ .text("CERTIFICATE OF COMPLETION", 40, 120, { width: contentWidth, align: "center" });
+
+ // Subtitle
+ doc
+ .fillColor("#4B5563")
+ .fontSize(16)
+ .text("PROUDLY PRESENTED TO", 40, 175, { width: contentWidth, align: "center" });
+
+ // Learner Name
+ doc
+ .fillColor("#1E3A8A")
+ .fontSize(28)
+ .text((data.learnerName || "Learner").toUpperCase(), 40, 210, { width: contentWidth, align: "center" });
+
+ // Description text
+ doc
+ .fillColor("#374151")
+ .fontSize(15)
+ .text("for successfully mastering and completing all modules of the course", 40, 260, {
+ width: contentWidth,
+ align: "center",
+ });
+
+ // Course Title
+ doc
+ .fillColor("#D97706")
+ .fontSize(24)
+ .text(`"${data.courseTitle || "Course"}"`, 40, 295, { width: contentWidth, align: "center" });
+
+ // Completion Date
+ const formattedDate = new Date(data.completionDate || Date.now()).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+
+ doc
+ .fillColor("#4B5563")
+ .fontSize(14)
+ .text(`Awarded on ${formattedDate}`, 40, 350, { width: contentWidth, align: "center" });
+
+ // Signatures
+ const sigY = 420;
+
+ // Instructor Signature Line
+ doc
+ .strokeColor("#9CA3AF")
+ .lineWidth(1)
+ .moveTo(150, sigY)
+ .lineTo(330, sigY)
+ .stroke();
+
+ doc
+ .fillColor("#111827")
+ .fontSize(13)
+ .text(data.instructorName || "DeenBridge Instructor", 150, sigY + 8, { width: 180, align: "center" });
+
+ doc
+ .fillColor("#6B7280")
+ .fontSize(11)
+ .text("Course Educator", 150, sigY + 24, { width: 180, align: "center" });
+
+ // Organization Signature Line
+ doc
+ .strokeColor("#9CA3AF")
+ .lineWidth(1)
+ .moveTo(width - 330, sigY)
+ .lineTo(width - 150, sigY)
+ .stroke();
+
+ doc
+ .fillColor("#111827")
+ .fontSize(13)
+ .text(data.instructorSignature || "DeenBridge Verification", width - 330, sigY + 8, {
+ width: 180,
+ align: "center",
+ });
+
+ doc
+ .fillColor("#6B7280")
+ .fontSize(11)
+ .text("Authorized Issuer", width - 330, sigY + 24, { width: 180, align: "center" });
+
+ // Footer - Certificate ID
+ doc
+ .fillColor("#9CA3AF")
+ .fontSize(10)
+ .text(`Certificate ID: ${data.certificateId}`, 40, height - 60, { width: contentWidth, align: "center" });
+
+ doc.end();
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
diff --git a/src/templates/certificate.template.ts b/src/templates/certificate.template.ts
new file mode 100644
index 00000000..6a5e94d9
--- /dev/null
+++ b/src/templates/certificate.template.ts
@@ -0,0 +1,139 @@
+import PDFDocument from "pdfkit";
+
+export function generateCertificatePDF(data: any): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ const doc = new PDFDocument({
+ layout: "landscape",
+ size: "A4",
+ margin: 40,
+ });
+
+ const buffers: Buffer[] = [];
+ doc.on("data", (chunk) => buffers.push(chunk));
+ doc.on("end", () => resolve(Buffer.concat(buffers)));
+ doc.on("error", (err) => reject(err));
+
+ const width = doc.page.width;
+ const height = doc.page.height;
+ const contentWidth = width - 80;
+
+ // Outer Border (Dark navy blue)
+ doc
+ .lineWidth(6)
+ .strokeColor("#1E3A8A")
+ .rect(20, 20, width - 40, height - 40)
+ .stroke();
+
+ // Inner Decorative Border (Gold/amber accent)
+ doc
+ .lineWidth(2)
+ .strokeColor("#D97706")
+ .rect(30, 30, width - 60, height - 60)
+ .stroke();
+
+ // Header Brand
+ doc
+ .fillColor("#1E3A8A")
+ .fontSize(22)
+ .text("DEENBRIDGE ACADEMY", 40, 75, { width: contentWidth, align: "center" });
+
+ // Title
+ doc
+ .fillColor("#111827")
+ .fontSize(32)
+ .text("CERTIFICATE OF COMPLETION", 40, 120, { width: contentWidth, align: "center" });
+
+ // Subtitle
+ doc
+ .fillColor("#4B5563")
+ .fontSize(16)
+ .text("PROUDLY PRESENTED TO", 40, 175, { width: contentWidth, align: "center" });
+
+ // Learner Name
+ doc
+ .fillColor("#1E3A8A")
+ .fontSize(28)
+ .text((data.learnerName || "Learner").toUpperCase(), 40, 210, { width: contentWidth, align: "center" });
+
+ // Description text
+ doc
+ .fillColor("#374151")
+ .fontSize(15)
+ .text("for successfully mastering and completing all modules of the course", 40, 260, {
+ width: contentWidth,
+ align: "center",
+ });
+
+ // Course Title
+ doc
+ .fillColor("#D97706")
+ .fontSize(24)
+ .text(`"${data.courseTitle || "Course"}"`, 40, 295, { width: contentWidth, align: "center" });
+
+ // Completion Date
+ const formattedDate = new Date(data.completionDate || Date.now()).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+
+ doc
+ .fillColor("#4B5563")
+ .fontSize(14)
+ .text(`Awarded on ${formattedDate}`, 40, 350, { width: contentWidth, align: "center" });
+
+ // Signatures
+ const sigY = 420;
+
+ // Instructor Signature Line
+ doc
+ .strokeColor("#9CA3AF")
+ .lineWidth(1)
+ .moveTo(150, sigY)
+ .lineTo(330, sigY)
+ .stroke();
+
+ doc
+ .fillColor("#111827")
+ .fontSize(13)
+ .text(data.instructorName || "DeenBridge Instructor", 150, sigY + 8, { width: 180, align: "center" });
+
+ doc
+ .fillColor("#6B7280")
+ .fontSize(11)
+ .text("Course Educator", 150, sigY + 24, { width: 180, align: "center" });
+
+ // Organization Signature Line
+ doc
+ .strokeColor("#9CA3AF")
+ .lineWidth(1)
+ .moveTo(width - 330, sigY)
+ .lineTo(width - 150, sigY)
+ .stroke();
+
+ doc
+ .fillColor("#111827")
+ .fontSize(13)
+ .text(data.instructorSignature || "DeenBridge Verification", width - 330, sigY + 8, {
+ width: 180,
+ align: "center",
+ });
+
+ doc
+ .fillColor("#6B7280")
+ .fontSize(11)
+ .text("Authorized Issuer", width - 330, sigY + 24, { width: 180, align: "center" });
+
+ // Footer - Certificate ID
+ doc
+ .fillColor("#9CA3AF")
+ .fontSize(10)
+ .text(`Certificate ID: ${data.certificateId}`, 40, height - 60, { width: contentWidth, align: "center" });
+
+ doc.end();
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
diff --git a/src/utils/analyticsCalculator.js b/src/utils/analyticsCalculator.js
new file mode 100644
index 00000000..59d8278e
--- /dev/null
+++ b/src/utils/analyticsCalculator.js
@@ -0,0 +1,263 @@
+// utils/analyticsCalculator.js
+//
+// Pure, side-effect-free helpers for turning raw course data (enrollment
+// counts, progress documents, confirmed transactions, lesson lists) into the
+// derived metrics surfaced by the creator analytics endpoints: completion and
+// conversion rates, revenue roll-ups, engagement averages, per-lesson
+// drop-off, and CSV serialisation.
+//
+// Keeping the math here (no DB access) makes each rule independently testable
+// and lets the service layer stay focused on queries.
+
+/**
+ * Round a number to a fixed number of decimal places, guarding against NaN.
+ *
+ * @param {number} value - Raw value to round.
+ * @param {number} [places] - Decimal places to keep (default 2).
+ * @returns {number} The rounded value, or 0 when the input is not finite.
+ */
+export const round = (value, places = 2) => {
+ if (!Number.isFinite(value)) return 0;
+ const factor = 10 ** places;
+ return Math.round(value * factor) / factor;
+};
+
+/**
+ * Completion rate as a percentage of enrolled learners who finished a course.
+ *
+ * @param {number} completions - Number of learners who completed the course.
+ * @param {number} enrollments - Total number of enrolled learners.
+ * @returns {number} Percentage in the range 0–100 (0 when there are no enrollments).
+ */
+export const computeCompletionRate = (completions, enrollments) => {
+ if (!enrollments || enrollments <= 0) return 0;
+ return round((completions / enrollments) * 100);
+};
+
+/**
+ * Conversion rate as a percentage of course viewers who enrolled.
+ *
+ * @param {number} enrollments - Number of enrollments in the window.
+ * @param {number} views - Number of course views (cumulative).
+ * @returns {number} Percentage in the range 0–100 (0 when there are no views).
+ */
+export const computeConversionRate = (enrollments, views) => {
+ if (!views || views <= 0) return 0;
+ return round((enrollments / views) * 100);
+};
+
+/**
+ * Sum confirmed transaction amounts into a per-currency revenue roll-up.
+ *
+ * Transaction amounts are stored as precision-preserving strings and may span
+ * multiple currencies, so revenue is grouped by currency rather than summed
+ * into a single (meaningless) cross-currency total.
+ *
+ * @param {Array<{amount: string|number, currency?: string}>} transactions
+ * Confirmed transactions for the course/creator in the window.
+ * @returns {{revenueByCurrency: Object, transactionCount: number, grossByCurrency: Array<{currency: string, amount: number}>}}
+ * `revenueByCurrency` maps currency code -> total; `grossByCurrency` is the
+ * same data as a sorted array for easy CSV/row rendering.
+ */
+export const sumRevenue = (transactions = []) => {
+ const revenueByCurrency = {};
+
+ for (const tx of transactions) {
+ const amount = parseFloat(tx?.amount);
+ if (!Number.isFinite(amount)) continue;
+ const currency = tx?.currency || "USDC";
+ revenueByCurrency[currency] = round((revenueByCurrency[currency] || 0) + amount, 7);
+ }
+
+ const grossByCurrency = Object.entries(revenueByCurrency)
+ .map(([currency, amount]) => ({ currency, amount }))
+ .sort((a, b) => a.currency.localeCompare(b.currency));
+
+ return {
+ revenueByCurrency,
+ grossByCurrency,
+ transactionCount: transactions.length,
+ };
+};
+
+/**
+ * Compute engagement averages across a set of progress documents.
+ *
+ * @param {Array<{percentComplete?: number, lessonsCompleted?: Array}>} progressDocs
+ * Progress records for learners of the course.
+ * @param {Date|null} [activeSince] - When provided, learners whose progress was
+ * updated on/after this date are counted as "active".
+ * @returns {{learnersStarted: number, avgPercentComplete: number, avgLessonsCompleted: number, activeLearners: number}}
+ */
+export const computeEngagement = (progressDocs = [], activeSince = null) => {
+ const learnersStarted = progressDocs.length;
+ if (!learnersStarted) {
+ return {
+ learnersStarted: 0,
+ avgPercentComplete: 0,
+ avgLessonsCompleted: 0,
+ activeLearners: 0,
+ };
+ }
+
+ let percentSum = 0;
+ let lessonsSum = 0;
+ let activeLearners = 0;
+
+ for (const progress of progressDocs) {
+ percentSum += Number(progress?.percentComplete || 0);
+ lessonsSum += Array.isArray(progress?.lessonsCompleted)
+ ? progress.lessonsCompleted.length
+ : 0;
+ if (activeSince && progress?.updatedAt && new Date(progress.updatedAt) >= activeSince) {
+ activeLearners += 1;
+ }
+ }
+
+ return {
+ learnersStarted,
+ avgPercentComplete: round(percentSum / learnersStarted),
+ avgLessonsCompleted: round(lessonsSum / learnersStarted),
+ activeLearners: activeSince ? activeLearners : learnersStarted,
+ };
+};
+
+/**
+ * Compute per-lesson reach and drop-off across ordered lessons.
+ *
+ * For each lesson (in course order) we count how many learners have completed
+ * it (`reached`). The drop-off at a lesson is the number of learners who
+ * reached the previous lesson but not this one — i.e. where they fell out of
+ * the funnel. The lesson with the largest drop is flagged as the biggest
+ * drop-off point.
+ *
+ * @param {Array<{lessonId: string, title?: string}>} lessons - Ordered lessons.
+ * @param {Array<{lessonsCompleted?: Array}>} progressDocs - Learner progress.
+ * @returns {{lessons: Array<{order: number, lessonId: string, title: string, reached: number, dropOff: number, dropOffRate: number}>, biggestDropOff: object|null}}
+ */
+export const computeDropOff = (lessons = [], progressDocs = []) => {
+ const completedSets = progressDocs.map(
+ (p) => new Set((p?.lessonsCompleted || []).map((id) => String(id)))
+ );
+
+ const rows = lessons.map((lesson, index) => {
+ const lessonId = String(lesson.lessonId);
+ const reached = completedSets.reduce(
+ (count, set) => (set.has(lessonId) ? count + 1 : count),
+ 0
+ );
+ return {
+ order: index + 1,
+ lessonId,
+ title: lesson.title || `Lesson ${index + 1}`,
+ reached,
+ dropOff: 0,
+ dropOffRate: 0,
+ };
+ });
+
+ // Drop-off is measured against the previous lesson's reach (the first lesson
+ // is the funnel entry point, so it has no upstream drop).
+ for (let i = 0; i < rows.length; i += 1) {
+ const prevReached = i === 0 ? rows[0].reached : rows[i - 1].reached;
+ const drop = Math.max(0, prevReached - rows[i].reached);
+ rows[i].dropOff = i === 0 ? 0 : drop;
+ rows[i].dropOffRate =
+ i === 0 || prevReached <= 0 ? 0 : round((drop / prevReached) * 100);
+ }
+
+ const biggestDropOff = rows
+ .filter((row) => row.order > 1)
+ .reduce((max, row) => (!max || row.dropOff > max.dropOff ? row : max), null);
+
+ return { lessons: rows, biggestDropOff };
+};
+
+/**
+ * Escape a single value for inclusion in a CSV cell (RFC 4180 style): wrap in
+ * double quotes and double any embedded quotes when the value contains a
+ * comma, quote, or newline.
+ *
+ * @param {*} value - The raw cell value.
+ * @returns {string} A CSV-safe cell string.
+ */
+export const escapeCsvCell = (value) => {
+ const str = value === null || value === undefined ? "" : String(value);
+ if (/[",\n\r]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+};
+
+/**
+ * Build a CSV string from a header row and an array of row arrays.
+ *
+ * @param {string[]} headers - Column headers.
+ * @param {Array>} rows - Row values (each inner array is one row).
+ * @returns {string} A CSV document terminated with a trailing newline.
+ */
+export const buildCsv = (headers, rows) => {
+ const lines = [headers.map(escapeCsvCell).join(",")];
+ for (const row of rows) {
+ lines.push(row.map(escapeCsvCell).join(","));
+ }
+ return `${lines.join("\r\n")}\r\n`;
+};
+
+/**
+ * Flatten a computed analytics object into a two-column (metric,value) CSV
+ * suitable for spreadsheet download. Per-currency revenue and per-lesson
+ * drop-off are expanded into their own labelled rows.
+ *
+ * @param {object} analytics - The object returned by the analytics service.
+ * @returns {string} CSV document.
+ */
+export const analyticsToCsv = (analytics = {}) => {
+ const rows = [];
+ const m = analytics.metrics || {};
+
+ rows.push(["Course ID", analytics.courseId || ""]);
+ rows.push(["Course Title", analytics.title || ""]);
+ rows.push(["Range Start", analytics.range?.startDate || "all-time"]);
+ rows.push(["Range End", analytics.range?.endDate || "all-time"]);
+ rows.push(["Views (cumulative)", m.views ?? 0]);
+ rows.push(["Enrollments (total)", m.enrollmentsTotal ?? 0]);
+ rows.push(["Enrollments (in range)", m.enrollments ?? 0]);
+ rows.push(["Completions", m.completions ?? 0]);
+ rows.push(["Completion Rate (%)", m.completionRate ?? 0]);
+ rows.push(["Conversion Rate (%)", m.conversionRate ?? 0]);
+ rows.push(["Learners Started", m.engagement?.learnersStarted ?? 0]);
+ rows.push(["Active Learners", m.engagement?.activeLearners ?? 0]);
+ rows.push(["Avg Percent Complete (%)", m.engagement?.avgPercentComplete ?? 0]);
+ rows.push(["Avg Lessons Completed", m.engagement?.avgLessonsCompleted ?? 0]);
+ rows.push(["Confirmed Transactions", m.revenue?.transactionCount ?? 0]);
+
+ for (const entry of m.revenue?.grossByCurrency || []) {
+ rows.push([`Revenue (${entry.currency})`, entry.amount]);
+ }
+
+ for (const lesson of m.dropOff?.lessons || []) {
+ rows.push([
+ `Lesson ${lesson.order} reached - ${lesson.title}`,
+ lesson.reached,
+ ]);
+ rows.push([
+ `Lesson ${lesson.order} drop-off (%) - ${lesson.title}`,
+ lesson.dropOffRate,
+ ]);
+ }
+
+ return buildCsv(["metric", "value"], rows);
+};
+
+export default {
+ round,
+ computeCompletionRate,
+ computeConversionRate,
+ sumRevenue,
+ computeEngagement,
+ computeDropOff,
+ escapeCsvCell,
+ buildCsv,
+ analyticsToCsv,
+};
diff --git a/src/utils/badge-criteria.js b/src/utils/badge-criteria.js
new file mode 100644
index 00000000..89e77aff
--- /dev/null
+++ b/src/utils/badge-criteria.js
@@ -0,0 +1,47 @@
+export const DEFAULT_BADGES = [
+ {
+ name: "First Steps",
+ slug: "first-course",
+ description: "Completed your first course on DeenBridge",
+ icon: "badge_first_course.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 1,
+ },
+ {
+ name: "Dedicated Learner",
+ slug: "5-courses",
+ description: "Successfully completed 5 courses",
+ icon: "badge_5_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 5,
+ },
+ {
+ name: "Knowledge Seeker",
+ slug: "10-courses",
+ description: "Successfully completed 10 courses",
+ icon: "badge_10_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 10,
+ },
+ {
+ name: "Master Scholar",
+ slug: "20-courses",
+ description: "Achieved mastery by completing 20 courses",
+ icon: "badge_20_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 20,
+ },
+ {
+ name: "Category Expert",
+ slug: "category-expert",
+ description: "Completed 3 or more courses within a single topic category",
+ icon: "badge_category_expert.png",
+ category: "achievement",
+ criteriaType: "category_completed",
+ threshold: 3,
+ },
+];
diff --git a/src/utils/badge-criteria.ts b/src/utils/badge-criteria.ts
new file mode 100644
index 00000000..89e77aff
--- /dev/null
+++ b/src/utils/badge-criteria.ts
@@ -0,0 +1,47 @@
+export const DEFAULT_BADGES = [
+ {
+ name: "First Steps",
+ slug: "first-course",
+ description: "Completed your first course on DeenBridge",
+ icon: "badge_first_course.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 1,
+ },
+ {
+ name: "Dedicated Learner",
+ slug: "5-courses",
+ description: "Successfully completed 5 courses",
+ icon: "badge_5_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 5,
+ },
+ {
+ name: "Knowledge Seeker",
+ slug: "10-courses",
+ description: "Successfully completed 10 courses",
+ icon: "badge_10_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 10,
+ },
+ {
+ name: "Master Scholar",
+ slug: "20-courses",
+ description: "Achieved mastery by completing 20 courses",
+ icon: "badge_20_courses.png",
+ category: "milestone",
+ criteriaType: "courses_completed",
+ threshold: 20,
+ },
+ {
+ name: "Category Expert",
+ slug: "category-expert",
+ description: "Completed 3 or more courses within a single topic category",
+ icon: "badge_category_expert.png",
+ category: "achievement",
+ criteriaType: "category_completed",
+ threshold: 3,
+ },
+];
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/utils/videoCompositor.js b/src/utils/videoCompositor.js
new file mode 100644
index 00000000..f1fe99cf
--- /dev/null
+++ b/src/utils/videoCompositor.js
@@ -0,0 +1,86 @@
+// utils/videoCompositor.js
+//
+// Dependency-free abstraction that records the *intent* of composing a
+// duet/stitch response video against an original reel. It intentionally does
+// NOT perform any frame compositing or transcoding (no ffmpeg, no native deps):
+// the returned descriptor is metadata that the downstream media pipeline uses
+// to render the final composited video asynchronously.
+//
+// - duet -> the response is played side-by-side with the original.
+// - stitch -> a clip of the original is prepended, then the response plays.
+
+export const DUET_TYPES = ["duet", "stitch"];
+
+export const COMPOSITION_LAYOUTS = {
+ duet: "side-by-side",
+ stitch: "prepend-clip",
+};
+
+export const isDuetType = (value) => DUET_TYPES.includes(value);
+
+/**
+ * Normalize a stitch clip range. Returns { start, end } in seconds, or null
+ * when the range is absent/invalid. `end` must be greater than `start`.
+ */
+export const normalizeStitchClip = (clip) => {
+ if (!clip) return null;
+ const start = Number(clip.start);
+ const end = Number(clip.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
+ if (start < 0 || end <= start) return null;
+ return { start, end };
+};
+
+/**
+ * Build a composition descriptor for a duet/stitch response.
+ *
+ * @param {Object} params
+ * @param {"duet"|"stitch"} params.type
+ * @param {Object} params.original - source original reel ({ _id, video, duration })
+ * @param {Object} params.response - the response video ({ video, duration })
+ * @param {Object} [params.clip] - stitch clip range ({ start, end }) in seconds
+ * @returns {Object} composition descriptor persisted on the derivative reel
+ */
+export const buildCompositionDescriptor = ({ type, original, response, clip } = {}) => {
+ if (!isDuetType(type)) {
+ throw new Error(`Unsupported composition type: ${type}`);
+ }
+
+ const descriptor = {
+ type,
+ layout: COMPOSITION_LAYOUTS[type],
+ // Rendering is performed later by the media pipeline; this marks intent.
+ status: "pending",
+ sources: {
+ original: {
+ reelId: original?._id ? String(original._id) : null,
+ video: original?.video ?? null,
+ duration: original?.duration ?? null,
+ },
+ response: {
+ video: response?.video ?? null,
+ duration: response?.duration ?? null,
+ },
+ },
+ };
+
+ if (type === "stitch") {
+ const range = normalizeStitchClip(clip);
+ if (!range) {
+ throw new Error(
+ "A stitch requires a valid clip range ({ start, end } in seconds, end > start)"
+ );
+ }
+ descriptor.clip = range;
+ }
+
+ return descriptor;
+};
+
+export default {
+ DUET_TYPES,
+ COMPOSITION_LAYOUTS,
+ isDuetType,
+ normalizeStitchClip,
+ buildCompositionDescriptor,
+};
diff --git a/src/validators/readingProgressValidators.js b/src/validators/readingProgressValidators.js
new file mode 100644
index 00000000..495244d5
--- /dev/null
+++ b/src/validators/readingProgressValidators.js
@@ -0,0 +1,50 @@
+// validators/readingProgressValidators.js
+import { body, param } from "express-validator";
+import mongoose from "mongoose";
+
+const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value);
+
+const bookIdParam = param("bookId")
+ .custom(isValidObjectId)
+ .withMessage("A valid book id is required");
+
+/**
+ * Validation for updating reading progress. Requires at least one of
+ * page / percentage / lastPosition, and bounds the numeric fields
+ * (percentage 0-100, page >= 0).
+ */
+export const updateReadingProgressValidation = [
+ bookIdParam,
+ body("percentage")
+ .optional({ nullable: true })
+ .isFloat({ min: 0, max: 100 })
+ .withMessage("percentage must be a number between 0 and 100"),
+ body("page")
+ .optional({ nullable: true })
+ .isInt({ min: 0 })
+ .withMessage("page must be an integer >= 0"),
+ body("totalPages")
+ .optional({ nullable: true })
+ .isInt({ min: 0 })
+ .withMessage("totalPages must be an integer >= 0"),
+ body("lastPosition")
+ .optional({ nullable: true })
+ .isString()
+ .withMessage("lastPosition must be a string"),
+ body("device")
+ .optional({ nullable: true })
+ .isString()
+ .withMessage("device must be a string"),
+ body().custom((value) => {
+ if (
+ (value.page === undefined || value.page === null) &&
+ (value.percentage === undefined || value.percentage === null) &&
+ (value.lastPosition === undefined || value.lastPosition === null)
+ ) {
+ throw new Error("Provide at least one of page, percentage or lastPosition");
+ }
+ return true;
+ }),
+];
+
+export const readingProgressBookIdValidation = [bookIdParam];
diff --git a/src/validators/reelValidators.js b/src/validators/reelValidators.js
new file mode 100644
index 00000000..39c8d142
--- /dev/null
+++ b/src/validators/reelValidators.js
@@ -0,0 +1,75 @@
+// validators/reelValidators.js
+import { body, param, query } from "express-validator";
+import mongoose from "mongoose";
+
+const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value);
+
+const reelIdParam = param("id")
+ .custom(isValidObjectId)
+ .withMessage("A valid reel id is required");
+
+// Create a duet/stitch response for the reel identified by :id.
+// Note: this runs *after* multer (upload.single), so multipart text fields are
+// available on req.body.
+export const createReelDuetValidation = [
+ reelIdParam,
+ body("description")
+ .exists({ values: "null" })
+ .withMessage("Description is required")
+ .bail()
+ .isString()
+ .withMessage("Description must be a string")
+ .bail()
+ .trim()
+ .notEmpty()
+ .withMessage("Description is required"),
+ body("type")
+ .exists({ values: "null" })
+ .withMessage("type is required")
+ .bail()
+ .isIn(["duet", "stitch"])
+ .withMessage("type must be one of: duet, stitch"),
+ body("stitchStart")
+ .if(body("type").equals("stitch"))
+ .exists({ values: "null" })
+ .withMessage("stitchStart is required for a stitch")
+ .bail()
+ .isFloat({ min: 0 })
+ .withMessage("stitchStart must be a number >= 0"),
+ body("stitchEnd")
+ .if(body("type").equals("stitch"))
+ .exists({ values: "null" })
+ .withMessage("stitchEnd is required for a stitch")
+ .bail()
+ .isFloat({ gt: 0 })
+ .withMessage("stitchEnd must be a number > 0")
+ .bail()
+ .custom((value, { req }) => {
+ if (Number(value) <= Number(req.body.stitchStart)) {
+ throw new Error("stitchEnd must be greater than stitchStart");
+ }
+ return true;
+ }),
+];
+
+// Browse duets/stitches for the reel identified by :id.
+export const listReelDuetsValidation = [
+ reelIdParam,
+ query("type")
+ .optional({ values: "falsy" })
+ .isIn(["duet", "stitch"])
+ .withMessage("type must be one of: duet, stitch"),
+ query("page")
+ .optional({ values: "falsy" })
+ .isInt({ min: 1 })
+ .withMessage("page must be a positive integer"),
+ query("limit")
+ .optional({ values: "falsy" })
+ .isInt({ min: 1, max: 50 })
+ .withMessage("limit must be between 1 and 50"),
+];
+
+export default {
+ createReelDuetValidation,
+ listReelDuetsValidation,
+};
diff --git a/src/validators/requestValidators.js b/src/validators/requestValidators.js
new file mode 100644
index 00000000..7dcaf45a
--- /dev/null
+++ b/src/validators/requestValidators.js
@@ -0,0 +1,147 @@
+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"),
+];
+
+// Optional prerequisites: an array of course ObjectIds a learner must complete
+// before enrolling. Each entry must be a valid ObjectId and (defensively) a
+// course may not list itself as a prerequisite. Shared by course create/update.
+export const prerequisitesValidation = [
+ body("prerequisites")
+ .optional({ values: "undefined" })
+ .isArray()
+ .withMessage("prerequisites must be an array"),
+ body("prerequisites.*")
+ .custom(isValidObjectId)
+ .withMessage("Each prerequisite must be a valid Mongo ObjectId"),
+ body("prerequisites")
+ .optional({ values: "undefined" })
+ .custom((value, { req }) => {
+ if (!Array.isArray(value)) return true;
+ const courseId = req.params?.id;
+ if (courseId && value.some((p) => String(p) === String(courseId))) {
+ throw new Error("A course cannot list itself as a prerequisite");
+ }
+ return true;
+ }),
+];
+
+export default {
+ registerValidation,
+ loginValidation,
+ initializePaymentValidation,
+ submitPaymentValidation,
+ connectWalletValidation,
+ prerequisitesValidation,
+};
diff --git a/src/validators/stellarAnalyticsValidators.js b/src/validators/stellarAnalyticsValidators.js
new file mode 100644
index 00000000..0f4ddda0
--- /dev/null
+++ b/src/validators/stellarAnalyticsValidators.js
@@ -0,0 +1,81 @@
+// validators/stellarAnalyticsValidators.js
+import { query } from "express-validator";
+import mongoose from "mongoose";
+import { getSupportedCodes } from "../config/assets.js";
+import { SUPPORTED_PERIODS } from "../services/stellar/analyticsService.js";
+
+/**
+ * express-validator chains for the payment analytics endpoints.
+ *
+ * All parameters are optional query-string filters. Enums are validated against
+ * the same sources of truth used by the Transaction model so the analytics API
+ * cannot drift from the schema.
+ *
+ * @module validators/stellarAnalyticsValidators
+ */
+
+const TRANSACTION_STATUSES = [
+ "pending",
+ "submitted",
+ "retrying",
+ "confirmed",
+ "failed",
+ "expired",
+ "refunded",
+ "disputed",
+];
+
+const TRANSACTION_TYPES = ["purchase", "donation"];
+
+const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value);
+
+/** Filters shared by every analytics endpoint. */
+const commonFilters = [
+ query("status")
+ .optional()
+ .isIn(TRANSACTION_STATUSES)
+ .withMessage(`status must be one of: ${TRANSACTION_STATUSES.join(", ")}`),
+ query("type")
+ .optional()
+ .isIn(TRANSACTION_TYPES)
+ .withMessage(`type must be one of: ${TRANSACTION_TYPES.join(", ")}`),
+ query("currency")
+ .optional()
+ .isIn(getSupportedCodes())
+ .withMessage(`currency must be a supported asset code`),
+ query("buyerId")
+ .optional()
+ .custom(isValidObjectId)
+ .withMessage("buyerId must be a valid Mongo ObjectId"),
+ query("creatorId")
+ .optional()
+ .custom(isValidObjectId)
+ .withMessage("creatorId must be a valid Mongo ObjectId"),
+ query("startDate")
+ .optional()
+ .isISO8601()
+ .withMessage("startDate must be an ISO 8601 date")
+ .toDate(),
+ query("endDate")
+ .optional()
+ .isISO8601()
+ .withMessage("endDate must be an ISO 8601 date")
+ .toDate(),
+];
+
+/** Validation for endpoints that accept a time-bucket `period`. */
+export const analyticsTimeSeriesValidation = [
+ query("period")
+ .optional()
+ .isIn(SUPPORTED_PERIODS)
+ .withMessage(`period must be one of: ${SUPPORTED_PERIODS.join(", ")}`),
+ ...commonFilters,
+];
+
+/** Validation for the summary endpoint (no `period`). */
+export const analyticsSummaryValidation = [...commonFilters];
+
+export default {
+ analyticsTimeSeriesValidation,
+ analyticsSummaryValidation,
+};
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/analyticsCalculator.test.js b/test/analyticsCalculator.test.js
new file mode 100644
index 00000000..238ab4fc
--- /dev/null
+++ b/test/analyticsCalculator.test.js
@@ -0,0 +1,156 @@
+import {
+ round,
+ computeCompletionRate,
+ computeConversionRate,
+ sumRevenue,
+ computeEngagement,
+ computeDropOff,
+ escapeCsvCell,
+ buildCsv,
+ analyticsToCsv,
+} from "../src/utils/analyticsCalculator.js";
+
+describe("analyticsCalculator", () => {
+ describe("round", () => {
+ it("rounds to two decimals and guards non-finite input", () => {
+ expect(round(1.23456)).toBe(1.23);
+ expect(round(Number.NaN)).toBe(0);
+ expect(round(1.23456, 3)).toBe(1.235);
+ });
+ });
+
+ describe("computeCompletionRate", () => {
+ it("returns percentage of completions over enrollments", () => {
+ expect(computeCompletionRate(5, 20)).toBe(25);
+ });
+ it("returns 0 when there are no enrollments", () => {
+ expect(computeCompletionRate(5, 0)).toBe(0);
+ });
+ });
+
+ describe("computeConversionRate", () => {
+ it("returns percentage of enrollments over views", () => {
+ expect(computeConversionRate(10, 200)).toBe(5);
+ });
+ it("returns 0 when there are no views", () => {
+ expect(computeConversionRate(10, 0)).toBe(0);
+ });
+ });
+
+ describe("sumRevenue", () => {
+ it("groups revenue by currency and counts transactions", () => {
+ const result = sumRevenue([
+ { amount: "10.5", currency: "USDC" },
+ { amount: "4.5", currency: "USDC" },
+ { amount: "2", currency: "XLM" },
+ { amount: "not-a-number", currency: "USDC" },
+ ]);
+ expect(result.revenueByCurrency.USDC).toBe(15);
+ expect(result.revenueByCurrency.XLM).toBe(2);
+ expect(result.transactionCount).toBe(4);
+ expect(result.grossByCurrency).toEqual([
+ { currency: "USDC", amount: 15 },
+ { currency: "XLM", amount: 2 },
+ ]);
+ });
+ });
+
+ describe("computeEngagement", () => {
+ it("averages progress and counts active learners since a date", () => {
+ const since = new Date("2026-01-10");
+ const result = computeEngagement(
+ [
+ {
+ percentComplete: 100,
+ lessonsCompleted: [1, 2, 3],
+ updatedAt: "2026-01-15",
+ },
+ {
+ percentComplete: 50,
+ lessonsCompleted: [1],
+ updatedAt: "2026-01-05",
+ },
+ ],
+ since
+ );
+ expect(result.learnersStarted).toBe(2);
+ expect(result.avgPercentComplete).toBe(75);
+ expect(result.avgLessonsCompleted).toBe(2);
+ expect(result.activeLearners).toBe(1);
+ });
+
+ it("returns zeros for an empty cohort", () => {
+ expect(computeEngagement([])).toEqual({
+ learnersStarted: 0,
+ avgPercentComplete: 0,
+ avgLessonsCompleted: 0,
+ activeLearners: 0,
+ });
+ });
+ });
+
+ describe("computeDropOff", () => {
+ it("computes per-lesson reach and biggest drop-off point", () => {
+ const lessons = [
+ { lessonId: "a", title: "Intro" },
+ { lessonId: "b", title: "Middle" },
+ { lessonId: "c", title: "End" },
+ ];
+ const progress = [
+ { lessonsCompleted: ["a", "b", "c"] },
+ { lessonsCompleted: ["a", "b"] },
+ { lessonsCompleted: ["a"] },
+ { lessonsCompleted: ["a"] },
+ ];
+ const { lessons: rows, biggestDropOff } = computeDropOff(lessons, progress);
+ expect(rows[0].reached).toBe(4);
+ expect(rows[1].reached).toBe(2);
+ expect(rows[2].reached).toBe(1);
+ expect(rows[1].dropOff).toBe(2);
+ expect(biggestDropOff.lessonId).toBe("b");
+ });
+ });
+
+ describe("CSV helpers", () => {
+ it("escapes cells containing commas, quotes and newlines", () => {
+ expect(escapeCsvCell("plain")).toBe("plain");
+ expect(escapeCsvCell("a,b")).toBe('"a,b"');
+ expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""');
+ });
+
+ it("builds a CSV document with a header row", () => {
+ const csv = buildCsv(["metric", "value"], [["views", 3]]);
+ expect(csv).toBe("metric,value\r\nviews,3\r\n");
+ });
+
+ it("serialises an analytics object into metric/value rows", () => {
+ const csv = analyticsToCsv({
+ courseId: "abc",
+ title: "Test Course",
+ range: { startDate: null, endDate: null },
+ metrics: {
+ views: 100,
+ enrollmentsTotal: 10,
+ enrollments: 10,
+ completions: 4,
+ completionRate: 40,
+ conversionRate: 10,
+ engagement: {
+ learnersStarted: 8,
+ activeLearners: 5,
+ avgPercentComplete: 55,
+ avgLessonsCompleted: 3,
+ },
+ revenue: {
+ transactionCount: 2,
+ grossByCurrency: [{ currency: "USDC", amount: 25 }],
+ },
+ dropOff: { lessons: [] },
+ },
+ });
+ expect(csv).toContain("Course ID,abc");
+ expect(csv).toContain("Revenue (USDC),25");
+ expect(csv).toContain("Completion Rate (%),40");
+ });
+ });
+});
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/badge.test.js b/test/badge.test.js
new file mode 100644
index 00000000..e46bd8c5
--- /dev/null
+++ b/test/badge.test.js
@@ -0,0 +1,100 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import Course from "../src/models/Course.js";
+import CourseProgress from "../src/models/CourseProgress.js";
+import Badge from "../src/models/badge.model.js";
+import UserBadge from "../src/models/user-badge.model.js";
+import User from "../src/models/User.js";
+import badgeService from "../src/services/badge.service.js";
+import { seedUserAndLogin } from "./helpers/testAuth.js";
+
+describe("Course Badges API", () => {
+ let mongoServer;
+ let token;
+ let user;
+
+ 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());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
+
+ beforeEach(async () => {
+ await UserBadge.deleteMany({});
+ await Badge.deleteMany({});
+ await CourseProgress.deleteMany({});
+ await Course.deleteMany({});
+ await User.deleteMany({});
+
+ const auth = await seedUserAndLogin(app, { email: "badge_student@example.com" });
+ token = auth.token;
+ user = auth.user;
+ });
+
+ it("should seed default badges and return all badge definitions", async () => {
+ const res = await request(app).get("/api/badges");
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data.length).toBeGreaterThan(0);
+ });
+
+ it("should automatically award First Steps badge when user completes 1 course", async () => {
+ const course = await Course.create({
+ title: "Introduction to Islam",
+ description: "Basics of faith",
+ category: "Theology",
+ createdBy: user._id,
+ });
+
+ await CourseProgress.create({
+ user: user._id,
+ course: course._id,
+ percentComplete: 100,
+ completedAt: new Date(),
+ });
+
+ const userBadges = await badgeService.getUserBadges(user._id);
+ expect(userBadges).toHaveLength(1);
+ expect(userBadges[0].badge.slug).toBe("first-course");
+ });
+
+ it("should fetch user badges via API endpoint", async () => {
+ const course = await Course.create({
+ title: "Fiqh 101",
+ description: "Introductory Fiqh",
+ category: "Fiqh",
+ createdBy: user._id,
+ });
+
+ await CourseProgress.create({
+ user: user._id,
+ course: course._id,
+ percentComplete: 100,
+ completedAt: new Date(),
+ });
+
+ const res = await request(app)
+ .get(`/api/badges/user/${user._id}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data.length).toBeGreaterThanOrEqual(1);
+ });
+});
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/certificate.test.js b/test/certificate.test.js
new file mode 100644
index 00000000..b17a7669
--- /dev/null
+++ b/test/certificate.test.js
@@ -0,0 +1,111 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import Course from "../src/models/Course.js";
+import CourseProgress from "../src/models/CourseProgress.js";
+import Certificate from "../src/models/certificate.model.js";
+import User from "../src/models/User.js";
+import { seedUserAndLogin } from "./helpers/testAuth.js";
+
+describe("Course Certificates API", () => {
+ let mongoServer;
+ let token;
+ let user;
+ let course;
+
+ 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());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
+
+ beforeEach(async () => {
+ await Certificate.deleteMany({});
+ await CourseProgress.deleteMany({});
+ await Course.deleteMany({});
+ await User.deleteMany({});
+
+ const auth = await seedUserAndLogin(app, { email: "certificate_student@example.com" });
+ token = auth.token;
+ user = auth.user;
+
+ course = await Course.create({
+ title: "Fullstack Web Development",
+ description: "Master Node and React",
+ category: "Programming",
+ price: 0,
+ createdBy: user._id,
+ });
+ });
+
+ it("should fail generating certificate if course is incomplete", async () => {
+ await CourseProgress.create({
+ user: user._id,
+ course: course._id,
+ percentComplete: 50,
+ });
+
+ const res = await request(app)
+ .post("/api/certificates/generate")
+ .set("Authorization", `Bearer ${token}`)
+ .send({ courseId: course._id });
+
+ expect(res.status).toBe(400);
+ expect(res.body.success).toBe(false);
+ expect(res.body.message).toMatch(/not been completed/i);
+ });
+
+ it("should generate certificate when course is 100% complete", async () => {
+ await CourseProgress.create({
+ user: user._id,
+ course: course._id,
+ percentComplete: 100,
+ completedAt: new Date(),
+ });
+
+ const res = await request(app)
+ .post("/api/certificates/generate")
+ .set("Authorization", `Bearer ${token}`)
+ .send({ courseId: course._id });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data.certificateId).toMatch(/^CERT-/);
+ expect(res.body.data.learnerName).toBe(user.name);
+ expect(res.body.data.courseTitle).toBe(course.title);
+ });
+
+ it("should download PDF certificate", async () => {
+ const cert = await Certificate.create({
+ certificateId: "CERT-TEST-12345",
+ user: user._id,
+ course: course._id,
+ learnerName: user.name,
+ courseTitle: course.title,
+ completionDate: new Date(),
+ certificateUrl: "/api/certificates/CERT-TEST-12345/download",
+ });
+
+ const res = await request(app).get(`/api/certificates/${cert.certificateId}/download`);
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toBe("application/pdf");
+ expect(res.headers["content-disposition"]).toMatch(/CERT-TEST-12345\.pdf/);
+ });
+});
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/courseBundle.test.js b/test/courseBundle.test.js
new file mode 100644
index 00000000..a020777f
--- /dev/null
+++ b/test/courseBundle.test.js
@@ -0,0 +1,143 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import Course from "../src/models/Course.js";
+import CourseBundle from "../src/models/course-bundle.model.js";
+import User from "../src/models/User.js";
+import { seedUserAndLogin } from "./helpers/testAuth.js";
+
+describe("Course Bundles API", () => {
+ let mongoServer;
+ let token;
+ let user;
+ let course1;
+ let course2;
+
+ 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());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
+
+ beforeEach(async () => {
+ await CourseBundle.deleteMany({});
+ await Course.deleteMany({});
+ await User.deleteMany({});
+
+ const auth = await seedUserAndLogin(app, { email: "bundle_creator@example.com" });
+ token = auth.token;
+ user = auth.user;
+
+ course1 = await Course.create({
+ title: "React Fundamentals",
+ description: "Learn React from scratch",
+ category: "Programming",
+ price: 100,
+ createdBy: user._id,
+ });
+
+ course2 = await Course.create({
+ title: "Advanced React",
+ description: "Master React patterns",
+ category: "Programming",
+ price: 150,
+ createdBy: user._id,
+ });
+ });
+
+ it("should create a course bundle and calculate discount percentage", async () => {
+ const res = await request(app)
+ .post("/api/course-bundles")
+ .set("Authorization", `Bearer ${token}`)
+ .send({
+ title: "Complete React Suite",
+ description: "Get both React courses at a discount",
+ courses: [course1._id, course2._id],
+ price: 200,
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data.originalPrice).toBe(250);
+ expect(res.body.data.discountPercentage).toBe(20);
+ expect(res.body.data.courses).toHaveLength(2);
+ });
+
+ it("should fetch all course bundles", async () => {
+ await CourseBundle.create({
+ title: "React Bundle",
+ description: "Bundle description",
+ courses: [course1._id, course2._id],
+ price: 200,
+ originalPrice: 250,
+ discountPercentage: 20,
+ createdBy: user._id,
+ });
+
+ const res = await request(app).get("/api/course-bundles");
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data).toHaveLength(1);
+ expect(res.body.data[0].title).toBe("React Bundle");
+ });
+
+ it("should fetch bundles containing a specific course", async () => {
+ await CourseBundle.create({
+ title: "React Bundle",
+ description: "Bundle description",
+ courses: [course1._id, course2._id],
+ price: 200,
+ originalPrice: 250,
+ discountPercentage: 20,
+ createdBy: user._id,
+ });
+
+ const res = await request(app).get(`/api/course-bundles/course/${course1._id}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+ expect(res.body.data).toHaveLength(1);
+ });
+
+ it("should purchase a bundle and enroll user in all courses", async () => {
+ const learnerAuth = await seedUserAndLogin(app, { email: "learner@example.com" });
+
+ const bundle = await CourseBundle.create({
+ title: "React Bundle",
+ description: "Bundle description",
+ courses: [course1._id, course2._id],
+ price: 200,
+ originalPrice: 250,
+ discountPercentage: 20,
+ createdBy: user._id,
+ });
+
+ const res = await request(app)
+ .post(`/api/course-bundles/${bundle._id}/purchase`)
+ .set("Authorization", `Bearer ${learnerAuth.token}`)
+ .send({});
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+
+ const updatedLearner = await User.findById(learnerAuth.user._id);
+ expect(updatedLearner.purchasedCourses).toHaveLength(2);
+ });
+});
diff --git a/test/coursePrerequisites.test.js b/test/coursePrerequisites.test.js
new file mode 100644
index 00000000..3effb229
--- /dev/null
+++ b/test/coursePrerequisites.test.js
@@ -0,0 +1,153 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Course from "../src/models/Course.js";
+import CourseProgress from "../src/models/CourseProgress.js";
+
+describe("Course prerequisites enrollment gate", () => {
+ let mongoServer;
+ let learnerToken;
+ let owner;
+ 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());
+ });
+
+ beforeEach(async () => {
+ await Promise.all([
+ User.deleteMany({}),
+ Course.deleteMany({}),
+ CourseProgress.deleteMany({}),
+ ]);
+
+ owner = await User.create({
+ name: "Owner",
+ email: "owner@example.com",
+ password: "Qx7#vLmp92Zt",
+ role: "mentor",
+ });
+ learner = await User.create({
+ name: "Learner",
+ email: "learner@example.com",
+ password: "Qx7#vLmp92Zt",
+ role: "student",
+ });
+
+ learnerToken = jwt.sign(
+ { userId: learner._id, sessionId: "l1" },
+ process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"
+ );
+ });
+
+ it("blocks enrollment when a prerequisite is not completed", async () => {
+ const prereq = await Course.create({
+ title: "Intro to Fiqh",
+ description: "Basics",
+ category: "Tech",
+ createdBy: owner._id,
+ });
+ const advanced = await Course.create({
+ title: "Advanced Fiqh",
+ description: "Deep dive",
+ category: "Tech",
+ createdBy: owner._id,
+ prerequisites: [prereq._id],
+ });
+
+ const res = await request(app)
+ .post(`/api/courses/${advanced._id}/enroll`)
+ .set("Authorization", `Bearer ${learnerToken}`)
+ .send({});
+
+ expect(res.status).toBe(400);
+ expect(res.body.success).toBe(false);
+ expect(res.body.message).toContain("Complete these prerequisites first");
+ expect(res.body.message).toContain("Intro to Fiqh");
+
+ const refreshed = await Course.findById(advanced._id);
+ expect(refreshed.enrolledUsers.map(String)).not.toContain(
+ learner._id.toString()
+ );
+ });
+
+ it("allows enrollment once the prerequisite is completed", async () => {
+ const prereq = await Course.create({
+ title: "Intro to Fiqh",
+ description: "Basics",
+ category: "Tech",
+ createdBy: owner._id,
+ });
+ const advanced = await Course.create({
+ title: "Advanced Fiqh",
+ description: "Deep dive",
+ category: "Tech",
+ createdBy: owner._id,
+ prerequisites: [prereq._id],
+ });
+
+ await CourseProgress.create({
+ user: learner._id,
+ course: prereq._id,
+ percentComplete: 100,
+ completedAt: new Date(),
+ });
+
+ const res = await request(app)
+ .post(`/api/courses/${advanced._id}/enroll`)
+ .set("Authorization", `Bearer ${learnerToken}`)
+ .send({});
+
+ expect(res.status).toBe(200);
+ expect(res.body.success).toBe(true);
+
+ const refreshed = await Course.findById(advanced._id);
+ expect(refreshed.enrolledUsers.map(String)).toContain(
+ learner._id.toString()
+ );
+ });
+
+ it("returns prerequisites (id + title) on the course detail endpoint", async () => {
+ const prereq = await Course.create({
+ title: "Intro to Fiqh",
+ description: "Basics",
+ category: "Tech",
+ createdBy: owner._id,
+ });
+ const advanced = await Course.create({
+ title: "Advanced Fiqh",
+ description: "Deep dive",
+ category: "Tech",
+ createdBy: owner._id,
+ prerequisites: [prereq._id],
+ });
+
+ const res = await request(app).get(`/api/courses/${advanced._id}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.course.prerequisites).toHaveLength(1);
+ expect(res.body.course.prerequisites[0].title).toBe("Intro to Fiqh");
+ });
+
+ afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
+});
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/dbHealth.test.js b/test/dbHealth.test.js
new file mode 100644
index 00000000..12390022
--- /dev/null
+++ b/test/dbHealth.test.js
@@ -0,0 +1,23 @@
+import request from "supertest";
+import app from "../app.js";
+import { checkDatabaseHealth } from "../mongo/utils/healthCheck.js";
+
+describe("Database Health Endpoint", () => {
+ it("GET /health/database should return health status and connection details", async () => {
+ const res = await request(app).get("/health/database");
+ expect([200, 503]).toContain(res.status);
+ expect(res.body).toHaveProperty("success");
+ expect(res.body).toHaveProperty("data");
+ expect(res.body.data).toHaveProperty("status");
+ expect(res.body.data).toHaveProperty("connection");
+ expect(res.body.data).toHaveProperty("responseTimeMs");
+ });
+
+ it("checkDatabaseHealth utility should return formatted object", async () => {
+ const health = await checkDatabaseHealth();
+ expect(health).toHaveProperty("healthy");
+ expect(health).toHaveProperty("status");
+ expect(health).toHaveProperty("connection");
+ expect(typeof health.responseTimeMs).toBe("number");
+ });
+});
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/highlight.test.js b/test/highlight.test.js
new file mode 100644
index 00000000..3915e621
--- /dev/null
+++ b/test/highlight.test.js
@@ -0,0 +1,175 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Book from "../src/models/Book.js";
+import Highlight from "../src/models/highlight.model.js";
+import Note from "../src/models/note.model.js";
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const generateToken = (userId, role = "student") => {
+ return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" });
+};
+
+describe("Highlights & Notes API (#204)", () => {
+ let mongoServer;
+ let user, author;
+ let token;
+ let testBook;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ 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 User.deleteMany({});
+ await Book.deleteMany({});
+ await Highlight.deleteMany({});
+ await Note.deleteMany({});
+
+ user = await User.create({
+ name: "Reader User",
+ email: "reader@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ author = await User.create({
+ name: "Author User",
+ email: "author@example.com",
+ password: "Password123!",
+ role: "mentor",
+ });
+
+ token = generateToken(user._id, "student");
+
+ testBook = await Book.create({
+ title: "Seerah of the Prophet",
+ author: author._id,
+ description: "Comprehensive biography",
+ category: "Seerah",
+ price: 0,
+ image: "https://example.com/cover.jpg",
+ fileUrl: "https://example.com/book.pdf",
+ });
+ });
+
+ it("allows reader to select and save text highlights with color options", async () => {
+ const res = await request(app)
+ .post(`/api/books/${testBook._id}/highlights`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({
+ text: "The Year of the Elephant witnessed remarkable events.",
+ color: "green",
+ pageNumber: 15,
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.highlight.text).toContain("Year of the Elephant");
+ expect(res.body.highlight.color).toBe("green");
+ expect(res.body.highlight.pageNumber).toBe(15);
+ });
+
+ it("allows adding notes to specific passages or pages", async () => {
+ const highlightRes = await request(app)
+ .post(`/api/books/${testBook._id}/highlights`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({
+ text: "Patience and steadfastness in Makkah.",
+ color: "yellow",
+ pageNumber: 42,
+ });
+
+ const highlightId = highlightRes.body.highlight._id;
+
+ const noteRes = await request(app)
+ .post(`/api/books/${testBook._id}/notes`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({
+ highlightId,
+ content: "Important reflection on perseverance during hardships.",
+ pageNumber: 42,
+ });
+
+ expect(noteRes.status).toBe(201);
+ expect(noteRes.body.success).toBe(true);
+ expect(noteRes.body.note.content).toContain("Important reflection");
+ });
+
+ it("views all highlights and notes for a book", async () => {
+ await request(app)
+ .post(`/api/books/${testBook._id}/highlights`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ text: "Passage 1", color: "blue" });
+
+ await request(app)
+ .post(`/api/books/${testBook._id}/notes`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ content: "Note 1" });
+
+ const res = await request(app)
+ .get(`/api/books/${testBook._id}/highlights-notes`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.highlights.length).toBe(1);
+ expect(res.body.notes.length).toBe(1);
+ });
+
+ it("searches through highlights and notes", async () => {
+ await request(app)
+ .post(`/api/books/${testBook._id}/highlights`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ text: "Prophet's migration to Madinah", color: "purple" });
+
+ await request(app)
+ .post(`/api/books/${testBook._id}/notes`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ content: "Lessons from Hijrah and brotherhood" });
+
+ const searchRes = await request(app)
+ .get(`/api/books/${testBook._id}/highlights-notes/search?q=Hijrah`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(searchRes.status).toBe(200);
+ expect(searchRes.body.results.notes.length).toBe(1);
+ });
+
+ it("exports highlights as text or PDF", async () => {
+ await request(app)
+ .post(`/api/books/${testBook._id}/highlights`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ text: "Key lesson from treaty of Hudaybiyyah", color: "pink", pageNumber: 88 });
+
+ const textExport = await request(app)
+ .get(`/api/books/${testBook._id}/highlights/export?format=text`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(textExport.status).toBe(200);
+ expect(textExport.text).toContain("BOOK: Seerah of the Prophet");
+ expect(textExport.text).toContain("Key lesson from treaty");
+
+ const pdfExport = await request(app)
+ .get(`/api/books/${testBook._id}/highlights/export?format=pdf`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(pdfExport.status).toBe(200);
+ expect(pdfExport.header["content-type"]).toContain("application/pdf");
+ });
+});
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/moderation.test.js b/test/moderation.test.js
new file mode 100644
index 00000000..68b7b423
--- /dev/null
+++ b/test/moderation.test.js
@@ -0,0 +1,155 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Reel from "../src/models/Reel.js";
+import ContentFlag from "../src/models/content-flag.model.js";
+import ModerationAction from "../src/models/moderation-action.model.js";
+import moderationService from "../src/services/moderation.service.js";
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const generateToken = (userId, role = "student", is2FAVerified = true) => {
+ return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" });
+};
+
+describe("Content Moderation Queue API (#213)", () => {
+ let mongoServer;
+ let user, creator, admin;
+ let userToken, creatorToken, adminToken;
+ let testReel;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ 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 User.deleteMany({});
+ await Reel.deleteMany({});
+ await ContentFlag.deleteMany({});
+ await ModerationAction.deleteMany({});
+
+ user = await User.create({
+ name: "Regular User",
+ email: "user@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ creator = await User.create({
+ name: "Content Creator",
+ email: "creator@example.com",
+ password: "Password123!",
+ role: "mentor",
+ });
+
+ admin = await User.create({
+ name: "Admin User",
+ email: "admin@example.com",
+ password: "Password123!",
+ role: "admin",
+ twoFactor: { enabled: true },
+ });
+
+ userToken = generateToken(user._id, "student");
+ creatorToken = generateToken(creator._id, "mentor");
+ adminToken = generateToken(admin._id, "admin", true);
+
+ testReel = await Reel.create({
+ title: "Inspirational Short",
+ description: "Daily reminder reel",
+ video: "https://example.com/video.mp4",
+ createdBy: creator._id,
+ user: creator._id,
+ });
+ });
+
+ it("allows users to flag reels for inappropriate content", async () => {
+ const res = await request(app)
+ .post(`/api/reels/${testReel._id}/flag`)
+ .set("Authorization", `Bearer ${userToken}`)
+ .send({
+ reason: "Inappropriate language",
+ details: "Contains offensive comments at 0:15",
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.flag.status).toBe("pending");
+ expect(res.body.flag.reason).toBe("Inappropriate language");
+ });
+
+ it("places flagged reels in admin moderation queue", async () => {
+ await request(app)
+ .post(`/api/reels/${testReel._id}/flag`)
+ .set("Authorization", `Bearer ${userToken}`)
+ .send({ reason: "Spam content" });
+
+ const queueRes = await request(app)
+ .get("/api/admin/moderation/queue?status=pending")
+ .set("Authorization", `Bearer ${adminToken}`);
+
+ expect(queueRes.status).toBe(200);
+ expect(queueRes.body.flags.length).toBe(1);
+ expect(queueRes.body.flags[0].reason).toBe("Spam content");
+ });
+
+ it("allows admins to approve or remove content and notifies creator", async () => {
+ const flagRes = await request(app)
+ .post(`/api/reels/${testReel._id}/flag`)
+ .set("Authorization", `Bearer ${userToken}`)
+ .send({ reason: "Copyright violation" });
+
+ const flagId = flagRes.body.flag._id;
+
+ const actionRes = await request(app)
+ .post(`/api/admin/moderation/${flagId}/action`)
+ .set("Authorization", `Bearer ${adminToken}`)
+ .send({
+ action: "remove",
+ notes: "Confirmed copyright infringement",
+ });
+
+ expect(actionRes.status).toBe(200);
+ expect(actionRes.body.flag.status).toBe("removed");
+ expect(actionRes.body.reel.status).toBe("removed");
+
+ const historyRes = await request(app)
+ .get("/api/admin/moderation/history")
+ .set("Authorization", `Bearer ${adminToken}`);
+
+ expect(historyRes.status).toBe(200);
+ expect(historyRes.body.actions.length).toBe(1);
+ expect(historyRes.body.actions[0].action).toBe("remove");
+ });
+
+ it("auto-flags reels based on keyword filters", async () => {
+ const badReel = await Reel.create({
+ title: "Hate speech and harassment reel",
+ description: "Contains prohibited spam and violence",
+ video: "https://example.com/bad.mp4",
+ createdBy: creator._id,
+ user: creator._id,
+ });
+
+ const flag = await moderationService.autoFlagReel(badReel);
+ expect(flag).not.toBeNull();
+ expect(flag.isAutoFlagged).toBe(true);
+ expect(flag.flaggedKeywords).toContain("hate");
+ expect(flag.flaggedKeywords).toContain("spam");
+ });
+});
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/readingGroup.test.js b/test/readingGroup.test.js
new file mode 100644
index 00000000..9196062d
--- /dev/null
+++ b/test/readingGroup.test.js
@@ -0,0 +1,220 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Book from "../src/models/Book.js";
+import ReadingGroup from "../src/models/reading-group.model.js";
+import ReadingGroupMember from "../src/models/reading-group-member.model.js";
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const generateToken = (userId, role = "student") => {
+ return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" });
+};
+
+describe("Book Clubs / Reading Groups API (#205)", () => {
+ let mongoServer;
+ let creatorUser, memberUser, inviteeUser, authorUser;
+ let creatorToken, memberToken, inviteeToken;
+ let testBook;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ 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 User.deleteMany({});
+ await Book.deleteMany({});
+ await ReadingGroup.deleteMany({});
+ await ReadingGroupMember.deleteMany({});
+
+ creatorUser = await User.create({
+ name: "Group Creator",
+ email: "creator@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ memberUser = await User.create({
+ name: "Group Member",
+ email: "member@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ inviteeUser = await User.create({
+ name: "Invited Member",
+ email: "invitee@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ authorUser = await User.create({
+ name: "Imam An-Nawawi",
+ email: "author@example.com",
+ password: "Password123!",
+ role: "mentor",
+ });
+
+ creatorToken = generateToken(creatorUser._id, "student");
+ memberToken = generateToken(memberUser._id, "student");
+ inviteeToken = generateToken(inviteeUser._id, "student");
+
+ testBook = await Book.create({
+ title: "Riyad as-Salihin",
+ author: authorUser._id,
+ description: "Gardens of the Righteous",
+ category: "Hadith",
+ price: 0,
+ image: "https://example.com/cover.jpg",
+ fileUrl: "https://example.com/book.pdf",
+ });
+ });
+
+ it("creates reading groups for specific books", async () => {
+ const res = await request(app)
+ .post("/api/books/reading-groups")
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ name: "Riyad Study Club",
+ description: "Weekly Hadith discussion",
+ bookId: testBook._id,
+ privacy: "public",
+ chaptersPerWeek: 2,
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.group.name).toBe("Riyad Study Club");
+ });
+
+ it("invites members or allows joining public/private groups", async () => {
+ const createRes = await request(app)
+ .post("/api/books/reading-groups")
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ name: "Private Hadith Group",
+ bookId: testBook._id,
+ privacy: "private",
+ });
+
+ const groupId = createRes.body.group._id;
+
+ const joinRes = await request(app)
+ .post(`/api/books/reading-groups/${groupId}/join`)
+ .set("Authorization", `Bearer ${memberToken}`);
+
+ expect(joinRes.status).toBe(200);
+ expect(joinRes.body.membership.status).toBe("pending");
+
+ const inviteRes = await request(app)
+ .post(`/api/books/reading-groups/${groupId}/invite`)
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({ targetUserId: inviteeUser._id });
+
+ expect(inviteRes.status).toBe(200);
+ expect(inviteRes.body.membership.status).toBe("invited");
+ });
+
+ it("sets reading schedules (chapters per week)", async () => {
+ const createRes = await request(app)
+ .post("/api/books/reading-groups")
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ name: "Scheduled Group",
+ bookId: testBook._id,
+ });
+
+ const groupId = createRes.body.group._id;
+
+ const scheduleRes = await request(app)
+ .put(`/api/books/reading-groups/${groupId}/schedule`)
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ chaptersPerWeek: 3,
+ readingSchedule: [
+ { chapter: 1, title: "Sincerity and Intention", targetPages: "1-10" },
+ { chapter: 2, title: "Repentance", targetPages: "11-25" },
+ ],
+ });
+
+ expect(scheduleRes.status).toBe(200);
+ expect(scheduleRes.body.group.chaptersPerWeek).toBe(3);
+ expect(scheduleRes.body.group.readingSchedule.length).toBe(2);
+ });
+
+ it("supports group discussion threads per chapter", async () => {
+ const createRes = await request(app)
+ .post("/api/books/reading-groups")
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ name: "Discussion Group",
+ bookId: testBook._id,
+ });
+
+ const groupId = createRes.body.group._id;
+
+ const postRes = await request(app)
+ .post(`/api/books/reading-groups/${groupId}/discussions`)
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ chapter: 1,
+ content: "What are your key takeaways regarding Niyyah (Intention)?",
+ });
+
+ expect(postRes.status).toBe(201);
+ expect(postRes.body.discussions.length).toBe(1);
+
+ const getRes = await request(app)
+ .get(`/api/books/reading-groups/${groupId}/discussions?chapter=1`)
+ .set("Authorization", `Bearer ${creatorToken}`);
+
+ expect(getRes.status).toBe(200);
+ expect(getRes.body.discussions[0].content).toContain("key takeaways");
+ });
+
+ it("tracks member progress in group dashboard", async () => {
+ const createRes = await request(app)
+ .post("/api/books/reading-groups")
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ name: "Dashboard Group",
+ bookId: testBook._id,
+ });
+
+ const groupId = createRes.body.group._id;
+
+ const progressRes = await request(app)
+ .put(`/api/books/reading-groups/${groupId}/progress`)
+ .set("Authorization", `Bearer ${creatorToken}`)
+ .send({
+ currentChapter: 5,
+ currentProgressPercent: 50,
+ });
+
+ expect(progressRes.status).toBe(200);
+ expect(progressRes.body.member.currentChapter).toBe(5);
+
+ const dashRes = await request(app)
+ .get(`/api/books/reading-groups/${groupId}/dashboard`)
+ .set("Authorization", `Bearer ${creatorToken}`);
+
+ expect(dashRes.status).toBe(200);
+ expect(dashRes.body.stats.totalMembers).toBe(1);
+ expect(dashRes.body.stats.avgProgressPercent).toBe(50);
+ });
+});
diff --git a/test/readingProgress.test.js b/test/readingProgress.test.js
new file mode 100644
index 00000000..3abbbfdd
--- /dev/null
+++ b/test/readingProgress.test.js
@@ -0,0 +1,172 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Book from "../src/models/Book.js";
+import ReadingProgress from "../src/models/ReadingProgress.js";
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const generateToken = (userId, role = "student") =>
+ jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" });
+
+describe("Reading progress sync API (#203)", () => {
+ let mongoServer;
+ let user;
+ let author;
+ let token;
+ let book;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ 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 Promise.all([
+ User.deleteMany({}),
+ Book.deleteMany({}),
+ ReadingProgress.deleteMany({}),
+ ]);
+
+ author = await User.create({
+ name: "Author",
+ email: "author@example.com",
+ password: "Qx7#vLmp92Zt",
+ role: "mentor",
+ });
+ user = await User.create({
+ name: "Reader",
+ email: "reader@example.com",
+ password: "Qx7#vLmp92Zt",
+ role: "student",
+ });
+ token = generateToken(user._id);
+
+ book = await Book.create({
+ title: "The Sealed Nectar",
+ author: author._id,
+ description: "A biography",
+ image: "https://example.com/cover.jpg",
+ fileUrl: "https://example.com/book.pdf",
+ });
+ });
+
+ it("upserts exactly one record per user + book", async () => {
+ const first = await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 10, totalPages: 100 });
+
+ expect(first.status).toBe(200);
+ expect(first.body.success).toBe(true);
+ expect(first.body.progress.percentage).toBe(10);
+
+ await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 25, totalPages: 100 });
+
+ const count = await ReadingProgress.countDocuments({
+ user: user._id,
+ book: book._id,
+ });
+ expect(count).toBe(1);
+ });
+
+ it("overwrites progress and bumps updatedAt / version on update", async () => {
+ const created = await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 5, percentage: 5, lastPosition: "cfi(/2)" });
+
+ const firstVersion = created.body.progress.version;
+ const firstUpdatedAt = new Date(created.body.progress.updatedAt).getTime();
+
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ const updated = await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 40, percentage: 40, lastPosition: "cfi(/8)" });
+
+ expect(updated.body.progress.percentage).toBe(40);
+ expect(updated.body.progress.lastPosition).toBe("cfi(/8)");
+ expect(updated.body.progress.version).toBe(firstVersion + 1);
+ expect(new Date(updated.body.progress.updatedAt).getTime()).toBeGreaterThan(
+ firstUpdatedAt
+ );
+ });
+
+ it("returns the last stored position so the reader can resume", async () => {
+ await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 73, percentage: 73, lastPosition: "cfi(/15)" });
+
+ const res = await request(app)
+ .get(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.progress.page).toBe(73);
+ expect(res.body.progress.percentage).toBe(73);
+ expect(res.body.progress.lastPosition).toBe("cfi(/15)");
+ });
+
+ it("returns null progress when nothing has been stored yet", async () => {
+ const res = await request(app)
+ .get(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.progress).toBeNull();
+ });
+
+ it("rejects a percentage greater than 100", async () => {
+ const res = await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ percentage: 150 });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects a percentage below 0", async () => {
+ const res = await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ percentage: -5 });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("includes progress percentage on the library listing", async () => {
+ await request(app)
+ .put(`/api/books/${book._id}/progress`)
+ .set("Authorization", `Bearer ${token}`)
+ .send({ page: 50, totalPages: 100 });
+
+ const res = await request(app)
+ .get(`/api/books/library/progress`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.library).toHaveLength(1);
+ expect(res.body.library[0].percentage).toBe(50);
+ expect(res.body.library[0].book._id.toString()).toBe(book._id.toString());
+ });
+});
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/reelDuet.test.js b/test/reelDuet.test.js
new file mode 100644
index 00000000..e1ea8761
--- /dev/null
+++ b/test/reelDuet.test.js
@@ -0,0 +1,159 @@
+import mongoose from "mongoose";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import Reel from "../src/models/Reel.js";
+import User from "../src/models/User.js";
+import {
+ createReelDerivative,
+ listReelDerivatives,
+} from "../src/services/reelDuetService.js";
+
+describe("Reel duet/stitch service", () => {
+ let mongoServer;
+ let author;
+ let original;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ if (process.env.MONGO_URI) {
+ try {
+ await mongoose.connect(`${process.env.MONGO_URI}_reelduet`, {
+ 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 Reel.deleteMany({});
+ await User.deleteMany({});
+
+ author = await User.create({
+ name: "Reel Author",
+ email: "reel_author@example.com",
+ password: "Qx7#vLmp92Zt",
+ role: "student",
+ });
+
+ original = await Reel.create({
+ description: "Original reel",
+ video: "https://cdn.example.com/original.mp4",
+ duration: 30,
+ createdBy: author._id,
+ });
+ });
+
+ test("creating a duet links it to the original and increments duetCount", async () => {
+ const derivative = await createReelDerivative({
+ originalReelId: original._id,
+ type: "duet",
+ userId: author._id,
+ description: "My duet response",
+ video: "https://cdn.example.com/duet-response.mp4",
+ duration: 30,
+ });
+
+ expect(String(derivative.originalReelId)).toBe(String(original._id));
+ expect(derivative.duetType).toBe("duet");
+ expect(derivative.composition.layout).toBe("side-by-side");
+ expect(derivative.composition.status).toBe("pending");
+ expect(derivative.composition.sources.original.reelId).toBe(
+ String(original._id)
+ );
+
+ const refreshed = await Reel.findById(original._id).lean();
+ expect(refreshed.duetCount).toBe(1);
+ expect(refreshed.stitchCount).toBe(0);
+ });
+
+ test("creating a stitch stores the clip range and increments stitchCount", async () => {
+ const derivative = await createReelDerivative({
+ originalReelId: original._id,
+ type: "stitch",
+ userId: author._id,
+ description: "My stitch response",
+ video: "https://cdn.example.com/stitch-response.mp4",
+ duration: 20,
+ clip: { start: 2, end: 7 },
+ });
+
+ expect(derivative.duetType).toBe("stitch");
+ expect(derivative.stitchClip.start).toBe(2);
+ expect(derivative.stitchClip.end).toBe(7);
+ expect(derivative.composition.layout).toBe("prepend-clip");
+ expect(derivative.composition.clip).toEqual({ start: 2, end: 7 });
+
+ const refreshed = await Reel.findById(original._id).lean();
+ expect(refreshed.stitchCount).toBe(1);
+ expect(refreshed.duetCount).toBe(0);
+ });
+
+ test("a stitch without a valid clip range is rejected", async () => {
+ await expect(
+ createReelDerivative({
+ originalReelId: original._id,
+ type: "stitch",
+ userId: author._id,
+ description: "Bad stitch",
+ video: "https://cdn.example.com/bad-stitch.mp4",
+ clip: { start: 5, end: 5 },
+ })
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ test("creating a derivative for a missing original throws 404", async () => {
+ await expect(
+ createReelDerivative({
+ originalReelId: new mongoose.Types.ObjectId(),
+ type: "duet",
+ userId: author._id,
+ description: "Orphan duet",
+ video: "https://cdn.example.com/orphan.mp4",
+ })
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ test("listing returns derivatives for a reel and supports type filtering", async () => {
+ await createReelDerivative({
+ originalReelId: original._id,
+ type: "duet",
+ userId: author._id,
+ description: "Duet A",
+ video: "https://cdn.example.com/a.mp4",
+ });
+ await createReelDerivative({
+ originalReelId: original._id,
+ type: "stitch",
+ userId: author._id,
+ description: "Stitch B",
+ video: "https://cdn.example.com/b.mp4",
+ clip: { start: 1, end: 4 },
+ });
+
+ const all = await listReelDerivatives(original._id, {});
+ expect(all.total).toBe(2);
+ expect(all.items).toHaveLength(2);
+ all.items.forEach((item) => {
+ expect(String(item.originalReelId)).toBe(String(original._id));
+ });
+
+ const onlyStitches = await listReelDerivatives(original._id, {
+ type: "stitch",
+ });
+ expect(onlyStitches.total).toBe(1);
+ expect(onlyStitches.items[0].duetType).toBe("stitch");
+ });
+});
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/spacePoll.test.js b/test/spacePoll.test.js
new file mode 100644
index 00000000..78a49fd0
--- /dev/null
+++ b/test/spacePoll.test.js
@@ -0,0 +1,227 @@
+import request from "supertest";
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import app from "../app.js";
+import User from "../src/models/User.js";
+import Space from "../src/models/Space.js";
+import SpacePoll from "../src/models/space-poll.model.js";
+import PollVote from "../src/models/poll-vote.model.js";
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const generateToken = (userId, role = "student") => {
+ return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" });
+};
+
+describe("Space Polls API (#210)", () => {
+ let mongoServer;
+ let hostUser, participantUser, otherUser;
+ let hostToken, participantToken, otherToken;
+ let testSpace;
+
+ beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ 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 User.deleteMany({});
+ await Space.deleteMany({});
+ await SpacePoll.deleteMany({});
+ await PollVote.deleteMany({});
+
+ hostUser = await User.create({
+ name: "Host User",
+ email: "host@example.com",
+ password: "Password123!",
+ role: "mentor",
+ });
+
+ participantUser = await User.create({
+ name: "Participant User",
+ email: "participant@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ otherUser = await User.create({
+ name: "Other User",
+ email: "other@example.com",
+ password: "Password123!",
+ role: "student",
+ });
+
+ hostToken = generateToken(hostUser._id, "mentor");
+ participantToken = generateToken(participantUser._id, "student");
+ otherToken = generateToken(otherUser._id, "student");
+
+ testSpace = await Space.create({
+ title: "Live Tafsir Session",
+ description: "Interactive Live Discussion",
+ category: "Quran",
+ host: hostUser._id,
+ eventDate: new Date(),
+ eventTime: "18:00",
+ duration: 60,
+ meetingRoom: "room-123-unique",
+ meetingUrl: "https://meet.jit.si/room-123-unique",
+ });
+ });
+
+ it("allows host to create polls with multiple choice options", async () => {
+ const res = await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Which surah topic should we discuss next?",
+ options: ["Surah Yasin", "Surah Al-Kahf", "Surah Al-Mulk"],
+ });
+
+ expect(res.status).toBe(201);
+ expect(res.body.success).toBe(true);
+ expect(res.body.poll.question).toBe("Which surah topic should we discuss next?");
+ expect(res.body.poll.results.length).toBe(3);
+ expect(res.body.poll.status).toBe("active");
+ });
+
+ it("prevents non-host from creating a poll in a space", async () => {
+ const res = await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${participantToken}`)
+ .send({
+ question: "Unauthorized poll?",
+ options: ["Yes", "No"],
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.success).toBe(false);
+ });
+
+ it("allows participants to vote in real-time and displays live vote counts and percentages", async () => {
+ const pollRes = await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Best session time?",
+ options: ["Morning", "Evening"],
+ });
+
+ const pollId = pollRes.body.poll._id;
+
+ const vote1 = await request(app)
+ .post(`/api/spaces/polls/${pollId}/vote`)
+ .set("Authorization", `Bearer ${participantToken}`)
+ .send({ optionIndex: 0 });
+
+ expect(vote1.status).toBe(200);
+ expect(vote1.body.poll.totalVotes).toBe(1);
+ expect(vote1.body.poll.results[0].votes).toBe(1);
+ expect(vote1.body.poll.results[0].percentage).toBe(100);
+
+ const vote2 = await request(app)
+ .post(`/api/spaces/polls/${pollId}/vote`)
+ .set("Authorization", `Bearer ${otherToken}`)
+ .send({ optionIndex: 1 });
+
+ expect(vote2.status).toBe(200);
+ expect(vote2.body.poll.totalVotes).toBe(2);
+ expect(vote2.body.poll.results[0].percentage).toBe(50);
+ expect(vote2.body.poll.results[1].percentage).toBe(50);
+ });
+
+ it("closes poll to stop new votes", async () => {
+ const pollRes = await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Ready to proceed?",
+ options: ["Yes", "No"],
+ });
+
+ const pollId = pollRes.body.poll._id;
+
+ const closeRes = await request(app)
+ .patch(`/api/spaces/polls/${pollId}/close`)
+ .set("Authorization", `Bearer ${hostToken}`);
+
+ expect(closeRes.status).toBe(200);
+ expect(closeRes.body.poll.status).toBe("closed");
+
+ const voteRes = await request(app)
+ .post(`/api/spaces/polls/${pollId}/vote`)
+ .set("Authorization", `Bearer ${participantToken}`)
+ .send({ optionIndex: 0 });
+
+ expect(voteRes.status).toBe(400);
+ expect(voteRes.body.message).toContain("closed");
+ });
+
+ it("exports poll results in JSON and CSV format", async () => {
+ const pollRes = await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Rating?",
+ options: ["Excellent", "Good"],
+ });
+
+ const pollId = pollRes.body.poll._id;
+
+ await request(app)
+ .post(`/api/spaces/polls/${pollId}/vote`)
+ .set("Authorization", `Bearer ${participantToken}`)
+ .send({ optionIndex: 0 });
+
+ const jsonExport = await request(app)
+ .get(`/api/spaces/polls/${pollId}/export`)
+ .set("Authorization", `Bearer ${hostToken}`);
+
+ expect(jsonExport.status).toBe(200);
+ expect(jsonExport.body.export.question).toBe("Rating?");
+ expect(jsonExport.body.export.totalVotes).toBe(1);
+
+ const csvExport = await request(app)
+ .get(`/api/spaces/polls/${pollId}/export?format=csv`)
+ .set("Authorization", `Bearer ${hostToken}`);
+
+ expect(csvExport.status).toBe(200);
+ expect(csvExport.text).toContain("Option Index,Option Text,Votes,Percentage");
+ });
+
+ it("supports multiple polls per space session", async () => {
+ await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Poll #1",
+ options: ["A", "B"],
+ });
+
+ await request(app)
+ .post(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${hostToken}`)
+ .send({
+ question: "Poll #2",
+ options: ["C", "D"],
+ });
+
+ const listRes = await request(app)
+ .get(`/api/spaces/${testSpace._id}/polls`)
+ .set("Authorization", `Bearer ${participantToken}`);
+
+ expect(listRes.status).toBe(200);
+ expect(listRes.body.polls.length).toBe(2);
+ });
+});
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");
+ });
+});