From 198b07d3faf936200adefb08463bd1854f4f63aa Mon Sep 17 00:00:00 2001
From: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Date: Wed, 12 Aug 2026 23:57:20 +0100
Subject: [PATCH 01/25] =?UTF-8?q?stellar:=20validate=20signed=20XDR=20cont?=
=?UTF-8?q?ents=20before=20submit;=20store=20expectedHa=E2=80=A6=20(#51)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* stellar: validate signed XDR contents before submit; store expectedHash/memo; verify on-chain before granting access; add tests
* test: add validateSignedPaymentXdr to stellarService mock (new import in paymentController)
* test: expect expectedHash at payment init (XDR pre-validation stores it there)
---------
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
---
src/config/validateEnv.js | 7 ++
src/controllers/stellar/donationController.js | 47 ++++++++++---
src/controllers/stellar/paymentController.js | 69 ++++++++++++++-----
src/models/Transaction.js | 9 ++-
src/services/stellar/stellarService.js | 65 +++++++++++++++++
test/stellarPaymentController.test.js | 5 +-
6 files changed, 174 insertions(+), 28 deletions(-)
diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js
index 8b7fa1ae..a9459573 100644
--- a/src/config/validateEnv.js
+++ b/src/config/validateEnv.js
@@ -56,6 +56,13 @@ const optionalEnvVars = [
];
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";
diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js
index 50b05be2..bfd81b57 100644
--- a/src/controllers/stellar/donationController.js
+++ b/src/controllers/stellar/donationController.js
@@ -8,6 +8,7 @@ import {
buildSep7Uri,
submitTransaction,
verifyPaymentOperations,
+ validateSignedPaymentXdr,
getExplorerUrl,
NETWORK,
DONATION_WALLET_PUBLIC_KEY,
@@ -93,7 +94,8 @@ export const initializeDonation = async (req, res) => {
amount: amount.toString(),
network: NETWORK,
status: "pending",
- stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual
+ expectedHash: paymentTx.hash,
+ memo: DONATION_MEMO,
});
await donation.save({ session });
@@ -159,7 +161,40 @@ export const submitDonation = async (req, res) => {
});
}
- // Update status to submitted
+ // Build expected payments array for validation
+ const expectedPayments = [
+ {
+ destination: donation.creatorWallet,
+ amount: donation.amount,
+ },
+ ];
+
+ // Validate signed XDR contents (memo, payments, optional source)
+ try {
+ validateSignedPaymentXdr(
+ signedXdr,
+ expectedPayments,
+ donation.memo,
+ donation.buyerWallet,
+ true
+ );
+ } catch (validationError) {
+ donation.status = "failed";
+ 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 });
@@ -186,12 +221,8 @@ export const submitDonation = async (req, res) => {
}
// 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(result.hash, expectedPayments);
if (!verification.verified) {
donation.stellarTxHash = result.hash;
diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js
index b6911842..21722ef7 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -13,6 +13,7 @@ import {
submitTransaction,
verifyTransaction,
verifyPaymentOperations,
+ validateSignedPaymentXdr,
findPaymentPaths,
applySlippage,
NETWORK,
@@ -499,7 +500,8 @@ export const initializePayment = async (req, res) => {
network: NETWORK,
status: "pending",
settlement: settlementMode,
- stellarTxHash: paymentTx.hash,
+ expectedHash: paymentTx.hash,
+ memo,
...(sendAssetInput && {
sendAsset: sendAssetInput,
sendMax,
@@ -610,6 +612,51 @@ 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,
+ },
+ ];
+
+ // Validate signed XDR contents (memo, payments, optional source)
+ try {
+ validateSignedPaymentXdr(
+ signedXdr,
+ expectedPayments,
+ transaction.memo,
+ transaction.buyerWallet,
+ true
+ );
+ } catch (validationError) {
+ transaction.status = "failed";
+ 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);
+
+ 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 });
@@ -634,23 +681,9 @@ 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,
- },
- ];
+ // 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,
diff --git a/src/models/Transaction.js b/src/models/Transaction.js
index f8b52be2..df21c1b1 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -7,10 +7,17 @@ const transactionSchema = new mongoose.Schema(
// Transaction identification
stellarTxHash: {
type: String,
- required: true,
+ sparse: true,
unique: true,
index: true,
},
+ expectedHash: {
+ type: String,
+ index: true,
+ },
+ memo: {
+ type: String,
+ },
stellarLedger: {
type: Number,
},
diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js
index 7f1b6600..ca9d2695 100644
--- a/src/services/stellar/stellarService.js
+++ b/src/services/stellar/stellarService.js
@@ -646,6 +646,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/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js
index cee52db8..817aeef6 100644
--- a/test/stellarPaymentController.test.js
+++ b/test/stellarPaymentController.test.js
@@ -39,6 +39,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 +202,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();
From edfb1616f4c5c0051fab0a296a369c14729b3f59 Mon Sep 17 00:00:00 2001
From: zeemscript <150973162+zeemscript@users.noreply.github.com>
Date: Thu, 13 Aug 2026 20:08:17 +0100
Subject: [PATCH 02/25] feat(stellar): publish Soroban giving-escrow contract
id in stellar.toml
Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed
On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set
GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage.
---
.env.example | 4 ++++
src/services/stellar/stellarTomlService.js | 15 +++++++++++++++
test/stellarToml.test.js | 18 ++++++++++++++++++
3 files changed, 37 insertions(+)
diff --git a/.env.example b/.env.example
index 36328386..7b8b8cee 100644
--- a/.env.example
+++ b/.env.example
@@ -76,6 +76,10 @@ SEP10_WEB_AUTH_ENDPOINT=
# SEP-10 signing key public counterpart (published in /.well-known/stellar.toml)
SIGNING_KEY=
+# Soroban "On-Chain Giving" escrow contract id (published in /.well-known/stellar.toml).
+# Testnet deployment: CBP3UFPBCVGNQPTWIHHRH52VDD4PE64JGFPREF7GVFIIF7G4DZOWKX7Z
+GIVING_ESCROW_CONTRACT_ID=
+
# Token TTLs
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
diff --git a/src/services/stellar/stellarTomlService.js b/src/services/stellar/stellarTomlService.js
index a74aba31..e854a5b2 100644
--- a/src/services/stellar/stellarTomlService.js
+++ b/src/services/stellar/stellarTomlService.js
@@ -61,6 +61,21 @@ export function buildStellarToml() {
lines.push(`# WEB_AUTH_ENDPOINT = "..." # Set SEP10_WEB_AUTH_ENDPOINT to publish (#25)`);
}
lines.push(`# TRANSFER_SERVER_SEP0024 = "..." # Populated by SEP-24 (#46)`);
+
+ // ── Soroban contracts ──
+ // DeenBridge On-Chain Giving escrow: non-custodial USDC escrow for scholarships
+ // and community events. Custom field (not part of SEP-1) — wallets ignore it, but
+ // it makes the deployed contract discoverable to anyone reading the toml.
+ const escrowContract = process.env.GIVING_ESCROW_CONTRACT_ID;
+ const isValidContractId =
+ typeof escrowContract === "string" && /^C[A-Z2-7]{55}$/.test(escrowContract.trim());
+ if (isValidContractId) {
+ lines.push(`GIVING_ESCROW_CONTRACT = "${escrowContract.trim()}"`);
+ } else {
+ lines.push(
+ `# GIVING_ESCROW_CONTRACT = "C..." # Set GIVING_ESCROW_CONTRACT_ID (Soroban escrow)`
+ );
+ }
lines.push(``);
// ── [DOCUMENTATION] — all fields env-driven, block omitted if nothing set ──
diff --git a/test/stellarToml.test.js b/test/stellarToml.test.js
index d3afeb67..c24a176d 100644
--- a/test/stellarToml.test.js
+++ b/test/stellarToml.test.js
@@ -138,6 +138,24 @@ describe("GET /.well-known/stellar.toml", () => {
});
});
+ it("emits GIVING_ESCROW_CONTRACT only for a valid Soroban contract id (C...)", async () => {
+ const validContract = "CBP3UFPBCVGNQPTWIHHRH52VDD4PE64JGFPREF7GVFIIF7G4DZOWKX7Z";
+ const invalid = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; // G-key, not a contract
+
+ await withEnv({ GIVING_ESCROW_CONTRACT_ID: validContract }, async () => {
+ const res = await request(app).get("/.well-known/stellar.toml");
+ const doc = TOML.parse(res.text);
+ expect(doc.GIVING_ESCROW_CONTRACT).toBe(validContract);
+ });
+
+ await withEnv({ GIVING_ESCROW_CONTRACT_ID: invalid }, async () => {
+ const res = await request(app).get("/.well-known/stellar.toml");
+ const doc = TOML.parse(res.text);
+ expect(doc.GIVING_ESCROW_CONTRACT).toBeUndefined();
+ expect(res.text).toContain('# GIVING_ESCROW_CONTRACT = "C..."');
+ });
+ });
+
it("does not require authentication", async () => {
const res = await request(app).get("/.well-known/stellar.toml");
expect(res.statusCode).not.toBe(401);
From 0ef4ea2d97c1b6bba718f5263fdcd153a2ef5c58 Mon Sep 17 00:00:00 2001
From: zeemscript <150973162+zeemscript@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:07:55 +0100
Subject: [PATCH 03/25] fix(stellar): resolve Horizon endpoints lazily +
network-aware default
Horizon client was constructed at import time with a hardcoded testnet
fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to
testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client
is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins.
---
src/services/stellar/horizonClient.js | 42 ++++++++++++++++++++++++---
1 file changed, 38 insertions(+), 4 deletions(-)
diff --git a/src/services/stellar/horizonClient.js b/src/services/stellar/horizonClient.js
index b4af414b..f16aa3e7 100644
--- a/src/services/stellar/horizonClient.js
+++ b/src/services/stellar/horizonClient.js
@@ -184,10 +184,44 @@ export class HorizonClient {
}
}
-// 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)
+// 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.
+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());
+}
+
+// Construct the client lazily on first use, so it reads HORIZON_URLS /
+// STELLAR_NETWORK AFTER the environment is fully loaded — not at import time
+// (which, being import-hoisted, runs before validateEnv() and could otherwise
+// capture the testnet fallback even on a mainnet config).
+let _client = null;
+export function getClient() {
+ if (!_client) {
+ _client = new HorizonClient(
+ resolveHorizonEndpoints(),
+ parseInt(process.env.HORIZON_TIMEOUT_MS || "10000", 10)
+ );
+ }
+ return _client;
+}
+
+// Back-compat: existing `import { client }` + `client.execute(...)` /
+// `client.endpoints` keep working unchanged, but construction is deferred to
+// the first property access via this proxy.
+export const client = new Proxy(
+ {},
+ {
+ get(_target, prop) {
+ const instance = getClient();
+ const value = instance[prop];
+ return typeof value === "function" ? value.bind(instance) : value;
+ },
+ }
);
export const getHorizonHealth = () => {
From cef0b4fb7ffc2e0cf84770a32a0ba62863a1b868 Mon Sep 17 00:00:00 2001
From: zeemscript <150973162+zeemscript@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:20:03 +0100
Subject: [PATCH 04/25] feat(auth): authenticated change-password endpoint
PUT /api/auth/change-password (protected): verifies current password,
enforces the password policy, updates the hash, and signs out all other
sessions. Adds the auth.password_change audit action.
---
src/controllers/authController.js | 66 +++++++++++++++++++++++++++++++
src/models/AuditLog.js | 1 +
src/routes/authRoutes.js | 2 +
3 files changed, 69 insertions(+)
diff --git a/src/controllers/authController.js b/src/controllers/authController.js
index d8ae4bad..3aa4b37a 100644
--- a/src/controllers/authController.js
+++ b/src/controllers/authController.js
@@ -762,3 +762,69 @@ export const logoutUser = catchAsync(async (req, res, next) => {
message: "Logged out successfully",
});
});
+
+// Change password for an authenticated user. Verifies the current password,
+// enforces the password policy on the new one, and signs out all OTHER sessions.
+export const changePassword = catchAsync(async (req, res, next) => {
+ const { currentPassword, newPassword } = req.body;
+
+ if (!currentPassword || !newPassword) {
+ return next(new APIError("Current and new password are required", 400));
+ }
+
+ const passwordIssue = firstPasswordIssue(newPassword, {
+ name: req.user?.name,
+ email: req.user?.email,
+ });
+ if (passwordIssue) {
+ return next(new APIError(passwordIssue, 400));
+ }
+
+ const user = await User.findById(req.user._id).select("+password");
+ if (!user || !user.password) {
+ return next(new APIError("User not found", 404));
+ }
+
+ const isCurrentCorrect = await bcrypt.compare(currentPassword, user.password);
+ if (!isCurrentCorrect) {
+ recordAudit({
+ action: AUDIT_ACTIONS.AUTH_PASSWORD_CHANGE,
+ actor: user._id,
+ req,
+ targetType: "User",
+ targetId: user._id.toString(),
+ status: "failure",
+ metadata: { reason: "invalid_current_password" },
+ });
+ return next(new APIError("Current password is incorrect", 401));
+ }
+
+ const isSameAsOld = await bcrypt.compare(newPassword, user.password);
+ if (isSameAsOld) {
+ return next(
+ new APIError("New password must be different from the current one", 400)
+ );
+ }
+
+ user.password = await bcrypt.hash(newPassword, 12);
+ await user.save();
+
+ // Sign out every OTHER session so a password change kicks other devices.
+ await Session.deleteMany({ user: user._id, _id: { $ne: req.sessionId } });
+
+ recordAudit({
+ action: AUDIT_ACTIONS.AUTH_PASSWORD_CHANGE,
+ actor: user._id,
+ req,
+ targetType: "User",
+ targetId: user._id.toString(),
+ status: "success",
+ metadata: {},
+ });
+
+ res.status(200).json({
+ success: true,
+ message:
+ "Password changed successfully. Other devices have been signed out.",
+ });
+});
diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js
index f4dad609..b7b9f133 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -24,6 +24,7 @@ export const AUDIT_ACTIONS = Object.freeze({
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",
// Wallet
WALLET_CONNECT_SUCCESS: "wallet.connect.success",
diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js
index 028818fb..b5a952fb 100644
--- a/src/routes/authRoutes.js
+++ b/src/routes/authRoutes.js
@@ -10,6 +10,7 @@ import {
logoutUser,
requestPasswordReset,
resetPassword,
+ changePassword,
verifyEmail,
resendVerification,
} from "../controllers/authController.js";
@@ -43,5 +44,6 @@ router.post("/logout", protect, logoutUser);
router.get("/sessions", protect, getSessions);
router.delete("/sessions/:sessionId", protect, revokeSession);
router.delete("/sessions", protect, revokeAllOtherSessions);
+router.put("/change-password", protect, changePassword);
export default router;
From 2a98de38718c92a663be280e4ebcff2e5da4b369 Mon Sep 17 00:00:00 2001
From: Alabi Ibrahim Abimbola
<139625252+abimbolaalabi@users.noreply.github.com>
Date: Sun, 16 Aug 2026 16:27:07 +0100
Subject: [PATCH 05/25] feat(auth): harden authentication against login
lockout, breached passwords, and signup abuse (#89) (#99)
* feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89)
- Add progressive per-account login lockout (failedLoginAttempts + lockUntil) with env-configurable escalating backoff, generic 429 while locked, and AUTH_ACCOUNT_LOCKED audit emission.
- Add HaveIBeenPwned breached-password check (SHA-1 prefix k-anonymity, never transmits the password) at register and password reset; fails open on outage.
- Add per-email throttling on /register and /resend-verification (emailAuthLimiter, survives IP rotation, active in test env) plus a pluggable captcha gate (no-op when unconfigured).
- Add User lockout fields and document new env vars in .env.example.
* fix(auth): address CodeRabbit review on login lockout, HIBP, captcha, and JWT secret (#89)
- loginUser: locked accounts now return the same generic 401 'Invalid credentials'
as a nonexistent account (no enumeration); failed-login counter incremented
atomically via findByIdAndUpdate \, lock persisted via updateOne
- resetPassword: breached-password check moved to after successful OTP validation
so unauthenticated callers cannot trigger HIBP lookups
- authController: refuse to start when JWT_SECRET is missing (no hardcoded fallback)
- hibp: request HIBP padding ('Add-Padding: true') and ignore zero-count records
- captcha: configurable CAPTCHA_TIMEOUT_MS (default 5s) passed to the provider call;
captcha rejection now returns the standard { success, message, data: null } shape
- tests: assert escalating backoff magnitude (120s on 6th failure) and the 24h cap;
locked-account test expects 401; per-email limiter buckets reset between tests;
outage test routed through mockHibp so the shared spy is cleaned up; added
padding-record and cap coverage
---
.env.example | 25 ++
src/controllers/authController.js | 137 ++++++++++-
src/middlewares/security.js | 56 ++++-
src/models/AuditLog.js | 1 +
src/models/User.js | 11 +
src/routes/authRoutes.js | 20 +-
src/utils/captcha.js | 47 ++++
src/utils/hibp.js | 81 +++++++
test/auditLog.test.js | 4 +
test/auth.test.js | 2 +
test/authSecurity.test.js | 366 ++++++++++++++++++++++++++++++
test/breachedPassword.test.js | 239 +++++++++++++++++++
test/passwordReset.test.js | 3 +
13 files changed, 977 insertions(+), 15 deletions(-)
create mode 100644 src/utils/captcha.js
create mode 100644 src/utils/hibp.js
create mode 100644 test/authSecurity.test.js
create mode 100644 test/breachedPassword.test.js
diff --git a/.env.example b/.env.example
index 7b8b8cee..21c578d2 100644
--- a/.env.example
+++ b/.env.example
@@ -84,6 +84,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
diff --git a/src/controllers/authController.js b/src/controllers/authController.js
index 3aa4b37a..77c57927 100644
--- a/src/controllers/authController.js
+++ b/src/controllers/authController.js
@@ -12,21 +12,45 @@ 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";
-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)"
+// Never fall back to a hardcoded default — that would sign tokens with a known
+// value. validateEnv() also enforces this at boot; refuse to start if missing.
+const JWT_SECRET = process.env.JWT_SECRET;
+
+// ── 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})`
+
+// Log JWT configuration on startup and fail fast if the secret is missing.
+if (!JWT_SECRET) {
+ logger.error(
+ "❌ JWT_SECRET is required to sign auth tokens but is missing or empty. Refusing to start."
);
+ process.exit(1);
}
+logger.info(
+ `✅ JWT_SECRET loaded from environment (length: ${JWT_SECRET.length})`
+);
// Helper: parse duration string to ms (e.g. 15m, 30d)
export const parseDurationToMs = (duration) => {
@@ -144,6 +168,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 +413,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,
@@ -394,8 +494,12 @@ export const loginUser = catchAsync(async (req, res, next) => {
logger.info(`👑 Promoted ${user.email} to admin (whitelisted account)`);
}
- // 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
@@ -536,6 +640,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;
diff --git a/src/middlewares/security.js b/src/middlewares/security.js
index c8e18fb4..438bf3c2 100644
--- a/src/middlewares/security.js
+++ b/src/middlewares/security.js
@@ -3,6 +3,7 @@ import rateLimit 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,59 @@ 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();
+};
+
/**
* Moderate – for mutation endpoints (purchase, email, upload, payouts).
* 100 requests per 15 minutes by default.
@@ -229,6 +281,8 @@ export default {
generousLimiter,
authLimiter,
refreshLimiter,
+ emailAuthLimiter,
+ captchaGate,
mongoSanitizeMiddleware,
hppMiddleware,
customSecurityHeaders,
diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js
index b7b9f133..96f25b13 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -21,6 +21,7 @@ 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",
diff --git a/src/models/User.js b/src/models/User.js
index e377b178..de690c7e 100644
--- a/src/models/User.js
+++ b/src/models/User.js
@@ -57,6 +57,17 @@ const userSchema = new mongoose.Schema(
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,
},
diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js
index b5a952fb..2189b289 100644
--- a/src/routes/authRoutes.js
+++ b/src/routes/authRoutes.js
@@ -19,17 +19,29 @@ import {
verifyStellarChallenge,
} from "../controllers/stellar/sep10Controller.js";
import { protect } from "../middlewares/authMiddleware.js";
-import { refreshLimiter } from "../middlewares/security.js";
+import {
+ refreshLimiter,
+ emailAuthLimiter,
+ captchaGate,
+} from "../middlewares/security.js";
const router = express.Router();
-// Public routes with auth rate limit
-router.post("/register", registerUser);
+// 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(), registerUser);
router.post("/login", 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
+);
// 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/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/test/auditLog.test.js b/test/auditLog.test.js
index 9854d001..6b87a8ae 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";
@@ -47,6 +48,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/authSecurity.test.js b/test/authSecurity.test.js
new file mode 100644
index 00000000..e79c2f54
--- /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 "bcrypt";
+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/breachedPassword.test.js b/test/breachedPassword.test.js
new file mode 100644
index 00000000..51797eb9
--- /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 "bcrypt";
+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/passwordReset.test.js b/test/passwordReset.test.js
index ec0d389c..cc425083 100644
--- a/test/passwordReset.test.js
+++ b/test/passwordReset.test.js
@@ -2,6 +2,7 @@ import { jest } from "@jest/globals";
import request from "supertest";
import mongoose from "mongoose";
import bcrypt from "bcrypt";
+import axios from "axios";
import app from "../app.js";
import User from "../src/models/User.js";
import PendingUser from "../src/models/PendingUser.js";
@@ -25,6 +26,8 @@ describe("Password Reset Flow", () => {
beforeAll(() => {
// Capture the OTP code from the [EMAIL LOG] fallback (SMTP is unset in tests)
loggerInfoSpy = jest.spyOn(logger, "info");
+ // 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) => {
From 16e6b6f7343d03fdafe1bf9cc2793d064e3f4c2d Mon Sep 17 00:00:00 2001
From: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Date: Mon, 17 Aug 2026 11:48:44 +0100
Subject: [PATCH 06/25] Feat/93 idempotency keys (#100)
* feat(stellar): publish Soroban giving-escrow contract id in stellar.toml
Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed
On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set
GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage.
* fix(stellar): resolve Horizon endpoints lazily + network-aware default
Horizon client was constructed at import time with a hardcoded testnet
fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to
testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client
is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins.
* feat(auth): authenticated change-password endpoint
PUT /api/auth/change-password (protected): verifies current password,
enforces the password policy, updates the hash, and signs out all other
sessions. Adds the auth.password_change audit action.
* feat(payment): add request-level idempotency keys to payment endpoints (#93)
* test: complement stellarService mock exports in idempotency test
* test: refine idempotency middleware concurrency lock test (#93)
* fix(stellar): export validateSignedPaymentXdr and complement test mock (#93)
* fix(stellar): remove duplicate validateSignedPaymentXdr export (#93)
---------
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
---
docs/idempotency.md | 56 ++++++
openapi.yaml | 12 ++
package-lock.json | 10 -
src/middlewares/idempotency.js | 133 +++++++++++++
src/models/IdempotencyKey.js | 52 +++++
src/routes/payoutRoutes.js | 5 +-
src/routes/stellar/donationRoutes.js | 5 +-
src/routes/stellar/paymentRoutes.js | 15 +-
test/educators.test.js | 21 +-
test/idempotency.test.js | 286 +++++++++++++++++++++++++++
test/refund.test.js | 11 +-
test/reviews.test.js | 15 +-
test/search.test.js | 22 ++-
13 files changed, 615 insertions(+), 28 deletions(-)
create mode 100644 docs/idempotency.md
create mode 100644 src/middlewares/idempotency.js
create mode 100644 src/models/IdempotencyKey.js
create mode 100644 test/idempotency.test.js
diff --git a/docs/idempotency.md b/docs/idempotency.md
new file mode 100644
index 00000000..e2529e12
--- /dev/null
+++ b/docs/idempotency.md
@@ -0,0 +1,56 @@
+# Request-Level Idempotency
+
+DeenBridge Backend provides header-driven request idempotency on all mutating payment, donation, refund, and payout endpoints. This prevents retried HTTP requests (due to flaky mobile connectivity, proxy retries, or double-tapped UI buttons) from creating duplicate transactions, submitting duplicate Stellar on-chain payments, or double-crediting educators.
+
+## Header
+
+Clients pass the `Idempotency-Key` HTTP header with a unique identifier (e.g. UUID v4):
+
+```http
+POST /api/stellar/payment/initialize HTTP/1.1
+Host: api.deenbridge.app
+Authorization: Bearer
+Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
+Content-Type: application/json
+
+{
+ "itemType": "book",
+ "itemId": "65b82e80cbf0776967aab8fe",
+ "buyerWallet": "GBKENR..."
+}
+```
+
+## Behavior & Lifecycle
+
+1. **First Use (`in_progress`)**:
+ An `IdempotencyKey` record is atomically inserted with `status: "in_progress"` scoped to `{ key, userId, endpoint }`. A Mongo unique compound index guarantees atomicity under concurrent requests.
+2. **Captured Response (`completed`)**:
+ Upon handler completion, the HTTP status code and JSON response body are captured and persisted with `status: "completed"`.
+3. **Replay**:
+ Subsequent requests with the same `Idempotency-Key`, matching body, and same user immediately receive the stored status code and response body without re-executing controller logic or database/on-chain mutations.
+4. **Expiration (TTL)**:
+ Idempotency keys automatically expire after 24 hours via MongoDB TTL index.
+
+## Error Responses & Concurrency
+
+| Scenario | HTTP Status | Response Description |
+|----------|-------------|----------------------|
+| **In-Flight Concurrency** | `409 Conflict` | A second request with the same idempotency key is already `in_progress`. Clients should back off and retry. |
+| **Payload Mismatch** | `422 Unprocessable Entity` | The idempotency key was reused with a different request body fingerprint. |
+| **Server Failure (5xx)** | — | Idempotency keys are purged on 5xx errors so clients can safely retry after a backend failure. |
+
+## Supported Endpoints & Policy
+
+Idempotency key protection is enabled on all mutating payment endpoints (`required: false` policy for backward compatibility):
+
+- `POST /api/stellar/payment/initialize`
+- `POST /api/stellar/payment/submit`
+- `POST /api/stellar/payment/transactions/:id/refund-request`
+- `POST /api/stellar/payment/refunds/:refundId/build`
+- `POST /api/stellar/payment/refunds/:refundId/submit`
+- `POST /api/stellar/payment/refunds/:refundId/reject`
+- `POST /api/stellar/payment/refunds/:refundId/dispute`
+- `POST /api/stellar/donation/initialize`
+- `POST /api/stellar/donation/submit`
+- `POST /api/payouts/build`
+- `POST /api/payouts/:batchId/submit`
diff --git a/openapi.yaml b/openapi.yaml
index 834a5f09..c66e863c 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -82,6 +82,13 @@ components:
scheme: bearer
description: Static token from the JOBS_DASHBOARD_TOKEN environment variable.
parameters:
+ IdempotencyKeyHeader:
+ name: Idempotency-Key
+ in: header
+ required: false
+ description: Unique key for request idempotency protection. Prevents duplicate charges and transactions on retried requests.
+ schema:
+ type: string
Page:
name: page
in: query
@@ -128,6 +135,11 @@ components:
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
+ UnprocessableEntity:
+ description: Request payload mismatch for idempotency key
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Error" }
TooManyRequests:
description: Rate limit exceeded. /api is rate limited globally and /api/auth more strictly.
content:
diff --git a/package-lock.json b/package-lock.json
index b48efac5..99f5109b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,7 +31,6 @@
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"multer-storage-cloudinary": "^4.0.0",
- "nodemailer": "^9.0.3",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
@@ -6055,15 +6054,6 @@
"node": ">=18"
}
},
- "node_modules/nodemailer": {
- "version": "9.0.3",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
- "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
- "license": "MIT-0",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
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/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/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/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/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js
index e6b9539b..8686c9ad 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,
@@ -28,8 +29,8 @@ router.use(protect);
// Payment flow
router.post("/quote", getQuote);
router.post("/preflight", getPaymentPreflight);
-router.post("/initialize", initializePayment);
-router.post("/submit", submitPayment);
+router.post("/initialize", idempotency(), initializePayment);
+router.post("/submit", idempotency(), submitPayment);
// Transaction management
router.get("/transactions", getTransactionHistory);
@@ -37,11 +38,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"),
diff --git a/test/educators.test.js b/test/educators.test.js
index 9ecd6888..a24ff5d5 100644
--- a/test/educators.test.js
+++ b/test/educators.test.js
@@ -6,15 +6,34 @@ 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 (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) {
+ try {
+ await mongoose.connect(`${process.env.MONGO_URI}_educators`);
+ return;
+ } catch (_err) {}
+ }
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
}, 60000);
+afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.connection.close();
+ }
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+});
+
beforeEach(async () => {
await Course.deleteMany({});
await Book.deleteMany({});
diff --git a/test/idempotency.test.js b/test/idempotency.test.js
new file mode 100644
index 00000000..227f0f4f
--- /dev/null
+++ b/test/idempotency.test.js
@@ -0,0 +1,286 @@
+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 () => {
+ 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();
+ });
+});
diff --git a/test/refund.test.js b/test/refund.test.js
index e54f7eaa..38bcae49 100644
--- a/test/refund.test.js
+++ b/test/refund.test.js
@@ -29,19 +29,22 @@ const generateToken = (userId, role = "student") => {
return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" });
};
+import { MongoMemoryServer } from "mongodb-memory-server";
+
describe("Non-Custodial Refund & Dispute Flow (#62)", () => {
let buyer, educator, otherUser, adminUser;
let buyerWallet;
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();
}
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
// Mock Horizon Server
jest.spyOn(server, "loadAccount").mockImplementation(async (publicKey) => {
diff --git a/test/reviews.test.js b/test/reviews.test.js
index 69091870..aa3780b9 100644
--- a/test/reviews.test.js
+++ b/test/reviews.test.js
@@ -13,19 +13,32 @@ const generateToken = (userId) => {
return jwt.sign({ userId }, 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 (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) {
+ try {
+ await mongoose.connect(`${process.env.MONGO_URI}_reviews`);
+ return;
+ } catch (_err) {}
+ }
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
}, 60000);
afterAll(async () => {
if (mongoose.connection.readyState !== 0) {
await mongoose.connection.close();
}
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
});
beforeEach(async () => {
diff --git a/test/search.test.js b/test/search.test.js
index fb2edb45..c60f3470 100644
--- a/test/search.test.js
+++ b/test/search.test.js
@@ -7,10 +7,30 @@ 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 (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) {
+ try {
+ await mongoose.connect(`${process.env.MONGO_URI}_search`);
+ return;
+ } catch (_err) {}
+ }
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
}, 60000);
+afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.connection.close();
+ }
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+});
+
beforeEach(async () => {
// Clean db
await Course.deleteMany({});
From c7fe869c5b688697856d35336de6db692bb27fc2 Mon Sep 17 00:00:00 2001
From: BountySpaghetti
Date: Mon, 17 Aug 2026 23:17:04 +0100
Subject: [PATCH 07/25] feat(auth): enforce resource ownership across mutating
endpoints (#88) (#105)
Add a centralized authorization layer that verifies the authenticated
user owns the target resource (or is an admin) before any mutating
handler runs, replacing the ad-hoc inline checks scattered across
controllers.
- add authorizeOwnership + authorizeReviewOwnership middleware
(src/middlewares/authorize.js); on success the loaded doc is attached
to req so handlers can reuse it
- apply the guards to book delete, course update, space update/delete,
and review update/delete on books and courses; review create stays
purchase-gated
- record ownership denials to the audit log (authz.ownership.denied)
- remove the now-redundant inline ownership checks from the book,
course, space, and review controllers
- document the resource x action x role matrix (docs/authorization-matrix.md)
and cover it with an integration test suite (test/ownershipAuthz.test.js)
Closes #88
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
---
docs/authorization-matrix.md | 62 ++++
src/controllers/books/bookController.js | 10 +-
src/controllers/courses/courseController.js | 11 +-
src/controllers/reviewController.js | 12 +-
src/controllers/spaceController.js | 20 +-
src/middlewares/authorize.js | 121 +++++++
src/models/AuditLog.js | 3 +
src/routes/books/bookRoutes.js | 12 +
src/routes/courses/courseRoutes.js | 12 +
src/routes/spaceRoutes.js | 4 +
test/authRoles.test.js | 24 +-
test/ownershipAuthz.test.js | 338 ++++++++++++++++++++
12 files changed, 582 insertions(+), 47 deletions(-)
create mode 100644 docs/authorization-matrix.md
create mode 100644 src/middlewares/authorize.js
create mode 100644 test/ownershipAuthz.test.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/src/controllers/books/bookController.js b/src/controllers/books/bookController.js
index 6ee3f7bd..6b3b8cc1 100644
--- a/src/controllers/books/bookController.js
+++ b/src/controllers/books/bookController.js
@@ -126,18 +126,12 @@ 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);
+ // Ownership is enforced by authorizeOwnership middleware (req.resource).
+ const book = req.resource || (await Book.findById(req.params.id));
if (!book) {
return res.status(404).json({ success: false, message: "Book not found" });
}
- 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",
- });
- }
-
await Book.findByIdAndDelete(req.params.id);
res.json({ success: true, message: "Book deleted" });
} catch (error) {
diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js
index 65780cc8..abcdea52 100644
--- a/src/controllers/courses/courseController.js
+++ b/src/controllers/courses/courseController.js
@@ -169,19 +169,12 @@ export const updateCourse = catchAsync(async (req, res, next) => {
logger.info(`Updating course: ${courseId}`);
- const course = await Course.findById(courseId);
+ // Ownership is enforced by authorizeOwnership middleware (req.resource).
+ const course = req.resource || (await Course.findById(courseId));
if (!course) {
return next(new APIError("Course not found", 404));
}
- // Check if user is the creator or admin (authorization)
- if (req.user.role !== "admin" && course.createdBy.toString() !== req.user._id.toString()) {
- logger.warn(`Unauthorized course update attempt by user: ${req.user._id}`);
- return next(
- new APIError("You are not authorized to update this course", 403)
- );
- }
-
// Update fields (URLs from frontend)
course.title = title || course.title;
course.description = description || course.description;
diff --git a/src/controllers/reviewController.js b/src/controllers/reviewController.js
index 87678533..f8d4fe15 100644
--- a/src/controllers/reviewController.js
+++ b/src/controllers/reviewController.js
@@ -172,9 +172,7 @@ export const updateReviewHandler = (Model, itemType) =>
return next(new APIError("Review not found", 404));
}
- if (review.user.toString() !== req.user._id.toString()) {
- return next(new APIError("Not authorized to update this review", 403));
- }
+ // Ownership is enforced by authorizeReviewOwnership middleware.
if (comment !== undefined) {
if (typeof comment !== "string" || comment.trim() === "") {
@@ -229,13 +227,7 @@ export const deleteReviewHandler = (Model, itemType) =>
return next(new APIError("Review not found", 404));
}
- const review = item.reviews[reviewIndex];
-
- const isOwner = review.user.toString() === req.user._id.toString();
- const isAdmin = req.user.role === "admin";
- if (!isOwner && !isAdmin) {
- return next(new APIError("Not authorized to delete this review", 403));
- }
+ // Ownership is enforced by authorizeReviewOwnership middleware.
item.reviews.splice(reviewIndex, 1);
await recomputeReviewStats(item);
diff --git a/src/controllers/spaceController.js b/src/controllers/spaceController.js
index 147cb6fd..aea36797 100644
--- a/src/controllers/spaceController.js
+++ b/src/controllers/spaceController.js
@@ -90,18 +90,12 @@ export const updateSpace = async (req, res) => {
if (req.body[key] !== undefined) updates[key] = req.body[key];
}
- const existingSpace = await Space.findById(id);
+ // Ownership is enforced by authorizeOwnership middleware (req.resource).
+ const existingSpace = req.resource || (await Space.findById(id));
if (!existingSpace) {
return res.status(404).json({ success: false, message: "Space not found" });
}
- if (req.user.role !== "admin" && existingSpace.host.toString() !== req.user._id.toString()) {
- return res.status(403).json({
- success: false,
- message: "Not authorized to update this space",
- });
- }
-
const space = await Space.findByIdAndUpdate(id, updates, {
new: true,
}).populate("host", "name email avatar");
@@ -158,18 +152,12 @@ export const getSpacesByHost = async (req, res) => {
export const deleteSpace = async (req, res) => {
try {
const { id } = req.params;
- const space = await Space.findById(id);
+ // Ownership is enforced by authorizeOwnership middleware (req.resource).
+ const space = req.resource || (await Space.findById(id));
if (!space) {
return res.status(404).json({ success: false, message: "Space not found" });
}
- if (req.user.role !== "admin" && space.host.toString() !== req.user._id.toString()) {
- return res.status(403).json({
- success: false,
- message: "Not authorized to delete this space",
- });
- }
-
await Space.findByIdAndDelete(id);
res.status(200).json({ success: true, message: "Space deleted" });
} catch (error) {
diff --git a/src/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/models/AuditLog.js b/src/models/AuditLog.js
index 96f25b13..de2c3773 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -42,6 +42,9 @@ export const AUDIT_ACTIONS = Object.freeze({
// Entitlements (access grants)
ENTITLEMENT_GRANT: "entitlement.grant",
+ // Authorization
+ AUTHZ_OWNERSHIP_DENIED: "authz.ownership.denied",
+
// Extension points — to be instrumented with issue #20 / #28
PAYOUT_BATCH_INITIATED: "payout.batch.initiated",
PAYOUT_BATCH_CONFIRMED: "payout.batch.confirmed",
diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js
index 56b3a833..eaa8a77a 100644
--- a/src/routes/books/bookRoutes.js
+++ b/src/routes/books/bookRoutes.js
@@ -20,6 +20,11 @@ import {
removeBookBookmark,
} from "../../controllers/books/bookmarkBookController.js";
import { protect } from "../../middlewares/authMiddleware.js";
+import {
+ authorizeOwnership,
+ authorizeReviewOwnership,
+} from "../../middlewares/authorize.js";
+import Book from "../../models/Book.js";
import {
cacheMiddleware,
invalidateCacheMiddleware,
@@ -84,6 +89,7 @@ router.get(
router.delete(
"/:id",
protect,
+ authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`, `${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.EDUCATORS}*`]),
deleteBook
);
@@ -98,36 +104,42 @@ router.post(
router.put(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
updateBookReview
);
router.patch(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
updateBookReview
);
router.put(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
updateBookReview
);
router.patch(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
updateBookReview
);
router.delete(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
deleteBookReview
);
router.delete(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Book }),
invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]),
deleteBookReview
);
diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js
index 57c46a1b..45fd2da4 100644
--- a/src/routes/courses/courseRoutes.js
+++ b/src/routes/courses/courseRoutes.js
@@ -23,6 +23,11 @@ import {
updateCourseProgress,
} from "../../controllers/analytics/analyticsController.js";
import { protect } from "../../middlewares/authMiddleware.js";
+import {
+ authorizeOwnership,
+ authorizeReviewOwnership,
+} from "../../middlewares/authorize.js";
+import Course from "../../models/Course.js";
import {
cacheMiddleware,
invalidateCacheMiddleware,
@@ -90,42 +95,49 @@ router.post(
router.put(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
updateCourseReview
);
router.patch(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
updateCourseReview
);
router.put(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
updateCourseReview
);
router.patch(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
updateCourseReview
);
router.delete(
"/:id/reviews",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
deleteCourseReview
);
router.delete(
"/:id/reviews/:reviewId",
protect,
+ authorizeReviewOwnership({ model: Course }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]),
deleteCourseReview
);
router.put(
"/:id",
protect,
+ authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }),
invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]),
updateCourse
);
diff --git a/src/routes/spaceRoutes.js b/src/routes/spaceRoutes.js
index 92f08b71..585cdf12 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 { authorizeOwnership } from "../middlewares/authorize.js";
+import Space from "../models/Space.js";
import upload from "../middlewares/upload.js";
import {
cacheMiddleware,
@@ -67,6 +69,7 @@ router.post(
router.put(
"/update/:id",
protect,
+ authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }),
invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`]),
updateSpace
);
@@ -75,6 +78,7 @@ router.put(
router.delete(
"/:id",
protect,
+ authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }),
invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`, `${CACHE_KEYS.EDUCATORS}*`]),
deleteSpace
);
diff --git a/test/authRoles.test.js b/test/authRoles.test.js
index d760c800..00df4a33 100644
--- a/test/authRoles.test.js
+++ b/test/authRoles.test.js
@@ -10,6 +10,8 @@ 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";
@@ -222,11 +224,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);
@@ -275,8 +282,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);
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);
+ });
+ });
+});
From 2895a69ff5f2cda50f6bf0636d455f86c97628a5 Mon Sep 17 00:00:00 2001
From: Samuel Ojetunde
Date: Mon, 17 Aug 2026 23:27:47 +0100
Subject: [PATCH 08/25] fix(security): stop logging OTP codes and verification
tokens in email bodies (#104)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The NODE_ENV === "test" branch of sendMail logged the full rendered email
body — including the password-reset OTP span and the verification link's
token query param — and pino's redact config cannot censor values baked
into interpolated strings, so the leak bypassed the app-wide redaction.
Remove the body from every log statement (log only recipient, subject, and
template id via structured fields), and give tests a sanctioned in-memory
outbox hook instead of log scraping. sendOtpEmail/sendVerificationEmail/
sendReceiptEmail now return the sendMail result so callers can capture it.
Closes #95
---
services/emails/sendMail.js | 51 ++++++++++++++++++++-----------
test/emailRoutes.test.js | 61 ++++++++++++++++++++++++++-----------
test/passwordReset.test.js | 50 +++++++++++++++++++++++++-----
3 files changed, 120 insertions(+), 42 deletions(-)
diff --git a/services/emails/sendMail.js b/services/emails/sendMail.js
index d068415e..0df55995 100644
--- a/services/emails/sendMail.js
+++ b/services/emails/sendMail.js
@@ -76,6 +76,12 @@ const primaryButton = (href, label) => `
`;
+// Test-only in-memory outbox. Populated ONLY when the email transport is
+// unavailable AND NODE_ENV === "test" (the CI/unit scenario) so tests can
+// assert on rendered bodies — including OTP codes and verification links —
+// without those secrets ever reaching a log stream. Never logged.
+export const testOutbox = [];
+
const sendMail = async ({
to,
subject,
@@ -85,25 +91,33 @@ const sendMail = async ({
cc,
bcc,
attachments,
+ template = "generic",
}) => {
const apiKey = process.env.SENDLIB_API_KEY || "";
const apiUrl = SENDLIB_API_URL();
const from = getFrom();
- // Security: never log the html body — it contains OTP codes / verification
- // tokens. Log recipient + subject only.
+ // Security: never log the html/text body — it contains OTP codes and
+ // verification tokens. Log only non-sensitive metadata (recipient, subject,
+ // template id) and route it through structured fields so pino redaction
+ // applies to any object it serializes.
if (!apiKey || !apiUrl) {
logger.warn(
+ { template },
"SENDLIB_API_KEY / SENDLIB_API_URL not set — email not sent"
);
- // Test env only (synthetic data): expose the body so tests can assert
- // OTP/verification behavior. NEVER logged in dev/prod — bodies carry secrets.
if (NODE_ENV() === "test") {
- logger.info(`[EMAIL LOG] To: ${to} | Subject: ${subject} | Body: ${html}`);
- } else {
- logger.info(`[EMAIL SKIPPED] To: ${to} | Subject: ${subject}`);
+ // Test hook: expose the rendered message so tests can inspect
+ // OTP/token behavior. This is an in-memory array, not a log.
+ const rendered = { template, to, subject, html, text };
+ testOutbox.push(rendered);
+ return rendered;
}
- return;
+ logger.info(
+ { template, to, subject },
+ "[EMAIL SKIPPED] email not sent (transport unconfigured)"
+ );
+ return null;
}
if (!from) {
logger.warn(
@@ -134,13 +148,13 @@ const sendMail = async ({
);
}
const messageId = payload.messageId || payload.id;
- logger.info(`Email sent to ${to}${messageId ? `: ${messageId}` : ""}`);
+ logger.info({ template, to, messageId }, "Email sent");
return payload;
} catch (error) {
- logger.error("Failed to send email:", error.message);
+ logger.error({ template, to, err: error.message }, "Failed to send email");
if (NODE_ENV() === "development") {
// Subject only — never log the body (contains OTP/token).
- logger.info(`[DEV FALLBACK] To: ${to} | Subject: ${subject}`);
+ logger.info({ template, to, subject }, "[DEV FALLBACK] email not sent");
return;
}
throw error;
@@ -149,7 +163,7 @@ const sendMail = async ({
export const sendOtpEmail = async (otp, email) => {
if (!email || !otp) throw new Error("Email and OTP are required");
- logger.info(`Sending OTP email to: ${email}`);
+ logger.info({ template: "otp", to: email }, "Sending OTP email");
const content = `
Password Reset
@@ -164,20 +178,21 @@ export const sendOtpEmail = async (otp, email) => {
`;
- await sendMail({
+ return sendMail({
to: email,
subject: "Your Password Reset Code — DeenBridge",
html: emailShell({
content,
preheader: "Use this code to reset your DeenBridge password.",
}),
+ template: "otp",
});
};
export const sendReceiptEmail = async (receipt) => {
if (!receipt.email || !receipt.txHash)
throw new Error("Receipt email and transaction hash are required");
- logger.info(`Sending receipt for ${receipt.txHash} to: ${receipt.email}`);
+ logger.info({ template: "receipt", to: receipt.email }, "Sending receipt email");
const content = `
Payment Receipt
@@ -200,20 +215,21 @@ export const sendReceiptEmail = async (receipt) => {
`;
- await sendMail({
+ return sendMail({
to: receipt.email,
subject: "Payment Receipt — DeenBridge",
html: emailShell({
content,
preheader: "Thank you for your contribution to DeenBridge.",
}),
+ template: "receipt",
});
};
export const sendVerificationEmail = async (email, token) => {
if (!email || !token) throw new Error("Email and token are required");
const link = `${FRONTEND_URL()}/verify-email?token=${token}`;
- logger.info(`Sending verification email to: ${email}`);
+ logger.info({ template: "verification", to: email }, "Sending verification email");
const content = `
Welcome to DeenBridge!
@@ -233,13 +249,14 @@ export const sendVerificationEmail = async (email, token) => {
`;
- await sendMail({
+ return sendMail({
to: email,
subject: "Verify your email — DeenBridge",
html: emailShell({
content,
preheader: "Click to verify your email and activate your DeenBridge account.",
}),
+ template: "verification",
});
};
diff --git a/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/passwordReset.test.js b/test/passwordReset.test.js
index cc425083..cb0d7acd 100644
--- a/test/passwordReset.test.js
+++ b/test/passwordReset.test.js
@@ -8,6 +8,7 @@ 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",
@@ -24,8 +25,10 @@ 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: "" });
@@ -107,6 +110,7 @@ describe("Password Reset Flow", () => {
beforeEach(() => {
usersStore = [];
sessionsStore = [];
+ testOutbox.length = 0;
if (loggerInfoSpy) loggerInfoSpy.mockClear();
});
@@ -115,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);
@@ -280,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("
Date: Mon, 17 Aug 2026 23:29:30 +0100
Subject: [PATCH 09/25] fix(transactions): prevent TTL index from deleting
confirmed purchases and donations (#103)
The Transaction collection used a blanket TTL index on expiresAt with a
schema default that stamped a 30-minute expiry on every row regardless of
status. Because confirm paths never cleared expiresAt, confirmed on-chain
purchases and donations were permanently reaped ~30 minutes after creation,
deleting the proof of payment and orphaning recorded earnings.
Scope the TTL index to status: "pending" via partialFilterExpression, make
the expiresAt default conditional on status, add a pre-save hook that clears
expiresAt for any terminal state, explicitly unset expiresAt on every
terminal transition (submit, donation, refund, dispute, cancel, job handler,
reconciliation promotion), and add an idempotent migration that rescues
legacy non-pending rows and rebuilds the index.
Closes #94
---
docs/transaction-lifecycle.md | 86 ++++++
src/controllers/stellar/donationController.js | 4 +
src/controllers/stellar/paymentController.js | 13 +-
src/controllers/stellar/refundController.js | 8 +-
src/jobs/handlers.js | 2 +
src/migrations/fixTtlTransactionExpiry.js | 82 ++++++
src/models/Transaction.js | 32 ++-
src/services/stellar/reconciliationService.js | 9 +
test/reconciliation.test.js | 3 +
test/stellarPaymentController.test.js | 3 +
test/ttlTransactionExpiry.test.js | 256 ++++++++++++++++++
11 files changed, 492 insertions(+), 6 deletions(-)
create mode 100644 docs/transaction-lifecycle.md
create mode 100644 src/migrations/fixTtlTransactionExpiry.js
create mode 100644 test/ttlTransactionExpiry.test.js
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/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js
index bfd81b57..88b0deb1 100644
--- a/src/controllers/stellar/donationController.js
+++ b/src/controllers/stellar/donationController.js
@@ -180,6 +180,7 @@ export const submitDonation = async (req, res) => {
);
} catch (validationError) {
donation.status = "failed";
+ donation.expiresAt = undefined;
donation.failureReason = `validation_failed: ${validationError.message}`;
await donation.save({ session });
await session.commitTransaction();
@@ -206,6 +207,7 @@ export const submitDonation = async (req, res) => {
result = await submitTransaction(signedXdr);
} catch (stellarError) {
donation.status = "failed";
+ donation.expiresAt = undefined;
donation.failureReason = stellarError.message;
await donation.save({ session });
await session.commitTransaction();
@@ -250,6 +252,7 @@ export const submitDonation = async (req, res) => {
});
}
donation.status = "failed";
+ donation.expiresAt = undefined;
donation.failureReason = `On-chain verification failed: ${verification.reason}`;
await donation.save({ session });
await session.commitTransaction();
@@ -271,6 +274,7 @@ export const submitDonation = async (req, res) => {
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",
diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js
index 21722ef7..fd229bb2 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -642,6 +642,7 @@ export const submitPayment = async (req, res) => {
);
} catch (validationError) {
transaction.status = "failed";
+ transaction.expiresAt = undefined;
transaction.failureReason = `validation_failed: ${validationError.message}`;
await transaction.save({ session });
await session.commitTransaction();
@@ -667,6 +668,7 @@ export const submitPayment = async (req, res) => {
result = await submitTransaction(signedXdr);
} catch (stellarError) {
transaction.status = "failed";
+ transaction.expiresAt = undefined;
transaction.failureReason = stellarError.message;
await transaction.save({ session });
await session.commitTransaction();
@@ -727,6 +729,7 @@ export const submitPayment = async (req, res) => {
});
}
transaction.status = "failed";
+ transaction.expiresAt = undefined;
transaction.failureReason = `On-chain verification failed: ${verification.reason}`;
await transaction.save({ session });
await session.commitTransaction();
@@ -761,6 +764,7 @@ export const submitPayment = async (req, res) => {
transaction.stellarLedger = result.ledger;
transaction.status = "confirmed";
transaction.confirmedAt = new Date();
+ transaction.expiresAt = undefined; // terminal state — never TTL-reapable
await transaction.save({ session });
paymentsConfirmed.inc({ type: "purchase" });
@@ -962,8 +966,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 }
);
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/jobs/handlers.js b/src/jobs/handlers.js
index c45d3fce..7699dcf9 100644
--- a/src/jobs/handlers.js
+++ b/src/jobs/handlers.js
@@ -48,6 +48,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,6 +56,7 @@ 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();
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/Transaction.js b/src/models/Transaction.js
index df21c1b1..1331df6b 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -147,15 +147,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/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/test/reconciliation.test.js b/test/reconciliation.test.js
index 02e5fa55..add02568 100644
--- a/test/reconciliation.test.js
+++ b/test/reconciliation.test.js
@@ -236,6 +236,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/stellarPaymentController.test.js b/test/stellarPaymentController.test.js
index 817aeef6..619fbe03 100644
--- a/test/stellarPaymentController.test.js
+++ b/test/stellarPaymentController.test.js
@@ -334,6 +334,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();
+ });
+ });
+});
From e7fb634a99795aae56905bfe9f311f507367b77d Mon Sep 17 00:00:00 2001
From: Alabi Ibrahim Abimbola
<139625252+abimbolaalabi@users.noreply.github.com>
Date: Tue, 18 Aug 2026 00:00:04 +0100
Subject: [PATCH 10/25] feat(security): implement educator verification
pipeline and content-creation gating (#92) (#102)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(security): implement educator verification pipeline and content-creation gating (#92)
- Add EducatorVerification model with legal state-machine transitions
(draft -> pending -> approved/rejected, resubmit from rejected)
- Add verifiedEducator durable flag on User, set atomically on approval
- Add AUDIT_ACTIONS: EDUCATOR_VERIFY_SUBMIT/_RESUBMIT/_APPROVE/_REJECT
with metadata allowlist entries in auditService
- requireVerifiedEducator middleware (403 for unverified, admin bypass)
- Applicant API: submit/resubmit app, get own app, signed doc URLs,
signed Cloudinary upload-signature for private credential uploads
- Admin review queue: list+filter pending, view signed docs,
approve/reject with notes (Mongo transaction for verifiedEducator grant)
- Gate all content-creation routes:
* POST /api/courses (courseRoutes.js)
* POST /api/books (bookRoutes.js)
* POST /api/spaces (spaceRoutes.js — live sessions per issue)
- Wire routes: /api/educator-verification + /api/admin/educator-verification
- Comprehensive test suite in test/educatorVerification.test.js
(state-machine, middleware gating, submit/resubmit, approve/reject,
content 403/2xx, both full lifecycles submit->pending->approve and
reject->resubmit->approve, signed URL security, admin-only gating,
audit log instrumentation)
Verification Results:
app.test.js: 22/22 PASS (CI boot + endpoint health)
auditLog.test.js: 21/21 PASS (append-only, redaction, admin gate)
Closes #92
* fix(ci): resolve educator verification pipeline test failures
- Remove redundant catchAsync double-wrap in educator-verification routes
(controllers are already pre-wrapped; the outer wrap called .catch() on
undefined, returning 500 for every new endpoint)
- Use MongoMemoryReplSet in educatorVerification.test.js so the approve/reject
MongoDB transaction can run (standalone MongoMemoryServer cannot)
- Use AuditLog.collection.deleteMany in test cleanup to bypass append-only
pre-hooks
- Return recordAudit's promise so callers can await durability; await it in
submitApplication and performReview to eliminate the fire-and-forget
audit-race in tests
- Fix testAuth.js password overwrite: destructure password out of the
override spread so the hashed value is not clobbered by plaintext
- Seed bookUpload test user as a verifiedEducator mentor so the new content
gate lets it through
---------
Co-authored-by: abimbolaalabi
---
app.js | 4 +
.../educatorVerificationAdminController.js | 247 ++++++
.../educatorVerificationController.js | 241 ++++++
src/middlewares/authMiddleware.js | 19 +
src/models/AuditLog.js | 6 +
src/models/EducatorVerification.js | 138 ++++
src/models/User.js | 4 +
.../admin/educatorVerificationAdminRoutes.js | 24 +
src/routes/books/bookRoutes.js | 3 +-
src/routes/courses/courseRoutes.js | 3 +-
src/routes/educatorVerificationRoutes.js | 19 +
src/routes/spaceRoutes.js | 3 +-
src/services/audit/auditService.js | 16 +-
test/bookUpload.test.js | 2 +
test/educatorVerification.test.js | 731 ++++++++++++++++++
test/helpers/testAuth.js | 8 +-
16 files changed, 1460 insertions(+), 8 deletions(-)
create mode 100644 src/controllers/admin/educatorVerificationAdminController.js
create mode 100644 src/controllers/educatorVerificationController.js
create mode 100644 src/models/EducatorVerification.js
create mode 100644 src/routes/admin/educatorVerificationAdminRoutes.js
create mode 100644 src/routes/educatorVerificationRoutes.js
create mode 100644 test/educatorVerification.test.js
diff --git a/app.js b/app.js
index 316d1e2c..f66f9fa8 100644
--- a/app.js
+++ b/app.js
@@ -56,6 +56,8 @@ import jobsRoutes from "./src/routes/jobsRoutes.js";
import wellKnownRoutes from "./src/routes/wellKnownRoutes.js";
import auditRoutes from "./src/routes/admin/auditRoutes.js";
import educatorRoutes from "./src/routes/educatorRoutes.js";
+import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js";
+import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
handleUncaughtException();
validateEnv();
@@ -194,6 +196,7 @@ 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/donation", generousLimiter, stellarDonationRoutes);
@@ -202,6 +205,7 @@ app.use("/api/notifications", generousLimiter, notificationRoutes);
// Admin — no rate limit
app.use("/admin/jobs", jobsRoutes);
app.use("/api/admin/audit", auditRoutes);
+app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes);
// ======================
// ERROR HANDLING
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/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/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js
index dc62e188..0bdd106c 100644
--- a/src/middlewares/authMiddleware.js
+++ b/src/middlewares/authMiddleware.js
@@ -68,5 +68,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/models/AuditLog.js b/src/models/AuditLog.js
index de2c3773..e6dd5ec6 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -49,6 +49,12 @@ export const AUDIT_ACTIONS = Object.freeze({
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",
});
const ACTION_VALUES = Object.values(AUDIT_ACTIONS);
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/User.js b/src/models/User.js
index de690c7e..0c7a9a1c 100644
--- a/src/models/User.js
+++ b/src/models/User.js
@@ -53,6 +53,10 @@ const userSchema = new mongoose.Schema(
type: Boolean,
default: false,
},
+ verifiedEducator: {
+ type: Boolean,
+ default: false,
+ },
lastLogin: {
type: Date,
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/books/bookRoutes.js b/src/routes/books/bookRoutes.js
index eaa8a77a..d4a4a5b5 100644
--- a/src/routes/books/bookRoutes.js
+++ b/src/routes/books/bookRoutes.js
@@ -19,7 +19,7 @@ import {
checkIfBookBookmarked,
removeBookBookmark,
} from "../../controllers/books/bookmarkBookController.js";
-import { protect } from "../../middlewares/authMiddleware.js";
+import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js";
import {
authorizeOwnership,
authorizeReviewOwnership,
@@ -43,6 +43,7 @@ const booksByAuthorCacheKey = (req) =>
router.post(
"/",
protect,
+ requireVerifiedEducator,
uploadBook.fields([
{ name: "thumbnail", maxCount: 1 },
{ name: "file", maxCount: 1 },
diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js
index 45fd2da4..56452584 100644
--- a/src/routes/courses/courseRoutes.js
+++ b/src/routes/courses/courseRoutes.js
@@ -22,7 +22,7 @@ import {
getCourseProgress,
updateCourseProgress,
} from "../../controllers/analytics/analyticsController.js";
-import { protect } from "../../middlewares/authMiddleware.js";
+import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js";
import {
authorizeOwnership,
authorizeReviewOwnership,
@@ -77,6 +77,7 @@ router.get(
router.post(
"/",
protect,
+ requireVerifiedEducator,
invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]),
createCourse
);
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/spaceRoutes.js b/src/routes/spaceRoutes.js
index 585cdf12..934530b1 100644
--- a/src/routes/spaceRoutes.js
+++ b/src/routes/spaceRoutes.js
@@ -1,5 +1,5 @@
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";
@@ -52,6 +52,7 @@ router.get(
router.post(
"/",
protect,
+ requireVerifiedEducator,
upload.fields([{ name: "thumbnail", maxCount: 1 }]),
invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.EDUCATORS}*`]),
createSpace
diff --git a/src/services/audit/auditService.js b/src/services/audit/auditService.js
index 4d5dca0c..6799a438 100644
--- a/src/services/audit/auditService.js
+++ b/src/services/audit/auditService.js
@@ -59,6 +59,14 @@ const METADATA_ALLOWLIST = new Set([
"newRole",
"changedBy",
+ // Educator verification (issue #92)
+ "verificationId",
+ "previousStatus",
+ "newStatus",
+ "reviewedBy",
+ "reviewNotes",
+ "documentCount",
+
// Generic error context
"reason",
"conflictUserId",
@@ -105,8 +113,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/test/bookUpload.test.js b/test/bookUpload.test.js
index a2d6ebcc..e059b184 100644
--- a/test/bookUpload.test.js
+++ b/test/bookUpload.test.js
@@ -41,6 +41,8 @@ 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;
diff --git a/test/educatorVerification.test.js b/test/educatorVerification.test.js
new file mode 100644
index 00000000..f8d65eff
--- /dev/null
+++ b/test/educatorVerification.test.js
@@ -0,0 +1,731 @@
+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" },
+ 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",
+ });
+ 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/helpers/testAuth.js b/test/helpers/testAuth.js
index 8507eea3..6bf1dbd5 100644
--- a/test/helpers/testAuth.js
+++ b/test/helpers/testAuth.js
@@ -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)
From 92f6b25bf05c3e6e89676ca674542c5af7d14fe7 Mon Sep 17 00:00:00 2001
From: Lspnjr1
Date: Tue, 18 Aug 2026 00:13:31 +0100
Subject: [PATCH 11/25] feat(auth): signed service-to-service authentication
for the AI service (#91) (#106)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Give the backend a real machine-to-machine auth channel for the AI
service (dnb-ai) — signed, scoped, rotatable keys instead of a single
static shared secret.
- add requireServiceAuth middleware (src/middlewares/serviceAuth.js):
HMAC-SHA256 over a canonical method/path/timestamp/body-digest string,
a ±300s replay window, constant-time signature comparison, per-key
scope enforcement, and req.service on success
- key store (src/config/serviceKeys.js): multiple active keys keyed by
kid for zero-downtime rotation; resilient env parsing, never throws
- mount a real internal route GET /api/internal/ai/whoami guarded by the
guard (scope ai:read-content), plus raw-body capture in app.js
- migrate /admin/jobs off raw !== to a length-guarded timingSafeEqual
- audit denials (service_auth.denied), require AI_SERVICE_KEYS in prod
(fail-fast), document the signing contract + rotation runbook
- cover the full accept/reject matrix in test/serviceAuth.test.js
Closes #91
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
---
.env.example | 7 +
app.js | 16 +-
docs/service-to-service-auth.md | 149 ++++++++++++++++++
src/config/serviceKeys.js | 106 +++++++++++++
src/config/validateEnv.js | 10 ++
src/middlewares/serviceAuth.js | 150 +++++++++++++++++++
src/models/AuditLog.js | 3 +
src/routes/internal/aiRoutes.js | 27 ++++
src/routes/jobsRoutes.js | 14 +-
src/services/audit/auditService.js | 5 +
test/serviceAuth.test.js | 233 +++++++++++++++++++++++++++++
11 files changed, 718 insertions(+), 2 deletions(-)
create mode 100644 docs/service-to-service-auth.md
create mode 100644 src/config/serviceKeys.js
create mode 100644 src/middlewares/serviceAuth.js
create mode 100644 src/routes/internal/aiRoutes.js
create mode 100644 test/serviceAuth.test.js
diff --git a/.env.example b/.env.example
index 21c578d2..f7afe7e6 100644
--- a/.env.example
+++ b/.env.example
@@ -131,3 +131,10 @@ 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}]
+
diff --git a/app.js b/app.js
index f66f9fa8..e27cde48 100644
--- a/app.js
+++ b/app.js
@@ -53,6 +53,7 @@ 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";
@@ -145,7 +146,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());
@@ -202,6 +213,9 @@ app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes);
app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
+// Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs
+app.use("/api/internal/ai", internalAiRoutes);
+
// Admin — no rate limit
app.use("/admin/jobs", jobsRoutes);
app.use("/api/admin/audit", auditRoutes);
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/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/validateEnv.js b/src/config/validateEnv.js
index a9459573..2b6bef9a 100644
--- a/src/config/validateEnv.js
+++ b/src/config/validateEnv.js
@@ -53,6 +53,9 @@ const optionalEnvVars = [
"SIGNING_KEY",
"INGESTION_WORKER_ENABLED",
"INGESTION_POLL_INTERVAL_MS",
+ // Service-to-service auth keys for the AI service (dnb-ai). Required in
+ // production (fail-fast below); optional in development/test.
+ "AI_SERVICE_KEYS",
];
export const validateEnv = () => {
@@ -88,6 +91,13 @@ 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");
+ }
+
if (missing.length > 0) {
logger.error(
`❌ Missing required environment variables: ${missing.join(", ")}`
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/models/AuditLog.js b/src/models/AuditLog.js
index e6dd5ec6..10c10246 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -45,6 +45,9 @@ export const AUDIT_ACTIONS = Object.freeze({
// 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",
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/services/audit/auditService.js b/src/services/audit/auditService.js
index 6799a438..72f53439 100644
--- a/src/services/audit/auditService.js
+++ b/src/services/audit/auditService.js
@@ -70,6 +70,11 @@ const METADATA_ALLOWLIST = new Set([
// Generic error context
"reason",
"conflictUserId",
+
+ // Service-to-service auth (dnb-ai)
+ "serviceId",
+ "kid",
+ "scope",
]);
/**
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
+ });
+});
From da78382dae396bee25faf3c5f605f601ae8e78ee Mon Sep 17 00:00:00 2001
From: Lspnjr1
Date: Tue, 18 Aug 2026 01:56:59 +0100
Subject: [PATCH 12/25] feat(webhooks): signed outbound webhook event system
(#45) (#107)
Add an outbound webhook/event system so external consumers can subscribe
to payment and enrollment lifecycle events over HMAC-signed HTTP
callbacks, with retries, dead-lettering, and redelivery.
- models: WebhookEndpoint (encrypted secret at rest, subscribed events,
auto-disable counters) and WebhookDelivery (all scheduling state in the
doc: status, attemptCount, nextAttemptAt indexed)
- webhookService.emitEvent: typed event catalog, per-event id for
consumer idempotency, strict payload allowlist (no secrets/emails/user
docs); persists a delivery per subscribed endpoint after the txn
commits, never blocks or fails the request path, no-ops when the DB is
unavailable
- signing: X-DeenBridge-Signature v1=hmac-sha256(secret, `${ts}.${body}`)
over the exact sent bytes; timing-safe verify + 5-min staleness window
- deliveryWorker: atomic findOneAndUpdate claim (no double-send),
exponential backoff + jitter, dead-letter after max attempts, endpoint
auto-disable after sustained failures
- management API (/api/webhooks, admin-gated): endpoint CRUD, rotate
secret, list deliveries, redeliver (atomic $set), and ping
- SSRF guard: https-only in prod, reject loopback/RFC-1918/link-local
- wire emitters into payment (initialized/confirmed/failed/expired),
enrollment, and wallet connect/disconnect; migrate /admin/jobs to a
timing-safe token compare
- docs/webhooks.md consumer verifier + full offline test suite
Closes #45
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
---
.env.example | 21 +
app.js | 4 +
docs/webhooks.md | 139 ++++
server.js | 17 +
src/config/validateEnv.js | 21 +
src/controllers/courses/courseController.js | 7 +
src/controllers/stellar/paymentController.js | 84 ++-
src/controllers/stellar/walletController.js | 12 +
src/controllers/webhookController.js | 308 ++++++++
src/models/AuditLog.js | 9 +
src/models/WebhookDelivery.js | 79 +++
src/models/WebhookEndpoint.js | 74 ++
src/routes/webhookRoutes.js | 42 ++
src/services/audit/auditService.js | 8 +
src/services/webhooks/deliveryWorker.js | 308 ++++++++
src/services/webhooks/signing.js | 83 +++
src/services/webhooks/urlGuard.js | 135 ++++
src/services/webhooks/webhookSecret.js | 69 ++
src/services/webhooks/webhookService.js | 166 +++++
test/webhooks.test.js | 697 +++++++++++++++++++
20 files changed, 2282 insertions(+), 1 deletion(-)
create mode 100644 docs/webhooks.md
create mode 100644 src/controllers/webhookController.js
create mode 100644 src/models/WebhookDelivery.js
create mode 100644 src/models/WebhookEndpoint.js
create mode 100644 src/routes/webhookRoutes.js
create mode 100644 src/services/webhooks/deliveryWorker.js
create mode 100644 src/services/webhooks/signing.js
create mode 100644 src/services/webhooks/urlGuard.js
create mode 100644 src/services/webhooks/webhookSecret.js
create mode 100644 src/services/webhooks/webhookService.js
create mode 100644 test/webhooks.test.js
diff --git a/.env.example b/.env.example
index f7afe7e6..4966808b 100644
--- a/.env.example
+++ b/.env.example
@@ -138,3 +138,24 @@ JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token
# "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/app.js b/app.js
index e27cde48..fe86e147 100644
--- a/app.js
+++ b/app.js
@@ -59,6 +59,7 @@ import auditRoutes from "./src/routes/admin/auditRoutes.js";
import educatorRoutes from "./src/routes/educatorRoutes.js";
import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js";
import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
+import webhookRoutes from "./src/routes/webhookRoutes.js";
handleUncaughtException();
validateEnv();
@@ -213,6 +214,9 @@ app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes);
app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
+// Outbound webhook management API (admin-gated)
+app.use("/api/webhooks", standardLimiter, webhookRoutes);
+
// Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs
app.use("/api/internal/ai", internalAiRoutes);
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/server.js b/server.js
index 4921eca2..56728d41 100644
--- a/server.js
+++ b/server.js
@@ -35,6 +35,19 @@ if (process.env.INGESTION_WORKER_ENABLED === "true") {
);
}
+// Start outbound webhook delivery worker if enabled
+let stopWebhookWorker;
+if (process.env.WEBHOOK_WORKER_ENABLED === "true") {
+ import("./src/services/webhooks/deliveryWorker.js").then(
+ ({ startDeliveryWorker, stopDeliveryWorker: stopFn }) => {
+ stopWebhookWorker = stopFn;
+ startDeliveryWorker().catch((err) =>
+ logger.error(err, "Webhook delivery worker startup failed")
+ );
+ }
+ );
+}
+
// Graceful shutdown
const gracefulShutdown = async (signal) => {
logger.info(`${signal} received. Starting graceful shutdown...`);
@@ -48,6 +61,10 @@ const gracefulShutdown = async (signal) => {
await stopIngestionWorker();
}
+ if (stopWebhookWorker) {
+ await stopWebhookWorker();
+ }
+
// Close Redis connection
await closeRedis();
diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js
index 2b6bef9a..1c8a4c86 100644
--- a/src/config/validateEnv.js
+++ b/src/config/validateEnv.js
@@ -53,6 +53,17 @@ 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",
@@ -98,6 +109,16 @@ export const validateEnv = () => {
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(", ")}`
diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js
index abcdea52..3571de6c 100644
--- a/src/controllers/courses/courseController.js
+++ b/src/controllers/courses/courseController.js
@@ -4,6 +4,7 @@ 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";
/**
* Create a new course
@@ -149,6 +150,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({
diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js
index fd229bb2..3e99b0dc 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -35,6 +35,7 @@ import {
} 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
@@ -541,6 +542,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,
@@ -650,6 +666,18 @@ export const submitPayment = async (req, res) => {
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",
@@ -676,6 +704,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",
@@ -753,6 +793,19 @@ export const submitPayment = async (req, res) => {
},
});
+ 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: result.hash,
+ 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",
@@ -816,11 +869,28 @@ export const submitPayment = async (req, res) => {
stellarLedger: result.ledger,
amount: transaction.amount,
itemType: transaction.itemType,
- itemId: transaction.itemId.toString(),
+ itemId: transaction.itemId?.toString(),
settlementMode: transaction.settlement,
},
});
+ // 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: result.hash,
+ stellarLedger: result.ledger,
+ buyerId: buyerId.toString(),
+ creatorId: transaction.creator?.toString(),
+ status: "confirmed",
+ });
+
res.status(200).json({
success: true,
message: "Payment successful!",
@@ -1002,6 +1072,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",
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/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/models/AuditLog.js b/src/models/AuditLog.js
index 10c10246..45501776 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -58,6 +58,15 @@ export const AUDIT_ACTIONS = Object.freeze({
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/WebhookDelivery.js b/src/models/WebhookDelivery.js
new file mode 100644
index 00000000..e307e22b
--- /dev/null
+++ b/src/models/WebhookDelivery.js
@@ -0,0 +1,79 @@
+// models/WebhookDelivery.js
+//
+// One row per (event, subscribed endpoint). ALL scheduling state lives in the
+// document (status, attemptCount, nextAttemptAt) rather than in worker memory,
+// so the delivery loop can later be swapped onto the durable job queue (issue
+// #32) without a schema change. `nextAttemptAt` is indexed for the claim query.
+import mongoose from "mongoose";
+
+// Bound the stored attempt history and per-attempt error text so a flapping
+// consumer can't grow a document unbounded.
+export const MAX_STORED_ATTEMPTS = 20;
+export const MAX_ERROR_LENGTH = 500;
+
+const attemptSchema = new mongoose.Schema(
+ {
+ at: { type: Date, default: Date.now },
+ statusCode: Number,
+ error: String,
+ durationMs: Number,
+ },
+ { _id: false }
+);
+
+const webhookDeliverySchema = new mongoose.Schema(
+ {
+ endpoint: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "WebhookEndpoint",
+ required: true,
+ index: true,
+ },
+ // Stable per-event id used by consumers for idempotency. Shared across the
+ // fan-out of one event to multiple endpoints.
+ eventId: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ eventType: {
+ type: String,
+ required: true,
+ },
+ // Frozen event envelope ({ eventId, type, createdAt, apiVersion, data }).
+ // Serialized ONCE at delivery time so the signed bytes match the sent body.
+ payload: {
+ type: mongoose.Schema.Types.Mixed,
+ required: true,
+ },
+ attempts: {
+ type: [attemptSchema],
+ default: [],
+ },
+ status: {
+ type: String,
+ enum: ["pending", "retrying", "delivered", "dead"],
+ default: "pending",
+ index: true,
+ },
+ attemptCount: {
+ type: Number,
+ default: 0,
+ },
+ // When the delivery becomes eligible for its next attempt. Indexed and
+ // used by the atomic claim query.
+ nextAttemptAt: {
+ type: Date,
+ default: Date.now,
+ index: true,
+ },
+ deliveredAt: Date,
+ lastError: String,
+ },
+ { timestamps: true }
+);
+
+// Compound index backing the worker's claim query.
+webhookDeliverySchema.index({ status: 1, nextAttemptAt: 1 });
+
+export default mongoose.model("WebhookDelivery", webhookDeliverySchema);
diff --git a/src/models/WebhookEndpoint.js b/src/models/WebhookEndpoint.js
new file mode 100644
index 00000000..9a9b0a57
--- /dev/null
+++ b/src/models/WebhookEndpoint.js
@@ -0,0 +1,74 @@
+// models/WebhookEndpoint.js
+//
+// A registered outbound webhook subscription. The signing `secret` is stored
+// ENCRYPTED at rest (AES-256-GCM via services/webhooks/webhookSecret.js) — the
+// delivery worker must recover the plaintext to sign each request, so a
+// one-way hash cannot be used. The encrypted field is `select:false` so it is
+// never returned by an accidental find(); read endpoints strip it explicitly.
+import mongoose from "mongoose";
+
+const webhookEndpointSchema = new mongoose.Schema(
+ {
+ // Destination URL. Validated (https-only outside development, no private
+ // targets) by services/webhooks/urlGuard.js at registration and delivery.
+ url: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ // AES-256-GCM ciphertext (`iv:authTag:ciphertext`, hex). Never selected by
+ // default; never returned by the API after creation/rotation.
+ secretEncrypted: {
+ type: String,
+ required: true,
+ select: false,
+ },
+ // Subscribed event types. `["*"]` subscribes to everything.
+ events: {
+ type: [String],
+ default: ["*"],
+ },
+ isActive: {
+ type: Boolean,
+ default: true,
+ index: true,
+ },
+ description: {
+ type: String,
+ trim: true,
+ maxlength: 500,
+ },
+ // Count of CONSECUTIVE dead deliveries. Reset to 0 on any successful
+ // delivery. When it reaches the auto-disable threshold the endpoint is
+ // deactivated by the delivery worker.
+ consecutiveFailures: {
+ type: Number,
+ default: 0,
+ },
+ lastDeliveryAt: Date,
+ lastSuccessAt: Date,
+ disabledAt: Date,
+ disabledReason: String,
+ // The admin who registered the endpoint.
+ owner: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ index: true,
+ },
+ },
+ { timestamps: true }
+);
+
+webhookEndpointSchema.index({ owner: 1, createdAt: -1 });
+
+// Defense in depth: never leak the encrypted secret through toJSON/toObject,
+// even if a caller forgot to `.select("-secretEncrypted")`.
+const stripSecret = (_doc, ret) => {
+ delete ret.secretEncrypted;
+ return ret;
+};
+webhookEndpointSchema.set("toJSON", { transform: stripSecret });
+webhookEndpointSchema.set("toObject", { transform: stripSecret });
+
+export default mongoose.model("WebhookEndpoint", webhookEndpointSchema);
diff --git a/src/routes/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/services/audit/auditService.js b/src/services/audit/auditService.js
index 72f53439..ac0e918b 100644
--- a/src/services/audit/auditService.js
+++ b/src/services/audit/auditService.js
@@ -75,6 +75,14 @@ const METADATA_ALLOWLIST = new Set([
"serviceId",
"kid",
"scope",
+
+ // Outbound webhooks (issue #45)
+ "endpointId",
+ "deliveryId",
+ "eventType",
+ "url",
+ "events",
+ "disabledReason",
]);
/**
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/test/webhooks.test.js b/test/webhooks.test.js
new file mode 100644
index 00000000..13f071de
--- /dev/null
+++ b/test/webhooks.test.js
@@ -0,0 +1,697 @@
+// 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(),
+ submitTransaction,
+ verifyTransaction: jest.fn(),
+ verifyPaymentOperations,
+ validateSignedPaymentXdr,
+ findPaymentPaths: jest.fn(),
+ applySlippage: jest.fn(),
+ NETWORK: "testnet",
+ getExplorerUrl,
+ USDC: "USDC",
+ PLATFORM_WALLET_PUBLIC_KEY: "",
+}));
+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,
+ });
+
+const tokenFor = (user) =>
+ jwt.sign({ userId: user._id, role: user.role, sessionId: "s1" }, 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");
+ });
+});
From 413affff4d28182e64b133e1ed9202fe08561d71 Mon Sep 17 00:00:00 2001
From: Alhassan Nuhu Idris
Date: Tue, 18 Aug 2026 09:55:42 +0100
Subject: [PATCH 13/25] =?UTF-8?q?feat(security):=20implement=20TOTP=20two-?=
=?UTF-8?q?factor=20authentication=20for=20admins=20a=E2=80=A6=20(#98)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(stellar): publish Soroban giving-escrow contract id in stellar.toml
Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed
On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set
GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage.
* fix(stellar): resolve Horizon endpoints lazily + network-aware default
Horizon client was constructed at import time with a hardcoded testnet
fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to
testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client
is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins.
* feat(auth): authenticated change-password endpoint
PUT /api/auth/change-password (protected): verifies current password,
enforces the password policy, updates the hash, and signs out all other
sessions. Adds the auth.password_change audit action.
* feat(security): implement TOTP two-factor authentication for admins and mentors
- Add TOTP (RFC 6238) lifecycle: secret generation, encrypted storage at rest (AES-256-GCM), otpauth:// URI and QR code generation
- Add 10 single-use bcrypt-hashed recovery codes
- Add two-factor rate-limiting middleware (5 verification attempts per 15-minute window)
- Update login controller to issue step-up mfaToken challenges when 2FA is enabled
- Update authorizeRoles('admin') middleware to enforce 2FA-enabled status and 2FA-verified sessions
- Log audit actions for all 2FA lifecycle events (setup, enable, login challenge, success, failure, disable, recovery code usage)
- Add comprehensive test suite in test/auth2FA.test.js and update existing auth test suites
* ci: update node version to 22 and sync package-lock.json
* ci: pin mongo service to 6.0 and add wait-for-mongodb step
* test: add 2FA enablement and 2FA verified token to admin in refund.test.js
* fix(auth): switch bcrypt imports to pure-JS bcryptjs for cross-platform compatibility
* fix(test): remove duplicate MongoMemoryServer import in refund.test.js
* fix(test): add errorHandler middleware to refund.test.js app
* fix(test): clean up MongoDB connection logic and add afterAll hook in refund.test.js
* fix(test): standardize Mongoose connection lifecycle and add missing afterAll hooks
* fix(test): remove duplicate afterAll hooks and handle Multer 413 response status
* test(refund): explicitly set 2FA enablement and 2FA verified tokens for admin in refund.test.js
* test: ensure admin test helpers include 2FA enablement and 2FA verified JWT claims
* fix(test): add 2FA enablement and 2FA verified tokens for admin in webhooks and educatorVerification tests
---------
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
---
.github/workflows/ci.yml | 30 +-
package-lock.json | 211 +++++++++++-
package.json | 2 +
src/controllers/authController.js | 344 ++++++++++++++++++-
src/controllers/uploadController.js | 4 +-
src/middlewares/authMiddleware.js | 39 +++
src/middlewares/security.js | 18 +
src/models/AuditLog.js | 11 +
src/models/Session.js | 4 +
src/models/User.js | 21 ++
src/routes/authRoutes.js | 16 +
src/services/audit/auditService.js | 5 +
src/utils/otp.js | 2 +-
src/utils/twoFactorCrypto.js | 167 ++++++++++
test/app.test.js | 7 +-
test/auditLog.test.js | 13 +-
test/auth2FA.test.js | 495 ++++++++++++++++++++++++++++
test/authRoles.test.js | 215 ++++++++++--
test/authSecurity.test.js | 2 +-
test/bookUpload.test.js | 39 ++-
test/breachedPassword.test.js | 2 +-
test/courseProgress.test.js | 24 +-
test/educatorVerification.test.js | 8 +-
test/educators.test.js | 9 +-
test/helpers/testAuth.js | 2 +-
test/idempotency.test.js | 13 +
test/notification.test.js | 9 +
test/passwordReset.test.js | 2 +-
test/reconciliation.test.js | 9 +
test/refund.test.js | 43 ++-
test/reviews.test.js | 16 +-
test/search.test.js | 9 +-
test/upload.test.js | 16 +-
test/webhooks.test.js | 16 +-
34 files changed, 1719 insertions(+), 104 deletions(-)
create mode 100644 src/utils/twoFactorCrypto.js
create mode 100644 test/auth2FA.test.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 09b7f9b0..7c1287d3 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:
@@ -48,7 +59,7 @@ jobs:
services:
mongodb:
- image: mongo:7
+ image: mongo:6.0
ports:
- 27017:27017
@@ -59,12 +70,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: Check syntax of all source files
run: |
find . -name "*.js" -not -path "./node_modules/*" -print0 \
diff --git a/package-lock.json b/package-lock.json
index 99f5109b..1678f6ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,10 +31,12 @@
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"multer-storage-cloudinary": "^4.0.0",
+ "otplib": "^13.4.1",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"prom-client": "^15.1.3",
+ "qrcode": "^1.5.4",
"redis": "^4.7.1",
"safe-stable-stringify": "^2.5.0",
"socket.io": "^4.8.1",
@@ -1082,6 +1084,62 @@
"node": ">=8.0.0"
}
},
+ "node_modules/@otplib/core": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
+ "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==",
+ "license": "MIT"
+ },
+ "node_modules/@otplib/hotp": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
+ "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.1",
+ "@otplib/uri": "13.4.1"
+ }
+ },
+ "node_modules/@otplib/plugin-base32-scure": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
+ "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.1",
+ "@scure/base": "^2.2.0"
+ }
+ },
+ "node_modules/@otplib/plugin-crypto-noble": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
+ "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^2.2.0",
+ "@otplib/core": "13.4.1"
+ }
+ },
+ "node_modules/@otplib/totp": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
+ "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.1",
+ "@otplib/hotp": "13.4.1",
+ "@otplib/uri": "13.4.1"
+ }
+ },
+ "node_modules/@otplib/uri": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
+ "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.1"
+ }
+ },
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -1176,6 +1234,15 @@
"@redis/client": "^1.0.0"
}
},
+ "node_modules/@scure/base": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz",
+ "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.12",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz",
@@ -1560,7 +1627,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2271,7 +2337,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2478,7 +2543,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -2491,7 +2555,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/color-string": {
@@ -2824,6 +2887,15 @@
"ms": "2.0.0"
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -2913,6 +2985,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -3581,7 +3659,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -5209,7 +5286,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -6315,6 +6391,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/otplib": {
+ "version": "13.4.1",
+ "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
+ "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.1",
+ "@otplib/hotp": "13.4.1",
+ "@otplib/plugin-base32-scure": "13.4.1",
+ "@otplib/plugin-crypto-noble": "13.4.1",
+ "@otplib/totp": "13.4.1",
+ "@otplib/uri": "13.4.1"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -6335,7 +6425,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
@@ -6348,7 +6437,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -6364,7 +6452,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6402,7 +6489,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6579,6 +6665,15 @@
"node": ">=8"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -6732,6 +6827,89 @@
"teleport": ">=0.2.0"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -6853,12 +7031,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -8171,6 +8354,12 @@
"node": ">= 8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
diff --git a/package.json b/package.json
index 428306d6..6ed4bbf8 100644
--- a/package.json
+++ b/package.json
@@ -39,10 +39,12 @@
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.2",
"multer-storage-cloudinary": "^4.0.0",
+ "otplib": "^13.4.1",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"prom-client": "^15.1.3",
+ "qrcode": "^1.5.4",
"redis": "^4.7.1",
"safe-stable-stringify": "^2.5.0",
"socket.io": "^4.8.1",
diff --git a/src/controllers/authController.js b/src/controllers/authController.js
index 77c57927..b0753281 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";
@@ -15,6 +15,16 @@ 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";
// Never fall back to a hardcoded default — that would sign tokens with a known
// value. validateEnv() also enforces this at boot; refuse to start if missing.
@@ -105,7 +115,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");
@@ -124,11 +135,12 @@ 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 },
+ { userId: user._id, role: user.role, sessionId: session._id, is2FAVerified },
JWT_SECRET,
{ expiresIn: accessTokenTtl }
);
@@ -482,16 +494,48 @@ 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" },
+ JWT_SECRET,
+ { 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 and clear any lockout state (success is the reset).
@@ -502,7 +546,7 @@ export const loginUser = catchAsync(async (req, res, next) => {
}
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})`);
@@ -716,6 +760,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");
@@ -733,6 +778,7 @@ export const refreshSession = catchAsync(async (req, res, next) => {
label: getDeviceLabel(req.headers["user-agent"]),
},
expiresAt: newExpiresAt,
+ is2FAVerified,
});
session.revokedAt = new Date();
@@ -742,7 +788,7 @@ 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 },
+ { userId: session.user._id, role: session.user.role, sessionId: newSession._id, is2FAVerified },
JWT_SECRET,
{ expiresIn: accessTokenTtl }
);
@@ -945,3 +991,283 @@ 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, JWT_SECRET);
+ } 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/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/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js
index 0bdd106c..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
diff --git a/src/middlewares/security.js b/src/middlewares/security.js
index 438bf3c2..6dc9fd36 100644
--- a/src/middlewares/security.js
+++ b/src/middlewares/security.js
@@ -175,6 +175,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
diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js
index 45501776..7884f235 100644
--- a/src/models/AuditLog.js
+++ b/src/models/AuditLog.js
@@ -27,6 +27,17 @@ export const AUDIT_ACTIONS = Object.freeze({
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",
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/User.js b/src/models/User.js
index 0c7a9a1c..db15358b 100644
--- a/src/models/User.js
+++ b/src/models/User.js
@@ -78,6 +78,27 @@ const userSchema = new mongoose.Schema(
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/routes/authRoutes.js b/src/routes/authRoutes.js
index 2189b289..0bbe448b 100644
--- a/src/routes/authRoutes.js
+++ b/src/routes/authRoutes.js
@@ -13,6 +13,9 @@ import {
changePassword,
verifyEmail,
resendVerification,
+ setup2FA,
+ verify2FA,
+ disable2FA,
} from "../controllers/authController.js";
import {
getStellarChallenge,
@@ -21,6 +24,7 @@ import {
import { protect } from "../middlewares/authMiddleware.js";
import {
refreshLimiter,
+ twoFactorLimiter,
emailAuthLimiter,
captchaGate,
} from "../middlewares/security.js";
@@ -43,6 +47,18 @@ router.post(
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.
router.get("/stellar/challenge", getStellarChallenge);
diff --git a/src/services/audit/auditService.js b/src/services/audit/auditService.js
index ac0e918b..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",
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/test/app.test.js b/test/app.test.js
index acc40e2f..f05d4100 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
diff --git a/test/auditLog.test.js b/test/auditLog.test.js
index 6b87a8ae..ea3ecc32 100644
--- a/test/auditLog.test.js
+++ b/test/auditLog.test.js
@@ -27,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,
};
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 00df4a33..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";
@@ -16,29 +15,199 @@ 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", () => {
@@ -73,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"),
@@ -193,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({
@@ -267,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);
@@ -344,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
index e79c2f54..e1d3b6ea 100644
--- a/test/authSecurity.test.js
+++ b/test/authSecurity.test.js
@@ -10,7 +10,7 @@
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 User from "../src/models/User.js";
import PendingUser from "../src/models/PendingUser.js";
diff --git a/test/bookUpload.test.js b/test/bookUpload.test.js
index e059b184..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) => {
@@ -48,15 +60,6 @@ describe("Media Upload Hardening", () => {
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)
@@ -70,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 () => {
@@ -151,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
index 51797eb9..949f2b4f 100644
--- a/test/breachedPassword.test.js
+++ b/test/breachedPassword.test.js
@@ -8,7 +8,7 @@
import { jest } from "@jest/globals";
import request from "supertest";
import mongoose from "mongoose";
-import bcrypt from "bcrypt";
+import bcrypt from "bcryptjs";
import crypto from "crypto";
import axios from "axios";
import app from "../app.js";
diff --git a/test/courseProgress.test.js b/test/courseProgress.test.js
index dcd1cdec..5ee34d4a 100644
--- a/test/courseProgress.test.js
+++ b/test/courseProgress.test.js
@@ -15,6 +15,15 @@ describe("Course progress endpoints", () => {
let learner;
beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ if (process.env.MONGO_URI) {
+ try {
+ await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 });
+ return;
+ } catch (_err) {}
+ }
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
@@ -43,11 +52,6 @@ describe("Course progress endpoints", () => {
learnerToken = jwt.sign({ userId: learner._id, sessionId: "l1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024");
});
- afterAll(async () => {
- await mongoose.disconnect();
- await mongoServer.stop();
- });
-
it("creates progress for a learner and computes percent completion idempotently", async () => {
const course = await Course.create({
title: "Course 1",
@@ -113,4 +117,14 @@ describe("Course progress endpoints", () => {
expect(res.body.courses).toHaveLength(1);
expect(res.body.courses[0].percentComplete).toBe(50);
});
+
+ afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
});
+
diff --git a/test/educatorVerification.test.js b/test/educatorVerification.test.js
index f8d65eff..5f14ea57 100644
--- a/test/educatorVerification.test.js
+++ b/test/educatorVerification.test.js
@@ -19,7 +19,12 @@ 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" },
+ {
+ userId: user._id.toString(),
+ role: user.role,
+ sessionId: "sess-test",
+ is2FAVerified: user.role === "admin" ? true : false,
+ },
JWT_SECRET,
{ expiresIn: "15m" }
);
@@ -74,6 +79,7 @@ describe("Issue #92 — Educator Verification Pipeline + Content Gating", () =>
email: "admin@example.com",
password: "Qx7#vLmp92Zt",
role: "admin",
+ twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() },
});
return { student, mentor, verifiedEducator, admin };
};
diff --git a/test/educators.test.js b/test/educators.test.js
index a24ff5d5..5ef82dae 100644
--- a/test/educators.test.js
+++ b/test/educators.test.js
@@ -15,9 +15,12 @@ let adminId;
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}_educators`);
+ await mongoose.connect(`${process.env.MONGO_URI}_educators`, { serverSelectionTimeoutMS: 2000 });
return;
} catch (_err) {}
}
@@ -27,7 +30,7 @@ beforeAll(async () => {
afterAll(async () => {
if (mongoose.connection.readyState !== 0) {
- await mongoose.connection.close();
+ await mongoose.disconnect();
}
if (mongoServer) {
await mongoServer.stop();
diff --git a/test/helpers/testAuth.js b/test/helpers/testAuth.js
index 6bf1dbd5..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
diff --git a/test/idempotency.test.js b/test/idempotency.test.js
index 227f0f4f..35ad1a86 100644
--- a/test/idempotency.test.js
+++ b/test/idempotency.test.js
@@ -73,6 +73,9 @@ describe("Request-Level Idempotency Layer (#93)", () => {
let mockConcurrencyHandler;
beforeAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
process.env.DONATION_WALLET_PUBLIC_KEY =
"GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K";
@@ -283,4 +286,14 @@ describe("Request-Level Idempotency Layer (#93)", () => {
);
expect(ttlIndex).toBeDefined();
});
+
+ afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.disconnect();
+ }
+ if (mongoServer) {
+ await mongoServer.stop();
+ }
+ });
});
+
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/passwordReset.test.js b/test/passwordReset.test.js
index cb0d7acd..944b9013 100644
--- a/test/passwordReset.test.js
+++ b/test/passwordReset.test.js
@@ -1,7 +1,7 @@
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";
diff --git a/test/reconciliation.test.js b/test/reconciliation.test.js
index add02568..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);
diff --git a/test/refund.test.js b/test/refund.test.js
index 38bcae49..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,21 +17,21 @@ 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" });
};
-import { MongoMemoryServer } from "mongodb-memory-server";
-
describe("Non-Custodial Refund & Dispute Flow (#62)", () => {
let buyer, educator, otherUser, adminUser;
let buyerWallet;
@@ -43,8 +44,17 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => {
if (mongoose.connection.readyState !== 0) {
await mongoose.disconnect();
}
- mongoServer = await MongoMemoryServer.create();
- await mongoose.connect(mongoServer.getUri());
+ 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
jest.spyOn(server, "loadAccount").mockImplementation(async (publicKey) => {
@@ -91,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 () => {
@@ -135,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({
@@ -384,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/reviews.test.js b/test/reviews.test.js
index aa3780b9..aeccd396 100644
--- a/test/reviews.test.js
+++ b/test/reviews.test.js
@@ -9,8 +9,8 @@ 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";
@@ -22,9 +22,12 @@ describe("Reviews & Ratings API (Course and Book)", () => {
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}_reviews`);
+ await mongoose.connect(`${process.env.MONGO_URI}_reviews`, { serverSelectionTimeoutMS: 2000 });
return;
} catch (_err) {}
}
@@ -34,7 +37,7 @@ describe("Reviews & Ratings API (Course and Book)", () => {
afterAll(async () => {
if (mongoose.connection.readyState !== 0) {
- await mongoose.connection.close();
+ await mongoose.disconnect();
}
if (mongoServer) {
await mongoServer.stop();
@@ -89,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 c60f3470..e87783b1 100644
--- a/test/search.test.js
+++ b/test/search.test.js
@@ -12,9 +12,12 @@ import { MongoMemoryServer } from "mongodb-memory-server";
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}_search`);
+ await mongoose.connect(`${process.env.MONGO_URI}_search`, { serverSelectionTimeoutMS: 2000 });
return;
} catch (_err) {}
}
@@ -24,7 +27,7 @@ beforeAll(async () => {
afterAll(async () => {
if (mongoose.connection.readyState !== 0) {
- await mongoose.connection.close();
+ await mongoose.disconnect();
}
if (mongoServer) {
await mongoServer.stop();
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
index 13f071de..3592c6a9 100644
--- a/test/webhooks.test.js
+++ b/test/webhooks.test.js
@@ -547,12 +547,22 @@ const makeUser = async (role) =>
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" }, JWT_SECRET, {
- expiresIn: "15m",
- });
+ 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;
From 9aa2629617c758824aa608906c8b8a1f91ebc1ec Mon Sep 17 00:00:00 2001
From: Mantissa
Date: Tue, 18 Aug 2026 20:13:17 +0100
Subject: [PATCH 14/25] Add scholarship escrow contract foundation (#108)
---
.github/workflows/contracts.yml | 37 +
contracts/.gitignore | 2 +
contracts/Cargo.lock | 2121 +++++++++++++++++++++++
contracts/Cargo.toml | 3 +
contracts/README.md | 60 +
contracts/scholarship_escrow/Cargo.toml | 14 +
contracts/scholarship_escrow/src/lib.rs | 829 +++++++++
docs/soroban-escrow-design.md | 171 ++
8 files changed, 3237 insertions(+)
create mode 100644 .github/workflows/contracts.yml
create mode 100644 contracts/.gitignore
create mode 100644 contracts/Cargo.lock
create mode 100644 contracts/Cargo.toml
create mode 100644 contracts/README.md
create mode 100644 contracts/scholarship_escrow/Cargo.toml
create mode 100644 contracts/scholarship_escrow/src/lib.rs
create mode 100644 docs/soroban-escrow-design.md
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/contracts/.gitignore b/contracts/.gitignore
new file mode 100644
index 00000000..2d67a90c
--- /dev/null
+++ b/contracts/.gitignore
@@ -0,0 +1,2 @@
+target/
+test_snapshots/
diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock
new file mode 100644
index 00000000..a30dd318
--- /dev/null
+++ b/contracts/Cargo.lock
@@ -0,0 +1,2121 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "ark-bls12-381"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5"
+dependencies = [
+ "ark-ec",
+ "ark-ff",
+ "ark-serialize",
+ "ark-std",
+]
+
+[[package]]
+name = "ark-bn254"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc"
+dependencies = [
+ "ark-ec",
+ "ark-ff",
+ "ark-std",
+]
+
+[[package]]
+name = "ark-ec"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce"
+dependencies = [
+ "ahash",
+ "ark-ff",
+ "ark-poly",
+ "ark-serialize",
+ "ark-std",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
+ "itertools",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ff"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70"
+dependencies = [
+ "ark-ff-asm",
+ "ark-ff-macros",
+ "ark-serialize",
+ "ark-std",
+ "arrayvec",
+ "digest 0.10.7",
+ "educe",
+ "itertools",
+ "num-bigint",
+ "num-traits",
+ "paste",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ff-asm"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-ff-macros"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-poly"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27"
+dependencies = [
+ "ahash",
+ "ark-ff",
+ "ark-serialize",
+ "ark-std",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
+dependencies = [
+ "ark-serialize-derive",
+ "ark-std",
+ "arrayvec",
+ "digest 0.10.7",
+ "num-bigint",
+]
+
+[[package]]
+name = "ark-serialize-derive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
+dependencies = [
+ "num-traits",
+ "rand",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes-lit"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b04f2b1d34cb428043f14aa4c853d14294532e8bbde3b6a3bc2faaaae31a1dd"
+dependencies = [
+ "num-bigint",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_eval"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crate-git-revision"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "crate-git-revision"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54851b5b3f24621804b1cded2820975623c205e3055d2d44031cdb1237339ac8"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctor"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto 0.2.9",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "curve25519-dalek-derive",
+ "digest 0.11.3",
+ "fiat-crypto 0.3.0",
+ "rustc_version",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.20",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid",
+ "crypto-common 0.1.6",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dtor"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek 4.1.3",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "educe"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417"
+dependencies = [
+ "enum-ordinalize",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "enum-ordinalize"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
+dependencies = [
+ "enum-ordinalize-derive",
+]
+
+[[package]]
+name = "enum-ordinalize-derive"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "escape-bytes"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2"
+
+[[package]]
+name = "ethnum"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "hex-literal"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "indexmap-nostd"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590"
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "sha2",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "macro-string"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scholarship-escrow"
+version = "0.1.0"
+dependencies = [
+ "soroban-sdk",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
+dependencies = [
+ "base64",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "jiff",
+ "schemars 0.8.22",
+ "schemars 0.9.0",
+ "schemars 1.2.2",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
+dependencies = [
+ "darling 0.23.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest 0.10.7",
+ "keccak",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "soroban-builtin-sdk-macros"
+version = "27.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b77bc93d930032c487cb1506b6ed166b2af49db76d52678ec4887ac621ecce01"
+dependencies = [
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "soroban-env-common"
+version = "27.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b22e9981cdd444f3aa6734bc58d76195bf7eca3ccf1dd432b875af5d02da068"
+dependencies = [
+ "arbitrary",
+ "crate-git-revision 0.0.6",
+ "ethnum",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "soroban-env-macros",
+ "soroban-wasmi",
+ "static_assertions",
+ "stellar-xdr",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-env-guest"
+version = "27.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6072f99ca6bf8e8d5b04e05d083dac785e5357d9c0f36a6658f819c2fd7d67"
+dependencies = [
+ "soroban-env-common",
+ "static_assertions",
+]
+
+[[package]]
+name = "soroban-env-host"
+version = "27.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c06afd7c75ce150ce53e4d77a77645b18e3fb61856a0ddc42bfcecdc39fa3b9"
+dependencies = [
+ "ark-bls12-381",
+ "ark-bn254",
+ "ark-ec",
+ "ark-ff",
+ "ark-serialize",
+ "curve25519-dalek 5.0.0",
+ "ecdsa",
+ "ed25519-dalek",
+ "elliptic-curve",
+ "generic-array",
+ "getrandom",
+ "hex-literal",
+ "hmac",
+ "k256",
+ "num-derive",
+ "num-integer",
+ "num-traits",
+ "p256",
+ "rand",
+ "rand_chacha",
+ "sec1",
+ "sha2",
+ "sha3",
+ "soroban-builtin-sdk-macros",
+ "soroban-env-common",
+ "soroban-wasmi",
+ "static_assertions",
+ "stellar-strkey 0.0.13",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-env-macros"
+version = "27.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "647811bdd28a3ec40296987f6635781e5e1141c8f5affbbd53ba12b6295b7bb6"
+dependencies = [
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "stellar-xdr",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "soroban-ledger-snapshot"
+version = "27.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b59883d8bd0d1aed8d57579a9974ab88eaf787dd0a1af104f6881b5707450558"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_with",
+ "soroban-env-common",
+ "soroban-env-host",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "soroban-sdk"
+version = "27.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c3f21971c84fcfb08957e3e8f5a9a70f134cb07ad9ee053ac7e6d7a887a82af"
+dependencies = [
+ "arbitrary",
+ "bytes-lit",
+ "crate-git-revision 0.0.9",
+ "ctor",
+ "derive_arbitrary",
+ "ed25519-dalek",
+ "rand",
+ "rustc_version",
+ "serde",
+ "serde_json",
+ "soroban-env-guest",
+ "soroban-env-host",
+ "soroban-ledger-snapshot",
+ "soroban-sdk-macros",
+ "stellar-strkey 0.0.16",
+ "visibility",
+]
+
+[[package]]
+name = "soroban-sdk-macros"
+version = "27.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bd4a847273d749807fe2eb52e2b9c1917ee482cd6a39465cad5c389548996ad"
+dependencies = [
+ "darling 0.20.11",
+ "heck",
+ "itertools",
+ "macro-string",
+ "proc-macro2",
+ "quote",
+ "sha2",
+ "soroban-env-common",
+ "soroban-spec",
+ "soroban-spec-rust",
+ "stellar-xdr",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "soroban-spec"
+version = "27.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473404322827b285cbcd87517f365986bd63af7842c78b2a86ee061715fda61e"
+dependencies = [
+ "base64",
+ "sha2",
+ "stellar-xdr",
+ "thiserror 1.0.69",
+ "wasmparser",
+]
+
+[[package]]
+name = "soroban-spec-rust"
+version = "27.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f25698b6ce2125850a9ef075cf9ba1e8d25b4cfa0c46aca42dadd80cc29d881"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "sha2",
+ "soroban-spec",
+ "stellar-xdr",
+ "syn 2.0.119",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "soroban-wasmi"
+version = "0.31.1-soroban.20.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719"
+dependencies = [
+ "smallvec",
+ "spin",
+ "wasmi_arena",
+ "wasmi_core",
+ "wasmparser-nostd",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "stellar-strkey"
+version = "0.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913"
+dependencies = [
+ "crate-git-revision 0.0.6",
+ "data-encoding",
+]
+
+[[package]]
+name = "stellar-strkey"
+version = "0.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520"
+dependencies = [
+ "crate-git-revision 0.0.6",
+ "data-encoding",
+ "heapless",
+]
+
+[[package]]
+name = "stellar-xdr"
+version = "27.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a"
+dependencies = [
+ "arbitrary",
+ "base64",
+ "cfg_eval",
+ "crate-git-revision 0.0.6",
+ "escape-bytes",
+ "ethnum",
+ "hex",
+ "serde",
+ "serde_with",
+ "sha2",
+ "stellar-strkey 0.0.13",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl 2.0.20",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "visibility"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasmi_arena"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073"
+
+[[package]]
+name = "wasmi_core"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a"
+dependencies = [
+ "downcast-rs",
+ "libm",
+ "num-traits",
+ "paste",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.116.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50"
+dependencies = [
+ "indexmap 2.14.0",
+ "semver",
+]
+
+[[package]]
+name = "wasmparser-nostd"
+version = "0.100.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa"
+dependencies = [
+ "indexmap-nostd",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml
new file mode 100644
index 00000000..7067e861
--- /dev/null
+++ b/contracts/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+resolver = "2"
+members = ["scholarship_escrow"]
diff --git a/contracts/README.md b/contracts/README.md
new file mode 100644
index 00000000..1d7aaad4
--- /dev/null
+++ b/contracts/README.md
@@ -0,0 +1,60 @@
+# Scholarship Escrow Contract
+
+This directory contains the Stage 1 Soroban scholarship escrow contract for
+issue 34. The contract stores an immutable scholarship schedule, accepts
+non-custodial donor funding in a Stellar Asset Contract token, releases exact
+milestone amounts after arbiter authorization, and permits pro-rata refunds
+after expiry.
+
+## Local Setup
+
+Install Rust with `rustup`, then install the target used by current Soroban
+tooling:
+
+```bash
+rustup target add wasm32v1-none
+```
+
+Install the Stellar CLI using the official Stellar CLI instructions. Check the
+local toolchain before building:
+
+```bash
+rustc --version
+cargo --version
+stellar --version
+```
+
+## Test and Format
+
+Run these commands from the repository root:
+
+```bash
+cargo fmt --manifest-path contracts/Cargo.toml -- --check
+cargo test --manifest-path contracts/Cargo.toml
+cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings
+```
+
+## Build
+
+Build the contract through Cargo:
+
+```bash
+cargo build --manifest-path contracts/Cargo.toml \
+ --package scholarship-escrow \
+ --target wasm32v1-none \
+ --release
+```
+
+The resulting WASM is written to
+`contracts/target/wasm32v1-none/release/scholarship_escrow.wasm`.
+
+The Stellar CLI can also build the workspace once it is installed:
+
+```bash
+stellar contract build --package scholarship-escrow
+```
+
+Testnet deployment, SAC wrapping for the configured USDC issuer, contract ID
+configuration, and the unsigned JavaScript invocation flow are Stage 2 work.
+Do not put secret keys in this repository or in application environment
+variables.
diff --git a/contracts/scholarship_escrow/Cargo.toml b/contracts/scholarship_escrow/Cargo.toml
new file mode 100644
index 00000000..065b96e6
--- /dev/null
+++ b/contracts/scholarship_escrow/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "scholarship-escrow"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+soroban-sdk = "=27.0.6"
+
+[dev-dependencies]
+soroban-sdk = { version = "=27.0.6", features = ["testutils"] }
diff --git a/contracts/scholarship_escrow/src/lib.rs b/contracts/scholarship_escrow/src/lib.rs
new file mode 100644
index 00000000..c5a9db4f
--- /dev/null
+++ b/contracts/scholarship_escrow/src/lib.rs
@@ -0,0 +1,829 @@
+#![no_std]
+
+use soroban_sdk::{
+ contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, Vec,
+};
+
+#[contracterror]
+#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum Error {
+ AlreadyInitialized = 1,
+ EmptyMilestones = 2,
+ InvalidExpiry = 3,
+ InvalidMilestoneAmount = 4,
+ InvalidMilestoneState = 5,
+ MilestoneTotalOverflow = 6,
+ InvalidAmount = 7,
+ FundingCapExceeded = 8,
+ EscrowExpired = 9,
+ InvalidMilestoneIndex = 10,
+ MilestoneAlreadyReleased = 11,
+ InsufficientFunds = 12,
+ DonorNotFound = 13,
+ AlreadyRefunded = 14,
+ NoRefundAvailable = 15,
+ ArithmeticOverflow = 16,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Milestone {
+ pub amount: i128,
+ pub released: bool,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EscrowState {
+ pub arbiter: Address,
+ pub beneficiary: Address,
+ pub token: Address,
+ pub expiry: u32,
+ pub milestone_total: i128,
+ pub funded_total: i128,
+ pub released_total: i128,
+ pub refunded_total: i128,
+ pub refund_pool: Option,
+}
+
+#[contracttype]
+enum DataKey {
+ State,
+ Milestones,
+ Donors,
+ DonorContribution(Address),
+ DonorRefund(Address),
+ RefundClaimed(Address),
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Initialized {
+ #[topic]
+ pub arbiter: Address,
+ #[topic]
+ pub beneficiary: Address,
+ pub token: Address,
+ pub expiry: u32,
+ pub milestone_total: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Funded {
+ #[topic]
+ pub donor: Address,
+ pub amount: i128,
+ pub donor_total: i128,
+ pub funded_total: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MilestoneApproved {
+ #[topic]
+ pub index: u32,
+ pub amount: i128,
+ pub released_total: i128,
+}
+
+#[contractevent]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Refunded {
+ #[topic]
+ pub donor: Address,
+ pub amount: i128,
+ pub refunded_total: i128,
+}
+
+#[contract]
+pub struct ScholarshipEscrow;
+
+#[contractimpl]
+impl ScholarshipEscrow {
+ pub fn init(
+ env: Env,
+ arbiter: Address,
+ beneficiary: Address,
+ token: Address,
+ milestones: Vec,
+ expiry: u32,
+ ) -> Result<(), Error> {
+ if env.storage().persistent().has(&DataKey::State) {
+ return Err(Error::AlreadyInitialized);
+ }
+
+ arbiter.require_auth();
+
+ if milestones.is_empty() {
+ return Err(Error::EmptyMilestones);
+ }
+ if expiry <= env.ledger().sequence() {
+ return Err(Error::InvalidExpiry);
+ }
+
+ let mut milestone_total = 0_i128;
+ for milestone in milestones.iter() {
+ if milestone.amount <= 0 {
+ return Err(Error::InvalidMilestoneAmount);
+ }
+ if milestone.released {
+ return Err(Error::InvalidMilestoneState);
+ }
+ milestone_total = milestone_total
+ .checked_add(milestone.amount)
+ .ok_or(Error::MilestoneTotalOverflow)?;
+ }
+
+ let state = EscrowState {
+ arbiter: arbiter.clone(),
+ beneficiary: beneficiary.clone(),
+ token: token.clone(),
+ expiry,
+ milestone_total,
+ funded_total: 0,
+ released_total: 0,
+ refunded_total: 0,
+ refund_pool: None,
+ };
+
+ env.storage().persistent().set(&DataKey::State, &state);
+ env.storage()
+ .persistent()
+ .set(&DataKey::Milestones, &milestones);
+ env.storage()
+ .persistent()
+ .set(&DataKey::Donors, &Vec::::new(&env));
+
+ Initialized {
+ arbiter,
+ beneficiary,
+ token,
+ expiry,
+ milestone_total,
+ }
+ .publish(&env);
+
+ Ok(())
+ }
+
+ pub fn fund(env: Env, donor: Address, amount: i128) -> Result<(), Error> {
+ let mut state = Self::load_state(&env);
+ Self::ensure_active(&env, &state)?;
+
+ if amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+
+ let funded_total = state
+ .funded_total
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+ if funded_total > state.milestone_total {
+ return Err(Error::FundingCapExceeded);
+ }
+
+ donor.require_auth();
+
+ let donor_key = DataKey::DonorContribution(donor.clone());
+ let donor_total = env
+ .storage()
+ .persistent()
+ .get(&donor_key)
+ .unwrap_or(0_i128)
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ let mut donors: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Donors)
+ .unwrap_or_else(|| Vec::new(&env));
+ if !donors.contains(&donor) {
+ donors.push_back(donor.clone());
+ env.storage().persistent().set(&DataKey::Donors, &donors);
+ }
+
+ let token_client = token::Client::new(&env, &state.token);
+ token_client.transfer(&donor, env.current_contract_address(), &amount);
+
+ state.funded_total = funded_total;
+ env.storage().persistent().set(&DataKey::State, &state);
+ env.storage().persistent().set(&donor_key, &donor_total);
+
+ Funded {
+ donor,
+ amount,
+ donor_total,
+ funded_total,
+ }
+ .publish(&env);
+
+ Ok(())
+ }
+
+ pub fn approve_milestone(env: Env, index: u32) -> Result<(), Error> {
+ let mut state = Self::load_state(&env);
+ Self::ensure_active(&env, &state)?;
+ state.arbiter.require_auth();
+
+ let mut milestones: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Milestones)
+ .unwrap();
+ let mut milestone = milestones.get(index).ok_or(Error::InvalidMilestoneIndex)?;
+
+ if milestone.released {
+ return Err(Error::MilestoneAlreadyReleased);
+ }
+
+ let available = state
+ .funded_total
+ .checked_sub(state.released_total)
+ .and_then(|value| value.checked_sub(state.refunded_total))
+ .ok_or(Error::ArithmeticOverflow)?;
+ if available < milestone.amount {
+ return Err(Error::InsufficientFunds);
+ }
+
+ let released_total = state
+ .released_total
+ .checked_add(milestone.amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ let token_client = token::Client::new(&env, &state.token);
+ token_client.transfer(
+ &env.current_contract_address(),
+ &state.beneficiary,
+ &milestone.amount,
+ );
+
+ milestone.released = true;
+ milestones.set(index, milestone.clone());
+ state.released_total = released_total;
+ env.storage()
+ .persistent()
+ .set(&DataKey::Milestones, &milestones);
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ MilestoneApproved {
+ index,
+ amount: milestone.amount,
+ released_total,
+ }
+ .publish(&env);
+
+ Ok(())
+ }
+
+ pub fn refund(env: Env, donor: Address) -> Result {
+ let mut state = Self::load_state(&env);
+ if env.ledger().sequence() < state.expiry {
+ return Err(Error::InvalidExpiry);
+ }
+
+ donor.require_auth();
+
+ let donor_key = DataKey::DonorContribution(donor.clone());
+ let contribution: i128 = env
+ .storage()
+ .persistent()
+ .get(&donor_key)
+ .ok_or(Error::DonorNotFound)?;
+ let claimed_key = DataKey::RefundClaimed(donor.clone());
+ if env
+ .storage()
+ .persistent()
+ .get(&claimed_key)
+ .unwrap_or(false)
+ {
+ return Err(Error::AlreadyRefunded);
+ }
+
+ let refund_pool = match state.refund_pool {
+ Some(pool) => pool,
+ None => {
+ let pool = state
+ .funded_total
+ .checked_sub(state.released_total)
+ .and_then(|value| value.checked_sub(state.refunded_total))
+ .ok_or(Error::ArithmeticOverflow)?;
+ if pool <= 0 {
+ return Err(Error::NoRefundAvailable);
+ }
+ state.refund_pool = Some(pool);
+ pool
+ }
+ };
+
+ if refund_pool <= 0 {
+ return Err(Error::NoRefundAvailable);
+ }
+
+ let donors: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Donors)
+ .unwrap_or_else(|| Vec::new(&env));
+ let unclaimed_donors = donors
+ .iter()
+ .filter(|address| {
+ !env.storage()
+ .persistent()
+ .get(&DataKey::RefundClaimed(address.clone()))
+ .unwrap_or(false)
+ })
+ .count();
+
+ let amount = if unclaimed_donors == 1 {
+ refund_pool
+ .checked_sub(state.refunded_total)
+ .ok_or(Error::ArithmeticOverflow)?
+ } else {
+ contribution
+ .checked_mul(refund_pool)
+ .ok_or(Error::ArithmeticOverflow)?
+ / state.funded_total
+ };
+
+ let refunded_total = state
+ .refunded_total
+ .checked_add(amount)
+ .ok_or(Error::ArithmeticOverflow)?;
+
+ if amount > 0 {
+ let token_client = token::Client::new(&env, &state.token);
+ token_client.transfer(&env.current_contract_address(), &donor, &amount);
+ }
+
+ env.storage().persistent().set(&claimed_key, &true);
+ env.storage()
+ .persistent()
+ .set(&DataKey::DonorRefund(donor.clone()), &amount);
+ state.refunded_total = refunded_total;
+ env.storage().persistent().set(&DataKey::State, &state);
+
+ Refunded {
+ donor,
+ amount,
+ refunded_total,
+ }
+ .publish(&env);
+
+ Ok(amount)
+ }
+
+ pub fn state(env: Env) -> EscrowState {
+ Self::load_state(&env)
+ }
+
+ pub fn funded_total(env: Env) -> i128 {
+ Self::load_state(&env).funded_total
+ }
+
+ pub fn milestone(env: Env, index: u32) -> Milestone {
+ let milestones: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Milestones)
+ .unwrap();
+ milestones.get(index).unwrap()
+ }
+
+ pub fn donor_contribution(env: Env, donor: Address) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::DonorContribution(donor))
+ .unwrap_or(0)
+ }
+
+ fn load_state(env: &Env) -> EscrowState {
+ env.storage().persistent().get(&DataKey::State).unwrap()
+ }
+
+ fn ensure_active(env: &Env, state: &EscrowState) -> Result<(), Error> {
+ if env.ledger().sequence() >= state.expiry {
+ Err(Error::EscrowExpired)
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ extern crate std;
+
+ use super::*;
+ use soroban_sdk::{
+ testutils::{Address as _, AuthorizedFunction, Events as _, Ledger as _},
+ token::StellarAssetClient,
+ Env, Event, IntoVal, Symbol,
+ };
+
+ const NOW: u32 = 1_000;
+ const EXPIRY: u32 = 2_000;
+
+ struct Context {
+ env: Env,
+ contract_id: Address,
+ token: Address,
+ arbiter: Address,
+ beneficiary: Address,
+ donor_a: Address,
+ donor_b: Address,
+ }
+
+ impl Context {
+ fn client(&self) -> ScholarshipEscrowClient<'_> {
+ ScholarshipEscrowClient::new(&self.env, &self.contract_id)
+ }
+
+ fn token_client(&self) -> StellarAssetClient<'_> {
+ StellarAssetClient::new(&self.env, &self.token)
+ }
+ }
+
+ fn context() -> Context {
+ let env = Env::default();
+ env.ledger().set_sequence_number(NOW);
+
+ let arbiter = Address::generate(&env);
+ let beneficiary = Address::generate(&env);
+ let donor_a = Address::generate(&env);
+ let donor_b = Address::generate(&env);
+
+ let token_contract = env.register_stellar_asset_contract_v2(arbiter.clone());
+ let token = token_contract.address();
+ let token_client = StellarAssetClient::new(&env, &token);
+
+ let contract_id = env.register(ScholarshipEscrow, ());
+ let client = ScholarshipEscrowClient::new(&env, &contract_id);
+
+ env.mock_all_auths();
+ token_client.mint(&donor_a, &1_000);
+ token_client.mint(&donor_b, &1_000);
+ client.init(
+ &arbiter,
+ &beneficiary,
+ &token,
+ &soroban_sdk::vec![
+ &env,
+ Milestone {
+ amount: 600,
+ released: false,
+ },
+ Milestone {
+ amount: 400,
+ released: false,
+ },
+ ],
+ &EXPIRY,
+ );
+
+ Context {
+ env,
+ contract_id,
+ token,
+ arbiter,
+ beneficiary,
+ donor_a,
+ donor_b,
+ }
+ }
+
+ #[test]
+ fn init_stores_fixed_state_and_emits_event() {
+ let ctx = context();
+ let events = ctx.env.events().all().filter_by_contract(&ctx.contract_id);
+ assert_eq!(
+ events.events(),
+ &[Initialized {
+ arbiter: ctx.arbiter.clone(),
+ beneficiary: ctx.beneficiary.clone(),
+ token: ctx.token.clone(),
+ expiry: EXPIRY,
+ milestone_total: 1_000,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+
+ let state = ctx.client().state();
+ assert_eq!(state.arbiter, ctx.arbiter);
+ assert_eq!(state.beneficiary, ctx.beneficiary);
+ assert_eq!(state.token, ctx.token);
+ assert_eq!(state.expiry, EXPIRY);
+ assert_eq!(state.milestone_total, 1_000);
+ assert_eq!(ctx.client().milestone(&0).amount, 600);
+ assert!(!ctx.client().milestone(&0).released);
+ }
+
+ #[test]
+ fn funding_is_tracked_per_donor_and_capped_at_milestone_total() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ ctx.client().fund(&ctx.donor_b, &400);
+
+ assert_eq!(ctx.client().funded_total(), 1_000);
+ assert_eq!(ctx.client().donor_contribution(&ctx.donor_a), 600);
+ assert_eq!(ctx.client().donor_contribution(&ctx.donor_b), 400);
+ assert_eq!(ctx.token_client().balance(&ctx.contract_id), 1_000);
+ assert_eq!(
+ ctx.client().try_fund(&ctx.donor_a, &1),
+ Err(Ok(Error::FundingCapExceeded))
+ );
+ }
+
+ #[test]
+ fn arbiter_releases_exact_milestone_amount_once() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ ctx.client().approve_milestone(&0);
+
+ assert_eq!(ctx.token_client().balance(&ctx.beneficiary), 600);
+ assert_eq!(ctx.client().state().released_total, 600);
+ assert!(ctx.client().milestone(&0).released);
+ assert_eq!(
+ ctx.client().try_approve_milestone(&0),
+ Err(Ok(Error::MilestoneAlreadyReleased))
+ );
+ }
+
+ #[test]
+ fn cannot_release_more_than_funded() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &500);
+
+ assert_eq!(
+ ctx.client().try_approve_milestone(&0),
+ Err(Ok(Error::InsufficientFunds))
+ );
+ }
+
+ #[test]
+ fn refunds_are_pro_rata_after_expiry_and_return_the_rounding_remainder() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ ctx.client().fund(&ctx.donor_b, &400);
+ ctx.client().approve_milestone(&0);
+ ctx.env.ledger().set_sequence_number(EXPIRY);
+
+ assert_eq!(ctx.client().refund(&ctx.donor_b), 160);
+ assert_eq!(ctx.client().refund(&ctx.donor_a), 240);
+ assert_eq!(ctx.token_client().balance(&ctx.donor_a), 640);
+ assert_eq!(ctx.token_client().balance(&ctx.donor_b), 760);
+ assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0);
+ assert_eq!(ctx.client().state().refunded_total, 400);
+ assert_eq!(
+ ctx.client().try_refund(&ctx.donor_a),
+ Err(Ok(Error::AlreadyRefunded))
+ );
+ }
+
+ #[test]
+ fn refund_rounding_dust_is_paid_to_the_final_claimant() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &333);
+ ctx.client().fund(&ctx.donor_b, &667);
+ ctx.client().approve_milestone(&0);
+ ctx.env.ledger().set_sequence_number(EXPIRY);
+
+ assert_eq!(ctx.client().refund(&ctx.donor_a), 133);
+ assert_eq!(ctx.client().refund(&ctx.donor_b), 267);
+ assert_eq!(ctx.client().state().refunded_total, 400);
+ assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0);
+ }
+
+ #[test]
+ fn refund_before_expiry_is_rejected() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+
+ assert_eq!(
+ ctx.client().try_refund(&ctx.donor_a),
+ Err(Ok(Error::InvalidExpiry))
+ );
+ }
+
+ #[test]
+ fn funding_and_approval_stop_at_expiry() {
+ let ctx = context();
+ ctx.env.ledger().set_sequence_number(EXPIRY);
+
+ assert_eq!(
+ ctx.client().try_fund(&ctx.donor_a, &1),
+ Err(Ok(Error::EscrowExpired))
+ );
+ assert_eq!(
+ ctx.client().try_approve_milestone(&0),
+ Err(Ok(Error::EscrowExpired))
+ );
+ }
+
+ #[test]
+ fn funding_requires_donor_authorization() {
+ let ctx = context();
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().fund(&ctx.donor_a, &1);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn approval_requires_arbiter_authorization() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().approve_milestone(&0);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn refund_requires_donor_authorization() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ ctx.env.ledger().set_sequence_number(EXPIRY);
+ ctx.env.set_auths(&[]);
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ ctx.client().refund(&ctx.donor_a);
+ }));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn state_changes_emit_indexable_events() {
+ let ctx = context();
+
+ ctx.client().fund(&ctx.donor_a, &600);
+ assert_eq!(
+ ctx.env
+ .events()
+ .all()
+ .filter_by_contract(&ctx.contract_id)
+ .events(),
+ &[Funded {
+ donor: ctx.donor_a.clone(),
+ amount: 600,
+ donor_total: 600,
+ funded_total: 600,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+
+ ctx.client().fund(&ctx.donor_b, &400);
+ ctx.client().approve_milestone(&0);
+ assert_eq!(
+ ctx.env
+ .events()
+ .all()
+ .filter_by_contract(&ctx.contract_id)
+ .events(),
+ &[MilestoneApproved {
+ index: 0,
+ amount: 600,
+ released_total: 600,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+
+ ctx.env.ledger().set_sequence_number(EXPIRY);
+ ctx.client().refund(&ctx.donor_b);
+ assert_eq!(
+ ctx.env
+ .events()
+ .all()
+ .filter_by_contract(&ctx.contract_id)
+ .events(),
+ &[Refunded {
+ donor: ctx.donor_b.clone(),
+ amount: 160,
+ refunded_total: 160,
+ }
+ .to_xdr(&ctx.env, &ctx.contract_id)]
+ );
+ }
+
+ #[test]
+ fn auth_tree_contains_donor_and_arbiter_authorizations() {
+ let ctx = context();
+ ctx.client().fund(&ctx.donor_a, &600);
+ let fund_auths = ctx.env.auths();
+ assert!(fund_auths
+ .iter()
+ .any(|(address, _)| address == &ctx.donor_a));
+
+ ctx.client().approve_milestone(&0);
+ let approval_auths = ctx.env.auths();
+ assert!(approval_auths.iter().any(|(address, invocation)| {
+ address == &ctx.arbiter
+ && invocation.function
+ == AuthorizedFunction::Contract((
+ ctx.contract_id.clone(),
+ Symbol::new(&ctx.env, "approve_milestone"),
+ (&0_u32,).into_val(&ctx.env),
+ ))
+ }));
+ }
+
+ #[test]
+ fn invalid_initialization_constraints_are_rejected() {
+ let env = Env::default();
+ env.ledger().set_sequence_number(NOW);
+ let arbiter = Address::generate(&env);
+ let beneficiary = Address::generate(&env);
+ let token = Address::generate(&env);
+ let contract_id = env.register(ScholarshipEscrow, ());
+ let client = ScholarshipEscrowClient::new(&env, &contract_id);
+
+ env.mock_all_auths();
+ assert_eq!(
+ client.try_init(
+ &arbiter,
+ &beneficiary,
+ &token,
+ &soroban_sdk::Vec::new(&env),
+ &EXPIRY,
+ ),
+ Err(Ok(Error::EmptyMilestones))
+ );
+ assert_eq!(
+ client.try_init(
+ &arbiter,
+ &beneficiary,
+ &token,
+ &soroban_sdk::vec![
+ &env,
+ Milestone {
+ amount: 0,
+ released: false,
+ }
+ ],
+ &EXPIRY,
+ ),
+ Err(Ok(Error::InvalidMilestoneAmount))
+ );
+ assert_eq!(
+ client.try_init(
+ &arbiter,
+ &beneficiary,
+ &token,
+ &soroban_sdk::vec![
+ &env,
+ Milestone {
+ amount: 1,
+ released: true,
+ }
+ ],
+ &EXPIRY,
+ ),
+ Err(Ok(Error::InvalidMilestoneState))
+ );
+ assert_eq!(
+ client.try_init(
+ &arbiter,
+ &beneficiary,
+ &token,
+ &soroban_sdk::vec![
+ &env,
+ Milestone {
+ amount: 1,
+ released: false,
+ }
+ ],
+ &NOW,
+ ),
+ Err(Ok(Error::InvalidExpiry))
+ );
+ }
+
+ #[test]
+ fn initialization_cannot_run_twice() {
+ let ctx = context();
+ let milestones = soroban_sdk::vec![
+ &ctx.env,
+ Milestone {
+ amount: 1_000,
+ released: false,
+ }
+ ];
+
+ assert_eq!(
+ ctx.client().try_init(
+ &ctx.arbiter,
+ &ctx.beneficiary,
+ &ctx.token,
+ &milestones,
+ &(EXPIRY + 1),
+ ),
+ Err(Ok(Error::AlreadyInitialized))
+ );
+ }
+}
diff --git a/docs/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.
From cbd640e86580bd18b00bf1b7b9024bc83c9c80f1 Mon Sep 17 00:00:00 2001
From: Mantissa
Date: Wed, 19 Aug 2026 02:26:03 +0100
Subject: [PATCH 15/25] Improve application test coverage (#110)
---
app.js | 23 +---
server.js | 15 ++-
src/controllers/authController.js | 33 +++---
test/coreFlows.test.js | 170 ++++++++++++++++++++++++++++++
test/jest.setup.js | 17 +--
5 files changed, 210 insertions(+), 48 deletions(-)
create mode 100644 test/coreFlows.test.js
diff --git a/app.js b/app.js
index fe86e147..815bf7c1 100644
--- a/app.js
+++ b/app.js
@@ -2,21 +2,11 @@ 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,
@@ -31,8 +21,6 @@ import { sanitizeInput } from "./src/middlewares/validate.js";
import {
errorHandler,
notFound,
- handleUnhandledRejection,
- handleUncaughtException,
} from "./src/middlewares/errorHandler.js";
import authRoutes from "./src/routes/authRoutes.js";
@@ -61,14 +49,6 @@ import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.
import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
import webhookRoutes from "./src/routes/webhookRoutes.js";
-handleUncaughtException();
-validateEnv();
-
-// Connect to MongoDB (skip during tests as tests handle their own connections)
-if (process.env.NODE_ENV !== "test") {
- connectDB();
-}
-
const app = express();
app.set("trust proxy", 1);
@@ -231,7 +211,6 @@ app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes);
app.use(notFound);
app.use(errorHandler);
-handleUnhandledRejection();
logger.info("DeenBridge API initialized");
logger.info(`Logging enabled - Level: ${logger.level}`);
diff --git a/server.js b/server.js
index 56728d41..372c06d0 100644
--- a/server.js
+++ b/server.js
@@ -1,9 +1,22 @@
-import app from "./app.js";
+import dotenv from "dotenv";
import logger from "./src/config/logger.js";
+import connectDB from "./src/config/db.js";
+import validateEnv from "./src/config/validateEnv.js";
import { initRedis, closeRedis } from "./src/config/redis.js";
import { startJobs, stopJobs } from "./src/jobs/queue.js";
+import {
+ handleUncaughtException,
+ handleUnhandledRejection,
+} from "./src/middlewares/errorHandler.js";
import "./src/jobs/handlers.js";
+dotenv.config();
+handleUncaughtException();
+validateEnv();
+await connectDB();
+handleUnhandledRejection();
+
+const { default: app } = await import("./app.js");
const PORT = process.env.PORT || 5000;
// Initialize Redis
diff --git a/src/controllers/authController.js b/src/controllers/authController.js
index b0753281..5c4d7edf 100644
--- a/src/controllers/authController.js
+++ b/src/controllers/authController.js
@@ -26,9 +26,16 @@ import {
generateOtpauthUrl,
} from "../utils/twoFactorCrypto.js";
-// Never fall back to a hardcoded default — that would sign tokens with a known
-// value. validateEnv() also enforces this at boot; refuse to start if missing.
-const JWT_SECRET = process.env.JWT_SECRET;
+// 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;
+};
// ── Progressive login lockout (issue #89) ───────────────────────────────────
// After LOGIN_MAX_FAILED_ATTEMPTS consecutive failures, the account is locked
@@ -51,17 +58,6 @@ const lockoutDurationMs = (failedAttempts) =>
Math.max(0, failedAttempts - LOGIN_MAX_FAILED_ATTEMPTS)
);
-// Log JWT configuration on startup and fail fast if the secret is missing.
-if (!JWT_SECRET) {
- logger.error(
- "❌ JWT_SECRET is required to sign auth tokens but is missing or empty. Refusing to start."
- );
- process.exit(1);
-}
-logger.info(
- `✅ JWT_SECRET loaded from environment (length: ${JWT_SECRET.length})`
-);
-
// Helper: parse duration string to ms (e.g. 15m, 30d)
export const parseDurationToMs = (duration) => {
const match = duration.match(/^(\d+)([smhd])$/);
@@ -141,7 +137,7 @@ export const createSessionAndTokens = async (user, req, res, options = {}) => {
const accessTokenTtl = process.env.ACCESS_TOKEN_TTL || "15m";
const accessToken = jwt.sign(
{ userId: user._id, role: user.role, sessionId: session._id, is2FAVerified },
- JWT_SECRET,
+ getJwtSecret(),
{ expiresIn: accessTokenTtl }
);
@@ -514,7 +510,7 @@ export const loginUser = catchAsync(async (req, res, next) => {
if (user.twoFactor?.enabled) {
const mfaToken = jwt.sign(
{ userId: user._id, type: "mfa_challenge" },
- JWT_SECRET,
+ getJwtSecret(),
{ expiresIn: "5m" }
);
@@ -789,7 +785,7 @@ 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, is2FAVerified },
- JWT_SECRET,
+ getJwtSecret(),
{ expiresIn: accessTokenTtl }
);
@@ -1052,7 +1048,7 @@ export const verify2FA = catchAsync(async (req, res, next) => {
if (mfaToken) {
let decoded;
try {
- decoded = jwt.verify(mfaToken, JWT_SECRET);
+ decoded = jwt.verify(mfaToken, getJwtSecret());
} catch (err) {
return next(new APIError("Invalid or expired 2FA challenge token", 401));
}
@@ -1270,4 +1266,3 @@ export const disable2FA = catchAsync(async (req, res, next) => {
message: "Two-factor authentication disabled successfully",
});
});
-
diff --git a/test/coreFlows.test.js b/test/coreFlows.test.js
new file mode 100644
index 00000000..4d27dda9
--- /dev/null
+++ b/test/coreFlows.test.js
@@ -0,0 +1,170 @@
+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).toEqual({
+ success: false,
+ message: "Invalid Stellar public key",
+ });
+
+ const persisted = await User.findById(user._id).select("stellarWallet");
+ expect(persisted.stellarWallet?.publicKey).toBeUndefined();
+ });
+});
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);
}
-
From ba4fe4fcc6adfeb0d2a8954ef28ee95a040cb42f Mon Sep 17 00:00:00 2001
From: Mantissa
Date: Wed, 19 Aug 2026 09:54:50 +0100
Subject: [PATCH 16/25] Add dependency health checks (#112)
---
.github/workflows/ci.yml | 17 ++++-
app.js | 10 +--
openapi.yaml | 57 ++++++++++++++-
src/controllers/healthController.js | 49 +++++++++++++
test/app.test.js | 28 ++++++-
test/health.test.js | 109 ++++++++++++++++++++++++++++
6 files changed, 256 insertions(+), 14 deletions(-)
create mode 100644 src/controllers/healthController.js
create mode 100644 test/health.test.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c1287d3..292a8948 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,7 +52,6 @@ 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
@@ -62,6 +61,10 @@ jobs:
image: mongo:6.0
ports:
- 27017:27017
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
steps:
- name: Checkout code
@@ -87,6 +90,17 @@ jobs:
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 \
@@ -116,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/app.js b/app.js
index 815bf7c1..1ed9aab1 100644
--- a/app.js
+++ b/app.js
@@ -48,6 +48,7 @@ import educatorRoutes from "./src/routes/educatorRoutes.js";
import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js";
import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
import webhookRoutes from "./src/routes/webhookRoutes.js";
+import { healthCheck, ping } from "./src/controllers/healthController.js";
const app = express();
@@ -158,13 +159,8 @@ app.get("/", (req, res) => {
});
});
-app.get("/health", (req, res) => {
- res.json({
- success: true,
- message: "pong",
- timestamp: new Date().toISOString(),
- });
-});
+app.get("/ping", ping);
+app.get("/health", healthCheck);
// SEP-1 stellar.toml — must be outside /api rate limiter
app.use("/.well-known", wellKnownRoutes);
diff --git a/openapi.yaml b/openapi.yaml
index c66e863c..1d764ab2 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -450,7 +450,7 @@ paths:
/health:
get:
tags: [Meta]
- summary: Liveness probe used by CI and the host
+ summary: Readiness probe for critical dependencies
security: []
responses:
"200":
@@ -461,8 +461,57 @@ paths:
type: object
properties:
success: { type: boolean }
- message: { type: string, examples: [pong] }
- timestamp: { type: string, format: date-time }
+ message: { type: string }
+ data:
+ type: object
+ properties:
+ status: { type: string, enum: [healthy] }
+ timestamp: { type: string, format: date-time }
+ uptime: { type: number }
+ environment: { type: string }
+ dependencies:
+ type: object
+ properties:
+ mongodb:
+ type: object
+ properties:
+ status: { type: string, enum: [up, down] }
+ state: { type: string }
+ redis:
+ type: object
+ properties:
+ status: { type: string, enum: [up, down] }
+ "503":
+ description: One or more critical dependencies are unavailable
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success: { type: boolean, examples: [false] }
+ message: { type: string }
+ data:
+ type: object
+ properties:
+ status: { type: string, enum: [unhealthy] }
+ timestamp: { type: string, format: date-time }
+ uptime: { type: number }
+ environment: { type: string }
+ dependencies:
+ type: object
+ /ping:
+ get:
+ tags: [Meta]
+ summary: Dependency free liveness probe
+ security: []
+ responses:
+ "200":
+ description: The process is alive
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: pong
/metrics:
get:
tags: [Meta]
@@ -2064,4 +2113,4 @@ paths:
type: array
items: { type: object }
"401": { $ref: "#/components/responses/Unauthorized" }
- "404": { $ref: "#/components/responses/NotFound" }
\ No newline at end of file
+ "404": { $ref: "#/components/responses/NotFound" }
diff --git a/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/test/app.test.js b/test/app.test.js
index f05d4100..9cbfdb7a 100644
--- a/test/app.test.js
+++ b/test/app.test.js
@@ -56,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/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");
+ });
+});
From c2dae8aed3bc5031f5f6b383ff1b19793278b4e3 Mon Sep 17 00:00:00 2001
From: Mantissa
Date: Wed, 19 Aug 2026 10:04:11 +0100
Subject: [PATCH 17/25] Validate auth and Stellar requests (#109)
* Validate auth and Stellar requests
* Address validation review feedback
---
src/middlewares/errorHandler.js | 7 +-
src/middlewares/validate.js | 23 ++-
src/routes/authRoutes.js | 16 +-
src/routes/stellar/paymentRoutes.js | 21 ++-
src/routes/stellar/walletRoutes.js | 10 +-
src/validators/requestValidators.js | 123 ++++++++++++++++
test/coreFlows.test.js | 11 +-
test/requestValidation.test.js | 220 ++++++++++++++++++++++++++++
8 files changed, 418 insertions(+), 13 deletions(-)
create mode 100644 src/validators/requestValidators.js
create mode 100644 test/requestValidation.test.js
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/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/routes/authRoutes.js b/src/routes/authRoutes.js
index 0bbe448b..ff69626c 100644
--- a/src/routes/authRoutes.js
+++ b/src/routes/authRoutes.js
@@ -28,6 +28,11 @@ import {
emailAuthLimiter,
captchaGate,
} from "../middlewares/security.js";
+import { validate } from "../middlewares/validate.js";
+import {
+ registerValidation,
+ loginValidation,
+} from "../validators/requestValidators.js";
const router = express.Router();
@@ -35,8 +40,15 @@ const router = express.Router();
// /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(), registerUser);
-router.post("/login", loginUser);
+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);
diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js
index 8686c9ad..1dc07f05 100644
--- a/src/routes/stellar/paymentRoutes.js
+++ b/src/routes/stellar/paymentRoutes.js
@@ -20,6 +20,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();
@@ -29,8 +34,20 @@ router.use(protect);
// Payment flow
router.post("/quote", getQuote);
router.post("/preflight", getPaymentPreflight);
-router.post("/initialize", idempotency(), initializePayment);
-router.post("/submit", idempotency(), submitPayment);
+router.post(
+ "/initialize",
+ initializePaymentValidation,
+ validate,
+ idempotency(),
+ initializePayment
+);
+router.post(
+ "/submit",
+ submitPaymentValidation,
+ validate,
+ idempotency(),
+ submitPayment
+);
// Transaction management
router.get("/transactions", getTransactionHistory);
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/validators/requestValidators.js b/src/validators/requestValidators.js
new file mode 100644
index 00000000..d6d72858
--- /dev/null
+++ b/src/validators/requestValidators.js
@@ -0,0 +1,123 @@
+import { body } from "express-validator";
+import mongoose from "mongoose";
+import * as StellarSdk from "@stellar/stellar-sdk";
+import { isValidPublicKey, NETWORK } from "../services/stellar/stellarService.js";
+import { PASSWORD_MIN } from "../utils/passwordPolicy.js";
+
+const networkPassphrase =
+ NETWORK === "mainnet"
+ ? StellarSdk.Networks.PUBLIC
+ : StellarSdk.Networks.TESTNET;
+
+const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value);
+
+const isWellFormedXdr = (value) => {
+ try {
+ StellarSdk.TransactionBuilder.fromXDR(value, networkPassphrase);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const requiredString = (field, message) =>
+ body(field)
+ .exists({ values: "null" })
+ .withMessage(message)
+ .bail()
+ .isString()
+ .withMessage(`${field} must be a string`)
+ .bail()
+ .trim()
+ .notEmpty()
+ .withMessage(message)
+ .bail();
+
+const objectIdField = (field) =>
+ requiredString(field, `${field} is required`)
+ .custom(isValidObjectId)
+ .withMessage(`${field} must be a valid Mongo ObjectId`);
+
+export const registerValidation = [
+ requiredString("name", "Name is required"),
+ body("email")
+ .exists({ values: "null" })
+ .withMessage("Email is required")
+ .bail()
+ .isString()
+ .withMessage("Email must be a string")
+ .bail()
+ .trim()
+ .isEmail()
+ .withMessage("Email must be a valid email address")
+ .normalizeEmail(),
+ body("password")
+ .exists({ values: "null" })
+ .withMessage("Password is required")
+ .bail()
+ .isString()
+ .withMessage("Password must be a string")
+ .bail()
+ .isLength({ min: PASSWORD_MIN })
+ .withMessage(`Password must be at least ${PASSWORD_MIN} characters`),
+ body("role")
+ .optional({ values: "undefined" })
+ .isIn(["student", "mentor", "admin"])
+ .withMessage("Role must be one of: student, mentor, admin"),
+];
+
+export const loginValidation = [
+ body("email")
+ .exists({ values: "null" })
+ .withMessage("Email is required")
+ .bail()
+ .isString()
+ .withMessage("Email must be a string")
+ .bail()
+ .trim()
+ .isEmail()
+ .withMessage("Email must be a valid email address")
+ .normalizeEmail(),
+ body("password")
+ .exists({ values: "null" })
+ .withMessage("Password is required")
+ .bail()
+ .isString()
+ .withMessage("Password must be a string")
+ .bail()
+ .custom((value) => value.trim().length > 0)
+ .withMessage("Password is required"),
+];
+
+export const initializePaymentValidation = [
+ body("itemType")
+ .exists({ values: "null" })
+ .withMessage("itemType is required")
+ .bail()
+ .isIn(["book", "course"])
+ .withMessage("itemType must be one of: book, course"),
+ objectIdField("itemId"),
+ requiredString("buyerWallet", "buyerWallet is required").custom(isValidPublicKey)
+ .withMessage("buyerWallet must be a valid Stellar public key"),
+];
+
+export const submitPaymentValidation = [
+ objectIdField("transactionId"),
+ requiredString("signedXdr", "signedXdr is required")
+ .custom(isWellFormedXdr)
+ .withMessage("signedXdr must be a well-formed Stellar transaction XDR"),
+];
+
+export const connectWalletValidation = [
+ requiredString("publicKey", "publicKey is required")
+ .custom(isValidPublicKey)
+ .withMessage("publicKey must be a valid Stellar public key"),
+];
+
+export default {
+ registerValidation,
+ loginValidation,
+ initializePaymentValidation,
+ submitPaymentValidation,
+ connectWalletValidation,
+};
diff --git a/test/coreFlows.test.js b/test/coreFlows.test.js
index 4d27dda9..acb5b8e0 100644
--- a/test/coreFlows.test.js
+++ b/test/coreFlows.test.js
@@ -159,9 +159,16 @@ describe("Core auth, authorization, and wallet flows", () => {
.send({ publicKey: "not-a-stellar-public-key" });
expect(response.status).toBe(400);
- expect(response.body).toEqual({
+ expect(response.body).toMatchObject({
success: false,
- message: "Invalid Stellar public key",
+ 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");
diff --git a/test/requestValidation.test.js b/test/requestValidation.test.js
new file mode 100644
index 00000000..2c9d0148
--- /dev/null
+++ b/test/requestValidation.test.js
@@ -0,0 +1,220 @@
+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"),
+};
+
+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();
+ });
+});
From 3a115abed095cf172fb881b9f05dc5d5cb718115 Mon Sep 17 00:00:00 2001
From: Mantissa
Date: Wed, 19 Aug 2026 10:05:03 +0100
Subject: [PATCH 18/25] Secure book deletion authorization (#113)
* Secure book deletion
* Keep delete response consistent
---
src/controllers/books/bookController.js | 40 +++++---
test/bookDeleteAuthorization.test.js | 119 ++++++++++++++++++++++++
2 files changed, 147 insertions(+), 12 deletions(-)
create mode 100644 test/bookDeleteAuthorization.test.js
diff --git a/src/controllers/books/bookController.js b/src/controllers/books/bookController.js
index 6b3b8cc1..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,20 +126,34 @@ export const getBooksByAuthor = async (req, res) => {
};
// delete book by id
-export const deleteBook = async (req, res) => {
- try {
- // Ownership is enforced by authorizeOwnership middleware (req.resource).
- const book = req.resource || (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;
- await Book.findByIdAndDelete(req.params.id);
- res.json({ success: true, message: "Book deleted" });
- } catch (error) {
- res.status(500).json({ success: false, message: error.message });
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return next(new APIError("Invalid book id", 400));
}
-};
+
+ 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/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);
+ });
+});
From b7cc4e9662fd63cce4449d42f3cd990856190841 Mon Sep 17 00:00:00 2001
From: Samuel Ojetunde
Date: Wed, 19 Aug 2026 10:05:41 +0100
Subject: [PATCH 19/25] feat(stellar): gift courses/books via claimable
balances with expiry reclaim (#116)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a gift-a-course/book flow built on Stellar claimable balances so a
buyer can send an item to another user — including one who has not
finished wallet onboarding — without the recipient needing a USDC
trustline. The sender creates an on-ledger USDC balance the recipient
claims when ready, with a sender reclaim-after-expiry predicate so funds
are never stranded. Includes a GiftClaim model (no document-deleting TTL,
so the record survives expiry for reclaim), a claimableBalanceService
(build create/claim transactions with complementary predicates, resolve
the REAL balance id from the create result XDR — not the tx hash — with
a Horizon forClaimant fallback, and validate the signed gift XDR before
any state change), gift routes/controller at /api/stellar/gifts, and
granting item access to the RECIPIENT (never the payer) on claim. Wires
a `{ fallback: "claimable_balance" }` response into the purchase flow
when a creator wallet/trustline is missing. Tests cover predicate
decoding, trustline-free single-signature claiming, claim authorization
before/after expiry, the balance-id-vs-tx-hash distinction, tampered-XDR
rejection, guards mirroring initializePayment, and the recipient access
grant.
---
app.js | 2 +
src/controllers/stellar/giftController.js | 639 ++++++++++++++++++
src/controllers/stellar/paymentController.js | 14 +
src/models/GiftClaim.js | 113 ++++
src/routes/stellar/giftRoutes.js | 30 +
.../stellar/claimableBalanceService.js | 335 +++++++++
test/claimableBalanceService.test.js | 267 ++++++++
test/giftClaimableBalances.test.js | 561 +++++++++++++++
test/webhooks.test.js | 1 +
9 files changed, 1962 insertions(+)
create mode 100644 src/controllers/stellar/giftController.js
create mode 100644 src/models/GiftClaim.js
create mode 100644 src/routes/stellar/giftRoutes.js
create mode 100644 src/services/stellar/claimableBalanceService.js
create mode 100644 test/claimableBalanceService.test.js
create mode 100644 test/giftClaimableBalances.test.js
diff --git a/app.js b/app.js
index 1ed9aab1..62a408d1 100644
--- a/app.js
+++ b/app.js
@@ -37,6 +37,7 @@ import callRoutes from "./src/routes/callRoutes.js";
import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js";
import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js";
import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js";
+import 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";
@@ -188,6 +189,7 @@ app.use("/api/educator-verification", standardLimiter, educatorVerificationRoute
app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes);
app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes);
app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes);
+app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
// Outbound webhook management API (admin-gated)
diff --git a/src/controllers/stellar/giftController.js b/src/controllers/stellar/giftController.js
new file mode 100644
index 00000000..06cac1e7
--- /dev/null
+++ b/src/controllers/stellar/giftController.js
@@ -0,0 +1,639 @@
+// controllers/stellar/giftController.js
+//
+// Gift-a-course/book flow built on Stellar claimable balances. The sender
+// funds a claimable balance the recipient claims whenever they're ready
+// (trustline-free), with a reclaim-after-expiry predicate. Access to the item
+// is granted to the RECIPIENT on claim — never the payer. All signing is
+// client-side; the server only builds unsigned XDR, records the real
+// claimable-balance id, verifies on-chain, and grants access.
+import User from "../../models/User.js";
+import Book from "../../models/Book.js";
+import Course from "../../models/Course.js";
+import GiftClaim from "../../models/GiftClaim.js";
+import {
+ buildCreateClaimableBalanceTx,
+ buildClaimTx,
+ resolveBalanceId,
+ getClaimableBalance,
+ validateSignedGiftXdr,
+ giftExpiryFromNow,
+} from "../../services/stellar/claimableBalanceService.js";
+import {
+ submitTransaction,
+ verifyTransaction,
+ NETWORK,
+ getExplorerUrl,
+} from "../../services/stellar/stellarService.js";
+import { grantItemAccess } from "../../services/stellar/reconciliationService.js";
+import logger from "../../config/logger.js";
+
+// Gift memos are tagged DNB-GIFT- so they are
+// never mistaken for a purchase (DNB-(BOOK|COURSE)-...) or donation memo by
+// the reconciliation worker.
+const buildGiftMemo = (itemId) => `DNB-GIFT-${String(itemId).slice(-8)}`;
+
+const isBeforeExpiry = (gift, now = Date.now()) =>
+ now < gift.expiresAt.getTime();
+
+/**
+ * Initialize a gift: validate the recipient, build an unsigned
+ * create_claimable_balance XDR, and persist a pending GiftClaim.
+ * POST /api/stellar/gifts/initialize
+ */
+export const initializeGift = async (req, res) => {
+ try {
+ const senderId = req.user._id;
+ const { itemType, itemId, recipientUserId } = req.body;
+
+ if (!["book", "course"].includes(itemType)) {
+ return res.status(400).json({
+ success: false,
+ message: "Invalid item type. Must be 'book' or 'course'",
+ });
+ }
+
+ const sender = await User.findById(senderId);
+ if (!sender?.stellarWallet?.publicKey) {
+ return res.status(400).json({
+ success: false,
+ message: "Please connect your Stellar wallet first",
+ });
+ }
+
+ const Model = itemType === "book" ? Book : Course;
+ const populateField = itemType === "book" ? "author" : "createdBy";
+ const item = await Model.findById(itemId).populate(
+ populateField,
+ "stellarWallet name"
+ );
+ if (!item) {
+ return res.status(404).json({
+ success: false,
+ message: `${itemType} not found`,
+ });
+ }
+ const creator = itemType === "book" ? item.author : item.createdBy;
+
+ if (!item.price || item.price === 0) {
+ return res.status(400).json({
+ success: false,
+ message: "This item is free, no gift needed",
+ });
+ }
+
+ if (recipientUserId === senderId.toString()) {
+ return res.status(400).json({
+ success: false,
+ message: "You cannot gift an item to yourself",
+ });
+ }
+
+ const recipient = await User.findById(recipientUserId);
+ if (!recipient) {
+ return res.status(404).json({
+ success: false,
+ message: "Recipient not found",
+ });
+ }
+ if (!recipient?.stellarWallet?.publicKey) {
+ return res.status(400).json({
+ success: false,
+ message: "Recipient has not connected their Stellar wallet yet",
+ });
+ }
+
+ // Already-owned guard, checked against the RECIPIENT (mirrors
+ // initializePayment's guard in the purchase flow).
+ const purchasedArray =
+ itemType === "book" ? recipient.purchasedBooks : recipient.purchasedCourses;
+ const idField = itemType === "book" ? "bookId" : "courseId";
+ const alreadyOwns = purchasedArray?.some(
+ (p) => p[idField]?.toString() === itemId
+ );
+ if (alreadyOwns) {
+ return res.status(400).json({
+ success: false,
+ message: `Recipient already owns this ${itemType}`,
+ });
+ }
+
+ // Duplicate-pending guard (mirrors initializePayment).
+ const existingGift = await GiftClaim.findOne({
+ sender: senderId,
+ recipient: recipientUserId,
+ itemType,
+ itemId,
+ status: { $in: ["pending_signature", "open"] },
+ });
+ if (existingGift) {
+ return res.status(400).json({
+ success: false,
+ message: "You already have a pending gift for this recipient and item",
+ giftId: existingGift._id,
+ });
+ }
+
+ const expiresAt = giftExpiryFromNow();
+ const paymentTx = await buildCreateClaimableBalanceTx({
+ sourcePublicKey: sender.stellarWallet.publicKey,
+ claimantPublicKey: recipient.stellarWallet.publicKey,
+ amount: item.price.toString(),
+ expiresAt,
+ memo: buildGiftMemo(itemId),
+ });
+
+ const gift = new GiftClaim({
+ sender: senderId,
+ recipient: recipient._id,
+ recipientWallet: recipient.stellarWallet.publicKey,
+ creator: creator?._id,
+ itemType,
+ itemId,
+ itemTypeModel: itemType === "book" ? "Book" : "Course",
+ itemTitle: item.title,
+ amount: item.price.toString(),
+ assetCode: "USDC",
+ status: "pending_signature",
+ expiresAt,
+ createTxHash: paymentTx.hash,
+ network: NETWORK,
+ });
+ await gift.save();
+
+ logger.info(
+ `Gift initialized: ${gift._id} from ${senderId} to ${recipient._id} for ${itemType} ${itemId}`
+ );
+
+ res.status(200).json({
+ success: true,
+ giftId: gift._id,
+ payment: {
+ xdr: paymentTx.xdr,
+ networkPassphrase: paymentTx.networkPassphrase,
+ expectedHash: paymentTx.hash,
+ },
+ expiresAt: gift.expiresAt,
+ item: {
+ title: item.title,
+ price: item.price,
+ type: itemType,
+ },
+ recipient: {
+ name: recipient.name,
+ wallet: recipient.stellarWallet.publicKey,
+ },
+ });
+ } catch (error) {
+ logger.error("Initialize gift error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to initialize gift",
+ error:
+ process.env.NODE_ENV === "development" ? error.message : undefined,
+ });
+ }
+};
+
+/**
+ * Submit the signed create_claimable_balance XDR, resolve the real balance
+ * id, and mark the gift open.
+ * POST /api/stellar/gifts/submit
+ */
+export const submitGift = async (req, res) => {
+ try {
+ const senderId = req.user._id;
+ const { giftId, signedXdr } = req.body;
+
+ if (!giftId || !signedXdr) {
+ return res.status(400).json({
+ success: false,
+ message: "Gift ID and signed XDR are required",
+ });
+ }
+
+ const gift = await GiftClaim.findOne({
+ _id: giftId,
+ sender: senderId,
+ status: "pending_signature",
+ });
+ if (!gift) {
+ return res.status(404).json({
+ success: false,
+ message: "Gift not found or already processed",
+ });
+ }
+
+ const sender = await User.findById(senderId);
+ if (!sender?.stellarWallet?.publicKey) {
+ return res.status(400).json({
+ success: false,
+ message: "Please connect your Stellar wallet first",
+ });
+ }
+
+ // Verify the signed XDR BEFORE any DB write or access grant — a tampered
+ // XDR (wrong asset/amount/claimants) is rejected outright.
+ try {
+ validateSignedGiftXdr(signedXdr, {
+ assetCode: gift.assetCode,
+ amount: gift.amount,
+ recipientWallet: gift.recipientWallet,
+ senderWallet: sender.stellarWallet.publicKey,
+ expiresAt: gift.expiresAt,
+ });
+ } catch (validationError) {
+ return res.status(400).json({
+ success: false,
+ message: "Signed transaction does not match expected gift details",
+ error: validationError.message,
+ });
+ }
+
+ let result;
+ try {
+ result = await submitTransaction(signedXdr);
+ } catch (stellarError) {
+ return res.status(400).json({
+ success: false,
+ message: "Transaction failed on Stellar network",
+ error: stellarError.message,
+ });
+ }
+
+ // Resolve the REAL claimable-balance id — NOT the tx hash.
+ const balanceId = await resolveBalanceId(result.hash, {
+ amount: gift.amount,
+ claimantPublicKey: gift.recipientWallet,
+ });
+ if (!balanceId) {
+ // Leave the gift pending_signature so the client can retry — the
+ // create tx is already on-chain, and a retry simply re-resolves the id.
+ return res.status(502).json({
+ success: false,
+ message: "Could not resolve the claimable balance id yet; please retry",
+ createTxHash: result.hash,
+ });
+ }
+
+ gift.createTxHash = result.hash;
+ gift.balanceId = balanceId;
+ gift.status = "open";
+ await gift.save();
+
+ logger.info(
+ `Gift submitted: ${gift._id}, balance ${balanceId} (tx ${result.hash})`
+ );
+
+ res.status(200).json({
+ success: true,
+ giftId: gift._id,
+ balanceId,
+ createTxHash: result.hash,
+ explorerUrl: getExplorerUrl(result.hash),
+ });
+ } catch (error) {
+ logger.error("Submit gift error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to submit gift",
+ error:
+ process.env.NODE_ENV === "development" ? error.message : undefined,
+ });
+ }
+};
+
+/**
+ * List gifts sent and received by the current user.
+ * GET /api/stellar/gifts
+ */
+export const listGifts = async (req, res) => {
+ try {
+ const userId = req.user._id;
+
+ // Lazy expiry transition: open gifts past their expiry flip to "expired"
+ // (the document is never deleted — the sender still needs it to reclaim).
+ await GiftClaim.updateMany(
+ {
+ $or: [{ sender: userId }, { recipient: userId }],
+ status: "open",
+ expiresAt: { $lte: new Date() },
+ },
+ { $set: { status: "expired" } }
+ );
+
+ const gifts = await GiftClaim.find({
+ $or: [{ sender: userId }, { recipient: userId }],
+ })
+ .sort({ createdAt: -1 })
+ .populate("sender", "name")
+ .populate("recipient", "name");
+
+ res.status(200).json({ success: true, gifts });
+ } catch (error) {
+ logger.error("List gifts error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch gifts",
+ });
+ }
+};
+
+/**
+ * Get a single gift with live Horizon status of the underlying balance.
+ * GET /api/stellar/gifts/:id
+ */
+export const getGift = async (req, res) => {
+ try {
+ const userId = req.user._id;
+ const { id } = req.params;
+
+ const gift = await GiftClaim.findOne({
+ _id: id,
+ $or: [{ sender: userId }, { recipient: userId }],
+ })
+ .populate("sender", "name")
+ .populate("recipient", "name");
+
+ if (!gift) {
+ return res.status(404).json({
+ success: false,
+ message: "Gift not found",
+ });
+ }
+
+ // Lazy expiry transition (see listGifts).
+ if (gift.status === "open" && !isBeforeExpiry(gift)) {
+ gift.status = "expired";
+ await gift.save();
+ }
+
+ let live = null;
+ if (gift.balanceId) {
+ const balance = await getClaimableBalance(gift.balanceId);
+ live = balance.exists
+ ? {
+ state: balance.record.state,
+ sponsor: balance.record.sponsor,
+ lastModifiedLedger: balance.record.last_modified_ledger,
+ }
+ : { state: "not_found" };
+ }
+
+ res.status(200).json({
+ success: true,
+ gift: { ...gift.toObject(), live },
+ });
+ } catch (error) {
+ logger.error("Get gift error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to fetch gift",
+ });
+ }
+};
+
+/**
+ * Build an unsigned claim (or reclaim) XDR for a gift.
+ * Recipient-only before expiry; sender-only after expiry (reclaim).
+ * POST /api/stellar/gifts/:id/claim/initialize
+ */
+export const claimInitialize = async (req, res) => {
+ try {
+ const userId = req.user._id;
+ const { id } = req.params;
+
+ const gift = await GiftClaim.findOne({
+ _id: id,
+ $or: [{ sender: userId }, { recipient: userId }],
+ });
+ if (!gift) {
+ return res.status(404).json({
+ success: false,
+ message: "Gift not found",
+ });
+ }
+
+ if (gift.status === "pending_signature") {
+ return res.status(400).json({
+ success: false,
+ message: "Gift has not been submitted yet",
+ });
+ }
+ if (gift.status === "claimed" || gift.status === "reclaimed") {
+ return res.status(400).json({
+ success: false,
+ message: "Gift has already been claimed",
+ });
+ }
+
+ const user = await User.findById(userId);
+ if (!user?.stellarWallet?.publicKey) {
+ return res.status(400).json({
+ success: false,
+ message: "Please connect your Stellar wallet first",
+ });
+ }
+
+ const isRecipient = gift.recipient.toString() === userId.toString();
+ const isSender = gift.sender.toString() === userId.toString();
+ if (!isRecipient && !isSender) {
+ return res.status(403).json({
+ success: false,
+ message: "You are not a party to this gift",
+ });
+ }
+
+ const beforeExpiry = isBeforeExpiry(gift);
+ // Lazy expiry transition before the authorization decision.
+ if (gift.status === "open" && !beforeExpiry) {
+ gift.status = "expired";
+ await gift.save();
+ }
+
+ if (beforeExpiry) {
+ if (!isRecipient) {
+ return res.status(403).json({
+ success: false,
+ message: "Only the recipient can claim this gift before it expires",
+ });
+ }
+ } else if (!isSender) {
+ return res.status(403).json({
+ success: false,
+ message: "This gift has expired; only the sender can reclaim it",
+ });
+ }
+
+ const claim = await buildClaimTx({
+ claimantPublicKey: user.stellarWallet.publicKey,
+ balanceId: gift.balanceId,
+ });
+
+ res.status(200).json({
+ success: true,
+ giftId: gift._id,
+ action: beforeExpiry ? "claim" : "reclaim",
+ claim: {
+ xdr: claim.xdr,
+ networkPassphrase: claim.networkPassphrase,
+ expectedHash: claim.hash,
+ includesChangeTrust: claim.includesChangeTrust,
+ },
+ });
+ } catch (error) {
+ logger.error("Claim initialize error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to build claim transaction",
+ error:
+ process.env.NODE_ENV === "development" ? error.message : undefined,
+ });
+ }
+};
+
+/**
+ * Submit a signed claim (or reclaim) XDR, verify it on-chain, and — for a
+ * recipient claim — grant item access to the RECIPIENT.
+ * POST /api/stellar/gifts/:id/claim/submit
+ */
+export const claimSubmit = async (req, res) => {
+ try {
+ const userId = req.user._id;
+ const { id } = req.params;
+ const { signedXdr } = req.body;
+
+ if (!signedXdr) {
+ return res.status(400).json({
+ success: false,
+ message: "Signed XDR is required",
+ });
+ }
+
+ const gift = await GiftClaim.findOne({
+ _id: id,
+ $or: [{ sender: userId }, { recipient: userId }],
+ });
+ if (!gift) {
+ return res.status(404).json({
+ success: false,
+ message: "Gift not found",
+ });
+ }
+
+ if (gift.status === "pending_signature") {
+ return res.status(400).json({
+ success: false,
+ message: "Gift has not been submitted yet",
+ });
+ }
+ if (gift.status === "claimed" || gift.status === "reclaimed") {
+ return res.status(400).json({
+ success: false,
+ message: "Gift has already been claimed",
+ });
+ }
+
+ const user = await User.findById(userId);
+ if (!user?.stellarWallet?.publicKey) {
+ return res.status(400).json({
+ success: false,
+ message: "Please connect your Stellar wallet first",
+ });
+ }
+
+ const isRecipient = gift.recipient.toString() === userId.toString();
+ const isSender = gift.sender.toString() === userId.toString();
+ if (!isRecipient && !isSender) {
+ return res.status(403).json({
+ success: false,
+ message: "You are not a party to this gift",
+ });
+ }
+
+ const beforeExpiry = isBeforeExpiry(gift);
+ if (gift.status === "open" && !beforeExpiry) {
+ gift.status = "expired";
+ await gift.save();
+ }
+
+ if (beforeExpiry && !isRecipient) {
+ return res.status(403).json({
+ success: false,
+ message: "Only the recipient can claim this gift before it expires",
+ });
+ }
+ if (!beforeExpiry && !isSender) {
+ return res.status(403).json({
+ success: false,
+ message: "This gift has expired; only the sender can reclaim it",
+ });
+ }
+
+ let result;
+ try {
+ result = await submitTransaction(signedXdr);
+ } catch (stellarError) {
+ return res.status(400).json({
+ success: false,
+ message: "Transaction failed on Stellar network",
+ error: stellarError.message,
+ });
+ }
+
+ // Verify on-chain that the claim_claimable_balance op actually succeeded.
+ const verification = await verifyTransaction(result.hash);
+ if (!verification.exists || !verification.successful) {
+ return res.status(400).json({
+ success: false,
+ message: "Claim transaction did not succeed on the Stellar network",
+ });
+ }
+ const claimOp = (verification.operations || []).find(
+ (op) => op.type === "claim_claimable_balance"
+ );
+ if (!claimOp) {
+ return res.status(400).json({
+ success: false,
+ message:
+ "Claim transaction did not contain a claim_claimable_balance operation",
+ });
+ }
+
+ if (beforeExpiry) {
+ // Recipient claim → grant access to the RECIPIENT, never the sender.
+ // This deliberately inverts the buyer-centric purchase flow: the payer
+ // (sender) funded the balance, but the beneficiary (recipient) is the
+ // one who gains course/book access.
+ await grantItemAccess({
+ buyerId: gift.recipient,
+ itemType: gift.itemType,
+ itemId: gift.itemId,
+ });
+ gift.status = "claimed";
+ } else {
+ gift.status = "reclaimed";
+ }
+ gift.claimTxHash = result.hash;
+ await gift.save();
+
+ logger.info(
+ `Gift ${gift._id} ${beforeExpiry ? "claimed" : "reclaimed"} by ${userId} (tx ${result.hash})`
+ );
+
+ res.status(200).json({
+ success: true,
+ giftId: gift._id,
+ action: beforeExpiry ? "claimed" : "reclaimed",
+ claimTxHash: result.hash,
+ explorerUrl: getExplorerUrl(result.hash),
+ });
+ } catch (error) {
+ logger.error("Claim submit error:", error);
+ res.status(500).json({
+ success: false,
+ message: "Failed to submit claim",
+ error:
+ process.env.NODE_ENV === "development" ? error.message : undefined,
+ });
+ }
+};
diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js
index 3e99b0dc..e4276cad 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -10,6 +10,7 @@ import {
buildSep7Uri,
calculateFeeSplit,
preflightPayment,
+ PREFLIGHT_REASON_CODES,
submitTransaction,
verifyTransaction,
verifyPaymentOperations,
@@ -60,6 +61,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",
},
};
}
@@ -253,6 +257,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 }),
});
}
@@ -286,9 +291,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);
@@ -344,6 +357,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 }),
});
}
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/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/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/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/giftClaimableBalances.test.js b/test/giftClaimableBalances.test.js
new file mode 100644
index 00000000..d91cf285
--- /dev/null
+++ b/test/giftClaimableBalances.test.js
@@ -0,0 +1,561 @@
+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(),
+ 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/webhooks.test.js b/test/webhooks.test.js
index 3592c6a9..bf8eab6f 100644
--- a/test/webhooks.test.js
+++ b/test/webhooks.test.js
@@ -38,6 +38,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({
buildSep7Uri: jest.fn(),
calculateFeeSplit: jest.fn(() => null),
preflightPayment: jest.fn(),
+ PREFLIGHT_REASON_CODES: {},
submitTransaction,
verifyTransaction: jest.fn(),
verifyPaymentOperations,
From 17910197cd6e4607cf835ca2a793258542e6280d Mon Sep 17 00:00:00 2001
From: Samuel Ojetunde
Date: Wed, 19 Aug 2026 10:06:21 +0100
Subject: [PATCH 20/25] feat(stellar): add idempotency protection to the
Stellar payment endpoints (#115)
Makes /api/stellar/payment/initialize and /submit safe against
double-clicks, client retries, and concurrent duplicates. Submit is
naturally idempotent per transaction hash: the deterministic hash of
the signed XDR is looked up against confirmed transactions before any
processing, so a replayed submission returns the original success
response without re-granting access, with the unique index on
stellarTxHash as the database-level backstop (an E11000 on the confirm
save is treated as already processed). Initialize no longer piles up
duplicates: a pending checkout for the same user+item returns the
existing record (with its persisted unsigned XDR) instead of creating
a new document, and stale pending records are reaped by the existing
pending-only TTL index. Adds a stricter per-user rate limiter
(paymentLimiter) on the payment routes, keyed on the authenticated
user id with an IPv6-aware IP fallback. Covers duplicate-submit,
duplicate-initialize, the E11000 race, and limiter enforcement with
tests.
---
app.js | 4 +-
src/controllers/stellar/paymentController.js | 108 ++++-
src/middlewares/security.js | 27 +-
src/models/Transaction.js | 7 +
test/paymentIdempotency.test.js | 400 +++++++++++++++++++
test/webhooks.test.js | 1 +
6 files changed, 541 insertions(+), 6 deletions(-)
create mode 100644 test/paymentIdempotency.test.js
diff --git a/app.js b/app.js
index 62a408d1..cb4109db 100644
--- a/app.js
+++ b/app.js
@@ -13,6 +13,7 @@ import {
standardLimiter,
generousLimiter,
authLimiter,
+ paymentLimiter,
mongoSanitizeMiddleware,
hppMiddleware,
customSecurityHeaders,
@@ -187,7 +188,8 @@ app.use("/api/calls", generousLimiter, callRoutes);
app.use("/api/educators", generousLimiter, educatorRoutes);
app.use("/api/educator-verification", standardLimiter, educatorVerificationRoutes);
app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes);
-app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes);
+// Payment routes mutate money state — stricter per-user limiter (issue #4).
+app.use("/api/stellar/payment", paymentLimiter, stellarPaymentRoutes);
app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes);
app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js
index e4276cad..22ec80b1 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -18,6 +18,7 @@ import {
findPaymentPaths,
applySlippage,
NETWORK,
+ networkPassphrase,
getExplorerUrl,
USDC,
PLATFORM_WALLET_PUBLIC_KEY,
@@ -403,11 +404,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,
});
}
@@ -516,6 +532,7 @@ export const initializePayment = async (req, res) => {
status: "pending",
settlement: settlementMode,
expectedHash: paymentTx.hash,
+ unsignedXdr: paymentTx.xdr,
memo,
...(sendAssetInput && {
sendAsset: sendAssetInput,
@@ -628,6 +645,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,
@@ -832,7 +897,42 @@ export const submitPayment = async (req, res) => {
transaction.status = "confirmed";
transaction.confirmedAt = new Date();
transaction.expiresAt = undefined; // terminal state — never TTL-reapable
- await transaction.save({ session });
+ 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 });
diff --git a/src/middlewares/security.js b/src/middlewares/security.js
index 6dc9fd36..28eaf9fc 100644
--- a/src/middlewares/security.js
+++ b/src/middlewares/security.js
@@ -1,5 +1,5 @@
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";
@@ -117,6 +117,31 @@ export const captchaGate = () => async (req, res, next) => {
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.
diff --git a/src/models/Transaction.js b/src/models/Transaction.js
index 1331df6b..92f60960 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -15,6 +15,13 @@ const transactionSchema = new mongoose.Schema(
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,
},
diff --git a/test/paymentIdempotency.test.js b/test/paymentIdempotency.test.js
new file mode 100644
index 00000000..66d6a0dc
--- /dev/null
+++ b/test/paymentIdempotency.test.js
@@ -0,0 +1,400 @@
+import { jest } from "@jest/globals";
+import express from "express";
+import request from "supertest";
+import mongoose from "mongoose";
+import * as StellarSdk from "@stellar/stellar-sdk";
+
+const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015";
+
+const buildPaymentTransaction = jest.fn();
+const buildSep7Uri = jest.fn();
+const calculateFeeSplit = jest.fn();
+const preflightPayment = jest.fn();
+const submitTransaction = jest.fn();
+const verifyPaymentOperations = jest.fn();
+const getExplorerUrl = jest.fn((hash) => `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(),
+ 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/webhooks.test.js b/test/webhooks.test.js
index bf8eab6f..8fa04f73 100644
--- a/test/webhooks.test.js
+++ b/test/webhooks.test.js
@@ -46,6 +46,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({
findPaymentPaths: jest.fn(),
applySlippage: jest.fn(),
NETWORK: "testnet",
+ networkPassphrase: "Test SDF Network ; September 2015",
getExplorerUrl,
USDC: "USDC",
PLATFORM_WALLET_PUBLIC_KEY: "",
From 47fffd2fe3945ab29bf6fea92828f4df1ac84774 Mon Sep 17 00:00:00 2001
From: Samuel Ojetunde
Date: Wed, 19 Aug 2026 10:06:53 +0100
Subject: [PATCH 21/25] feat(stellar): validate Stellar config at startup and
document the mainnet switch (#114)
Adds a single source of truth for the Stellar network configuration
(src/config/stellar.js) that resolves the network name, network
passphrase, Horizon URLs, and USDC issuer from STELLAR_NETWORK, and
validates the whole setup fail-fast at boot so a misconfigured
deployment (bad network value, mainnet flag with testnet Horizon or
issuer) fails with an error naming the exact problem instead of at
request time. stellarService.js and horizonClient.js now consume this
module. Adds docs/MAINNET.md covering the env changes, creator
trustlines, and a first-mainnet-transaction smoke checklist, plus unit
tests for resolution and validation across both networks.
---
.env.example | 5 +-
README.md | 3 +-
docs/MAINNET.md | 187 +++++++++++++++++++++++++
src/config/stellar.js | 177 +++++++++++++++++++++++
src/config/stellarConfig.test.js | 179 +++++++++++++++++++++++
src/config/validateEnv.js | 27 +++-
src/services/stellar/horizonClient.js | 14 +-
src/services/stellar/stellarService.js | 26 ++--
8 files changed, 590 insertions(+), 28 deletions(-)
create mode 100644 docs/MAINNET.md
create mode 100644 src/config/stellar.js
create mode 100644 src/config/stellarConfig.test.js
diff --git a/.env.example b/.env.example
index 4966808b..5c51896d 100644
--- a/.env.example
+++ b/.env.example
@@ -36,7 +36,10 @@ SENDLIB_API_URL=https://sendlib.samueltuoyo.com/api/send
# SendLib. Prefer a Workspace address on your own domain (e.g. no-reply@deenbridge.app).
EMAIL_FROM=no-reply@deenbridge.com
-# Stellar blockchain network (testnet or mainnet)
+# Stellar blockchain network (testnet or mainnet; "public" is accepted as an
+# alias for mainnet). Validated fail-fast at boot — a mainnet flag paired
+# with a testnet Horizon URL or USDC issuer aborts startup with the exact
+# problem named. See docs/MAINNET.md for the full mainnet switch checklist.
STELLAR_NETWORK=testnet
# Resilient Horizon Client Configuration (Optional)
diff --git a/README.md b/README.md
index c2832506..069fbebc 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,8 @@ 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` |
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/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 1c8a4c86..8a27d99a 100644
--- a/src/config/validateEnv.js
+++ b/src/config/validateEnv.js
@@ -1,4 +1,5 @@
import logger from "./logger.js";
+import { validateStellarConfig, resolveStellarConfig } from "./stellar.js";
/**
* Validate required environment variables
@@ -81,19 +82,33 @@ 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";
+ // 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) => {
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/stellarService.js b/src/services/stellar/stellarService.js
index ca9d2695..6c289f78 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);
From 90f325d4d137c2c796287c838172ddd8d42fb631 Mon Sep 17 00:00:00 2001
From: Ezekiel Akawa
Date: Wed, 19 Aug 2026 11:25:20 +0100
Subject: [PATCH 22/25] feat(stellar): fee-bump sponsorship with structural
whitelist and spend caps (#111)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Let the platform pay a user's Stellar network fee by wrapping the user-signed
transaction in a fee-bump signed by a dedicated fee-source account, so a user
holding USDC but ~no XLM can buy a book, buy a course, or donate. Opt-in per
submit via `requestSponsorship: true`; off by default and byte-for-byte
identical when disabled.
Guard rails (server signs on the platform's behalf):
- Structural whitelist (reject-by-default, allow-list of `payment` ops only):
source, exact op count/order, destinations, amounts (stroops), asset, and
memo must match the pending Transaction row exactly. Any foreign/extra
operation — including unknown future types — is rejected.
- Durable spend caps (SponsorshipSpend, keyed by UTC day): per-transaction fee
ceiling, per-day total, and per-user per-day count.
- Sponsor float pre-check so an underfunded sponsor never marks the user's
transaction failed.
Fee-bump fee is priced over inner ops + wrapper and clamped to the ceiling
(verified against @stellar/stellar-sdk v16 and asserted in tests). Sponsored
rows record `sponsored`, `sponsorFeeCharged`, and the fee-bump (outer) hash
alongside the inner hash. Sponsorship-specific failures return a distinct
non-fatal 4xx/503 with `retryUnsponsored: true` and never mark the row failed.
- Config: FEE_SPONSOR_* in .env.example + validateEnv (fail-fast at boot when
enabled with a missing/invalid secret); secret never logged or returned.
- Admin status endpoint GET /api/stellar/payment/sponsorship/status exposes the
sponsor public key, live float, caps, and today's spend.
- Prometheus counters for approved/rejected sponsorship decisions.
- Docs: docs/fee-sponsorship.md, README, and openapi.yaml.
- Tests: feeSponsorService (whitelist adversarial matrix, fee correctness,
inner-untouched, caps, secret handling, boot config) and feeSponsorSubmit
(payment + donation flag-off regression, flag-on sponsorship, cap/whitelist
rejections that don't fail the row).
Closes #30
---
.env.example | 21 +
README.md | 2 +
docs/fee-sponsorship.md | 133 +++++
openapi.yaml | 33 ++
src/config/metrics.js | 18 +
src/config/validateEnv.js | 18 +
src/controllers/stellar/donationController.js | 151 +++--
src/controllers/stellar/paymentController.js | 213 +++++--
src/models/SponsorshipSpend.js | 43 ++
src/models/Transaction.js | 19 +
src/routes/stellar/paymentRoutes.js | 8 +
src/services/stellar/feeSponsorService.js | 528 +++++++++++++++++
src/services/stellar/stellarService.js | 4 +
test/feeSponsorService.test.js | 533 ++++++++++++++++++
test/feeSponsorSubmit.test.js | 380 +++++++++++++
test/giftClaimableBalances.test.js | 1 +
test/paymentIdempotency.test.js | 1 +
test/requestValidation.test.js | 1 +
test/stellarPaymentController.test.js | 1 +
test/webhooks.test.js | 6 +
20 files changed, 2032 insertions(+), 82 deletions(-)
create mode 100644 docs/fee-sponsorship.md
create mode 100644 src/models/SponsorshipSpend.js
create mode 100644 src/services/stellar/feeSponsorService.js
create mode 100644 test/feeSponsorService.test.js
create mode 100644 test/feeSponsorSubmit.test.js
diff --git a/.env.example b/.env.example
index 5c51896d..ab6f7986 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,27 @@ DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PLATFORM_FEE_PERCENT=0
PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+# ── Fee-bump sponsorship (#30) ────────────────────────────────────────────────
+# The platform can pay a user's Stellar network fee by wrapping their signed
+# transaction in a fee-bump. A user holding USDC but ~no XLM can then transact.
+# All of these are OPTIONAL: with FEE_SPONSOR_ENABLED unset/false (the default)
+# the feature is completely inert and the payment/donation flow is unchanged.
+#
+# Master switch. Leave false to disable sponsorship entirely.
+FEE_SPONSOR_ENABLED=false
+# DEDICATED fee-source secret (S…) for an account holding a small XLM float used
+# ONLY to pay network fees. It MUST NOT be the donation or platform receiving
+# wallet, and can never move user funds — it only signs the fee-bump wrapper.
+# Required (and validated at boot) when FEE_SPONSOR_ENABLED=true. Never commit a
+# real secret; keep it out of source control like the donation secret.
+FEE_SPONSOR_SECRET=
+# Per-transaction fee ceiling in stroops (default 1000000 = 0.1 XLM).
+FEE_SPONSOR_MAX_FEE_STROOPS=1000000
+# Total XLM-fee spend allowed per UTC day, in stroops (default 100000000 = 10 XLM).
+FEE_SPONSOR_DAILY_CAP_STROOPS=100000000
+# Max sponsored transactions per user per UTC day (default 10).
+FEE_SPONSOR_PER_USER_DAILY_LIMIT=10
+
# SEP-1 stellar.toml (/.well-known/stellar.toml) — optional, omitted when blank
STELLAR_PLATFORM_PUBLIC_KEY=
ORG_NAME=
diff --git a/README.md b/README.md
index 069fbebc..0b71fad1 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
@@ -83,6 +84,7 @@ The API runs at `http://localhost:5000`.
| `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.
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/openapi.yaml b/openapi.yaml
index 1d764ab2..8897be9a 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1732,6 +1732,12 @@ paths:
post:
tags: [Payments]
summary: Submit a signed transaction and verify it on chain
+ description: >
+ Submits the buyer-signed transaction and verifies it on chain. When
+ `requestSponsorship: true` and fee sponsorship is enabled, the platform
+ pays the network fee via a fee-bump wrapper (see docs/fee-sponsorship.md).
+ Sponsorship-specific failures return a distinct non-fatal 4xx/503 with
+ `retryUnsponsored: true` and never mark the transaction failed.
requestBody:
required: true
content:
@@ -1741,12 +1747,17 @@ paths:
properties:
signedXdr: { type: string }
transactionId: { $ref: "#/components/schemas/ObjectId" }
+ requestSponsorship:
+ type: boolean
+ description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled.
required: [signedXdr]
responses:
"200": { $ref: "#/components/responses/Ok" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"409": { $ref: "#/components/responses/Conflict" }
+ "422": { $ref: "#/components/responses/BadRequest" }
+ "429": { $ref: "#/components/responses/BadRequest" }
/api/stellar/payment/transactions:
get:
tags: [Payments]
@@ -1940,6 +1951,18 @@ paths:
"200": { $ref: "#/components/responses/Ok" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
+ /api/stellar/payment/sponsorship/status:
+ get:
+ tags: [Payments]
+ summary: Fee-bump sponsorship status (admin)
+ description: >
+ Admin-only. Returns whether fee sponsorship is enabled, the sponsor
+ account public key (never the secret) and live XLM float, the configured
+ caps, and today's spend. See docs/fee-sponsorship.md.
+ responses:
+ "200": { $ref: "#/components/responses/Ok" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "403": { $ref: "#/components/responses/Forbidden" }
/api/stellar/donation/stats:
get:
tags: [Donations]
@@ -1970,6 +1993,10 @@ paths:
post:
tags: [Donations]
summary: Submit a signed donation transaction
+ description: >
+ Submits the donor-signed transaction and verifies it on chain. Supports
+ the same optional `requestSponsorship: true` fee-bump flow as the payment
+ submit endpoint (see docs/fee-sponsorship.md).
requestBody:
required: true
content:
@@ -1978,11 +2005,17 @@ paths:
type: object
properties:
signedXdr: { type: string }
+ donationId: { $ref: "#/components/schemas/ObjectId" }
+ requestSponsorship:
+ type: boolean
+ description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled.
required: [signedXdr]
responses:
"200": { $ref: "#/components/responses/Ok" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
+ "422": { $ref: "#/components/responses/BadRequest" }
+ "429": { $ref: "#/components/responses/BadRequest" }
/api/payouts/me/balance:
get:
tags: [Payouts]
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/validateEnv.js b/src/config/validateEnv.js
index 8a27d99a..17b915da 100644
--- a/src/config/validateEnv.js
+++ b/src/config/validateEnv.js
@@ -1,5 +1,6 @@
import logger from "./logger.js";
import { validateStellarConfig, resolveStellarConfig } from "./stellar.js";
+import { validateFeeSponsorBootConfig } from "../services/stellar/feeSponsorService.js";
/**
* Validate required environment variables
@@ -68,6 +69,14 @@ const optionalEnvVars = [
// 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 = () => {
@@ -144,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/stellar/donationController.js b/src/controllers/stellar/donationController.js
index 88b0deb1..11215d47 100644
--- a/src/controllers/stellar/donationController.js
+++ b/src/controllers/stellar/donationController.js
@@ -13,6 +13,12 @@ import {
NETWORK,
DONATION_WALLET_PUBLIC_KEY,
} from "../../services/stellar/stellarService.js";
+import {
+ isFeeSponsorEnabled,
+ prepareSponsoredSubmission,
+ recordSponsorshipSpend,
+ SponsorshipError,
+} from "../../services/stellar/feeSponsorService.js";
import logger from "../../config/logger.js";
import { enqueue } from "../../jobs/queue.js";
import {
@@ -20,6 +26,8 @@ import {
paymentsSubmitted,
paymentsConfirmed,
paymentsFailed,
+ sponsorshipsApproved,
+ sponsorshipsRejected,
} from "../../config/metrics.js";
const DONATION_MEMO = "DNB-SADAQAH";
@@ -134,7 +142,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) {
@@ -169,30 +177,71 @@ export const submitDonation = async (req, res) => {
},
];
- // 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" });
+ // 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);
+ 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,
- });
+ 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
@@ -204,7 +253,7 @@ 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;
@@ -222,12 +271,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)
// (expectedPayments already defined above for pre-submission validation)
- const verification = await verifyPaymentOperations(result.hash, expectedPayments);
+ 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;
@@ -238,7 +317,7 @@ export const submitDonation = async (req, res) => {
{
attempts: 5,
backoffMs: 1000,
- idempotencyKey: `verify:${result.hash}`,
+ idempotencyKey: `verify:${settledHash}`,
session,
}
);
@@ -247,8 +326,9 @@ 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";
@@ -270,7 +350,7 @@ 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();
@@ -282,7 +362,7 @@ export const submitDonation = async (req, res) => {
{
attempts: 5,
backoffMs: 1000,
- idempotencyKey: `receipt:${result.hash}`,
+ idempotencyKey: `receipt:${settledHash}`,
session,
}
);
@@ -290,14 +370,19 @@ export const submitDonation = async (req, res) => {
paymentsConfirmed.inc({ type: "donation" });
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/paymentController.js b/src/controllers/stellar/paymentController.js
index 22ec80b1..377f0ad8 100644
--- a/src/controllers/stellar/paymentController.js
+++ b/src/controllers/stellar/paymentController.js
@@ -25,6 +25,13 @@ import {
} 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";
@@ -34,6 +41,8 @@ import {
paymentsSubmitted,
paymentsConfirmed,
paymentsFailed,
+ sponsorshipsApproved,
+ sponsorshipsRejected,
} from "../../config/metrics.js";
import { recordAudit } from "../../services/audit/auditService.js";
import { AUDIT_ACTIONS } from "../../models/AuditLog.js";
@@ -634,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) {
@@ -726,42 +735,86 @@ export const submitPayment = async (req, res) => {
},
];
- // 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);
+ // 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;
- 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}`,
- });
+ 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,
- });
+ 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
@@ -772,7 +825,7 @@ 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;
@@ -802,18 +855,52 @@ export const submitPayment = async (req, res) => {
});
}
+ // 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;
@@ -825,7 +912,7 @@ export const submitPayment = async (req, res) => {
{
attempts: 5,
backoffMs: 1000,
- idempotencyKey: `verify:${result.hash}`,
+ idempotencyKey: `verify:${settledHash}`,
session,
}
);
@@ -843,8 +930,9 @@ 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";
@@ -867,7 +955,7 @@ export const submitPayment = async (req, res) => {
status: "failure",
metadata: {
transactionId,
- stellarTxHash: result.hash,
+ stellarTxHash: settledHash,
failureReason: `On-chain verification failed: ${verification.reason}`,
},
});
@@ -879,7 +967,7 @@ export const submitPayment = async (req, res) => {
amount: transaction.amount,
currency: transaction.currency,
network: transaction.network,
- stellarTxHash: result.hash,
+ stellarTxHash: settledHash,
buyerId: buyerId.toString(),
status: "failed",
failureReason: `On-chain verification failed: ${verification.reason}`,
@@ -892,7 +980,7 @@ export const submitPayment = async (req, res) => {
});
}
- transaction.stellarTxHash = result.hash;
+ transaction.stellarTxHash = settledHash;
transaction.stellarLedger = result.ledger;
transaction.status = "confirmed";
transaction.confirmedAt = new Date();
@@ -951,7 +1039,7 @@ export const submitPayment = async (req, res) => {
{
attempts: 5,
backoffMs: 1000,
- idempotencyKey: `receipt:${result.hash}`,
+ idempotencyKey: `receipt:${settledHash}`,
session,
}
);
@@ -967,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({
@@ -979,12 +1067,13 @@ 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(),
settlementMode: transaction.settlement,
+ sponsored: !!sponsorship,
},
});
@@ -998,11 +1087,12 @@ export const submitPayment = async (req, res) => {
currency: transaction.currency,
network: transaction.network,
settlement: transaction.settlement,
- stellarTxHash: result.hash,
+ stellarTxHash: settledHash,
stellarLedger: result.ledger,
buyerId: buyerId.toString(),
creatorId: transaction.creator?.toString(),
status: "confirmed",
+ sponsored: !!sponsorship,
});
res.status(200).json({
@@ -1010,11 +1100,16 @@ export const submitPayment = async (req, res) => {
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) {
@@ -1209,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/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 92f60960..23f9cb22 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -128,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,
diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js
index 1dc07f05..93d8e360 100644
--- a/src/routes/stellar/paymentRoutes.js
+++ b/src/routes/stellar/paymentRoutes.js
@@ -10,6 +10,7 @@ import {
getTransactionHistory,
getTransaction,
cancelTransaction,
+ sponsorshipStatus,
} from "../../controllers/stellar/paymentController.js";
import {
requestRefund,
@@ -73,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/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/stellarService.js b/src/services/stellar/stellarService.js
index 6c289f78..3d25dcff 100644
--- a/src/services/stellar/stellarService.js
+++ b/src/services/stellar/stellarService.js
@@ -616,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);
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
index d91cf285..27d12d8a 100644
--- a/test/giftClaimableBalances.test.js
+++ b/test/giftClaimableBalances.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(),
diff --git a/test/paymentIdempotency.test.js b/test/paymentIdempotency.test.js
index 66d6a0dc..73a80a25 100644
--- a/test/paymentIdempotency.test.js
+++ b/test/paymentIdempotency.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(),
diff --git a/test/requestValidation.test.js b/test/requestValidation.test.js
index 2c9d0148..80025b7e 100644
--- a/test/requestValidation.test.js
+++ b/test/requestValidation.test.js
@@ -34,6 +34,7 @@ const paymentHandlers = {
getTransactionHistory: controller("history"),
getTransaction: controller("transaction"),
cancelTransaction: controller("cancel"),
+ sponsorshipStatus: controller("sponsorshipStatus"),
};
const refundHandlers = {
diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js
index 619fbe03..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(),
diff --git a/test/webhooks.test.js b/test/webhooks.test.js
index 8fa04f73..96816a89 100644
--- a/test/webhooks.test.js
+++ b/test/webhooks.test.js
@@ -50,6 +50,12 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({
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,
From 5f179599dba08d1b4a18dc9c66cf2ee4ba61edf2 Mon Sep 17 00:00:00 2001
From: Mfon <67503972+TS-mfon@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:45:04 +0100
Subject: [PATCH 23/25] feat: add managed course categories (#118)
* feat(courses): add managed category taxonomy
* fix(categories): preserve legacy course creation
---
README.md | 3 +
app.js | 2 +
package.json | 2 +
src/controllers/categoryController.js | 118 ++++++++++++++++++++
src/controllers/courses/courseController.js | 31 ++++-
src/models/Book.js | 1 +
src/models/Category.js | 19 ++++
src/models/Course.js | 2 +-
src/routes/categoryRoutes.js | 13 +++
src/routes/courses/courseRoutes.js | 3 +-
src/scripts/migrateCategories.js | 28 +++++
src/scripts/seedCategories.js | 32 ++++++
src/services/categoryService.js | 46 ++++++++
src/utils/cache.js | 1 +
14 files changed, 294 insertions(+), 7 deletions(-)
create mode 100644 src/controllers/categoryController.js
create mode 100644 src/models/Category.js
create mode 100644 src/routes/categoryRoutes.js
create mode 100644 src/scripts/migrateCategories.js
create mode 100644 src/scripts/seedCategories.js
create mode 100644 src/services/categoryService.js
diff --git a/README.md b/README.md
index 9a5fa058..af0bb777 100644
--- a/README.md
+++ b/README.md
@@ -145,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 cb4109db..a97d390d 100644
--- a/app.js
+++ b/app.js
@@ -50,6 +50,7 @@ import educatorRoutes from "./src/routes/educatorRoutes.js";
import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js";
import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js";
import webhookRoutes from "./src/routes/webhookRoutes.js";
+import categoryRoutes from "./src/routes/categoryRoutes.js";
import { healthCheck, ping } from "./src/controllers/healthController.js";
const app = express();
@@ -178,6 +179,7 @@ app.use("/api/payouts", standardLimiter, payoutRoutes);
// Read-heavy & content routes — generous limiter
app.use("/api/courses", generousLimiter, courseRoutes);
+app.use("/api/categories", generousLimiter, categoryRoutes);
app.use("/api/reels", generousLimiter, reelsRoute);
app.use("/api/books", generousLimiter, bookRoutes);
app.use("/api/books", generousLimiter, recommendedBooksRoutes);
diff --git a/package.json b/package.json
index 6ed4bbf8..51efca64 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,8 @@
"start": "node --import dotenv/config server.js",
"dev": "nodemon --import dotenv/config server.js",
"seed": "node src/scripts/seedDatabase.js",
+ "seed:categories": "node src/scripts/seedCategories.js",
+ "migrate:categories": "node src/scripts/migrateCategories.js",
"migrate:review-stats": "node src/migrations/backfillReviewStats.js",
"test-redis": "node test-redis.js",
"payouts:audit": "node src/scripts/auditPayouts.js",
diff --git a/src/controllers/categoryController.js b/src/controllers/categoryController.js
new file mode 100644
index 00000000..01fa4b5d
--- /dev/null
+++ b/src/controllers/categoryController.js
@@ -0,0 +1,118 @@
+import mongoose from "mongoose";
+import Category from "../models/Category.js";
+import Course from "../models/Course.js";
+import { deleteCachePattern } from "../utils/cache.js";
+import { slugifyCategory, uniqueCategorySlug } from "../services/categoryService.js";
+
+const categoryProjection = {
+ name: 1,
+ slug: 1,
+ description: 1,
+ icon: 1,
+ image: 1,
+ parent: 1,
+ order: 1,
+};
+
+export const listCategories = async (_req, res) => {
+ const categories = await Category.aggregate([
+ { $match: { isActive: true } },
+ {
+ $lookup: {
+ from: "courses",
+ localField: "_id",
+ foreignField: "categoryRef",
+ as: "courses",
+ },
+ },
+ {
+ $addFields: {
+ courseCount: { $size: "$courses" },
+ enrollmentCount: {
+ $sum: {
+ $map: { input: "$courses", as: "course", in: { $size: { $ifNull: ["$$course.enrolledUsers", []] } } },
+ },
+ },
+ freeCount: {
+ $size: { $filter: { input: "$courses", as: "course", cond: { $eq: ["$$course.price", 0] } } },
+ },
+ paidCount: {
+ $size: { $filter: { input: "$courses", as: "course", cond: { $gt: ["$$course.price", 0] } } },
+ },
+ minPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $min: "$courses.price" }, null] },
+ maxPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $max: "$courses.price" }, null] },
+ },
+ },
+ { $project: { ...categoryProjection, courseCount: 1, enrollmentCount: 1, freeCount: 1, paidCount: 1, minPrice: 1, maxPrice: 1 } },
+ { $sort: { order: 1, name: 1 } },
+ ]);
+ res.json({ success: true, categories });
+};
+
+export const getCategory = async (req, res) => {
+ const category = await Category.findOne({ slug: slugifyCategory(req.params.slug), isActive: true })
+ .select(categoryProjection)
+ .lean();
+ if (!category) return res.status(404).json({ success: false, message: "Category not found" });
+
+ const page = Math.max(1, Number(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
+ const sorts = { newest: { createdAt: -1 }, popular: { enrolledUsers: -1 }, price: { price: 1 } };
+ const sort = sorts[req.query.sort] || sorts.newest;
+ const filter = { categoryRef: category._id };
+ const [courses, total] = await Promise.all([
+ Course.find(filter).sort(sort).skip((page - 1) * limit).limit(limit).populate("createdBy", "name avatar"),
+ Course.countDocuments(filter),
+ ]);
+ res.json({ success: true, category, courses, pagination: { page, limit, total, pages: Math.ceil(total / limit) } });
+};
+
+export const createCategory = async (req, res) => {
+ const { name, description, icon, image, parent, order, isActive } = req.body;
+ if (!name?.trim()) return res.status(400).json({ success: false, message: "Category name is required" });
+ const duplicate = await Category.exists({ name: new RegExp(`^${name.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") });
+ if (duplicate) return res.status(409).json({ success: false, message: "Category name already exists" });
+ if (parent) {
+ const parentCategory = await Category.findById(parent);
+ if (!parentCategory || parentCategory.parent) return res.status(400).json({ success: false, message: "Parent must be a top-level category" });
+ }
+ const category = await Category.create({ name: name.trim(), slug: await uniqueCategorySlug(name), description, icon, image, parent: parent || null, order, isActive });
+ await deleteCachePattern("categories:*");
+ res.status(201).json({ success: true, category });
+};
+
+export const updateCategory = async (req, res) => {
+ if (!mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ success: false, message: "Invalid category id" });
+ const category = await Category.findById(req.params.id);
+ if (!category) return res.status(404).json({ success: false, message: "Category not found" });
+ const allowed = ["description", "icon", "image", "order", "isActive"];
+ for (const field of allowed) if (req.body[field] !== undefined) category[field] = req.body[field];
+ if (req.body.name && req.body.name.trim() !== category.name) {
+ category.name = req.body.name.trim();
+ category.slug = await uniqueCategorySlug(category.name, category._id);
+ }
+ if (req.body.parent !== undefined) {
+ if (req.body.parent) {
+ const parent = await Category.findById(req.body.parent);
+ if (!parent || parent.parent || parent._id.equals(category._id)) return res.status(400).json({ success: false, message: "Invalid parent category" });
+ }
+ category.parent = req.body.parent || null;
+ }
+ await category.save();
+ await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]);
+ res.json({ success: true, category });
+};
+
+export const deleteCategory = async (req, res) => {
+ const category = await Category.findById(req.params.id);
+ if (!category) return res.status(404).json({ success: false, message: "Category not found" });
+ const courseCount = await Course.countDocuments({ categoryRef: category._id });
+ if (courseCount > 0) {
+ category.isActive = false;
+ await category.save();
+ } else {
+ await category.deleteOne();
+ }
+ await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]);
+ res.json({ success: true, softDeleted: courseCount > 0 });
+};
diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js
index 3571de6c..648c52b5 100644
--- a/src/controllers/courses/courseController.js
+++ b/src/controllers/courses/courseController.js
@@ -5,6 +5,11 @@ import { catchAsync, APIError } from "../../middlewares/errorHandler.js";
import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js";
import { createNewCourseNotification } from "../notificationController.js";
import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js";
+import {
+ categoryTaxonomyExists,
+ categoryValidationError,
+ resolveActiveCategory,
+} from "../../services/categoryService.js";
/**
* Create a new course
@@ -22,12 +27,17 @@ export const createCourse = catchAsync(async (req, res, next) => {
new APIError("Title, description, and category are required", 400)
);
}
+ const categoryDoc = await resolveActiveCategory(category);
+ if (!categoryDoc && (await categoryTaxonomyExists())) {
+ return next(new APIError(await categoryValidationError(), 400));
+ }
// Create course with URLs from frontend
const course = await Course.create({
title,
description,
- category,
+ category: categoryDoc?.name || category,
+ categoryRef: categoryDoc?._id,
price: price || 0,
createdBy: req.user._id,
thumbnail: thumbnail || null, // URL from frontend
@@ -49,9 +59,15 @@ export const createCourse = catchAsync(async (req, res, next) => {
});
// 📚 Get all courses
-export const getCourses = async (_req, res) => {
+export const getCourses = async (req, res) => {
try {
- const courses = await Course.find().populate(
+ const filter = {};
+ if (req.query.category) {
+ const categoryDoc = await resolveActiveCategory(req.query.category);
+ if (!categoryDoc) return res.status(404).json({ success: false, message: "Category not found" });
+ filter.categoryRef = categoryDoc._id;
+ }
+ const courses = await Course.find(filter).populate(
"createdBy",
"name email avatar"
);
@@ -185,7 +201,14 @@ export const updateCourse = catchAsync(async (req, res, next) => {
// Update fields (URLs from frontend)
course.title = title || course.title;
course.description = description || course.description;
- course.category = category || course.category;
+ if (category) {
+ const categoryDoc = await resolveActiveCategory(category);
+ if (!categoryDoc && (await categoryTaxonomyExists())) {
+ return next(new APIError(await categoryValidationError(), 400));
+ }
+ course.category = categoryDoc?.name || category;
+ course.categoryRef = categoryDoc?._id;
+ }
course.price = price !== undefined ? price : course.price;
// Update media URLs if provided
diff --git a/src/models/Book.js b/src/models/Book.js
index 458aca70..2309cb7b 100644
--- a/src/models/Book.js
+++ b/src/models/Book.js
@@ -12,6 +12,7 @@ const bookSchema = new mongoose.Schema({
required: true,
},
category: String,
+ categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true },
price: {
type: Number,
default: 0,
diff --git a/src/models/Category.js b/src/models/Category.js
new file mode 100644
index 00000000..1c40936f
--- /dev/null
+++ b/src/models/Category.js
@@ -0,0 +1,19 @@
+import mongoose from "mongoose";
+
+const categorySchema = new mongoose.Schema(
+ {
+ name: { type: String, required: true, trim: true, unique: true },
+ slug: { type: String, required: true, trim: true, lowercase: true, unique: true, index: true },
+ description: { type: String, default: "" },
+ icon: { type: String, default: "" },
+ image: { type: String, default: "" },
+ parent: { type: mongoose.Schema.Types.ObjectId, ref: "Category", default: null },
+ order: { type: Number, default: 0 },
+ isActive: { type: Boolean, default: true, index: true },
+ },
+ { timestamps: true }
+);
+
+categorySchema.index({ parent: 1, order: 1 });
+
+export default mongoose.model("Category", categorySchema);
diff --git a/src/models/Course.js b/src/models/Course.js
index 57847a9f..000d962e 100644
--- a/src/models/Course.js
+++ b/src/models/Course.js
@@ -16,6 +16,7 @@ const courseSchema = new mongoose.Schema(
type: String,
required: true,
},
+ categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true },
thumbnail: {
type: String, // image URL
},
@@ -95,4 +96,3 @@ const courseSchema = new mongoose.Schema(
courseSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } });
courseSchema.index({ rating: -1 });
export default mongoose.model("Course", courseSchema);
-
diff --git a/src/routes/categoryRoutes.js b/src/routes/categoryRoutes.js
new file mode 100644
index 00000000..b8bde138
--- /dev/null
+++ b/src/routes/categoryRoutes.js
@@ -0,0 +1,13 @@
+import express from "express";
+import { authorizeRoles, protect } from "../middlewares/authMiddleware.js";
+import { cacheMiddleware, invalidateCacheMiddleware } from "../middlewares/cache.js";
+import { CACHE_TTL } from "../utils/cache.js";
+import { createCategory, deleteCategory, getCategory, listCategories, updateCategory } from "../controllers/categoryController.js";
+
+const router = express.Router();
+router.get("/", cacheMiddleware(CACHE_TTL.COURSES, () => "categories:list"), listCategories);
+router.get("/:slug", cacheMiddleware(CACHE_TTL.COURSES, (req) => `categories:${req.originalUrl}`), getCategory);
+router.post("/", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), createCategory);
+router.patch("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), updateCategory);
+router.delete("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), deleteCategory);
+export default router;
diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js
index 56452584..9c709e3d 100644
--- a/src/routes/courses/courseRoutes.js
+++ b/src/routes/courses/courseRoutes.js
@@ -37,7 +37,7 @@ import { CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js";
const router = express.Router();
// Cache key generators
-const coursesListCacheKey = () => `${CACHE_KEYS.COURSES}list`;
+const coursesListCacheKey = (req) => `${CACHE_KEYS.COURSES}list:${req.query.category || "all"}`;
const courseDetailCacheKey = (req) => `${CACHE_KEYS.COURSE}${req.params.id}`;
const coursesByUserCacheKey = (req) =>
`${CACHE_KEYS.COURSES}user:${req.query.createdBy}`;
@@ -144,4 +144,3 @@ router.put(
);
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/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/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:",
};
/**
From d308967ef966673ca45ba0a475de59a941910d2c Mon Sep 17 00:00:00 2001
From: Mfon <67503972+TS-mfon@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:45:42 +0100
Subject: [PATCH 24/25] feat: add recurring sadaqah pledges (#119)
* feat(donations): add recurring sadaqah pledges
* fix(pledges): preserve donation test compatibility
* fix(pledges): ignore non-persisted transactions
---
app.js | 2 +
server.js | 16 ++
src/controllers/stellar/donationController.js | 85 ++--------
src/controllers/stellar/pledgeController.js | 145 ++++++++++++++++++
src/jobs/handlers.js | 5 +
src/models/Notification.js | 3 +
src/models/Pledge.js | 23 +++
src/models/PledgeCycle.js | 15 ++
src/routes/stellar/pledgeRoutes.js | 23 +++
src/services/pledgeService.js | 56 +++++++
src/services/stellar/donationIntentService.js | 68 ++++++++
src/workers/pledgeScheduler.js | 68 ++++++++
12 files changed, 435 insertions(+), 74 deletions(-)
create mode 100644 src/controllers/stellar/pledgeController.js
create mode 100644 src/models/Pledge.js
create mode 100644 src/models/PledgeCycle.js
create mode 100644 src/routes/stellar/pledgeRoutes.js
create mode 100644 src/services/pledgeService.js
create mode 100644 src/services/stellar/donationIntentService.js
create mode 100644 src/workers/pledgeScheduler.js
diff --git a/app.js b/app.js
index a97d390d..c3fb2491 100644
--- a/app.js
+++ b/app.js
@@ -38,6 +38,7 @@ import callRoutes from "./src/routes/callRoutes.js";
import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js";
import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js";
import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js";
+import stellarPledgeRoutes from "./src/routes/stellar/pledgeRoutes.js";
import stellarGiftRoutes from "./src/routes/stellar/giftRoutes.js";
import payoutRoutes from "./src/routes/payoutRoutes.js";
import uploadRoutes from "./src/routes/uploadRoutes.js";
@@ -193,6 +194,7 @@ app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes);
// Payment routes mutate money state — stricter per-user limiter (issue #4).
app.use("/api/stellar/payment", paymentLimiter, stellarPaymentRoutes);
app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes);
+app.use("/api/stellar/pledges", generousLimiter, stellarPledgeRoutes);
app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes);
app.use("/api/notifications", generousLimiter, notificationRoutes);
diff --git a/server.js b/server.js
index 372c06d0..804b889f 100644
--- a/server.js
+++ b/server.js
@@ -61,6 +61,18 @@ if (process.env.WEBHOOK_WORKER_ENABLED === "true") {
);
}
+let stopPledgeScheduler;
+if (process.env.PLEDGE_SCHEDULER_ENABLED === "true") {
+ import("./src/workers/pledgeScheduler.js").then(
+ ({ startPledgeScheduler, stopPledgeScheduler: stopFn }) => {
+ stopPledgeScheduler = stopFn;
+ startPledgeScheduler().catch((err) =>
+ logger.error(err, "Pledge scheduler startup failed")
+ );
+ }
+ );
+}
+
// Graceful shutdown
const gracefulShutdown = async (signal) => {
logger.info(`${signal} received. Starting graceful shutdown...`);
@@ -78,6 +90,10 @@ const gracefulShutdown = async (signal) => {
await stopWebhookWorker();
}
+ if (stopPledgeScheduler) {
+ await stopPledgeScheduler();
+ }
+
// Close Redis connection
await closeRedis();
diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js
index 11215d47..08ccfb7e 100644
--- a/src/controllers/stellar/donationController.js
+++ b/src/controllers/stellar/donationController.js
@@ -2,17 +2,15 @@
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,
@@ -30,8 +28,6 @@ import {
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
@@ -43,70 +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",
- expectedHash: paymentTx.hash,
- memo: DONATION_MEMO,
- });
-
- await donation.save({ session });
+ const { transaction: donation, transactionXdr, sep7Uri, networkPassphrase } =
+ await createDonationIntent({ donorId, publicKey, amount, session });
await session.commitTransaction();
paymentsInitialized.inc({ type: "donation" });
@@ -115,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,
});
@@ -368,6 +302,9 @@ export const submitDonation = async (req, res) => {
);
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: ${settledHash}${sponsorship ? " (sponsored)" : ""}`
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/jobs/handlers.js b/src/jobs/handlers.js
index 7699dcf9..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"
@@ -60,6 +61,10 @@ registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => {
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/models/Notification.js b/src/models/Notification.js
index e76cd33f..f1bb95ca 100644
--- a/src/models/Notification.js
+++ b/src/models/Notification.js
@@ -26,6 +26,7 @@ const notificationSchema = new mongoose.Schema(
"system", // System notification
"welcome", // Welcome notification
"recommendation", // New recommendation
+ "pledge_due", // Recurring sadaqah cycle ready to sign
],
required: true,
},
@@ -55,6 +56,8 @@ const notificationSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: "Reel",
},
+ pledgeId: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge" },
+ pledgeCycleId: { type: mongoose.Schema.Types.ObjectId, ref: "PledgeCycle" },
commentId: String,
// Any other relevant data
},
diff --git a/src/models/Pledge.js b/src/models/Pledge.js
new file mode 100644
index 00000000..4cfa7986
--- /dev/null
+++ b/src/models/Pledge.js
@@ -0,0 +1,23 @@
+import mongoose from "mongoose";
+
+const pledgeSchema = new mongoose.Schema(
+ {
+ user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
+ publicKey: { type: String, required: true },
+ amount: { type: String, required: true },
+ cadence: { type: String, enum: ["daily", "weekly", "monthly"], required: true },
+ anchorDay: { type: Number, min: 0, max: 6 },
+ anchorDate: { type: Number, min: 1, max: 31 },
+ status: { type: String, enum: ["active", "paused", "cancelled"], default: "active", index: true },
+ nextDueAt: { type: Date, required: true, index: true },
+ consecutivePaid: { type: Number, default: 0 },
+ longestStreak: { type: Number, default: 0 },
+ totalPaidStroops: { type: String, default: "0" },
+ lastPaidAt: Date,
+ schedulerLockUntil: Date,
+ },
+ { timestamps: true }
+);
+
+pledgeSchema.index({ status: 1, nextDueAt: 1 });
+export default mongoose.model("Pledge", pledgeSchema);
diff --git a/src/models/PledgeCycle.js b/src/models/PledgeCycle.js
new file mode 100644
index 00000000..2e0ea655
--- /dev/null
+++ b/src/models/PledgeCycle.js
@@ -0,0 +1,15 @@
+import mongoose from "mongoose";
+
+const pledgeCycleSchema = new mongoose.Schema(
+ {
+ pledge: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge", required: true, index: true },
+ dueAt: { type: Date, required: true },
+ status: { type: String, enum: ["due", "notified", "paid", "skipped", "lapsed"], default: "due", index: true },
+ transaction: { type: mongoose.Schema.Types.ObjectId, ref: "Transaction", default: null },
+ windowEndsAt: { type: Date, required: true, index: true },
+ },
+ { timestamps: true }
+);
+
+pledgeCycleSchema.index({ pledge: 1, dueAt: 1 }, { unique: true });
+export default mongoose.model("PledgeCycle", pledgeCycleSchema);
diff --git a/src/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/services/pledgeService.js b/src/services/pledgeService.js
new file mode 100644
index 00000000..17d2603e
--- /dev/null
+++ b/src/services/pledgeService.js
@@ -0,0 +1,56 @@
+import mongoose from "mongoose";
+
+import Pledge from "../models/Pledge.js";
+import PledgeCycle from "../models/PledgeCycle.js";
+
+const STROOPS_PER_UNIT = 10000000n;
+const toStroops = (amount) => {
+ const [whole, fraction = ""] = amount.toString().split(".");
+ return BigInt(whole || "0") * STROOPS_PER_UNIT + BigInt((fraction + "0000000").slice(0, 7));
+};
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export const addPledgeCadence = (date, pledge) => {
+ const source = new Date(date);
+ if (pledge.cadence === "daily") return new Date(source.getTime() + DAY_MS);
+ if (pledge.cadence === "weekly") return new Date(source.getTime() + 7 * DAY_MS);
+ const year = source.getUTCFullYear();
+ const month = source.getUTCMonth() + 1;
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
+ const day = Math.min(pledge.anchorDate || source.getUTCDate(), lastDay);
+ return new Date(Date.UTC(year, month, day, source.getUTCHours(), source.getUTCMinutes(), source.getUTCSeconds(), source.getUTCMilliseconds()));
+};
+
+export const firstDueAt = ({ cadence, anchorDay, anchorDate, startAt = new Date() }) => {
+ const start = new Date(startAt);
+ if (cadence === "daily") return start;
+ if (cadence === "weekly") {
+ const target = anchorDay ?? start.getUTCDay();
+ const delta = (target - start.getUTCDay() + 7) % 7;
+ return new Date(start.getTime() + delta * DAY_MS);
+ }
+ const target = anchorDate ?? start.getUTCDate();
+ const lastDay = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate();
+ if (start.getUTCDate() <= Math.min(target, lastDay)) {
+ return new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), Math.min(target, lastDay), start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds()));
+ }
+ return addPledgeCadence(start, { cadence, anchorDate: target });
+};
+
+export const markPledgeTransactionPaid = async (transaction, paidAt = new Date()) => {
+ if (!mongoose.Types.ObjectId.isValid(transaction?._id)) return null;
+ const cycle = await PledgeCycle.findOne({ transaction: transaction._id, status: { $ne: "paid" } });
+ if (!cycle) return null;
+ cycle.status = "paid";
+ await cycle.save();
+ const pledge = await Pledge.findById(cycle.pledge);
+ if (!pledge) return cycle;
+ const consecutivePaid = pledge.consecutivePaid + 1;
+ pledge.consecutivePaid = consecutivePaid;
+ pledge.longestStreak = Math.max(pledge.longestStreak, consecutivePaid);
+ pledge.totalPaidStroops = (BigInt(pledge.totalPaidStroops || "0") + toStroops(transaction.amount)).toString();
+ pledge.lastPaidAt = paidAt;
+ await pledge.save();
+ return cycle;
+};
diff --git a/src/services/stellar/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/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; };
From 063c3466cc0a588b3cff408cbd40a88cb91e7a0d Mon Sep 17 00:00:00 2001
From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com>
Date: Mon, 24 Aug 2026 17:55:21 +0100
Subject: [PATCH 25/25] feat(stellar): real-time payment notifications over
Socket.io (closes #164)
---
server.js | 9 +-
src/models/Transaction.js | 18 ++++
src/sockets/index.js | 77 ++++++++++++++
src/sockets/paymentEvents.js | 110 ++++++++++++++++++++
src/sockets/paymentGateway.js | 179 +++++++++++++++++++++++++++++++++
src/sockets/paymentNotifier.js | 97 ++++++++++++++++++
6 files changed, 489 insertions(+), 1 deletion(-)
create mode 100644 src/sockets/index.js
create mode 100644 src/sockets/paymentEvents.js
create mode 100644 src/sockets/paymentGateway.js
create mode 100644 src/sockets/paymentNotifier.js
diff --git a/server.js b/server.js
index 804b889f..8bf53f31 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,10 @@
import dotenv from "dotenv";
+import http from "http";
import logger from "./src/config/logger.js";
import connectDB from "./src/config/db.js";
import validateEnv from "./src/config/validateEnv.js";
import { initRedis, closeRedis } from "./src/config/redis.js";
+import { initSockets, closeSockets } from "./src/sockets/index.js";
import { startJobs, stopJobs } from "./src/jobs/queue.js";
import {
handleUncaughtException,
@@ -27,7 +29,11 @@ initRedis().catch((err) => {
);
});
-const server = app.listen(PORT, () => {
+// Real-time payment notifications (Socket.io) share the HTTP server.
+const server = http.createServer(app);
+initSockets(server);
+
+server.listen(PORT, () => {
logger.info(`🚀🕌 DeenBridge API running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
logger.info(`Process ID: ${process.pid}`);
@@ -95,6 +101,7 @@ const gracefulShutdown = async (signal) => {
}
// Close Redis connection
+ await closeSockets();
await closeRedis();
process.exit(0);
diff --git a/src/models/Transaction.js b/src/models/Transaction.js
index 23f9cb22..887e472e 100644
--- a/src/models/Transaction.js
+++ b/src/models/Transaction.js
@@ -1,6 +1,7 @@
// models/Transaction.js
import mongoose from "mongoose";
import { getSupportedCodes } from "../config/assets.js";
+import { notifyPaymentStatus } from "../sockets/paymentNotifier.js";
const transactionSchema = new mongoose.Schema(
{
@@ -198,6 +199,23 @@ transactionSchema.pre("save", function (next) {
if (TERMINAL_STATUSES.includes(this.status)) {
this.expiresAt = undefined;
}
+
+ // Remember whether this save is worth a realtime push (creation or any
+ // status transition). Read back by the post-save hook below; mongoose
+ // clears its modified-path tracking after save completes, so the flag is
+ // captured here while it is still reliable.
+ this.$locals.__statusChanged = this.isNew || this.isModified("status");
+ next();
+});
+
+// Realtime payment notifications: push status transitions to subscribed
+// websocket clients (see src/sockets/). Best-effort — a gateway outage must
+// never fail a save. Covers every code path that persists through `save()`
+// (donation controller, reconciliation service, ingestion worker).
+transactionSchema.post("save", function (doc, next) {
+ if (doc.$locals?.__statusChanged) {
+ Promise.resolve(notifyPaymentStatus(doc)).catch(() => {});
+ }
next();
});
diff --git a/src/sockets/index.js b/src/sockets/index.js
new file mode 100644
index 00000000..11e66404
--- /dev/null
+++ b/src/sockets/index.js
@@ -0,0 +1,77 @@
+/**
+ * Socket.io bootstrap for real-time payment notifications.
+ * ---------------------------------------------------------------------------
+ * Owns the singleton Socket.io server instance:
+ *
+ * - `initSockets(httpServer)` — attach Socket.io to the HTTP server with
+ * CORS aligned to the REST API's allow-list and register the payment
+ * gateway. Call once from the server entrypoint.
+ * - `getIO()` — safe accessor used by emitters; returns `null` when sockets
+ * are not running (e.g. worker processes that import models but never
+ * start an HTTP server), so callers can no-op instead of crashing.
+ * - `closeSockets()` — graceful shutdown hook.
+ */
+import { Server } from "socket.io";
+import logger from "../config/logger.js";
+import { registerPaymentGateway } from "./paymentGateway.js";
+
+/** Origins allowed to open a websocket, kept in sync with app.js corsOptions. */
+const ALLOWED_ORIGINS = [
+ "https://dnb-frontend.vercel.app",
+ "http://localhost:3000",
+ "http://localhost:3001",
+ "https://deenbridge.vercel.app",
+ "http://deenbridge.vercel.app",
+];
+
+/** @type {import("socket.io").Server|null} */
+let io = null;
+
+/**
+ * Attach Socket.io to the given HTTP server and wire up handlers.
+ *
+ * @param {import("http").Server} httpServer
+ * @returns {import("socket.io").Server}
+ */
+export function initSockets(httpServer) {
+ if (io) return io;
+
+ io = new Server(httpServer, {
+ cors: {
+ origin(origin, callback) {
+ // Non-browser clients (curl, workers) send no Origin header.
+ if (!origin || ALLOWED_ORIGINS.includes(origin)) {
+ return callback(null, true);
+ }
+ logger.warn({ origin }, "Blocked websocket connection from origin");
+ return callback(new Error("Not allowed by CORS"));
+ },
+ credentials: true,
+ methods: ["GET", "POST"],
+ },
+ });
+
+ registerPaymentGateway(io);
+ logger.info("🔌 Socket.io payment gateway ready");
+
+ return io;
+}
+
+/**
+ * Current Socket.io server, or null when sockets were never initialized.
+ *
+ * @returns {import("socket.io").Server|null}
+ */
+export function getIO() {
+ return io;
+}
+
+/**
+ * Disconnect every client and release the instance (graceful shutdown).
+ */
+export async function closeSockets() {
+ if (!io) return;
+ await new Promise((resolve) => io.close(() => resolve()));
+ io = null;
+ logger.info("Socket.io connections closed");
+}
diff --git a/src/sockets/paymentEvents.js b/src/sockets/paymentEvents.js
new file mode 100644
index 00000000..f8515ec6
--- /dev/null
+++ b/src/sockets/paymentEvents.js
@@ -0,0 +1,110 @@
+/**
+ * Payment event contracts for the Socket.io real-time notification channel.
+ * ---------------------------------------------------------------------------
+ * This module is the single source of truth for every event name and payload
+ * shape emitted over the payment namespace. It is the equivalent of a DTO
+ * layer for our WebSocket surface: clients (and tests) should import the
+ * constants from here instead of hard-coding strings, and the JSDoc typedefs
+ * below document exactly what each event carries.
+ *
+ * Rooms
+ * -----
+ * - `user:` — every connected socket joins its own room on
+ * authentication; user-scoped events land here.
+ * - `payment:` — opt-in room for a specific transaction. A
+ * socket may only join it if its authenticated
+ * user is the buyer or the creator of that
+ * transaction (see paymentGateway.js).
+ *
+ * Status mapping
+ * --------------
+ * Internal Transaction statuses are normalized to the coarse client-facing
+ * statuses required by the realtime contract:
+ *
+ * pending | submitted | retrying → "pending"
+ * confirmed → "success"
+ * failed | expired | refunded | disputed → "failed"
+ *
+ * The raw internal status always travels alongside in `payment.rawStatus` so
+ * clients that need finer granularity can branch without a second event.
+ */
+
+/**
+ * @typedef {"pending"|"submitted"|"retrying"|"confirmed"|"failed"|
+ * "expired"|"refunded"|"disputed"} InternalPaymentStatus
+ * Raw status as stored on the Transaction document.
+ */
+
+/**
+ * @typedef {"pending"|"success"|"failed"} ClientPaymentStatus
+ * Normalized status pushed to clients.
+ */
+
+/**
+ * Payload for the `payment:status` event.
+ *
+ * @typedef {object} PaymentStatusEvent
+ * @property {string} transactionId Transaction `_id`.
+ * @property {string} [reference] `expectedHash` of the transaction, when
+ * set — the client-side checkout handle.
+ * @property {ClientPaymentStatus} status Normalized lifecycle status.
+ * @property {InternalPaymentStatus} rawStatus Raw stored status.
+ * @property {("purchase"|"donation")} type Transaction kind.
+ * @property {string} [itemTitle] Title of the purchased item (purchases).
+ * @property {string} amount Amount as a precision-preserving string.
+ * @property {string} currency Asset code (e.g. "USDC").
+ * @property {string} [stellarTxHash] On-chain hash once submitted.
+ * @property {string} [failureReason] Why the payment failed, when it did.
+ * @property {string} updatedAt ISO timestamp of the transition.
+ */
+
+/** Event emitted whenever a transaction's status changes. */
+export const PAYMENT_STATUS_EVENT = "payment:status";
+
+/** Client → server: ask to follow one specific transaction's updates. */
+export const SUBSCRIBE_PAYMENT = "payment:subscribe";
+
+/** Client → server: stop following a previously subscribed transaction. */
+export const UNSUBSCRIBE_PAYMENT = "payment:unsubscribe";
+
+/** Server → client: ack/error reply to subscribe/unsubscribe requests. */
+export const PAYMENT_ACK = "payment:ack";
+
+/**
+ * Normalize an internal Transaction status into the client-facing one.
+ *
+ * @param {InternalPaymentStatus} rawStatus
+ * @returns {ClientPaymentStatus}
+ */
+export function toClientStatus(rawStatus) {
+ if (rawStatus === "confirmed") return "success";
+ if (
+ rawStatus === "failed" ||
+ rawStatus === "expired" ||
+ rawStatus === "refunded" ||
+ rawStatus === "disputed"
+ ) {
+ return "failed";
+ }
+ return "pending";
+}
+
+/**
+ * Build the room name holding every socket belonging to one user.
+ *
+ * @param {string} userId
+ * @returns {string} e.g. "user:507f1f77bcf86cd799439011"
+ */
+export function userRoom(userId) {
+ return `user:${userId}`;
+}
+
+/**
+ * Build the opt-in room for a single transaction.
+ *
+ * @param {string} transactionId
+ * @returns {string} e.g. "payment:507f1f77bcf86cd799439012"
+ */
+export function paymentRoom(transactionId) {
+ return `payment:${transactionId}`;
+}
diff --git a/src/sockets/paymentGateway.js b/src/sockets/paymentGateway.js
new file mode 100644
index 00000000..3edfa221
--- /dev/null
+++ b/src/sockets/paymentGateway.js
@@ -0,0 +1,179 @@
+/**
+ * Socket.io gateway for authenticated real-time payment updates.
+ * ---------------------------------------------------------------------------
+ * Handles the lifecycle of a payment websocket connection:
+ *
+ * 1. **Handshake auth** — mirrors `authMiddleware.protect`: the client must
+ * present a valid JWT (in `auth.token`, an `Authorization: Bearer` header,
+ * or the `authToken` cookie). The token is verified and the user must
+ * still exist; otherwise the connection is rejected before any handler
+ * runs. Defense in depth only — authorization of *data* is still enforced
+ * by ownership checks below.
+ * 2. **Personal room** — every authenticated socket automatically joins
+ * `user:` so user-scoped payment events can reach all its tabs.
+ * 3. **Transaction rooms** — clients may opt into `payment:` rooms via
+ * the subscribe handlers. Joining is only allowed when the authenticated
+ * user is the buyer or the creator (donations have no creator) of that
+ * transaction, so payment details never leak across accounts.
+ */
+import mongoose from "mongoose";
+import jwt from "jsonwebtoken";
+import logger from "../config/logger.js";
+import User from "../models/User.js";
+import {
+ PAYMENT_ACK,
+ SUBSCRIBE_PAYMENT,
+ UNSUBSCRIBE_PAYMENT,
+ userRoom,
+ paymentRoom,
+} from "./paymentEvents.js";
+
+/**
+ * Lazily resolve the Transaction model instead of importing it statically so
+ * this module keeps a one-way dependency direction (sockets → models stays
+ * runtime-only, avoiding an import cycle with Transaction's save hook).
+ */
+function transactionModel() {
+ return mongoose.model("Transaction");
+}
+
+const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024";
+
+/** Extract the JWT from wherever our REST clients legitimately put it. */
+function extractToken(auth = {}) {
+ if (auth.token) return auth.token;
+
+ const header = auth.headers?.authorization ?? auth.authorization;
+ if (typeof header === "string" && header.startsWith("Bearer ")) {
+ return header.slice(7);
+ }
+
+ // Same cookie name the REST session flow sets (js-cookie on the frontend).
+ const rawCookie = auth.cookie;
+ if (typeof rawCookie === "string") {
+ const match = /(?:^|;\s*)authToken=([^;]+)/.exec(rawCookie);
+ if (match) return decodeURIComponent(match[1]);
+ }
+ return null;
+}
+
+/**
+ * Socket.io middleware: authenticate the handshake or reject it.
+ * Mirrors the checks performed by `protect` in authMiddleware.js.
+ */
+async function authenticateSocket(socket, next) {
+ try {
+ const token = extractToken(socket.handshake.auth);
+ if (!token) return next(new Error("No token, authorization denied"));
+
+ let decoded;
+ try {
+ decoded = jwt.verify(token, JWT_SECRET);
+ } catch {
+ return next(new Error("Not authorized, token failed"));
+ }
+
+ const user = await User.findById(decoded.userId).select("_id");
+ if (!user) return next(new Error("User not found"));
+
+ socket.data.userId = String(user._id);
+ next();
+ } catch (err) {
+ logger.error(err, "Socket authentication failed unexpectedly");
+ next(new Error("Authentication error"));
+ }
+}
+
+/**
+ * Subscribe a socket to one transaction's room after an ownership check.
+ * Accepts either the transaction `_id` or its `expectedHash` reference.
+ */
+async function handleSubscribe(socket, payload, callback) {
+ const ack =
+ typeof callback === "function"
+ ? callback
+ : () => {}; // fire-and-forget callers still work
+
+ try {
+ const identifier = payload?.transactionId ?? payload?.reference;
+ if (!identifier || typeof identifier !== "string") {
+ return ack({ ok: false, error: "transactionId is required" });
+ }
+
+ const filter = mongoose.isValidObjectId(identifier)
+ ? { _id: identifier }
+ : { expectedHash: identifier };
+
+ const transaction = await transactionModel()
+ .findById(filter)
+ .select("buyer creator")
+ .lean();
+ if (!transaction) {
+ return ack({ ok: false, error: "Transaction not found" });
+ }
+
+ const userId = socket.data.userId;
+ const isBuyer = transaction.buyer && String(transaction.buyer) === userId;
+ const isCreator =
+ transaction.creator && String(transaction.creator) === userId;
+ if (!isBuyer && !isCreator) {
+ logger.warn(
+ { userId, transactionId: String(transaction._id) },
+ "Blocked payment room subscription — not a party to the transaction"
+ );
+ return ack({ ok: false, error: "Not authorized for this transaction" });
+ }
+
+ const room = paymentRoom(String(transaction._id));
+ await socket.join(room);
+ return ack({ ok: true, room });
+ } catch (err) {
+ logger.error(err, "payment:subscribe failed");
+ return ack({ ok: false, error: "Subscription failed" });
+ }
+}
+
+/** Remove a socket from a transaction room it previously joined. */
+async function handleUnsubscribe(socket, payload, callback) {
+ const ack = typeof callback === "function" ? callback : () => {};
+ const identifier = payload?.transactionId ?? payload?.reference;
+ if (!identifier || typeof identifier !== "string") {
+ return ack({ ok: false, error: "transactionId is required" });
+ }
+ await socket.leave(paymentRoom(String(identifier)));
+ return ack({ ok: true });
+}
+
+/**
+ * Register connection handling + rooms on a Socket.io server instance.
+ * Called once from initSockets() in ./index.js.
+ */
+export function registerPaymentGateway(io) {
+ io.use(authenticateSocket);
+
+ io.on("connection", async (socket) => {
+ // Personal room so events can target a user across tabs/devices.
+ await socket.join(userRoom(socket.data.userId));
+ logger.debug(
+ { userId: socket.data.userId, socketId: socket.id },
+ "Payment socket connected"
+ );
+
+ socket.on(SUBSCRIBE_PAYMENT, (payload, cb) =>
+ handleSubscribe(socket, payload, cb)
+ );
+ socket.on(UNSUBSCRIBE_PAYMENT, (payload, cb) =>
+ handleUnsubscribe(socket, payload, cb)
+ );
+ socket.on(PAYMENT_ACK, () => {
+ /* reserved for future ack-only probes */
+ });
+
+ socket.on("disconnect", (reason) => {
+ logger.debug(
+ { userId: socket.data.userId, socketId: socket.id, reason },
+ "Payment socket disconnected"
+ );
+ });
+ });
+}
diff --git a/src/sockets/paymentNotifier.js b/src/sockets/paymentNotifier.js
new file mode 100644
index 00000000..329f902a
--- /dev/null
+++ b/src/sockets/paymentNotifier.js
@@ -0,0 +1,97 @@
+/**
+ * Emitter helpers pushing payment status changes to connected clients.
+ * ---------------------------------------------------------------------------
+ * All emits are no-ops when Socket.io is not running (workers, tests, scripts)
+ * so payment code paths never depend on the gateway being up.
+ *
+ * Events and payload shapes are defined in ./paymentEvents.js — import the
+ * constants from there rather than inlining strings.
+ */
+import logger from "../config/logger.js";
+import { getIO } from "./index.js";
+import {
+ PAYMENT_STATUS_EVENT,
+ toClientStatus,
+ userRoom,
+ paymentRoom,
+} from "./paymentEvents.js";
+
+/**
+ * Build the client-facing payload from a Transaction document.
+ *
+ * @param {object} transaction A Transaction document (or lean object).
+ * @returns {import("./paymentEvents.js").PaymentStatusEvent}
+ */
+function buildStatusPayload(transaction) {
+ return {
+ transactionId: String(transaction._id),
+ reference: transaction.expectedHash ?? undefined,
+ status: toClientStatus(transaction.status),
+ rawStatus: transaction.status,
+ type: transaction.type,
+ itemTitle: transaction.itemTitle ?? undefined,
+ amount: transaction.amount,
+ currency: transaction.currency ?? "USDC",
+ stellarTxHash: transaction.stellarTxHash ?? undefined,
+ failureReason: transaction.failureReason ?? undefined,
+ updatedAt:
+ transaction.updatedAt?.toISOString?.() ??
+ new Date().toISOString(),
+ };
+}
+
+/**
+ * Notify every interested socket that a transaction's status changed.
+ *
+ * Fans out to:
+ * - the `payment:` room (opt-in subscribers),
+ * - the buyer's personal room,
+ * - the creator's personal room, for purchases.
+ *
+ * Safe to call with no Socket.io server running.
+ *
+ * @param {object} transaction Transaction document AFTER its save/update.
+ * @returns {Promise}
+ */
+export async function notifyPaymentStatus(transaction) {
+ const socketServer = getIO();
+ if (!socketServer) return;
+
+ try {
+ const payload = buildStatusPayload(transaction);
+ const rooms = [paymentRoom(payload.transactionId)];
+
+ if (transaction.buyer) {
+ rooms.push(userRoom(String(transaction.buyer._id ?? transaction.buyer)));
+ }
+ if (transaction.creator) {
+ rooms.push(
+ userRoom(String(transaction.creator._id ?? transaction.creator))
+ );
+ }
+
+ for (const room of rooms) {
+ socketServer.to(room).emit(PAYMENT_STATUS_EVENT, payload);
+ }
+ } catch (err) {
+ // Realtime is best-effort: a fan-out failure must never break payments.
+ logger.error(
+ { err, transactionId: String(transaction?._id ?? "") },
+ "Failed to emit payment status update"
+ );
+ }
+}
+
+/**
+ * Convenience wrapper for flows that persist via `findOneAndUpdate` and hold
+ * only a plain result object instead of a full document.
+ *
+ * @param {object} rawTransaction Lean object with at least `_id` and `status`.
+ */
+export async function notifyPaymentStatusLean(rawTransaction) {
+ if (!rawTransaction) return;
+ await notifyPaymentStatus({
+ ...rawTransaction,
+ updatedAt: rawTransaction.updatedAt ?? new Date(),
+ });
+}