germansango01 commited on
Commit
b6ee67e
·
1 Parent(s): ce80584

Creacion de modulo auth

Browse files
backend/.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Variables de entorno del backend PolySignal.
2
+ # Copiar este archivo a backend/.env y rellenar los valores.
3
+ # El archivo .env real NO debe commitearse (asegurar entrada en .gitignore).
4
+
5
+ NODE_ENV=development
6
+ PORT=7860
7
+
8
+ # SQLite path relativo al archivo schema.prisma (backend/prisma/).
9
+ DATABASE_URL=file:./polysignal.db
10
+
11
+ # Auth — generar con: openssl rand -hex 48
12
+ JWT_SECRET=replace-with-64-plus-random-chars
13
+ JWT_EXPIRES_IN=1h
14
+ BCRYPT_ROUNDS=10
15
+
16
+ # CORS del frontend Vite en desarrollo.
17
+ CORS_ORIGIN=http://localhost:5173
18
+
19
+ # Logger pino (trace | debug | info | warn | error | fatal).
20
+ LOG_LEVEL=info
backend/docs/AUTH.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Auth — Guía de uso
2
+
3
+ Login con email + password. El backend emite un JWT (HS256, default 1h) que viaja en `Authorization: Bearer <token>`. No hay registro público en esta fase: los usuarios se crean vía el seeder. **No hay roles** — `requireAuth` solo valida que el usuario esté logueado y activo, para que pueda mantener sus preferencias.
4
+
5
+ ## 1. Variables de entorno
6
+
7
+ Copiar `backend/.env.example` a `backend/.env` y rellenar:
8
+
9
+ | Variable | Default | Notas |
10
+ |---|---|---|
11
+ | `NODE_ENV` | `development` | `development` \| `test` \| `production` |
12
+ | `PORT` | `7860` | Puerto del backend (HF Spaces requiere 7860) |
13
+ | `DATABASE_URL` | `file:./polysignal.db` | Path SQLite **relativo a `prisma/schema.prisma`** |
14
+ | `JWT_SECRET` | — (obligatorio) | Mínimo 32 chars. Generar con `openssl rand -hex 48` |
15
+ | `JWT_EXPIRES_IN` | `1h` | Formato `jsonwebtoken` (`1h`, `15m`, `7d`, ...) |
16
+ | `BCRYPT_ROUNDS` | `10` | Entre 4 y 15 |
17
+ | `CORS_ORIGIN` | `http://localhost:5173` | Origen del frontend en dev |
18
+ | `LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
19
+
20
+ > Si el `.env` falta una variable obligatoria o un valor es inválido, el backend imprime el error y aborta el arranque (validación con Zod en `src/config.js`).
21
+
22
+ ## 2. Primer arranque
23
+
24
+ Desde `backend/`:
25
+
26
+ ```bash
27
+ npm install # instala deps
28
+ npm run db:migrate -- --name init_auth # solo la primera vez (ya hecho)
29
+ npm run db:seed # crea los 2 usuarios de prueba
30
+ npm run dev # arranca en http://localhost:7860
31
+ ```
32
+
33
+ Para inspeccionar la DB en una UI:
34
+
35
+ ```bash
36
+ npm run db:studio
37
+ ```
38
+
39
+ ## 3. Usuarios de prueba
40
+
41
+ Sembrados por `prisma/seed.js` (idempotente, se puede re-ejecutar):
42
+
43
+ | Email | Password |
44
+ |---|---|
45
+ | `admin@polysignal.test` | `Admin123!` |
46
+ | `user@polysignal.test` | `User123!` |
47
+
48
+ ## 4. Endpoints
49
+
50
+ ### `GET /api/v1/health`
51
+
52
+ Sanity check. Respuesta:
53
+
54
+ ```json
55
+ { "ok": true, "data": { "status": "up" } }
56
+ ```
57
+
58
+ ### `POST /api/v1/auth/login`
59
+
60
+ Body:
61
+
62
+ ```json
63
+ { "email": "admin@polysignal.test", "password": "Admin123!" }
64
+ ```
65
+
66
+ Respuesta `200`:
67
+
68
+ ```json
69
+ {
70
+ "ok": true,
71
+ "data": {
72
+ "token": "eyJhbGciOiJIUzI1NiJ9...",
73
+ "user": { "id": 1, "email": "admin@polysignal.test" }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Errores:
79
+
80
+ | HTTP | code | cuándo |
81
+ |---|---|---|
82
+ | `400` | `VALIDATION_ERROR` | Email inválido o password < 8 chars |
83
+ | `401` | `INVALID_CREDENTIALS` | Email no existe, usuario desactivado, o password incorrecta |
84
+ | `429` | `TOO_MANY_REQUESTS` | Más de 5 intentos en 15 min desde la misma IP |
85
+
86
+ ### `GET /api/v1/auth/me`
87
+
88
+ Requiere header `Authorization: Bearer <token>`. Respuesta `200`:
89
+
90
+ ```json
91
+ {
92
+ "ok": true,
93
+ "data": {
94
+ "user": {
95
+ "id": 1,
96
+ "email": "admin@polysignal.test",
97
+ "isActive": true,
98
+ "createdAt": "2026-05-16T07:11:43.000Z"
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Errores:
105
+
106
+ | HTTP | code | cuándo |
107
+ |---|---|---|
108
+ | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
109
+
110
+ ## 5. Ejemplos con `curl`
111
+
112
+ ```bash
113
+ # 1) Login y guardar token en variable
114
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
115
+ -H 'Content-Type: application/json' \
116
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' \
117
+ | jq -r '.data.token')
118
+
119
+ # 2) Llamar a /me con el token
120
+ curl -s http://localhost:7860/api/v1/auth/me \
121
+ -H "Authorization: Bearer $TOKEN" | jq
122
+ ```
123
+
124
+ ## 6. Login desde el frontend (referencia)
125
+
126
+ El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido.
127
+
128
+ ```js
129
+ const res = await fetch('/api/v1/auth/login', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ email, password }),
133
+ });
134
+ const json = await res.json();
135
+ if (!json.ok) throw new Error(json.error.code);
136
+ const { token, user } = json.data;
137
+ // guardar token y user
138
+
139
+ // requests autenticados
140
+ fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
141
+ ```
142
+
143
+ > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya está configurado por si el front llama directo.
144
+
145
+ ## 7. Cómo proteger nuevos endpoints
146
+
147
+ ```js
148
+ import { requireAuth } from '../middlewares/requireAuth.js';
149
+
150
+ router.get('/positions', requireAuth, controller.list);
151
+ ```
152
+
153
+ Dentro del controller, `req.user` ya está disponible con `{ id, email, isActive, createdAt }` — suficiente para filtrar datos del usuario logueado (ej. sus preferencias, posiciones, watchlist).
154
+
155
+ ## 8. Rotar `JWT_SECRET`
156
+
157
+ Genera uno nuevo y reemplaza el valor de `JWT_SECRET` en `backend/.env`:
158
+
159
+ ```bash
160
+ openssl rand -hex 48
161
+ ```
162
+
163
+ Al cambiar el secreto, todos los tokens emitidos previamente quedan invalidados (los clientes deben volver a hacer login).
164
+
165
+ ## 9. Notas técnicas
166
+
167
+ - **Hashing:** `bcryptjs` (puro JS, sin compilación nativa — más portable a HF Spaces) con coste `BCRYPT_ROUNDS` (default 10).
168
+ - **JWT:** algoritmo `HS256`, claim `sub = user.id`, `email`, `iat`, `exp`.
169
+ - **Rate limit en `/auth/login`:** `express-rate-limit`, 5 intentos / 15 min / IP.
170
+ - **Validación de body:** zod schema en `src/auth/auth.validators.js`.
171
+ - **Errores formateados:** todas las respuestas siguen `{ ok, data }` o `{ ok:false, error:{ code, message, details? } }` vía `src/utils/apiResponse.js` y el middleware `errorHandler`.
172
+ - **Prisma:** singleton en `src/utils/prisma.js` para no abrir conexiones de más con `node --watch`.
backend/package.json CHANGED
@@ -2,23 +2,34 @@
2
  "name": "polysignal-backend",
3
  "version": "1.0.0",
4
  "description": "Backend API de PolySignal — Hackathon CIFO Barcelona La Violeta",
 
5
  "main": "src/index.js",
6
  "prisma": {
7
- "schema": "prisma/schema.prisma"
 
8
  },
9
  "scripts": {
10
  "start": "node src/index.js",
11
  "dev": "node --watch src/index.js",
12
  "db:migrate": "prisma migrate dev",
13
  "db:generate": "prisma generate",
 
14
  "db:studio": "prisma studio"
15
  },
16
  "dependencies": {
 
 
 
 
17
  "express": "^5.2.1",
18
- "socket.io": "^4.8.3",
 
 
19
  "node-cron": "^4.2.1",
20
- "@prisma/client": "^6.19.2",
21
- "prisma": "^6.19.2"
 
 
22
  },
23
  "engines": {
24
  "node": ">=24.0.0"
 
2
  "name": "polysignal-backend",
3
  "version": "1.0.0",
4
  "description": "Backend API de PolySignal — Hackathon CIFO Barcelona La Violeta",
5
+ "type": "module",
6
  "main": "src/index.js",
7
  "prisma": {
8
+ "schema": "prisma/schema.prisma",
9
+ "seed": "node prisma/seed.js"
10
  },
11
  "scripts": {
12
  "start": "node src/index.js",
13
  "dev": "node --watch src/index.js",
14
  "db:migrate": "prisma migrate dev",
15
  "db:generate": "prisma generate",
16
+ "db:seed": "node prisma/seed.js",
17
  "db:studio": "prisma studio"
18
  },
19
  "dependencies": {
20
+ "@prisma/client": "^6.19.2",
21
+ "bcryptjs": "^2.4.3",
22
+ "cors": "^2.8.5",
23
+ "dotenv": "^16.4.5",
24
  "express": "^5.2.1",
25
+ "express-rate-limit": "^7.4.0",
26
+ "helmet": "^8.0.0",
27
+ "jsonwebtoken": "^9.0.2",
28
  "node-cron": "^4.2.1",
29
+ "pino": "^9.5.0",
30
+ "prisma": "^6.19.2",
31
+ "socket.io": "^4.8.3",
32
+ "zod": "^3.23.8"
33
  },
34
  "engines": {
35
  "node": ">=24.0.0"
backend/prisma/migrations/20260516071158_init_auth/migration.sql ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- CreateTable
2
+ CREATE TABLE "User" (
3
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4
+ "email" TEXT NOT NULL,
5
+ "passwordHash" TEXT NOT NULL,
6
+ "role" TEXT NOT NULL DEFAULT 'user',
7
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
8
+ "telegramChatId" TEXT,
9
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "updatedAt" DATETIME NOT NULL
11
+ );
12
+
13
+ -- CreateTable
14
+ CREATE TABLE "Market" (
15
+ "id" TEXT NOT NULL PRIMARY KEY,
16
+ "question" TEXT NOT NULL,
17
+ "category" TEXT,
18
+ "countryCode" TEXT,
19
+ "yesPrice" REAL,
20
+ "noPrice" REAL,
21
+ "volumeEur" REAL,
22
+ "liquidityEur" REAL,
23
+ "status" TEXT NOT NULL DEFAULT 'active',
24
+ "closesAt" DATETIME,
25
+ "lastSynced" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
26
+ );
27
+
28
+ -- CreateTable
29
+ CREATE TABLE "AISignal" (
30
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
31
+ "marketId" TEXT NOT NULL,
32
+ "signal" TEXT NOT NULL,
33
+ "confidence" REAL NOT NULL,
34
+ "summary" TEXT,
35
+ "keyRisk" TEXT,
36
+ "newsCount" INTEGER NOT NULL DEFAULT 0,
37
+ "modelVersion" TEXT NOT NULL DEFAULT 'Qwen3-8B',
38
+ "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
39
+ CONSTRAINT "AISignal_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
40
+ );
41
+
42
+ -- CreateTable
43
+ CREATE TABLE "Position" (
44
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
45
+ "userId" INTEGER NOT NULL,
46
+ "marketId" TEXT NOT NULL,
47
+ "outcome" TEXT NOT NULL,
48
+ "amountEur" REAL NOT NULL,
49
+ "entryPrice" REAL NOT NULL,
50
+ "currentPrice" REAL,
51
+ "pnl" REAL NOT NULL DEFAULT 0,
52
+ "kellyFraction" REAL,
53
+ "status" TEXT NOT NULL DEFAULT 'open',
54
+ "openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
55
+ "closedAt" DATETIME,
56
+ CONSTRAINT "Position_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
57
+ CONSTRAINT "Position_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
58
+ );
59
+
60
+ -- CreateTable
61
+ CREATE TABLE "Watchlist" (
62
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
63
+ "userId" INTEGER NOT NULL,
64
+ "marketId" TEXT NOT NULL,
65
+ "alertThreshold" REAL,
66
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
67
+ CONSTRAINT "Watchlist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
68
+ CONSTRAINT "Watchlist_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
69
+ );
70
+
71
+ -- CreateTable
72
+ CREATE TABLE "Alert" (
73
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
74
+ "userId" INTEGER NOT NULL,
75
+ "marketId" TEXT NOT NULL,
76
+ "type" TEXT NOT NULL,
77
+ "message" TEXT NOT NULL,
78
+ "sentAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
79
+ CONSTRAINT "Alert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
80
+ CONSTRAINT "Alert_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
81
+ );
82
+
83
+ -- CreateIndex
84
+ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
85
+
86
+ -- CreateIndex
87
+ CREATE INDEX "AISignal_marketId_generatedAt_idx" ON "AISignal"("marketId", "generatedAt");
88
+
89
+ -- CreateIndex
90
+ CREATE INDEX "Position_userId_status_idx" ON "Position"("userId", "status");
91
+
92
+ -- CreateIndex
93
+ CREATE INDEX "Position_marketId_idx" ON "Position"("marketId");
94
+
95
+ -- CreateIndex
96
+ CREATE INDEX "Watchlist_userId_idx" ON "Watchlist"("userId");
97
+
98
+ -- CreateIndex
99
+ CREATE UNIQUE INDEX "Watchlist_userId_marketId_key" ON "Watchlist"("userId", "marketId");
100
+
101
+ -- CreateIndex
102
+ CREATE INDEX "Alert_userId_sentAt_idx" ON "Alert"("userId", "sentAt");
103
+
104
+ -- CreateIndex
105
+ CREATE INDEX "Alert_marketId_idx" ON "Alert"("marketId");
backend/prisma/migrations/20260516072006_remove_user_role/migration.sql ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ Warnings:
3
+
4
+ - You are about to drop the column `role` on the `User` table. All the data in the column will be lost.
5
+
6
+ */
7
+ -- RedefineTables
8
+ PRAGMA defer_foreign_keys=ON;
9
+ PRAGMA foreign_keys=OFF;
10
+ CREATE TABLE "new_User" (
11
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
12
+ "email" TEXT NOT NULL,
13
+ "passwordHash" TEXT NOT NULL,
14
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
15
+ "telegramChatId" TEXT,
16
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
+ "updatedAt" DATETIME NOT NULL
18
+ );
19
+ INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "passwordHash", "telegramChatId", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "passwordHash", "telegramChatId", "updatedAt" FROM "User";
20
+ DROP TABLE "User";
21
+ ALTER TABLE "new_User" RENAME TO "User";
22
+ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
23
+ PRAGMA foreign_keys=ON;
24
+ PRAGMA defer_foreign_keys=OFF;
backend/prisma/migrations/migration_lock.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (e.g., Git)
3
+ provider = "sqlite"
backend/prisma/schema.prisma CHANGED
@@ -22,8 +22,12 @@ datasource db {
22
 
23
  model User {
24
  id Int @id @default(autoincrement())
25
- telegramChatId String? // Configurado manualmente para demo
 
 
 
26
  createdAt DateTime @default(now())
 
27
 
28
  positions Position[]
29
  watchlist Watchlist[]
 
22
 
23
  model User {
24
  id Int @id @default(autoincrement())
25
+ email String @unique
26
+ passwordHash String
27
+ isActive Boolean @default(true)
28
+ telegramChatId String? // Configurado manualmente para demo
29
  createdAt DateTime @default(now())
30
+ updatedAt DateTime @updatedAt
31
 
32
  positions Position[]
33
  watchlist Watchlist[]
backend/prisma/seed.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import bcrypt from 'bcryptjs';
2
+ import { PrismaClient } from '@prisma/client';
3
+
4
+ const prisma = new PrismaClient();
5
+
6
+ const ROUNDS = Number(process.env.BCRYPT_ROUNDS ?? 10);
7
+
8
+ const users = [
9
+ { email: 'admin@polysignal.test', password: 'Admin123!' },
10
+ { email: 'user@polysignal.test', password: 'User123!' },
11
+ ];
12
+
13
+ const run = async () => {
14
+ for (const u of users) {
15
+ const passwordHash = await bcrypt.hash(u.password, ROUNDS);
16
+ const record = await prisma.user.upsert({
17
+ where: { email: u.email },
18
+ update: { passwordHash, isActive: true },
19
+ create: { email: u.email, passwordHash, isActive: true },
20
+ });
21
+ console.log(`seeded user ${record.email} (id=${record.id})`);
22
+ }
23
+ };
24
+
25
+ run()
26
+ .catch((err) => {
27
+ console.error(err);
28
+ process.exit(1);
29
+ })
30
+ .finally(async () => {
31
+ await prisma.$disconnect();
32
+ });
backend/src/app.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import helmet from 'helmet';
4
+ import { config } from './config.js';
5
+ import { ok } from './utils/apiResponse.js';
6
+ import authRoutes from './auth/auth.routes.js';
7
+ import { notFound } from './middlewares/notFound.js';
8
+ import { errorHandler } from './middlewares/errorHandler.js';
9
+
10
+ const app = express();
11
+
12
+ app.use(helmet());
13
+ app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
14
+ app.use(express.json({ limit: '1mb' }));
15
+
16
+ app.get('/api/v1/health', (_req, res) => ok(res, { status: 'up' }));
17
+ app.use('/api/v1/auth', authRoutes);
18
+
19
+ app.use(notFound);
20
+ app.use(errorHandler);
21
+
22
+ export default app;
backend/src/auth/auth.controller.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as authService from './auth.service.js';
2
+ import { ok } from '../utils/apiResponse.js';
3
+
4
+ export const login = async (req, res) => {
5
+ const data = await authService.login(req.body);
6
+ ok(res, data);
7
+ };
8
+
9
+ export const me = async (req, res) => {
10
+ ok(res, { user: req.user });
11
+ };
backend/src/auth/auth.routes.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import * as ctrl from './auth.controller.js';
3
+ import { loginSchema } from './auth.validators.js';
4
+ import { validate } from '../middlewares/validate.js';
5
+ import { requireAuth } from '../middlewares/requireAuth.js';
6
+ import { rateLimitLogin } from '../middlewares/rateLimitLogin.js';
7
+
8
+ const router = Router();
9
+
10
+ router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
11
+ router.get('/me', requireAuth, ctrl.me);
12
+
13
+ export default router;
backend/src/auth/auth.service.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import bcrypt from 'bcryptjs';
2
+ import { prisma } from '../utils/prisma.js';
3
+ import { HttpError } from '../utils/apiResponse.js';
4
+ import { signToken } from './jwt.js';
5
+
6
+ const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
7
+
8
+ export const login = async ({ email, password }) => {
9
+ const user = await prisma.user.findUnique({ where: { email } });
10
+ if (!user || !user.isActive) throw INVALID_CREDENTIALS;
11
+
12
+ const passwordOk = await bcrypt.compare(password, user.passwordHash);
13
+ if (!passwordOk) throw INVALID_CREDENTIALS;
14
+
15
+ const token = signToken({ sub: user.id, email: user.email });
16
+
17
+ return {
18
+ token,
19
+ user: { id: user.id, email: user.email },
20
+ };
21
+ };
backend/src/auth/auth.validators.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ export const loginSchema = z.object({
4
+ email: z.string().email('Invalid email').toLowerCase().trim(),
5
+ password: z.string().min(8, 'Password must be at least 8 characters'),
6
+ });
backend/src/auth/jwt.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jwt from 'jsonwebtoken';
2
+ import { config } from '../config.js';
3
+
4
+ export const signToken = (payload) =>
5
+ jwt.sign(payload, config.JWT_SECRET, {
6
+ algorithm: 'HS256',
7
+ expiresIn: config.JWT_EXPIRES_IN,
8
+ });
9
+
10
+ export const verifyToken = (token) =>
11
+ jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
backend/src/config.js CHANGED
@@ -1,9 +1,23 @@
1
- /**
2
- * Configuración centralizada de variables de entorno.
3
- *
4
- * Carga los valores de process.env con defaults seguros para desarrollo.
5
- * Expone constantes como: PORT, DATABASE_URL, HF_TOKEN, FINNHUB_API_KEY,
6
- * OPENROUTER_API_KEY, TELEGRAM_BOT_TOKEN, NODE_ENV.
7
- *
8
- * Evita dispersar process.env por todo el código.
9
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import { z } from 'zod';
3
+
4
+ const schema = z.object({
5
+ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
6
+ PORT: z.coerce.number().int().positive().default(7860),
7
+ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
8
+ JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
9
+ JWT_EXPIRES_IN: z.string().default('1h'),
10
+ BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
11
+ CORS_ORIGIN: z.string().default('http://localhost:5173'),
12
+ LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
13
+ });
14
+
15
+ const parsed = schema.safeParse(process.env);
16
+
17
+ if (!parsed.success) {
18
+ console.error('Invalid environment variables:');
19
+ console.error(parsed.error.flatten().fieldErrors);
20
+ process.exit(1);
21
+ }
22
+
23
+ export const config = Object.freeze(parsed.data);
backend/src/index.js CHANGED
@@ -1,9 +1,20 @@
1
- /**
2
- * Entry point de la aplicacion PolySignal.
3
- *
4
- * Inicializa el servidor Express, configura Socket.io para comunicacion en tiempo real,
5
- * sirve los archivos estaticos del frontend desde ../frontend/, monta las rutas REST
6
- * bajo /api/v1/* y arranca el scheduler de node-cron.
7
- *
8
- * Puerto por defecto: 7860 (requerido por HuggingFace Spaces).
9
- */
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from 'node:http';
2
+ import app from './app.js';
3
+ import { config } from './config.js';
4
+ import { logger } from './utils/logger.js';
5
+ import { prisma } from './utils/prisma.js';
6
+
7
+ const httpServer = http.createServer(app);
8
+
9
+ httpServer.listen(config.PORT, () => {
10
+ logger.info({ port: config.PORT, env: config.NODE_ENV }, 'PolySignal backend up');
11
+ });
12
+
13
+ for (const sig of ['SIGTERM', 'SIGINT']) {
14
+ process.on(sig, async () => {
15
+ logger.info({ sig }, 'shutting down');
16
+ httpServer.close();
17
+ await prisma.$disconnect();
18
+ process.exit(0);
19
+ });
20
+ }
backend/src/middlewares/errorHandler.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from '../utils/apiResponse.js';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ export const errorHandler = (err, req, res, _next) => {
5
+ if (err instanceof HttpError) {
6
+ return res.status(err.status).json({
7
+ ok: false,
8
+ error: { code: err.code, message: err.message, details: err.details },
9
+ });
10
+ }
11
+
12
+ logger.error({ err, path: req.path, method: req.method }, 'unhandled error');
13
+
14
+ return res.status(500).json({
15
+ ok: false,
16
+ error: { code: 'INTERNAL', message: 'Internal server error' },
17
+ });
18
+ };
backend/src/middlewares/notFound.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const notFound = (req, res) =>
2
+ res.status(404).json({
3
+ ok: false,
4
+ error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` },
5
+ });
backend/src/middlewares/rateLimitLogin.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import rateLimit from 'express-rate-limit';
2
+
3
+ export const rateLimitLogin = rateLimit({
4
+ windowMs: 15 * 60 * 1000,
5
+ max: 5,
6
+ standardHeaders: true,
7
+ legacyHeaders: false,
8
+ message: {
9
+ ok: false,
10
+ error: { code: 'TOO_MANY_REQUESTS', message: 'Too many login attempts, try again later' },
11
+ },
12
+ });
backend/src/middlewares/requireAuth.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { verifyToken } from '../auth/jwt.js';
2
+ import { prisma } from '../utils/prisma.js';
3
+ import { HttpError } from '../utils/apiResponse.js';
4
+
5
+ const UNAUTHORIZED = new HttpError(401, 'UNAUTHORIZED', 'Authentication required');
6
+
7
+ export const requireAuth = async (req, _res, next) => {
8
+ try {
9
+ const header = req.headers.authorization;
10
+ if (!header || !header.startsWith('Bearer ')) throw UNAUTHORIZED;
11
+
12
+ const token = header.slice('Bearer '.length).trim();
13
+ if (!token) throw UNAUTHORIZED;
14
+
15
+ const payload = verifyToken(token);
16
+ const user = await prisma.user.findUnique({
17
+ where: { id: payload.sub },
18
+ select: { id: true, email: true, isActive: true, createdAt: true },
19
+ });
20
+
21
+ if (!user || !user.isActive) throw UNAUTHORIZED;
22
+
23
+ req.user = user;
24
+ next();
25
+ } catch (err) {
26
+ if (err instanceof HttpError) return next(err);
27
+ next(UNAUTHORIZED);
28
+ }
29
+ };
backend/src/middlewares/validate.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from '../utils/apiResponse.js';
2
+
3
+ export const validate = (schema, source = 'body') => (req, _res, next) => {
4
+ const result = schema.safeParse(req[source]);
5
+ if (!result.success) {
6
+ return next(
7
+ new HttpError(400, 'VALIDATION_ERROR', 'Invalid request', result.error.flatten().fieldErrors),
8
+ );
9
+ }
10
+ req[source] = result.data;
11
+ next();
12
+ };
backend/src/utils/apiResponse.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const ok = (res, data, meta) =>
2
+ res.status(200).json(meta ? { ok: true, data, meta } : { ok: true, data });
3
+
4
+ export const created = (res, data) => res.status(201).json({ ok: true, data });
5
+
6
+ export const noContent = (res) => res.status(204).end();
7
+
8
+ export class HttpError extends Error {
9
+ constructor(status, code, message, details) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.details = details;
14
+ }
15
+ }
backend/src/utils/logger.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import pino from 'pino';
2
+
3
+ export const logger = pino({
4
+ level: process.env.LOG_LEVEL ?? 'info',
5
+ });
backend/src/utils/prisma.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from '@prisma/client';
2
+
3
+ const globalForPrisma = globalThis;
4
+
5
+ export const prisma =
6
+ globalForPrisma.__prisma__ ??
7
+ new PrismaClient({
8
+ log: ['warn', 'error'],
9
+ });
10
+
11
+ if (process.env.NODE_ENV !== 'production') {
12
+ globalForPrisma.__prisma__ = prisma;
13
+ }