Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Loan Management System — Next.js + Express + MongoDB (assignment)
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
• Live app: https://loan-management-system-beta-pearl.vercel.app
• Demo video & login credentials: https://drive.google.com/drive/folders/1otgfW5d0zABoxoPfGcvLKE824S2YY9yt?usp=sharing
• API health: https://loan-management-system-wl8j.onrender.com/health
The backend runs on Render's free tier; the first request after idle can take up to a minute to wake up. The app shows a "Waking up the server" banner while it retries.README 전문 보기 ↗The backend runs on Render's free tier; the first request after idle can take up to a minute to wake up. The app shows a "Waking up the server" banner while it retries.
A lending platform where borrowers apply for a personal loan online, and internal teams (Sales, Sanction, Disbursement, Collection, Admin) move each loan through its lifecycle until it is repaid:
APPLIED → SANCTIONED → DISBURSED → CLOSED (or APPLIED → REJECTED)
A step-by-step walkthrough of the full flow, role by role, is in docs/EVALUATOR_GUIDE.md. This is a demo: please don't enter real personal data or upload real documents.
Borrower portal (/apply): a wizard that resumes at the borrower's last completed step.
/apply/loans): every loan the borrower has applied for, newest first, each opening its own timeline.Operations dashboard (/dashboard). Each role sees only its module; Admin sees everything.
| Module | Who | What |
|---|---|---|
| Sales | SALES, ADMIN | Registered borrowers who haven't applied, with their stage (details pending / not eligible / slip pending / ready) |
| Sanction | SANCTION, ADMIN | APPLIED loans → review the applicant, BRE result and salary slip (inline viewer) → Approve or Reject with a reason |
| Disbursement | DISBURSEMENT, ADMIN | SANCTIONED loans → Mark disbursed (confirm dialog) |
| Collection | COLLECTION, ADMIN | DISBURSED loans → record payments (unique UTR, amount, date); the loan auto-closes when fully repaid |
| Overview | ADMIN | Counts per status, plus every loan with a status filter |
| Staff | ADMIN | Every user with their role (search, role filter); add staff members with a temporary password and change roles, with safety rules: an admin can't change their own role, the last admin can't be demoted, and a borrower with loans can't become staff |
| Part | Tech | Hosting |
|---|---|---|
| Frontend | Next.js 16 (App Router), React 19, TypeScript 6, Tailwind CSS 4, zod, jose, sonner | Vercel |
| Backend | Node 24, Express 5, TypeScript 6 (ESM), Mongoose 9, zod 4, jose, bcrypt, helmet, multer, file-type, pino | Render |
| Database | MongoDB Atlas 8.0 (salary slips in GridFS) | Atlas M0 |
| Tests | vitest, supertest, mongodb-memory-server (replica set, for transactions) | GitHub Actions |
flowchart LR
B[Browser] -->|"HTTPS, one origin"| V["Next.js on Vercel<br/>pages + proxy.ts route guard"]
V -->|"rewrite /api/* → BACKEND_URL"| R["Express API on Render<br/>routes → controllers → services → models"]
R -->|"Mongoose over TLS"| A[("MongoDB Atlas<br/>users · borrower_profiles · loans · payments<br/>GridFS salary_slips")]
/api/* to Render. The JWT cookie is therefore first-party (httpOnly; Secure; SameSite=Lax) with no cross-site cookie problems.routes → controller (HTTP only) → service (business logic) → model. Services throw a typed AppError, and one error handler formats every error as { success: false, error: { code, message, details? } }. Successes are { success: true, data }.utils/bre.ts, loan-math.ts, loan-state-machine.ts, payment-rules.ts. The BRE and loan math are mirrored in frontend/src/lib and tested against the same JSON test vectors as the server.proxy.ts) and the role-filtered sidebar are UX only.More detail: docs/ARCHITECTURE.md.
stateDiagram-v2
[*] --> APPLIED: borrower applies (BRE passes, slip uploaded, no active loan)
APPLIED --> SANCTIONED: approve (SANCTION / ADMIN)
APPLIED --> REJECTED: reject + reason (SANCTION / ADMIN)
SANCTIONED --> DISBURSED: mark disbursed (DISBURSEMENT / ADMIN)
DISBURSED --> CLOSED: automatically, when totalPaid = totalRepayment
REJECTED --> [*]
CLOSED --> [*]
backend/src/utils/loan-state-machine.ts. An invalid transition returns 409.{ _id, status: from }).statusHistory of { from, to, by, byRole, at, note }.Eligibility (BRE). The server is the source of truth, and the rules run again when the borrower applies, because age changes over time. A failure returns 422 with all failed rules.
| Rule | Fails when |
|---|---|
| Age | not 23–50 inclusive, from the exact date of birth (birthday passed or not; calendar date in India) |
| Salary | monthly salary below ₹25,000 |
| PAN | doesn't match ^[A-Z]{5}[0-9]{4}[A-Z]$ after trimming and uppercasing |
| Employment | unemployed |
Why server and client: the client mirror gives instant feedback, but anyone can call the API directly, so only the server's decision counts (DECISIONS #21).
Loan math. All money is stored as integer paise.
SI = round((P × 12 × T) / (365 × 100)), with T in days; totalRepayment = P + SI.Payments.
totalRepayment − totalPaid). The date can't be in the future or before disbursal. The loan must be DISBURSED.totalPaid increment and the auto-close happen in one MongoDB transaction. Concurrent payments can never overpay (tested).| Question | Answer |
|---|---|
| What's the correct PAN regex? | ^[A-Z]{5}[0-9]{4}[A-Z]$ (5 letters, 4 digits, 1 letter), checked after trimming and uppercasing |
| BRE on the client, server or both? Why? | Both. The server decides (and re-checks on apply), because anyone can call the API directly; the client mirror only gives instant feedback. Both run the same test vectors |
| What transitions happen in Sanction? | APPLIED → SANCTIONED (approve) or APPLIED → REJECTED (reject, reason required); anything else is 409 |
| What's the status after disbursement? | DISBURSED: the loan moves to the Collection module |
| How is the outstanding balance tracked? | Each loan stores totalRepayment and a running totalPaid (paise), incremented in the same transaction that inserts the payment; outstanding = totalRepayment − totalPaid. When it reaches 0, the same transaction sets CLOSED |
| What validations on the payment amount? | A whole number of paise, > 0 and ≤ outstanding (no overpayment, even with concurrent requests); UTR unique; date not in the future or before disbursal; the loan must be DISBURSED |
| How are roles stored? | One role per user, a string enum on the users document (ADMIN, SALES, SANCTION, DISBURSEMENT, COLLECTION, BORROWER). Signup always creates BORROWER; only an admin can assign staff roles |
| How does the middleware check them? | authenticate verifies the JWT in the httpOnly cookie and reloads the user from the database (so role changes apply at once) → requireRole(...roles) on every route → services scope staff to their module's status and borrowers to their own records. The frontend guard (proxy.ts) only mirrors this for UX |
| What HTTP status for unauthorized access? | 401 when not logged in, 403 for the wrong role, 404 for a loan outside the caller's module or another borrower's record (so existence isn't revealed) |
erDiagram
USERS ||--o| BORROWER_PROFILES : "has (userId)"
USERS ||--o{ LOANS : "applies for (borrowerId)"
LOANS ||--o{ PAYMENTS : "receives (loanId)"
USERS ||--o{ PAYMENTS : "records (recordedBy)"
BORROWER_PROFILES |o--o| SALARY_SLIPS : "current slip"
LOANS }o--|| SALARY_SLIPS : "slip at apply time"
| Collection | Key fields | Indexes |
|---|---|---|
users | name, email, passwordHash (select: false), role | email unique; { role, createdAt, _id } |
borrower_profiles | userId, fullName, pan, dateOfBirth, monthlySalary (paise), employmentMode, breResult, salarySlip | userId unique |
loans | borrowerId, principal, tenureDays, annualInterestRate, simpleInterest, totalRepayment, totalPaid, status, applicant snapshot, salarySlip snapshot, rejectionReason, disbursedAt/By, closedAt, statusHistory | { status, createdAt, _id }; { borrowerId, createdAt, _id }; partial unique borrowerId while active |
payments | loanId, utr, amount (paise), paymentDate, recordedBy | utr unique; { loanId, paymentDate, _id } |
salary_slips.files/.chunks | GridFS; generated filename, metadata: { ownerId, contentType } | GridFS defaults |
The applicant's details and salary slip are snapshotted onto the loan at apply time, so later profile edits don't rewrite an application.
Base path /api/v1. Full request and response shapes and every error code are in docs/API.md.
| Method | Path | Roles |
|---|---|---|
| GET | /health (unversioned) | public |
| POST | /auth/signup · /auth/login · /auth/logout | public (rate-limited) |
| GET | /auth/me | any logged-in user |
| GET | /borrower/progress | BORROWER |
| PUT | /borrower/profile | BORROWER |
| POST, GET | /borrower/salary-slip | BORROWER |
| POST | /borrower/loans | BORROWER |
| GET | /borrower/loans · /borrower/loans/:loanId | BORROWER (own loans only) |
| GET | /loans · /loans/:loanId | SANCTION, DISBURSEMENT, COLLECTION, ADMIN (scoped to each module's status) |
| POST | /loans/:loanId/approve · /loans/:loanId/reject | SANCTION, ADMIN |
| POST | /loans/:loanId/disburse | DISBURSEMENT, ADMIN |
| GET | /loans/:loanId/salary-slip | SANCTION, ADMIN |
| GET, POST | /loans/:loanId/payments | COLLECTION, ADMIN |
| GET | /leads | SALES, ADMIN |
| GET | /dashboard/summary | ADMIN |
| GET, POST | /admin/users | ADMIN |
| PATCH | /admin/users/:userId/role | ADMIN |
Status codes: 400 validation or malformed request · 401 not logged in · 403 wrong role · 404 · 409 conflict · 413 too large · 415 file type or encoding · 422 business rule (BRE / payment) · 429 rate limited · 503 database unavailable. Every list is paginated (?page&limit).
RBAC on every route, with a test matrix covering 21 protected endpoints × 7 identities. Admin-only staff management with lock-out and segregation-of-duties rules. Also: IDOR-safe borrower routes, strict zod schemas, NoSQL-injection guards, magic-byte upload checks, an httpOnly cookie session, rate limits, an Origin check, helmet, and redacted logs. The full review against the project rules, the test evidence and the known limitations are in docs/SECURITY.md.
backend/
src/
config/ env (zod), db, constants, logger
middleware/ authenticate, require-role, validate, verify-origin, rate-limit, upload, error-handler
modules/ auth, borrower, loans, payments, uploads, dashboard, admin, health
(*.routes.ts → *.controller.ts → *.service.ts, *.schema.ts, *.dto.ts)
models/ user, borrower-profile, loan, payment
utils/ bre, loan-math, loan-state-machine, payment-rules, dates, pan, jwt, …
scripts/ seed CLI, demo data, test-data/ (the removable @test.lms.dev QA data)
app.ts createApp(): Express app without listen (testable)
server.ts connect → build indexes → listen
tests/ unit/, integration/, fixtures/ (shared BRE + loan-math vectors), helpers/
frontend/src/
app/ (auth)/login|signup, (borrower)/apply/…, dashboard/…, forbidden, not-found
components/ ui/, borrower/, dashboard/, auth/
lib/ api-client, route-access, bre, loan-math, wizard, format, dates
hooks/, types/
proxy.ts role-aware route guard (Next 16's renamed middleware)
docs/ EVALUATOR_GUIDE, API, ARCHITECTURE, DECISIONS, DEPLOYMENT, SECURITY, TEST_ACCOUNTS, UI, screenshots/
audit/ pre-submission audit: AUDIT_REPORT.md, findings/, evidence scripts/
Prerequisites: Node 24 (nvm use) and npm 11. There is no local MongoDB: the app uses a free Atlas cluster with an lms_dev database (docs/DEPLOYMENT.md, steps 1–2). Tests use an in-memory MongoDB and never touch Atlas.
The database must be a replica set, because recording a payment uses a transaction. Atlas always is. If you use your own MongoDB instead, start it as a single-node replica set (mongod --replSet rs0, then rs.initiate() once); on a standalone mongod everything works except payments, which fail with a 500.
Install dependencies. The root install only adds git hooks and Prettier.
npm install
npm install --prefix backend
npm install --prefix frontend
npm then reports 5 high-severity advisories in the frontend. They're in the dev-only lint tooling (braces via eslint-config-next), never shipped, and have no published fix (docs/SECURITY.md); don't run npm audit fix --force, which downgrades Next's ESLint config. allow-scripts warnings about bcrypt, esbuild and mongodb-memory-server are harmless.
Configure the backend: copy the example, then set MONGODB_URI (…/lms_dev?…) and JWT_SECRET (openssl rand -base64 48). Every variable is validated at startup.
cp backend/.env.example backend/.env
Configure the frontend: BACKEND_URL=http://localhost:4000, and the same JWT_SECRET as the backend.
cp frontend/.env.example frontend/.env.local
Seed the demo accounts and data. The seed creates one account per role (Admin, Sales, Sanction, Disbursement, Collection, Borrower). Login credentials are in the Google Drive folder linked above. It also adds demo borrowers in every loan state, and it's safe to re-run: it resets the demo accounts and their loans and leaves other users alone. It deletes any payment whose UTR starts with SEED (the prefix the seed uses), so don't record real payments with that prefix.
npm run seed --prefix backend
Optionally add the QA data described in docs/TEST_ACCOUNTS.md: 60 removable @test.lms.dev accounts, so every role sees at least 5 records (--remove-test-data takes it out again):
npm run seed --prefix backend -- --test-data
Run the API on http://localhost:4000 and the web app on http://localhost:3000, in two terminals:
Run from the repository root; each command runs in both apps:
npm test
npm run lint
npm run typecheck
npm run build
npm run secrets
Tests and lint need no configuration. typecheck and build read frontend/.env.local (setup step 3), because Next validates BACKEND_URL and JWT_SECRET when it loads its config.
npm audit for each app, a full-history gitleaks scan, and a Conventional-Commit PR title check. main is protected and requires the Backend, Frontend and Secret scan checks (the PR-title check runs on every PR but isn't required; DECISIONS #24).| Piece | Service | Notes |
|---|---|---|
| Database | MongoDB Atlas M0 (AWS Singapore) | network access 0.0.0.0/0 (Render free has no static IPs); separate dev and prod databases |
| API | Render web service from render.yaml (free, Singapore) | rootDir: backend; build npm ci --include=dev && npm run build; start node dist/server.js; health check /health; deploys main after CI passes |
| Web app | Vercel (root directory frontend) | BACKEND_URL and JWT_SECRET (Production only); builds main only |
Production is seeded from a laptop, because Render's free tier has no shell:
cd backend && MONGODB_URI='<prod connection string>' NODE_ENV=production npm run seed -- --force
The optional test data uses the same command with --test-data (or --remove-test-data) added; see docs/DEPLOYMENT.md.
Step-by-step instructions (Atlas, Render, Vercel, env vars, post-deploy checks) are in docs/DEPLOYMENT.md.
main, one PR per branch (each listed in PROGRESS.md).TRUST_PROXY_HOPS, which must be measured on the deployed proxy chain.Details and mitigations: docs/SECURITY.md.
npm run dev --prefix backend
npm run dev --prefix frontend