diff --git a/.env.example b/.env.example index 809b567c..5444fbdb 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,56 @@ -CLOUDINARY_API_KEY=your_cloudinary_api_key -CLOUDINARY_API_SECRET=your_cloudinary_api_secret -CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name -CLOUDINARY_URL=your_cloudinary_url -JWT_SECRET=your_jwt_secret -MONGO_URI="your_mongodb_uri" -PORT=5000 +# DeenBridge Backend Environment Variables + +# MongoDB connection string +MONGO_URI=mongodb+srv://user:password@cluster.mongodb.net/dnb-backend?retryWrites=true&w=majority + +# JWT secret for authentication (use a strong random string, min 32 characters) +JWT_SECRET=your_jwt_secret_here + +# Node environment (development, production, test) NODE_ENV=development + +# Server port +PORT=5000 + +# Cloudinary configuration for file uploads +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret +CLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name + +# EmailJS configuration for sending emails EMAILJS_API_URL=https://api.emailjs.com/api/v1.0/email/send -EMAILJS_PRIVATE_KEY=your_emailjs_private_key -EMAILJS_PUBLIC_KEY=your_emailjs_public_key -EMAILJS_SERVICE_ID=your_emailjs_service_id -EMAILJS_TEMPLATE_ID=your_emailjs_template_id +EMAILJS_PRIVATE_KEY=your_private_key +EMAILJS_PUBLIC_KEY=your_public_key +EMAILJS_SERVICE_ID=your_service_id +EMAILJS_TEMPLATE_ID=your_template_id EMAILJS_RECEIPT_TEMPLATE_ID=your_emailjs_receipt_template_id + +# Stellar blockchain network (testnet or mainnet) STELLAR_NETWORK=testnet + +# 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) +# HORIZON_MAX_RETRIES=3 (Maximum number of retries for transient errors) +# HORIZON_CB_THRESHOLD=5 (Number of consecutive failures before opening the circuit breaker) +# HORIZON_CB_COOLDOWN_MS=30000 (Time to wait in ms before attempting a half-open probe) + +# SEP-1 discovery metadata (optional) +STELLAR_PLATFORM_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +ORG_NAME=DeenBridge +ORG_URL=https://deenbridge.com +ORG_DESCRIPTION="A platform for Islamic education and Stellar-based creator payments." +ORG_LOGO=https://deenbridge.com/logo.png +ORG_GITHUB=Deen-Bridge +ORG_TELEGRAM_URL=https://t.me/+nst9lXNj1wc4ZDE0 +# Set these when SEP-10 and SEP-24 are enabled +SIGNING_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +WEB_AUTH_ENDPOINT=https://api.deenbridge.com/auth +TRANSFER_SERVER_SEP0024=https://api.deenbridge.com/sep24 # Stellar donation fund (public key only - the secret key must NEVER be stored here) DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + # Platform fee split on purchases (0-20, 0 disables the split) PLATFORM_FEE_PERCENT=0 PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX @@ -28,3 +64,20 @@ JOBS_ENABLED=true QUEUE_DRIVER=mongo JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token +# 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 + +# Option 2: Use separate credentials +REDIS_HOST=localhost +REDIS_PORT=6379 +# REDIS_USERNAME=default +# REDIS_PASSWORD=your_password + +# Jitsi configuration for video calls (optional) +# JITSI_MEET_DOMAIN=your_jitsi_domain +# JITSI_APP_ID=your_app_id +# JITSI_PRIVATE_KEY=your_private_key +# JITSI_PUBLIC_KEY_ID=your_public_key_id +# JITSI_KID=your_kid +# JITSI_TENANT=your_tenant diff --git a/QUICK_START.md b/QUICK_START.md index 8c2e38fc..b11fb2bc 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -19,6 +19,21 @@ NODE_ENV=development PORT=5000 ``` +Optional Stellar discovery settings: + +```env +STELLAR_NETWORK=testnet +STELLAR_PLATFORM_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +ORG_URL=https://deenbridge.com +ORG_LOGO=https://deenbridge.com/logo.png +SIGNING_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +WEB_AUTH_ENDPOINT=https://api.deenbridge.com/auth +TRANSFER_SERVER_SEP0024=https://api.deenbridge.com/sep24 +``` + +Unset optional values are left out of `GET /.well-known/stellar.toml`. The +endpoint remains available and returns the configured network and USDC metadata. + ### **3. Start Server** ```bash diff --git a/app.js b/app.js index 6a929f1d..8b50df99 100644 --- a/app.js +++ b/app.js @@ -45,6 +45,7 @@ import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; import payoutRoutes from "./src/routes/payoutRoutes.js"; import jobsRoutes from "./src/routes/jobsRoutes.js"; +import { getStellarToml } from "./src/routes/wellKnownRoutes.js"; handleUncaughtException(); validateEnv(); @@ -105,6 +106,9 @@ app.get("/metrics", metricsMiddleware); app.use(helmetMiddleware); app.use(customSecurityHeaders); +// SEP-1 discovery must allow requests from wallets on any origin. +app.get("/.well-known/stellar.toml", getStellarToml); + const corsOptions = { origin: function (origin, callback) { const allowedOrigins = [ diff --git a/package-lock.json b/package-lock.json index 9485dc1e..dc863d3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "xss-clean": "^0.1.4" }, "devDependencies": { + "@iarna/toml": "^2.2.5", "concurrently": "^9.1.2", "jest": "^29.7.0", "mongodb-memory-server": "^11.2.0", @@ -645,6 +646,13 @@ "node": ">=14.0.0" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "dev": true, + "license": "ISC" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/package.json b/package.json index 82ca0652..614b233c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "xss-clean": "^0.1.4" }, "devDependencies": { + "@iarna/toml": "^2.2.5", "concurrently": "^9.1.2", "jest": "^29.7.0", "mongodb-memory-server": "^11.2.0", diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 1c583be3..f6d94b62 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -17,6 +17,16 @@ const optionalEnvVars = [ "JITSI_KID", "JITSI_TENANT", "STELLAR_NETWORK", + "STELLAR_PLATFORM_PUBLIC_KEY", + "ORG_NAME", + "ORG_URL", + "ORG_DESCRIPTION", + "ORG_LOGO", + "ORG_GITHUB", + "ORG_TELEGRAM_URL", + "SIGNING_KEY", + "WEB_AUTH_ENDPOINT", + "TRANSFER_SERVER_SEP0024", "DONATION_WALLET_PUBLIC_KEY", "PLATFORM_FEE_PERCENT", "PLATFORM_WALLET_PUBLIC_KEY", @@ -28,6 +38,17 @@ const optionalEnvVars = [ "JOBS_ENABLED", "JOBS_DASHBOARD_TOKEN", "EMAILJS_RECEIPT_TEMPLATE_ID", + // Redis configuration (optional - app works without Redis) + "REDIS_URL", + "REDIS_HOST", + "REDIS_PORT", + "REDIS_USERNAME", + "REDIS_PASSWORD", + "HORIZON_URLS", + "HORIZON_TIMEOUT_MS", + "HORIZON_MAX_RETRIES", + "HORIZON_CB_THRESHOLD", + "HORIZON_CB_COOLDOWN_MS", ]; export const validateEnv = () => { @@ -35,6 +56,19 @@ export const validateEnv = () => { 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"; + if (!process.env.HORIZON_URLS) { + process.env.HORIZON_URLS = + network === "mainnet" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org"; + } + 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"; + const missing = []; requiredEnvVars.forEach((envVar) => { diff --git a/src/config/validateEnv.test.js b/src/config/validateEnv.test.js new file mode 100644 index 00000000..5b413007 --- /dev/null +++ b/src/config/validateEnv.test.js @@ -0,0 +1,57 @@ +import { jest } from "@jest/globals"; +import { validateEnv } from "./validateEnv.js"; + +describe("validateEnv", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { + ...originalEnv, + MONGO_URI: "mongodb://localhost:27017/test", + JWT_SECRET: "test-secret-key-for-ci-minimum-32-chars", + NODE_ENV: "test", + PORT: "5000", + }; + // Ensure new vars are unset + delete process.env.HORIZON_URLS; + delete process.env.HORIZON_TIMEOUT_MS; + delete process.env.HORIZON_MAX_RETRIES; + delete process.env.HORIZON_CB_THRESHOLD; + delete process.env.HORIZON_CB_COOLDOWN_MS; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("should derive testnet default endpoint when STELLAR_NETWORK is unset or testnet", () => { + delete process.env.STELLAR_NETWORK; + validateEnv(); + expect(process.env.HORIZON_URLS).toBe("https://horizon-testnet.stellar.org"); + expect(process.env.HORIZON_TIMEOUT_MS).toBe("10000"); + expect(process.env.HORIZON_MAX_RETRIES).toBe("3"); + expect(process.env.HORIZON_CB_THRESHOLD).toBe("5"); + expect(process.env.HORIZON_CB_COOLDOWN_MS).toBe("30000"); + }); + + it("should derive mainnet default endpoint when STELLAR_NETWORK is mainnet", () => { + process.env.STELLAR_NETWORK = "mainnet"; + validateEnv(); + expect(process.env.HORIZON_URLS).toBe("https://horizon.stellar.org"); + }); + + it("should preserve explicitly set Horizon values", () => { + process.env.HORIZON_URLS = "https://custom.stellar.org"; + process.env.HORIZON_TIMEOUT_MS = "5000"; + process.env.HORIZON_MAX_RETRIES = "1"; + process.env.HORIZON_CB_THRESHOLD = "10"; + process.env.HORIZON_CB_COOLDOWN_MS = "10000"; + validateEnv(); + expect(process.env.HORIZON_URLS).toBe("https://custom.stellar.org"); + expect(process.env.HORIZON_TIMEOUT_MS).toBe("5000"); + expect(process.env.HORIZON_MAX_RETRIES).toBe("1"); + expect(process.env.HORIZON_CB_THRESHOLD).toBe("10"); + expect(process.env.HORIZON_CB_COOLDOWN_MS).toBe("10000"); + }); +}); diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index f93511e9..9679ac5d 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -79,6 +79,12 @@ export const errorHandler = (err, req, res, next) => { err.status = err.status || "error"; if (process.env.NODE_ENV === "development") { + if (err.name === "AllEndpointsOpenError") { + return res.status(503).json({ + error: "Stellar network currently unreachable. Please try again later.", + code: "NETWORK_UNAVAILABLE" + }); + } sendErrorDev(err, req, res); } else { let error = { ...err }; @@ -89,6 +95,13 @@ export const errorHandler = (err, req, res, next) => { if (err.name === "ValidationError") error = handleValidationErrorDB(err); if (err.name === "JsonWebTokenError") error = handleJWTError(); if (err.name === "TokenExpiredError") error = handleJWTExpiredError(); + + if (err.name === "AllEndpointsOpenError") { + return res.status(503).json({ + error: "Stellar network currently unreachable. Please try again later.", + code: "NETWORK_UNAVAILABLE" + }); + } sendErrorProd(error, req, res); } diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js index 4883f9b8..78b04e8e 100644 --- a/src/routes/books/bookRoutes.js +++ b/src/routes/books/bookRoutes.js @@ -17,11 +17,21 @@ import { removeBookBookmark, } from "../../controllers/books/bookmarkBookController.js"; import { protect } from "../../middlewares/authMiddleware.js"; +import { + cacheMiddleware, + invalidateCacheMiddleware, +} from "../../middlewares/cache.js"; +import { CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; const router = express.Router(); -// creating book +// Cache key generators +const booksListCacheKey = () => `${CACHE_KEYS.BOOKS}list`; +const bookDetailCacheKey = (req) => `${CACHE_KEYS.BOOK}${req.params.id}`; +const booksByAuthorCacheKey = (req) => + `${CACHE_KEYS.BOOKS}author:${req.params.authorId}`; +// creating book - invalidates books list cache router.post( "/", protect, @@ -29,13 +39,19 @@ router.post( { name: "thumbnail", maxCount: 1 }, { name: "file", maxCount: 1 }, ]), + invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`]), createBook ); -// getting all books -router.get("/", getBooks); -// get recommended books for user -router.get("/recom", fetchRecommendedBooks); +// getting all books - cached for 15 minutes +router.get("/", cacheMiddleware(CACHE_TTL.BOOKS, booksListCacheKey), getBooks); + +// get recommended books for user - cached for 5 minutes +router.get( + "/recom", + cacheMiddleware(CACHE_TTL.SHORT, () => `${CACHE_KEYS.BOOKS}recommended`), + fetchRecommendedBooks +); // Bookmarks (must come before dynamic :id routes) router.get("/bookmarks", protect, getBookmarkedBooks); @@ -43,17 +59,34 @@ router.post("/:bookId/bookmark", protect, toggleBookBookmark); router.get("/:bookId/bookmark/check", protect, checkIfBookBookmarked); router.delete("/:bookId/bookmark", protect, removeBookBookmark); -//get books created by the author -router.get("/by-author/:authorId", getBooksByAuthor); +// get books created by the author - cached for 15 minutes +router.get( + "/by-author/:authorId", + cacheMiddleware(CACHE_TTL.BOOKS, booksByAuthorCacheKey), + getBooksByAuthor +); -//get a spefic book +// get a specific book - cached for 15 minutes router.get("/:id/preview", protect, streamBookPreview); -router.get("/:id", getBook); +router.get( + "/:id", + cacheMiddleware(CACHE_TTL.BOOKS, bookDetailCacheKey), + getBook +); -// delete a book -router.delete("/:id", deleteBook); +// delete a book - invalidates book caches +router.delete( + "/:id", + invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`, `${CACHE_KEYS.BOOK}*`]), + deleteBook +); -//review a book -router.post("/:id/reviews", protect, addBookReview); +// review a book - invalidates specific book cache +router.post( + "/:id/reviews", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`]), + addBookReview +); export default router; diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index 2fc3a5b4..be3bfb88 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -16,28 +16,70 @@ import { removeBookmark, } from "../../controllers/courses/bookmarkController.js"; import { protect } from "../../middlewares/authMiddleware.js"; +import { + cacheMiddleware, + invalidateCacheMiddleware, +} from "../../middlewares/cache.js"; +import { CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; const router = express.Router(); -// Public routes -router.get("/", getCourses); // GET /api/courses -router.get("/user", getCoursesByUser); // GET /api/courses/user -router.post("/recommended", fetchRecommendedCourses); // POST /api/courses/recommended +// Cache key generators +const coursesListCacheKey = () => `${CACHE_KEYS.COURSES}list`; +const courseDetailCacheKey = (req) => `${CACHE_KEYS.COURSE}${req.params.id}`; +const coursesByUserCacheKey = (req) => + `${CACHE_KEYS.COURSES}user:${req.query.createdBy}`; + +// Public routes - cached for 15 minutes +router.get( + "/", + cacheMiddleware(CACHE_TTL.COURSES, coursesListCacheKey), + getCourses +); +router.get( + "/user", + cacheMiddleware(CACHE_TTL.COURSES, coursesByUserCacheKey), + getCoursesByUser +); +router.post("/recommended", fetchRecommendedCourses); // POST routes not cached // Bookmark routes (MUST come before /:id route to avoid conflicts) -router.get("/bookmarks", protect, getBookmarkedCourses); // Get all bookmarks -router.post("/:courseId/bookmark", protect, toggleCourseBookmark); // Toggle bookmark -router.get("/:courseId/bookmark/check", protect, checkIfBookmarked); // Check if bookmarked -router.delete("/:courseId/bookmark", protect, removeBookmark); // Remove bookmark +router.get("/bookmarks", protect, getBookmarkedCourses); +router.post("/:courseId/bookmark", protect, toggleCourseBookmark); +router.get("/:courseId/bookmark/check", protect, checkIfBookmarked); +router.delete("/:courseId/bookmark", protect, removeBookmark); -// Dynamic routes (MUST come after specific routes like /bookmarks) -router.get("/:id", getCourseById); // GET /api/courses/123 +// Dynamic routes - cached for 15 minutes +router.get( + "/:id", + cacheMiddleware(CACHE_TTL.COURSES, courseDetailCacheKey), + getCourseById +); -// Protected routes -// Note: No file upload middleware needed - files uploaded from frontend -router.post("/", protect, createCourse); -router.post("/:id/enroll", protect, enrollInCourse); -router.post("/:id/reviews", protect, addCourseReview); -router.put("/:id", protect, updateCourse); +// Protected routes with cache invalidation +router.post( + "/", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`]), + createCourse +); +router.post( + "/:id/enroll", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`]), + enrollInCourse +); +router.post( + "/:id/reviews", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`]), + addCourseReview +); +router.put( + "/:id", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]), + updateCourse +); export default router; diff --git a/src/routes/searchRoutes.js b/src/routes/searchRoutes.js index 860f9e78..40e753fd 100644 --- a/src/routes/searchRoutes.js +++ b/src/routes/searchRoutes.js @@ -1,9 +1,18 @@ import express from "express"; import { searchAll } from "../controllers/searchController.js"; +import { cacheMiddleware } from "../middlewares/cache.js"; +import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js"; const router = express.Router(); -// Main search endpoint -router.get("/", searchAll); +// Cache key generator for search queries +const searchCacheKey = (req) => { + const query = req.query.q || req.query.query || ""; + const type = req.query.type || "all"; + return `${CACHE_KEYS.SEARCH}${type}:${query.toLowerCase().trim()}`; +}; + +// Main search endpoint - cached for 5 minutes +router.get("/", cacheMiddleware(CACHE_TTL.SEARCH, searchCacheKey), searchAll); export default router; diff --git a/src/routes/spaceRoutes.js b/src/routes/spaceRoutes.js index 2e7d20af..abe4f8b3 100644 --- a/src/routes/spaceRoutes.js +++ b/src/routes/spaceRoutes.js @@ -1,6 +1,11 @@ import express from "express"; import { protect } from "../middlewares/authMiddleware.js"; import upload from "../middlewares/upload.js"; +import { + cacheMiddleware, + invalidateCacheMiddleware, +} from "../middlewares/cache.js"; +import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js"; import { getSpaces, @@ -9,28 +14,69 @@ import { updateSpace, joinWaitList, deleteSpace, - getSpacesByHost + getSpacesByHost, } from "../controllers/spaceController.js"; const router = express.Router(); -// Get all spaces -router.get("/", getSpaces); -// Get all spaces by host (user) -router.get("/by-host/:hostId", getSpacesByHost); -// Get a single space by ID -router.get("/:id", getSpaceById); -// Create a new space +// Cache key generators +const spacesListCacheKey = () => `${CACHE_KEYS.SPACES}list`; +const spaceDetailCacheKey = (req) => `${CACHE_KEYS.SPACE}${req.params.id}`; +const spacesByHostCacheKey = (req) => + `${CACHE_KEYS.SPACES}host:${req.params.hostId}`; + +// Get all spaces - cached for 5 minutes (shorter TTL as spaces are time-sensitive) +router.get( + "/", + cacheMiddleware(CACHE_TTL.SPACES, spacesListCacheKey), + getSpaces +); + +// Get all spaces by host (user) - cached for 5 minutes +router.get( + "/by-host/:hostId", + cacheMiddleware(CACHE_TTL.SPACES, spacesByHostCacheKey), + getSpacesByHost +); + +// Get a single space by ID - cached for 5 minutes +router.get( + "/:id", + cacheMiddleware(CACHE_TTL.SPACES, spaceDetailCacheKey), + getSpaceById +); + +// Create a new space - invalidates spaces cache router.post( "/", protect, upload.fields([{ name: "thumbnail", maxCount: 1 }]), + invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`]), createSpace ); -router.post("/:id/waitlist", protect, joinWaitList); -// Update a space -router.put("/update/:id", protect, updateSpace); -// Delete a space -router.delete("/:id", protect, deleteSpace); + +// Join waitlist - invalidates space cache +router.post( + "/:id/waitlist", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.SPACE}*`]), + joinWaitList +); + +// Update a space - invalidates space caches +router.put( + "/update/:id", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`]), + updateSpace +); + +// Delete a space - invalidates space caches +router.delete( + "/:id", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`]), + deleteSpace +); export default router; diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 87f5ab18..552fc6fe 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -16,31 +16,108 @@ import { getUserStats, } from "../controllers/userController.js"; import { searchAll } from "../controllers/searchController.js"; +import { + cacheMiddleware, + invalidateCacheMiddleware, +} from "../middlewares/cache.js"; +import { CACHE_TTL, CACHE_KEYS } from "../utils/cache.js"; const router = express.Router(); -// Update user profile (with avatar upload) -router.put("/update/:id", protect, upload.single("avatar"), updateUser); -// Get user by ID -router.get("/:id", protect, getUser); -// Delete user -router.delete("/:id", protect, deleteUser); - -// Follow/Unfollow routes -router.post("/follow/:userId", protect, followUser); -router.delete("/unfollow/:userId", protect, unfollowUser); -router.get("/:userId/followers", protect, getFollowers); -router.get("/:userId/following", protect, getFollowing); -router.get("/:userId/followers/count", protect, getFollowersCount); -router.get("/:userId/following/count", protect, getFollowingCount); -router.get("/:userId/check-following", protect, checkIfFollowing); +// Cache key generators +const userCacheKey = (req) => `${CACHE_KEYS.USER}${req.params.id}`; +const userStatsCacheKey = (req) => `${CACHE_KEYS.USER}${req.params.id}:stats`; +const followersCacheKey = (req) => + `${CACHE_KEYS.USER}${req.params.userId}:followers`; +const followingCacheKey = (req) => + `${CACHE_KEYS.USER}${req.params.userId}:following`; + +// Get personalized recommendations - cached for 10 minutes (must be before /:id) +router.get( + "/recommendations", + protect, + cacheMiddleware(CACHE_TTL.USERS, (req) => + `${CACHE_KEYS.USER}${req.user._id}:recommendations` + ), + getRecommendations +); + +// Update user profile (with avatar upload) - invalidates user cache +router.put( + "/update/:id", + protect, + upload.single("avatar"), + invalidateCacheMiddleware([`${CACHE_KEYS.USER}*`]), + updateUser +); -// Get personalized recommendations -router.get("/recommendations", protect, getRecommendations); +// Get user by ID - cached for 10 minutes +router.get( + "/:id", + protect, + cacheMiddleware(CACHE_TTL.USERS, userCacheKey), + getUser +); -// Get user statistics -router.get("/:id/stats", protect, getUserStats); +// Delete user - invalidates user cache +router.delete( + "/:id", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.USER}*`]), + deleteUser +); + +// Follow/Unfollow routes - invalidates follower/following caches +router.post( + "/follow/:userId", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.USER}*:followers`, `${CACHE_KEYS.USER}*:following`]), + followUser +); +router.delete( + "/unfollow/:userId", + protect, + invalidateCacheMiddleware([`${CACHE_KEYS.USER}*:followers`, `${CACHE_KEYS.USER}*:following`]), + unfollowUser +); + +// Get followers/following - cached for 10 minutes +router.get( + "/:userId/followers", + protect, + cacheMiddleware(CACHE_TTL.USERS, followersCacheKey), + getFollowers +); +router.get( + "/:userId/following", + protect, + cacheMiddleware(CACHE_TTL.USERS, followingCacheKey), + getFollowing +); +router.get( + "/:userId/followers/count", + protect, + cacheMiddleware(CACHE_TTL.USERS, (req) => + `${CACHE_KEYS.USER}${req.params.userId}:followers:count` + ), + getFollowersCount +); +router.get( + "/:userId/following/count", + protect, + cacheMiddleware(CACHE_TTL.USERS, (req) => + `${CACHE_KEYS.USER}${req.params.userId}:following:count` + ), + getFollowingCount +); +router.get("/:userId/check-following", protect, checkIfFollowing); -// Remove search endpoint +// Get user statistics - cached for 10 minutes +router.get( + "/:id/stats", + protect, + cacheMiddleware(CACHE_TTL.USERS, userStatsCacheKey), + getUserStats +); export default router; diff --git a/src/routes/wellKnownRoutes.js b/src/routes/wellKnownRoutes.js new file mode 100644 index 00000000..0efb7b25 --- /dev/null +++ b/src/routes/wellKnownRoutes.js @@ -0,0 +1,9 @@ +import { buildStellarToml } from "../services/stellar/stellarTomlService.js"; + +export const getStellarToml = (req, res) => { + res + .status(200) + .set("Content-Type", "text/toml; charset=utf-8") + .set("Access-Control-Allow-Origin", "*") + .send(buildStellarToml()); +}; diff --git a/src/services/stellar/horizonClient.js b/src/services/stellar/horizonClient.js new file mode 100644 index 00000000..b4af414b --- /dev/null +++ b/src/services/stellar/horizonClient.js @@ -0,0 +1,200 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; + +export class HorizonClient { + constructor(urls, timeoutMs = 10000) { + this.timeoutMs = timeoutMs; + this.endpoints = urls.map(url => ({ + url, + server: new StellarSdk.Horizon.Server(url), + state: 'closed', // 'closed' | 'open' | 'half-open' + consecutiveFailures: 0, + openedAt: null + })); + this.maxRetries = parseInt(process.env.HORIZON_MAX_RETRIES || "3", 10); + this.cbThreshold = parseInt(process.env.HORIZON_CB_THRESHOLD || "5", 10); + this.cbCooldownMs = parseInt(process.env.HORIZON_CB_COOLDOWN_MS || "30000", 10); + } + + /** + * Determine if an error is retriable and calculate its delay. + * @param {Error} error + * @param {number} attempt + * @returns {{ retriable: boolean, delayMs?: number }} + */ + classifyError(error, attempt) { + if (error.name === "TimeoutError") { + return { retriable: true, delayMs: this.calculateBackoff(attempt) }; + } + + const status = error.response?.status; + + // Deterministic Horizon rejections + if (status === 404 || status === 400) { + // 400 usually contains result_codes which must not be retried + if (error.response?.data?.extras?.result_codes) { + return { retriable: false }; + } + if (status === 404) { + return { retriable: false }; + } + } + + // Rate Limiting + if (status === 429) { + const retryAfterStr = error.response?.headers?.['retry-after']; + if (retryAfterStr) { + const retryAfterSeconds = parseInt(retryAfterStr, 10); + if (!isNaN(retryAfterSeconds)) { + return { retriable: true, delayMs: retryAfterSeconds * 1000 }; + } + } + return { retriable: true, delayMs: this.calculateBackoff(attempt) }; + } + + // Network errors or 5xx server errors + if (!status || status >= 500) { + return { retriable: true, delayMs: this.calculateBackoff(attempt) }; + } + + return { retriable: false }; + } + + /** + * Exponential backoff with full jitter. + */ + calculateBackoff(attempt) { + const base = 500; + const max = 10000; + const exp = Math.min(max, base * Math.pow(2, attempt)); + return Math.floor(Math.random() * exp); + } + + /** + * Get the current primary endpoint and advance to the next if requested. + */ + getNextEndpoint(startIndex = 0) { + const now = Date.now(); + for (let i = 0; i < this.endpoints.length; i++) { + const index = (startIndex + i) % this.endpoints.length; + const ep = this.endpoints[index]; + + if (ep.state === 'open') { + if (now - ep.openedAt >= this.cbCooldownMs) { + ep.state = 'half-open'; + return { endpoint: ep, nextIndex: (index + 1) % this.endpoints.length }; + } + } else { + return { endpoint: ep, nextIndex: (index + 1) % this.endpoints.length }; + } + } + return { endpoint: null, nextIndex: 0 }; + } + + recordFailure(endpoint) { + endpoint.consecutiveFailures++; + if (endpoint.state === 'half-open' || endpoint.consecutiveFailures >= this.cbThreshold) { + endpoint.state = 'open'; + endpoint.openedAt = Date.now(); + logger.warn(`Circuit breaker opened for Horizon endpoint ${endpoint.url}`); + } + } + + recordSuccess(endpoint) { + if (endpoint.state === 'half-open') { + logger.info(`Circuit breaker closed for Horizon endpoint ${endpoint.url} (recovery)`); + } + endpoint.state = 'closed'; + endpoint.consecutiveFailures = 0; + endpoint.openedAt = null; + } + + /** + * Execute a Horizon call against the current primary endpoint. + * @param {Function} fn - The function to execute, receives (server). + * @param {Object} opts - Options for execution. { mode: 'read' | 'submit' } + */ + async execute(fn, opts = { mode: 'read' }) { + let attempt = 0; + let endpointIndex = 0; + + while (attempt <= this.maxRetries) { + const { endpoint, nextIndex } = this.getNextEndpoint(endpointIndex); + + if (!endpoint) { + const err = new Error("All endpoints open"); + err.name = "AllEndpointsOpenError"; + throw err; + } + + endpointIndex = nextIndex; + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, this.timeoutMs); + + try { + const callPromise = fn(endpoint.server); + + const timeoutPromise = new Promise((_, reject) => { + abortController.signal.addEventListener('abort', () => { + const err = new Error("Horizon request timed out"); + err.name = "TimeoutError"; + reject(err); + }); + }); + + const result = await Promise.race([callPromise, timeoutPromise]); + clearTimeout(timeoutId); + + this.recordSuccess(endpoint); + + return result; + } catch (error) { + clearTimeout(timeoutId); + + if (opts.mode === 'submit') { + if (error.name === 'TimeoutError' && opts.verifyFn && attempt === 0) { + const landedResult = await opts.verifyFn(); + if (landedResult && landedResult.successful) { + return landedResult; + } + attempt++; + continue; // resubmit at most once + } + throw error; // bypass generic blind retry entirely + } + + const classification = this.classifyError(error, attempt); + + if (classification.retriable) { + this.recordFailure(endpoint); + } + + if (!classification.retriable || attempt === this.maxRetries) { + throw error; + } + + // Wait for the computed delay before retrying + await new Promise(resolve => setTimeout(resolve, classification.delayMs)); + attempt++; + } + } + } +} + +// Export a pre-configured instance of HorizonClient +export const client = new HorizonClient( + (process.env.HORIZON_URLS || "https://horizon-testnet.stellar.org").split(",").map(u => u.trim()), + parseInt(process.env.HORIZON_TIMEOUT_MS || "10000", 10) +); + +export const getHorizonHealth = () => { + return client.endpoints.map(ep => ({ + url: ep.url, + state: ep.state, + consecutiveFailures: ep.consecutiveFailures, + openedAt: ep.openedAt + })); +}; diff --git a/src/services/stellar/horizonClient.test.js b/src/services/stellar/horizonClient.test.js new file mode 100644 index 00000000..291cdbdc --- /dev/null +++ b/src/services/stellar/horizonClient.test.js @@ -0,0 +1,303 @@ +// src/services/stellar/horizonClient.test.js +import { jest } from "@jest/globals"; +import { HorizonClient } from "./horizonClient.js"; + +describe("HorizonClient - Phase 2", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("should enforce HORIZON_TIMEOUT_MS and reject with timeout error", async () => { + const originalRetries = process.env.HORIZON_MAX_RETRIES; + process.env.HORIZON_MAX_RETRIES = "0"; + const client = new HorizonClient(["https://fake-url"], 5000); + + const hangingCall = async () => new Promise(() => {}); // never resolves + + const executePromise = client.execute(hangingCall); + + jest.advanceTimersByTime(5001); + + await expect(executePromise).rejects.toThrow("Horizon request timed out"); + if (originalRetries === undefined) { + delete process.env.HORIZON_MAX_RETRIES; + } else { + process.env.HORIZON_MAX_RETRIES = originalRetries; + } + }); + + it("should succeed if call completes before timeout", async () => { + const client = new HorizonClient(["https://fake-url"], 5000); + + const executePromise = client.execute(async () => "success"); + + const result = await executePromise; + expect(result).toBe("success"); + }); +}); + +describe("HorizonClient - Phase 3 (Classification & Backoff)", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(global.Math, 'random').mockReturnValue(0.99); // max jitter + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + const client = new HorizonClient(["https://url1", "https://url2"], 5000); + + it.each([ + ["Network Error", { name: "Error" }, true], + ["Timeout Error", { name: "TimeoutError" }, true], + ["500 Server Error", { response: { status: 500 } }, true], + ["503 Server Error", { response: { status: 503 } }, true], + ["429 Rate Limit", { response: { status: 429 } }, true], + ["404 Not Found", { response: { status: 404 } }, false], + ["400 tx_bad_seq", { response: { status: 400, data: { extras: { result_codes: { transaction: "tx_bad_seq" } } } } }, false], + ["400 op_underfunded", { response: { status: 400, data: { extras: { result_codes: { operations: ["op_underfunded"] } } } } }, false], + ])("should classify %s correctly", (_, errorObj, expectedRetriable) => { + const classification = client.classifyError(errorObj, 0); + expect(classification.retriable).toBe(expectedRetriable); + }); + + it("should honor Retry-After header for 429", async () => { + const c = new HorizonClient(["https://url1"], 5000); + let attempts = 0; + + const executePromise = c.execute(async () => { + attempts++; + if (attempts === 1) { + const err = new Error("Rate limit"); + err.response = { status: 429, headers: { 'retry-after': '2' } }; + throw err; + } + return "success"; + }); + + // Let the first call throw and setTimeout to be scheduled + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.advanceTimersByTime(1999); + expect(attempts).toBe(1); + + jest.advanceTimersByTime(2); + const result = await executePromise; + expect(result).toBe("success"); + expect(attempts).toBe(2); + }); + + it("should use exponential backoff if no Retry-After is present", async () => { + const c = new HorizonClient(["https://url1"], 5000); + let attempts = 0; + + const executePromise = c.execute(async () => { + attempts++; + if (attempts < 3) throw new Error("Network error"); + return "success"; + }); + + // Wait for the first attempt to fail and backoff timer to start + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toBe(1); + + // Attempt 1 backoff (base 500, attempt 0 -> 500 * 0.99 = 495) + jest.advanceTimersByTime(494); + expect(attempts).toBe(1); + + jest.advanceTimersByTime(2); // reaches 496, unblocks attempt 2 + + // Allow promise chain to queue the next backoff + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toBe(2); + + // Attempt 2 backoff (base 500, attempt 1 -> 1000 * 0.99 = 990) + jest.advanceTimersByTime(989); + expect(attempts).toBe(2); + + jest.advanceTimersByTime(2); // reaches 991, unblocks attempt 3 + + const result = await executePromise; + expect(result).toBe("success"); + expect(attempts).toBe(3); + }); +}); + +describe("HorizonClient - Phase 4 (Circuit Breaker)", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("should open circuit after HORIZON_CB_THRESHOLD failures, allow half-open, and close on success", async () => { + const c = new HorizonClient(["https://url1"], 5000); + c.maxRetries = 0; // disable retry so we can directly trigger failures + c.cbThreshold = 2; + c.cbCooldownMs = 30000; + + const failCall = async () => { throw new Error("Network error"); }; + const successCall = async () => "success"; + + // Failure 1 + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + expect(c.endpoints[0].state).toBe("closed"); + expect(c.endpoints[0].consecutiveFailures).toBe(1); + + // Failure 2 -> Opens circuit + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + expect(c.endpoints[0].state).toBe("open"); + expect(c.endpoints[0].consecutiveFailures).toBe(2); + + // Call while open -> All endpoints open + await expect(c.execute(successCall)).rejects.toThrow("All endpoints open"); + + // Advance time past cooldown + jest.advanceTimersByTime(30000); + + // Half-open success -> Closes circuit + const result = await c.execute(successCall); + expect(result).toBe("success"); + expect(c.endpoints[0].state).toBe("closed"); + expect(c.endpoints[0].consecutiveFailures).toBe(0); + }); + + it("should return to open state if half-open probe fails", async () => { + const c = new HorizonClient(["https://url1"], 5000); + c.maxRetries = 0; + c.cbThreshold = 1; + c.cbCooldownMs = 30000; + + const failCall = async () => { throw new Error("Network error"); }; + + // Failure 1 -> Opens circuit + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + expect(c.endpoints[0].state).toBe("open"); + + // Advance time past cooldown + jest.advanceTimersByTime(30000); + + // Half-open failure -> Opens circuit again + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + expect(c.endpoints[0].state).toBe("open"); + expect(c.endpoints[0].consecutiveFailures).toBe(2); + }); + + it("should fail fast if all endpoints are open", async () => { + const c = new HorizonClient(["https://url1", "https://url2"], 5000); + c.maxRetries = 0; + c.cbThreshold = 1; + + const failCall = async () => { throw new Error("Network error"); }; + + // Fail endpoint 1 + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + // Fail endpoint 2 + await expect(c.execute(failCall)).rejects.toThrow("Network error"); + + // Both open, should fail fast + try { + await c.execute(async () => "success"); + fail("Should have thrown"); + } catch (error) { + expect(error.message).toBe("All endpoints open"); + expect(error.name).toBe("AllEndpointsOpenError"); + } + }); +}); + +describe("HorizonClient - Phase 5 (Submission Safety)", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("should not double submit if transaction landed during timeout", async () => { + const c = new HorizonClient(["https://url1"], 5000); + + let submitCount = 0; + const submitCall = async () => { + submitCount++; + return new Promise(() => {}); // timeout + }; + + const verifyFn = async () => { + return { successful: true, ledger: 100 }; + }; + + const executePromise = c.execute(submitCall, { mode: 'submit', verifyFn }); + + await jest.advanceTimersByTimeAsync(5001); + + const result = await executePromise; + expect(result.successful).toBe(true); + expect(result.ledger).toBe(100); + expect(submitCount).toBe(1); + }); + + it("should single resubmit if transaction did not land during timeout", async () => { + const c = new HorizonClient(["https://url1"], 5000); + + let submitCount = 0; + const submitCall = async () => { + submitCount++; + if (submitCount === 1) return new Promise(() => {}); // timeout + return { successful: true, ledger: 101 }; + }; + + const verifyFn = async () => { + return null; // not found + }; + + const executePromise = c.execute(submitCall, { mode: 'submit', verifyFn }); + + await jest.advanceTimersByTimeAsync(5001); + + const result = await executePromise; + expect(result.successful).toBe(true); + expect(result.ledger).toBe(101); + expect(submitCount).toBe(2); + }); + + it("should not retry on immediate result_codes rejection", async () => { + const c = new HorizonClient(["https://url1"], 5000); + + let submitCount = 0; + const submitCall = async () => { + submitCount++; + const err = new Error("Bad Request"); + err.response = { status: 400, data: { extras: { result_codes: { transaction: "tx_bad_seq" } } } }; + throw err; + }; + + const verifyFn = async () => null; + + const executePromise = c.execute(submitCall, { mode: 'submit', verifyFn }); + + await expect(executePromise).rejects.toThrow("Bad Request"); + expect(submitCount).toBe(1); + }); +}); + + + diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 5bb58776..255aafc5 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -3,13 +3,9 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import logger from "../../config/logger.js"; import { observeHorizonDuration } from "../../config/metrics.js"; -const NETWORK = process.env.STELLAR_NETWORK || "testnet"; -const HORIZON_URL = - NETWORK === "mainnet" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; +import { client } from "./horizonClient.js"; -const server = new StellarSdk.Horizon.Server(HORIZON_URL); +const NETWORK = process.env.STELLAR_NETWORK || "testnet"; const networkPassphrase = NETWORK === "mainnet" ? StellarSdk.Networks.PUBLIC @@ -265,7 +261,7 @@ const parseAccountSummary = (account) => { export const getAccountBalance = async (publicKey) => { try { const account = await timedHorizonCall("loadAccount", () => - server.loadAccount(publicKey) + client.execute(server => server.loadAccount(publicKey)) ); const summary = parseAccountSummary(account); @@ -424,7 +420,7 @@ export const buildPaymentTransaction = async ({ }) => { try { const sourceAccount = await timedHorizonCall("loadAccount", () => - server.loadAccount(sourcePublicKey) + client.execute(server => server.loadAccount(sourcePublicKey)) ); const feeSplit = applyPlatformFee ? calculateFeeSplit(amount) : null; @@ -528,8 +524,17 @@ export const submitTransaction = async (signedXdr) => { networkPassphrase ); + // Using mode: 'submit' and passing a verifyFn to safely handle timeouts + const verifyFn = async () => { + const ver = await verifyTransaction(transaction.hash().toString("hex")); + if (ver.exists) { + return { hash: transaction.hash().toString("hex"), ledger: ver.ledger, successful: ver.successful }; + } + return null; + }; + const result = await timedHorizonCall("submitTransaction", () => - server.submitTransaction(transaction) + client.execute(server => server.submitTransaction(transaction), { mode: 'submit', verifyFn }) ); return { hash: result.hash, @@ -570,10 +575,10 @@ export const submitTransaction = async (signedXdr) => { export const verifyTransaction = async (txHash) => { try { const tx = await timedHorizonCall("fetchTransaction", () => - server.transactions().transaction(txHash).call() + client.execute(server => server.transactions().transaction(txHash).call()) ); const operations = await timedHorizonCall("fetchOperations", () => - server.operations().forTransaction(txHash).call() + client.execute(server => server.operations().forTransaction(txHash).call()) ); return { @@ -668,8 +673,10 @@ export const getAccountExplorerUrl = (publicKey) => { return baseUrl + publicKey; }; +// Export client.endpoints[0].server as a fallback for other modules not yet refactored (e.g. payoutService) +export const server = client.endpoints[0].server; + export { - server, USDC, USDC_ISSUER, NETWORK, diff --git a/src/services/stellar/stellarTomlService.js b/src/services/stellar/stellarTomlService.js new file mode 100644 index 00000000..8351a6e8 --- /dev/null +++ b/src/services/stellar/stellarTomlService.js @@ -0,0 +1,111 @@ +import { + isValidPublicKey, + NETWORK, + USDC_ISSUER, + networkPassphrase, +} from "./stellarService.js"; + +/** + * @typedef {Object} StellarTomlConfig + * @property {string} version + * @property {string} networkPassphrase + * @property {string[]} accounts + * @property {string} [webAuthEndpoint] + * @property {string} [signingKey] + * @property {string} [transferServerSep0024] + * @property {string} [telegramUrl] + * @property {Record} documentation + * @property {Array>} currencies + */ + +const valueToToml = (value) => { + if (Array.isArray(value)) { + return `[${value.map(valueToToml).join(", ")}]`; + } + if (typeof value === "string") { + return JSON.stringify(value); + } + return String(value); +}; + +const appendFields = (lines, fields) => { + Object.entries(fields).forEach(([key, value]) => { + if (value !== undefined && value !== "" && value !== null) { + lines.push(`${key} = ${valueToToml(value)}`); + } + }); +}; + +/** + * Build the SEP-1 configuration from environment values and the active + * Stellar service constants. + * @returns {StellarTomlConfig} + */ +export const createStellarTomlConfig = (env = process.env) => ({ + version: "2.7.0", + networkPassphrase, + accounts: isValidPublicKey(env.STELLAR_PLATFORM_PUBLIC_KEY) + ? [env.STELLAR_PLATFORM_PUBLIC_KEY] + : [], + webAuthEndpoint: env.WEB_AUTH_ENDPOINT, + signingKey: isValidPublicKey(env.SIGNING_KEY) ? env.SIGNING_KEY : undefined, + transferServerSep0024: env.TRANSFER_SERVER_SEP0024, + telegramUrl: env.ORG_TELEGRAM_URL, + documentation: { + ORG_NAME: env.ORG_NAME, + ORG_URL: env.ORG_URL, + ORG_DESCRIPTION: env.ORG_DESCRIPTION, + ORG_LOGO: env.ORG_LOGO, + ORG_GITHUB: env.ORG_GITHUB, + }, + currencies: [ + { + code: "USDC", + issuer: USDC_ISSUER, + status: NETWORK === "mainnet" ? "live" : "test", + display_decimals: 2, + name: "USD Coin", + desc: "USDC used to settle payments on DeenBridge.", + is_asset_anchored: true, + anchor_asset_type: "fiat", + anchor_asset: "USD", + }, + ], +}); + +/** + * Serialize a typed SEP-1 configuration as TOML. + * @param {StellarTomlConfig} config + */ +export const buildStellarToml = (config = createStellarTomlConfig()) => { + const lines = []; + + appendFields(lines, { + VERSION: config.version, + NETWORK_PASSPHRASE: config.networkPassphrase, + ACCOUNTS: config.accounts.length ? config.accounts : undefined, + WEB_AUTH_ENDPOINT: config.webAuthEndpoint, + SIGNING_KEY: config.signingKey, + TRANSFER_SERVER_SEP0024: config.transferServerSep0024, + }); + + lines.push("", "[DOCUMENTATION]"); + appendFields(lines, config.documentation); + + config.currencies.forEach((currency) => { + lines.push("", "[[CURRENCIES]]"); + appendFields(lines, currency); + }); + + lines.push(""); + if (config.telegramUrl) { + lines.push(`# Telegram: ${config.telegramUrl}`); + } + lines.push( + "# Set TRANSFER_SERVER_SEP0024 when the SEP-24 service is available.", + "# Add another [[CURRENCIES]] table here when multi-asset support is enabled.", + "" + ); + + return lines.join("\n"); +}; diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js new file mode 100644 index 00000000..fef923ba --- /dev/null +++ b/test/stellarPaymentController.test.js @@ -0,0 +1,345 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +const buildPaymentTransaction = jest.fn(); +const buildSep7Uri = jest.fn(); +const submitTransaction = jest.fn(); +const verifyPaymentOperations = jest.fn(); +const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); +const recordSaleEarnings = jest.fn(); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + buildPaymentTransaction, + buildSep7Uri, + submitTransaction, + verifyPaymentOperations, + verifyTransaction: jest.fn(), + NETWORK: "testnet", + getExplorerUrl, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); + +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 Course = (await import("../src/models/Course.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; +}; + +describe("Stellar payment controller", () => { + let buyerId; + let creatorId; + let itemId; + let buyerWallet; + let creatorWallet; + let session; + let savedTransactions; + + beforeEach(() => { + jest.restoreAllMocks(); + buildPaymentTransaction.mockReset(); + buildSep7Uri.mockReset(); + submitTransaction.mockReset(); + verifyPaymentOperations.mockReset(); + getExplorerUrl.mockClear(); + recordSaleEarnings.mockReset(); + + 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("initializes a book payment and returns unsigned XDR details", 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, + }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(null)); + buildPaymentTransaction.mockResolvedValue({ + xdr: "unsigned-xdr", + hash: "expected-hash", + networkPassphrase: "Test SDF Network ; September 2015", + feeSplit: null, + }); + buildSep7Uri.mockReturnValue("web+stellar:pay?destination=creator"); + + const res = await request(mountPaymentApp(buyerId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + buyerWallet, + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.payment).toMatchObject({ + xdr: "unsigned-xdr", + expectedHash: "expected-hash", + }); + expect(buildPaymentTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + sourcePublicKey: buyerWallet, + destinationPublicKey: creatorWallet, + amount: "15", + applyPlatformFee: true, + }) + ); + expect(savedTransactions).toHaveLength(1); + expect(savedTransactions[0]).toMatchObject({ + buyer: buyerId, + buyerWallet, + creator: creatorId, + creatorWallet, + status: "pending", + stellarTxHash: "expected-hash", + }); + expect(session.commitTransaction).toHaveBeenCalledTimes(1); + expect(session.abortTransaction).not.toHaveBeenCalled(); + }); + + it("rejects an invalid signed XDR and stores the Stellar failure reason", async () => { + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + status: "pending", + save: jest.fn(() => Promise.resolve()), + }; + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockRejectedValue(new Error("Invalid XDR")); + + const res = await request(mountPaymentApp(buyerId)) + .post("/submit") + .send({ + transactionId: tx._id.toString(), + signedXdr: "tampered-xdr", + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + success: false, + message: "Transaction failed on Stellar network", + error: "Invalid XDR", + }); + expect(tx.status).toBe("failed"); + expect(tx.failureReason).toBe("Invalid XDR"); + expect(session.commitTransaction).toHaveBeenCalledTimes(1); + expect(session.abortTransaction).not.toHaveBeenCalled(); + }); + + it("does not grant access when on-chain verification fails", async () => { + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + itemType: "course", + itemId, + itemTitle: "Course", + amount: "25", + creatorWallet, + status: "pending", + save: jest.fn(() => Promise.resolve()), + }; + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + const findByIdSpy = jest.spyOn(User, "findById"); + submitTransaction.mockResolvedValue({ + hash: "hash-failed-verification", + ledger: 44, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ + verified: false, + reason: "Missing expected USDC payment", + }); + + const res = await request(mountPaymentApp(buyerId)) + .post("/submit") + .send({ + transactionId: tx._id.toString(), + signedXdr: "signed-xdr", + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe( + "Payment could not be verified on the Stellar network" + ); + expect(tx.status).toBe("failed"); + expect(tx.failureReason).toContain("On-chain verification failed"); + expect(findByIdSpy).not.toHaveBeenCalled(); + expect(recordSaleEarnings).not.toHaveBeenCalled(); + expect(session.commitTransaction).toHaveBeenCalledTimes(1); + }); + + it("grants course access only after successful Stellar verification", async () => { + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + creator: creatorId, + itemType: "course", + itemId, + itemTitle: "Course", + amount: "25", + creatorWallet, + status: "pending", + save: jest.fn(() => Promise.resolve()), + }; + const buyer = { + _id: buyerId, + purchasedCourses: [], + stat: { coursesEnrolled: 0 }, + save: jest.fn(() => Promise.resolve()), + }; + + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest + .spyOn(Course, "findByIdAndUpdate") + .mockResolvedValue({ _id: itemId }); + submitTransaction.mockResolvedValue({ + hash: "hash-confirmed", + ledger: 77, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: 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.success).toBe(true); + expect(verifyPaymentOperations).toHaveBeenCalledWith("hash-confirmed", [ + { destination: creatorWallet, amount: "25" }, + ]); + expect(recordSaleEarnings).toHaveBeenCalledWith(tx, { session }); + expect(buyer.purchasedCourses).toHaveLength(1); + expect(buyer.purchasedCourses[0].courseId).toBe(itemId); + expect(buyer.stat.coursesEnrolled).toBe(1); + expect(Course.findByIdAndUpdate).toHaveBeenCalledWith( + itemId, + { $addToSet: { enrolledUsers: buyerId } }, + { session } + ); + expect(tx.status).toBe("confirmed"); + expect(session.commitTransaction).toHaveBeenCalledTimes(1); + expect(session.abortTransaction).not.toHaveBeenCalled(); + }); + + it("aborts the Mongo transaction if granting access fails", async () => { + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + creator: creatorId, + itemType: "book", + itemId, + itemTitle: "Book", + amount: "12", + creatorWallet, + status: "pending", + save: jest.fn(() => Promise.resolve()), + }; + const buyer = { + _id: buyerId, + purchasedBooks: [], + stat: { booksRead: 0 }, + save: jest.fn(() => Promise.reject(new Error("grant failed"))), + }; + + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + submitTransaction.mockResolvedValue({ + hash: "hash-before-access-failure", + ledger: 88, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: true }); + + const res = await request(mountPaymentApp(buyerId)) + .post("/submit") + .send({ + transactionId: tx._id.toString(), + signedXdr: "signed-xdr", + }); + + expect(res.statusCode).toBe(500); + expect(res.body).toMatchObject({ + success: false, + message: "Failed to process payment", + }); + expect(recordSaleEarnings).toHaveBeenCalled(); + expect(session.abortTransaction).toHaveBeenCalledTimes(1); + expect(session.commitTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/test/stellarService.test.js b/test/stellarService.test.js new file mode 100644 index 00000000..1966548e --- /dev/null +++ b/test/stellarService.test.js @@ -0,0 +1,221 @@ +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + buildPaymentTransaction, + getAccountBalance, + hasUsdcTrustline, + isValidPublicKey, + networkPassphrase, + server, + submitTransaction, + USDC_ISSUER, + verifyPaymentOperations, +} from "../src/services/stellar/stellarService.js"; + +const makeHorizonError = (operationCode) => { + const error = new Error("Horizon rejected transaction"); + error.response = { + data: { + extras: { + result_codes: { + operations: [operationCode], + }, + }, + }, + }; + return error; +}; + +const buildSignedXdr = () => { + const source = StellarSdk.Keypair.random(); + const destination = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1"); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination: destination.publicKey(), + asset: StellarSdk.Asset.native(), + amount: "1", + }) + ) + .setTimeout(30) + .build(); + tx.sign(source); + return tx.toXDR(); +}; + +describe("Stellar service payment flow", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("builds an unsigned USDC payment transaction with the expected operation", async () => { + const source = StellarSdk.Keypair.random(); + const destination = StellarSdk.Keypair.random(); + jest.spyOn(server, "loadAccount").mockResolvedValue( + new StellarSdk.Account(source.publicKey(), "100") + ); + + const payment = await buildPaymentTransaction({ + sourcePublicKey: source.publicKey(), + destinationPublicKey: destination.publicKey(), + amount: "12.345", + memo: "DNB-BOOK-1234", + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR( + payment.xdr, + networkPassphrase + ); + expect(tx.signatures).toHaveLength(0); + expect(tx.operations).toHaveLength(1); + expect(tx.operations[0]).toMatchObject({ + type: "payment", + destination: destination.publicKey(), + amount: "12.3450000", + }); + expect(tx.operations[0].asset.code).toBe("USDC"); + expect(tx.operations[0].asset.issuer).toBe(USDC_ISSUER); + }); + + it.each([ + ["op_underfunded", "Insufficient USDC balance"], + [ + "op_no_trust", + "Recipient does not have a USDC trustline. They need to add USDC to their wallet first.", + ], + ["op_no_destination", "Destination account does not exist"], + ])("maps %s to a clear submit error", async (operationCode, message) => { + jest + .spyOn(server, "submitTransaction") + .mockRejectedValue(makeHorizonError(operationCode)); + + await expect(submitTransaction(buildSignedXdr())).rejects.toThrow(message); + }); + + it("verifies matching USDC payment operations", async () => { + const destination = StellarSdk.Keypair.random().publicKey(); + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ + call: async () => ({ + successful: true, + ledger: 123, + created_at: "2026-07-21T00:00:00Z", + }), + }), + }); + jest.spyOn(server, "operations").mockReturnValue({ + forTransaction: () => ({ + call: async () => ({ + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + to: destination, + amount: "9.5000000", + }, + ], + }), + }), + }); + + await expect( + verifyPaymentOperations("tx_hash", [ + { destination, amount: "9.5" }, + ]) + ).resolves.toEqual({ verified: true }); + }); + + it.each([ + { name: "wrong amount", expectedAmount: "10" }, + { + name: "wrong destination", + expectedAmount: "9.5", + expectedDestination: StellarSdk.Keypair.random().publicKey(), + }, + { name: "wrong asset", expectedAmount: "9.5", assetCode: "XLM" }, + ])( + "rejects a payment with $name", + async ({ expectedAmount, expectedDestination, assetCode, assetIssuer }) => { + const destination = StellarSdk.Keypair.random().publicKey(); + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ + call: async () => ({ + successful: true, + ledger: 123, + created_at: "2026-07-21T00:00:00Z", + }), + }), + }); + jest.spyOn(server, "operations").mockReturnValue({ + forTransaction: () => ({ + call: async () => ({ + records: [ + { + type: "payment", + asset_code: assetCode || "USDC", + asset_issuer: assetIssuer || USDC_ISSUER, + to: destination, + amount: "9.5000000", + }, + ], + }), + }), + }); + + const result = await verifyPaymentOperations("tx_hash", [ + { + destination: expectedDestination || destination, + amount: expectedAmount, + }, + ]); + + expect(result.verified).toBe(false); + expect(result.reason).toContain("Missing expected USDC payment"); + } + ); + + it("returns balance and trustline information from Horizon", async () => { + jest.spyOn(server, "loadAccount").mockResolvedValue({ + balances: [ + { asset_type: "native", balance: "3.25" }, + { + asset_code: "USDC", + asset_issuer: USDC_ISSUER, + balance: "44.5", + }, + ], + }); + + await expect(getAccountBalance("GACCOUNT")).resolves.toEqual({ + exists: true, + xlmBalance: "3.25", + usdcBalance: "44.5", + hasTrustline: true, + }); + await expect(hasUsdcTrustline("GACCOUNT")).resolves.toBe(true); + }); + + it("treats missing accounts and Horizon failures as no trustline", async () => { + const notFound = new Error("not found"); + notFound.response = { status: 404 }; + jest.spyOn(server, "loadAccount").mockRejectedValue(notFound); + + await expect(getAccountBalance("GMISSING")).resolves.toEqual({ + exists: false, + xlmBalance: "0", + usdcBalance: "0", + hasTrustline: false, + }); + await expect(hasUsdcTrustline("GMISSING")).resolves.toBe(false); + }); + + it("validates Stellar public keys without network calls", () => { + expect(isValidPublicKey(StellarSdk.Keypair.random().publicKey())).toBe(true); + expect(isValidPublicKey("not-a-stellar-key")).toBe(false); + }); +}); diff --git a/test/stellarToml.test.js b/test/stellarToml.test.js new file mode 100644 index 00000000..719f15a6 --- /dev/null +++ b/test/stellarToml.test.js @@ -0,0 +1,91 @@ +import TOML from "@iarna/toml"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import request from "supertest"; +import { + NETWORK, + USDC_ISSUER, + networkPassphrase, +} from "../src/services/stellar/stellarService.js"; +import { + buildStellarToml, + createStellarTomlConfig, +} from "../src/services/stellar/stellarTomlService.js"; + +const PLATFORM_ACCOUNT = + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +describe("SEP-1 stellar.toml", () => { + let app; + let originalPlatformAccount; + let originalOrgName; + + beforeAll(async () => { + originalPlatformAccount = process.env.STELLAR_PLATFORM_PUBLIC_KEY; + originalOrgName = process.env.ORG_NAME; + process.env.STELLAR_PLATFORM_PUBLIC_KEY = PLATFORM_ACCOUNT; + process.env.ORG_NAME = "DeenBridge"; + ({ default: app } = await import("../app.js")); + }); + + afterAll(() => { + if (originalPlatformAccount === undefined) { + delete process.env.STELLAR_PLATFORM_PUBLIC_KEY; + } else { + process.env.STELLAR_PLATFORM_PUBLIC_KEY = originalPlatformAccount; + } + if (originalOrgName === undefined) { + delete process.env.ORG_NAME; + } else { + process.env.ORG_NAME = originalOrgName; + } + }); + + it("serves parseable network and asset metadata with SEP-1 headers", async () => { + const response = await request(app) + .get("/.well-known/stellar.toml") + .set("Origin", "https://wallet.example"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("text/toml; charset=utf-8"); + expect(response.headers["access-control-allow-origin"]).toBe("*"); + + const document = TOML.parse(response.text); + expect(document.NETWORK_PASSPHRASE).toBe(networkPassphrase); + expect(document.ACCOUNTS).toContain(PLATFORM_ACCOUNT); + expect(document.DOCUMENTATION.ORG_NAME).toBe("DeenBridge"); + expect(document.CURRENCIES).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "USDC", + issuer: USDC_ISSUER, + status: NETWORK === "mainnet" ? "live" : "test", + }), + ]) + ); + }); + + it("omits unset optional metadata without producing invalid TOML", () => { + const document = TOML.parse(buildStellarToml(createStellarTomlConfig({}))); + + expect(document.ACCOUNTS).toBeUndefined(); + expect(document.WEB_AUTH_ENDPOINT).toBeUndefined(); + expect(document.SIGNING_KEY).toBeUndefined(); + expect(document.DOCUMENTATION.ORG_URL).toBeUndefined(); + expect(document.CURRENCIES[0].issuer).toBe(USDC_ISSUER); + }); + + it("does not serialize invalid Stellar public keys", () => { + const secretSeed = StellarSdk.Keypair.random().secret(); + const document = TOML.parse( + buildStellarToml( + createStellarTomlConfig({ + STELLAR_PLATFORM_PUBLIC_KEY: secretSeed, + SIGNING_KEY: "not-a-public-key", + }) + ) + ); + + expect(document.ACCOUNTS).toBeUndefined(); + expect(document.SIGNING_KEY).toBeUndefined(); + }); +});