Skip to content

stellar: validate signed XDR contents before submit; store expectedHa… - #51

Merged
zeemscript merged 4 commits into
Deen-Bridge:devfrom
Banx17:feature/issue-18-verify-stellar-xdr
Aug 12, 2026
Merged

stellar: validate signed XDR contents before submit; store expectedHa…#51
zeemscript merged 4 commits into
Deen-Bridge:devfrom
Banx17:feature/issue-18-verify-stellar-xdr

Conversation

@Banx17

@Banx17 Banx17 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #18

Summary by CodeRabbit

  • Security & Reliability
    • Added validation for signed Stellar payments before submission.
    • Payments with mismatched amounts, destinations, memos, or wallets are rejected and marked as failed.
    • Improved transaction tracking with expected payment details for verification.
  • Bug Fixes
    • Prevented invalid or tampered payment transactions from being submitted to Stellar.
    • Added clearer failure details when payment validation fails.
  • Testing
    • Added default test environment values to simplify automated test execution.

…sh/memo; verify on-chain before granting access; add tests
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@zeemscript, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c6c88bd-4049-4555-a543-9d7eb11c25ad

📥 Commits

Reviewing files that changed from the base of the PR and between 025a827 and 0460ab5.

📒 Files selected for processing (1)
  • test/stellarPaymentController.test.js

Walkthrough

The change stores expected Stellar payment metadata, adds signed XDR validation, and applies validation before payment and donation submission. Invalid submissions now fail with persisted reasons and metrics. Test mode supplies default environment values.

Changes

Stellar payment integrity

Layer / File(s) Summary
Expected transaction metadata
src/config/validateEnv.js, src/models/Transaction.js, src/controllers/stellar/donationController.js, src/controllers/stellar/paymentController.js
Test mode sets default environment values. Pending transactions store expectedHash and memo, while stellarTxHash becomes optional and sparse.
Signed XDR validation
src/services/stellar/stellarService.js
validateSignedPaymentXdr parses signed XDR and checks memo, source account, USDC asset, destination, and normalized amounts.
Validated payment and donation submission
src/controllers/stellar/paymentController.js, src/controllers/stellar/donationController.js
Payment and donation submissions validate signed XDR before broadcasting. Failed validation persists failure state, updates metrics, and returns HTTP 400. Expected payments are reused for on-chain verification.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: 🟠 High · up to 025a8

The PR adds signed-payment validation and broader asset support, but the current implementation can accept unauthorized operations, reject valid non-USDC payments, miss fee-recipient failures, and combine balances across different assets. These risks could cause failed settlements, unauthorized transfers, or incorrect accounting, so the PR is not ready to merge until the major issues are fixed.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant paymentController
  participant validateSignedPaymentXdr
  participant Stellar
  Client->>paymentController: Submit signed XDR
  paymentController->>validateSignedPaymentXdr: Validate payment details
  validateSignedPaymentXdr-->>paymentController: Return parsed transaction or error
  paymentController->>Stellar: Submit validated transaction
  Stellar-->>paymentController: Return submission result
Loading

Possibly related PRs

Suggested reviewers: abimbolaalabi, emarc99

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements XDR validation and post-submit verification, but the provided changes do not include the tampered-transaction tests required by issue #18. Add tests for incorrect amounts and destinations, and verify that rejected transactions do not submit or grant item access.
Out of Scope Changes check ⚠️ Warning The validateEnv test-mode defaults are unrelated to the Stellar payment validation objectives in issue #18. Remove the environment-default changes or move them to a separate pull request with relevant tests and objectives.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary Stellar security change: validating signed XDR contents before submission and storing the expected hash.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/services/stellar/stellarService.js (1)

409-420: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer the public Memo API over _type/_value.

tx.memo._type/tx.memo._value reach into the SDK's private storage fields instead of the documented memo.type/memo.value getters. It works today (the getters just return these fields), but it's not the guaranteed public contract and could silently break if the SDK internals change.

♻️ Proposed refactor to use public getters
   if (expectedMemo) {
     const memo = tx.memo;
     let memoText = null;
-    if (memo && memo._type === "text") {
-      const val = memo._value;
+    if (memo && memo.type === "text") {
+      const val = memo.value;
       memoText = Buffer.isBuffer(val) ? val.toString() : String(val);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stellar/stellarService.js` around lines 409 - 420, Update the
memo validation logic in the expectedMemo block to use the public tx.memo.type
and tx.memo.value getters instead of the private _type and _value fields.
Preserve the existing text-type check, Buffer/string conversion, and mismatch
error behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config/validateEnv.js`:
- Around line 34-38: Centralize test environment setup before application
imports: in src/config/validateEnv.js, remove the test-mode assignments for
JWT_SECRET, MONGO_URI, and PORT so validateEnv() only consumes preconfigured
values; in test/jest.setup.js, preserve the generated MongoMemoryServer URI
while moving the static JWT secret and port defaults into the earliest
environment bootstrap path.

In `@src/controllers/stellar/paymentController.js`:
- Around line 433-477: Update validateSignedPaymentXdr in stellarService.js to
recognize and validate path_payment_strict_receive operations in addition to
regular payment operations. Match path payments against the expected destination
and amount using the operation’s path-payment fields, while preserving existing
validation behavior for standard payments so legitimate path-payment XDRs pass
pre-submission validation.

In `@src/services/stellar/stellarService.js`:
- Around line 389-453: Update validateSignedPaymentXdr to include
pathPaymentStrictReceive operations alongside payment operations, mirroring the
operation handling in verifyPaymentOperations. For path payments, compare the
destination asset, destination amount, and destination address against each
expected payment while preserving the existing native/USDC payment matching
behavior.

---

Nitpick comments:
In `@src/services/stellar/stellarService.js`:
- Around line 409-420: Update the memo validation logic in the expectedMemo
block to use the public tx.memo.type and tx.memo.value getters instead of the
private _type and _value fields. Preserve the existing text-type check,
Buffer/string conversion, and mismatch error behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 708eb564-bb9f-4865-afe3-c1cdc522109a

📥 Commits

Reviewing files that changed from the base of the PR and between 2866f7b and f3284e3.

📒 Files selected for processing (7)
  • jest.config.js
  • src/config/validateEnv.js
  • src/controllers/stellar/donationController.js
  • src/controllers/stellar/paymentController.js
  • src/models/Transaction.js
  • src/services/stellar/stellarService.js
  • test/jest.setup.js

Comment thread src/config/validateEnv.js
Comment on lines +34 to +38
// 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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Centralize test configuration before application imports.

The two files duplicate source-controlled test defaults, and validateEnv() can run before beforeAll publishes the in-memory URI. Remove hardcoded credentials/configuration and establish the test environment before importing application modules.

  • src/config/validateEnv.js#L34-L38: remove the JWT, Mongo URI, and port literals; require values from test bootstrap/environment.
  • test/jest.setup.js#L11-L14: retain the generated MongoMemoryServer URI, but move static defaults to environment bootstrap that runs before app imports.
📍 Affects 2 files
  • src/config/validateEnv.js#L34-L38 (this comment)
  • test/jest.setup.js#L11-L14
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/validateEnv.js` around lines 34 - 38, Centralize test environment
setup before application imports: in src/config/validateEnv.js, remove the
test-mode assignments for JWT_SECRET, MONGO_URI, and PORT so validateEnv() only
consumes preconfigured values; in test/jest.setup.js, preserve the generated
MongoMemoryServer URI while moving the static JWT secret and port defaults into
the earliest environment bootstrap path.

Source: Path instructions

Comment on lines +433 to +477
// Build expected payments to validate XDR BEFORE submit
let 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

Path payments will always fail this pre-submission check.

expectedPayments here doesn't account for path payments (see isPathPayment branch at Lines 278-304/295-304, which builds a path_payment_strict_receive operation via buildPathPaymentTransaction). validateSignedPaymentXdr in stellarService.js only matches on op.type === "payment", so a signed path-payment XDR will never find a match and every legitimate path payment will be rejected with 400 and marked failed. Root cause and fix belong in stellarService.js's validateSignedPaymentXdr — see that file's review for details.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 433 - 477, Update
validateSignedPaymentXdr in stellarService.js to recognize and validate
path_payment_strict_receive operations in addition to regular payment
operations. Match path payments against the expected destination and amount
using the operation’s path-payment fields, while preserving existing validation
behavior for standard payments so legitimate path-payment XDRs pass
pre-submission validation.

Comment on lines +389 to +453
/**
* 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;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and nearby symbols first.
ast-grep outline src/services/stellar/stellarService.js --view expanded || true

# Show the target region with line numbers.
sed -n '340,560p' src/services/stellar/stellarService.js | cat -n

# Find the path-payment builder / initializer references.
rg -n "buildPathPaymentTransaction|isPathPayment|validateSignedPaymentXdr|verifyPaymentOperations|path_payment_strict_receive|pathPaymentStrictReceive|toStroops" src/services/stellar/stellarService.js

Repository: Deen-Bridge/dnb-backend

Length of output: 10474


validateSignedPaymentXdr should accept path payments.
paymentOps only keeps op.type === "payment", so valid pathPaymentStrictReceive XDRs fail this pre-submission check and get marked failed before the on-chain verifier runs. Mirror the verifyPaymentOperations branch here and compare the path-payment destination asset and amount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stellar/stellarService.js` around lines 389 - 453, Update
validateSignedPaymentXdr to include pathPaymentStrictReceive operations
alongside payment operations, mirroring the operation handling in
verifyPaymentOperations. For path payments, compare the destination asset,
destination amount, and destination address against each expected payment while
preserving the existing native/USDC payment matching behavior.

Source: Path instructions

…y-stellar-xdr

# Conflicts:
#	jest.config.js
#	src/controllers/stellar/paymentController.js
#	test/jest.setup.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
src/controllers/stellar/paymentController.js (6)

615-642: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject signed XDRs with unapproved operations.

validateSignedPaymentXdr only verifies that each expected payment exists. It does not reject additional payments or other Stellar operations. A compromised client can submit a buyer-signed XDR that includes the required payment plus an unapproved transfer or account operation. Require an exact, one-to-one operation match before submitTransaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 615 - 642, Update
the signed-XDR validation flow around validateSignedPaymentXdr to require an
exact one-to-one match between the transaction’s operations and
expectedPayments, rejecting any extra payments or non-payment Stellar operations
before submitTransaction. Preserve the existing memo, payment, and optional
source validation while ensuring approved operations cannot be accompanied by
unapproved ones.

636-642: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate against the stored settlement asset.

initializePayment permits every supported direct-payment asset, but validateSignedPaymentXdr only accepts USDC with USDC_ISSUER. This controller does not pass transaction.currency or transaction.assetIssuer to the validator. A valid direct EURC or native-XLM payment will therefore fail with HTTP 400. Pass the expected asset configuration to the validator and match that asset instead of hardcoding USDC.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 636 - 642, Update
the validateSignedPaymentXdr call in the payment controller to pass
transaction.currency and transaction.assetIssuer as the expected settlement
asset configuration. Modify validateSignedPaymentXdr to validate the signed
payment against those supplied values rather than hardcoded USDC and
USDC_ISSUER, while preserving existing memo, wallet, and payment validation
behavior.

276-286: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preflight the platform-fee recipient.

When feeSplitPreview exists, buildPaymentTransaction adds a second payment to feeSplit.platformWallet. This preflight call checks only destinationPublicKey. If the platform wallet is unfunded or lacks the required asset trustline, preflight returns success but the signed transaction fails on-chain. Validate every payment destination and merge the preflight failures before returning the response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 276 - 286, Update
the payment preflight flow around feeSplitPreview and preflightPayment to also
validate feeSplit.platformWallet whenever a fee split exists, alongside
destinationPublicKey. Merge failures from both payment-destination preflights
and return the combined failure response before transaction construction, while
preserving the existing single-destination behavior when no platform fee
applies.

767-767: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not aggregate settlement amounts across assets.

This flow now confirms direct payments in supported non-USDC currencies. recordSaleEarnings adds stroops into one balance and creates ledger entries without a currency or issuer. A EURC sale and a USDC sale can therefore change the same balance, which makes payout and reporting totals incorrect. Store balances and ledger entries by asset, or restrict settlement to USDC until accounting supports multiple assets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` at line 767, Update the
settlement flow around recordSaleEarnings so amounts are never aggregated across
different assets: either key balances and ledger entries by currency and issuer
throughout the accounting path, or restrict this confirmation flow to USDC
before recording earnings. Preserve direct-payment confirmation only for assets
supported by the chosen accounting model.

230-301: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add Jest coverage for payment preflight and signed-XDR validation.

test/stellarPaymentController.test.js does not mount getPaymentPreflight. Add tests for tampered destination, amount, memo, and source values. Assert that each validation failure returns 400 and does not call submitTransaction. Add validateSignedPaymentXdr to the Stellar service mock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 230 - 301, Extend
test/stellarPaymentController.test.js to mount and cover getPaymentPreflight,
including tampered destination, amount, memo, and source values; assert each
validation failure returns 400 and submitTransaction is not called. Update the
Stellar service mock to include validateSignedPaymentXdr, preserving the
existing controller test setup and assertions.

Source: Path instructions


233-250: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate itemId before resolving the item.

Model.findById(itemId) throws a Mongoose CastError for malformed ObjectIds, so the catch block returns HTTP 500 instead of HTTP 400. Use mongoose.isValidObjectId(itemId) before calling resolvePaymentDestination.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 233 - 250, In the
payment flow before calling resolvePaymentDestination, validate itemId with
mongoose.isValidObjectId(itemId) and return the existing HTTP 400 invalid-input
response when it is malformed. Ensure this validation occurs after itemType
validation and before any item lookup, preventing resolvePaymentDestination from
receiving an invalid identifier.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/controllers/stellar/paymentController.js`:
- Around line 615-642: Update the signed-XDR validation flow around
validateSignedPaymentXdr to require an exact one-to-one match between the
transaction’s operations and expectedPayments, rejecting any extra payments or
non-payment Stellar operations before submitTransaction. Preserve the existing
memo, payment, and optional source validation while ensuring approved operations
cannot be accompanied by unapproved ones.
- Around line 636-642: Update the validateSignedPaymentXdr call in the payment
controller to pass transaction.currency and transaction.assetIssuer as the
expected settlement asset configuration. Modify validateSignedPaymentXdr to
validate the signed payment against those supplied values rather than hardcoded
USDC and USDC_ISSUER, while preserving existing memo, wallet, and payment
validation behavior.
- Around line 276-286: Update the payment preflight flow around feeSplitPreview
and preflightPayment to also validate feeSplit.platformWallet whenever a fee
split exists, alongside destinationPublicKey. Merge failures from both
payment-destination preflights and return the combined failure response before
transaction construction, while preserving the existing single-destination
behavior when no platform fee applies.
- Line 767: Update the settlement flow around recordSaleEarnings so amounts are
never aggregated across different assets: either key balances and ledger entries
by currency and issuer throughout the accounting path, or restrict this
confirmation flow to USDC before recording earnings. Preserve direct-payment
confirmation only for assets supported by the chosen accounting model.
- Around line 230-301: Extend test/stellarPaymentController.test.js to mount and
cover getPaymentPreflight, including tampered destination, amount, memo, and
source values; assert each validation failure returns 400 and submitTransaction
is not called. Update the Stellar service mock to include
validateSignedPaymentXdr, preserving the existing controller test setup and
assertions.
- Around line 233-250: In the payment flow before calling
resolvePaymentDestination, validate itemId with mongoose.isValidObjectId(itemId)
and return the existing HTTP 400 invalid-input response when it is malformed.
Ensure this validation occurs after itemType validation and before any item
lookup, preventing resolvePaymentDestination from receiving an invalid
identifier.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bf25728d-769c-4b7b-8be8-d571ac17e77b

📥 Commits

Reviewing files that changed from the base of the PR and between f3284e3 and 025a827.

📒 Files selected for processing (4)
  • src/config/validateEnv.js
  • src/controllers/stellar/paymentController.js
  • src/models/Transaction.js
  • src/services/stellar/stellarService.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/models/Transaction.js
  • src/config/validateEnv.js
  • src/services/stellar/stellarService.js

@zeemscript
zeemscript merged commit 198b07d into Deen-Bridge:dev Aug 12, 2026
3 checks passed
zeemscript added a commit that referenced this pull request Aug 24, 2026
* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(api): serve interactive Swagger UI at /api-docs

- src/config/swagger.js loads root openapi.yaml via js-yaml
- src/routes/api-docs.js mounts swagger-ui-express with
  persistAuthorization for testing protected endpoints
- Deen-Bridge branding via embedded custom CSS
- mounted outside rate limiters alongside /.well-known

---------

Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
zeemscript added a commit that referenced this pull request Aug 24, 2026
* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
zeemscript added a commit that referenced this pull request Aug 24, 2026
* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* feat(stellar): add loyalty points Soroban contract and service (closes #161)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* refactor(db): Create /mongo folder structure (#280)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(soroban): Implement loyalty points contract (#279)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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 …
zeemscript added a commit that referenced this pull request Aug 24, 2026
…283)

* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(stellar): add loyalty points Soroban contract and service (closes #161)

* refactor(db): Create /mongo folder structure (#280)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

---------

Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* refactor(db): Create /mongo folder structure (#280)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(soroban): Implement loyalty points contract (#279)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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-fa…
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* refactor(db): Create /mongo folder structure (#280)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(soroban): Implement loyalty points contract (#279)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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 wh…
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* refactor(db): Create /mongo folder structure (#280)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* feat(soroban): Implement loyalty points contract (#279)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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…
zeemscript added a commit that referenced this pull request Aug 24, 2026
* Merge dev into main (#117)

* stellar: validate signed XDR contents before submit; store expectedHa… (#51)

* 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>

* 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(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

* 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>

* 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>

* fix(security): stop logging OTP codes and verification tokens in email bodies (#104)

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

* 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

* feat(security): implement educator verification pipeline and content-creation gating (#92) (#102)

* 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 <abimbolaalabi@users.noreply.github.com>

* feat(auth): signed service-to-service authentication for the AI service (#91) (#106)

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>

* 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>

* feat(security): implement TOTP two-factor authentication for admins a… (#98)

* 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>

* Add scholarship escrow contract foundation (#108)

* Improve application test coverage (#110)

* Add dependency health checks (#112)

* Validate auth and Stellar requests (#109)

* Validate auth and Stellar requests

* Address validation review feedback

* Secure book deletion authorization (#113)

* Secure book deletion

* Keep delete response consistent

* feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116)

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.

* 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.

* 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.

* feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111)

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

* feat: add managed course categories (#118)

* feat(courses): add managed category taxonomy

* fix(categories): preserve legacy course creation

* feat: add recurring sadaqah pledges (#119)

* feat(donations): add recurring sadaqah pledges

* fix(pledges): preserve donation test compatibility

* fix(pledges): ignore non-persisted transactions

---------

Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>

* refactor(db): scaffold /mongo data-layer structure (closes #167)

---------

Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com>
Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com>
Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com>
Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com>
Co-authored-by: BountySpaghetti <zeemroyals@gmail.com>
Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com>
Co-authored-by: Samuel Ojetunde <samuelojetunde898@gmail.com>
Co-authored-by: abimbolaalabi <abimbolaalabi@users.noreply.github.com>
Co-authored-by: Lspnjr1 <shittulukmanbabatunde@gmail.com>
Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com>
Co-authored-by: Alhassan Nuhu Idris <alhassannuhu0@gmail.com>
Co-authored-by: Mantissa <negativemantissa@gmail.com>
Co-authored-by: Ezekiel Akawa <akawaezekiel4@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants