diff --git a/docs/API.md b/docs/API.md index 2fa0834..4cfe698 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,315 +1,171 @@ -# API de `api_notas` +Aquí tienes la documentación completa generada en formato `README.md` a partir de la arquitectura y el código de tu API. -Esta guía resume las rutas disponibles, qué datos esperan y los tipos más importantes del proyecto. +--- -## Base URL +# Documentación de la API: Notas Encriptadas (E2EE) -Todas las rutas cuelgan de: +Esta API proporciona el backend para una aplicación de notas con sincronización **Offline-First** y **Cifrado de Extremo a Extremo (E2EE)**. Está construida sobre Node.js con Express y utiliza PostgreSQL (vía Sequelize) como base de datos. -```text -/api -``` +## 🏗 Arquitectura y Flujo de Datos -Ejemplo local: +El sistema sigue un patrón estructurado en capas para separar responsabilidades: -```text -http://localhost:3000/api -``` +1. **Punto de Entrada e Inicialización:** El archivo `app.js` inicializa Express, habilita CORS y permite payloads JSON de hasta 50MB. El archivo `initDB.js` se encarga de sincronizar los modelos con la base de datos PostgreSQL utilizando Sequelize. -## Tipos principales -- `UUID`: identificador único en formato UUID. -- `string`: texto plano o cifrado. -- `boolean`: `true` o `false`. -- `number`: valor numérico. -- `ISO date string`: fecha en formato ISO 8601, por ejemplo `2026-05-18T10:00:00.000Z`. +2. **Enrutamiento Principal (`index.js`):** Todo el tráfico entra por `/api`. De ahí, el router principal distribuye las peticiones hacia `/auth` (autenticación), `/user` (datos del usuario) y `/` (sincronización). -## Rutas disponibles -### `GET /api/status` +3. **Controladores (`*Controller.js`):** Son la capa de seguridad y transporte. Validan que las peticiones vengan con un JWT válido en las cabeceras (Access Token), extraen el `userId` y envían los parámetros limpios a los servicios. -Comprueba que la API está viva. -Respuesta ejemplo: +4. **Servicios (`*Service.js`):** Contienen toda la lógica de negocio profunda. Manejan la creación de tokens, el hasheo de contraseñas, las transacciones SQL y la lógica de resolución de conflictos durante la sincronización. + +5. **Modelos (`.js`):** Definen las tablas y relaciones estructurales en la base de datos. + + + +--- + +## 🗄 Modelos de Base de Datos y Relaciones + +El esquema de la base de datos se distribuye en cuatro entidades principales interconectadas: + +* **User (`User.js`):** Almacena el `email` y `password` (hasheado con bcrypt) del usuario. También guarda la `encrypted_master_key`, que es la llave maestra para desencriptar las notas, pero cifrada con la contraseña del usuario para asegurar el E2EE. Un usuario tiene muchas Notas, Categorías y RefreshTokens. + + +* **Note (`Note.js`):** Guarda los datos cifrados de cada nota (`title`, `body`), su orden (`position`), versión en el servidor (`serverVersion`) y estados de borrado (`delete`, `permanentDelete`). + + +* **Category (`Category.js`):** Guarda información visual (`colorValue`, `iconCodePoint`) y el nombre cifrado (`encrypted_name`) de las categorías. + + +* **Relación Nota-Categoría:** Las Notas y las Categorías tienen una relación de *Muchos a Muchos* (M:N) a través de una tabla intermedia llamada `NoteCategories`. + + +* **RefreshToken (`RefreshToken.js`):** Gestiona las sesiones multidispositivo. Guarda un token largo, seguro y único asociado al `deviceName` del usuario. Si el usuario se elimina, sus tokens se borran en cascada. + + + +--- + +## 🚀 Endpoints de la API + +### 1. Autenticación (`/api/auth`) + +Estos endpoints gestionan el registro, inicio de sesión y ciclo de vida de los tokens. El sistema usa un Access Token de corta duración (15 min) y un Refresh Token persistente en base de datos. + +* **Registro de Usuario** +* **Ruta:** `POST /api/auth/register` + +* **Body:** `{ "email": "...", "password": "...", "encrypted_master_key": "...", "deviceName": "..." }` + +* **Descripción:** Comprueba que el usuario no exista, hashea su contraseña, crea la cuenta y automáticamente inicia sesión, devolviendo ambos tokens (Access y Refresh) y la llave maestra. + + + + +* **Inicio de Sesión** +* **Ruta:** `POST /api/auth/login` + +* **Body:** `{ "email": "...", "password": "...", "deviceName": "..." }` + +* **Descripción:** Verifica credenciales y crea una nueva sesión para el dispositivo (registrando un nuevo Refresh Token en DB). Retorna los tokens y la `encrypted_master_key`. + + + + +* **Refrescar Token** +* **Ruta:** `POST /api/auth/refresh` + +* **Body:** `{ "refresh_token": "..." }` + +* **Descripción:** Intercambia un Refresh Token válido por un nuevo Access Token limpio de 15 minutos. + + + + +* **Cerrar Sesión Local** +* **Ruta:** `POST /api/auth/logout` + +* **Body:** `{ "refresh_token": "..." }` + +* **Descripción:** Borra el Refresh Token proporcionado de la base de datos, revocando el acceso a ese dispositivo específico. + + + + +* **Borrado Total de Cuenta (Danger Zone)** +* **Ruta:** `DELETE /api/auth/delete-all-data` + +* **Headers:** `Authorization: Bearer ` + +* **Descripción:** Mediante una transacción de base de datos, borra irremediablemente todas las notas, categorías y sesiones (tokens) asociadas al usuario actual. + + + + +* **Listar Dispositivos Activos** +* **Ruta:** `GET /api/auth/getSessionsInfo` + +* **Headers:** `Authorization: Bearer ` + +* **Descripción:** Devuelve un listado de todos los dispositivos (`deviceName`), fechas de creación y los IDs de los Refresh Tokens asociados al usuario. + + + + + +### 2. Estadísticas de Usuario (`/api/user`) + +* **Obtener Estadísticas** +* **Ruta:** `GET /api/user/stats` + +* **Headers:** `Authorization: Bearer ` + +* **Descripción:** Descarga parcialmente las notas y categorías de la base de datos (solo los campos pesados como títulos y cuerpos) para calcular el peso en bytes (RAM-friendly) del almacenamiento en la nube del usuario. Devuelve totales formateados (KB/MB). + + + + + +### 3. Sincronización Offline-First (`/api/sync`) + +Este endpoint único procesa de forma bidireccional los cambios ocurridos tanto en la app local como en la base de datos del servidor. + +* **Endpoint de Sincronización** +* **Ruta:** `POST /api/sync` + +* **Headers:** `Authorization: Bearer ` + +* **Body:** ```json { - "message": "¡API funcionando correctamente!" -} -``` - -### `POST /api/auth/register` - -Crea un usuario nuevo. - -Body: - -```json -{ - "email": "maria", - "password": "123456", - "encrypted_master_key": "texto_cifrado_opcional", - "deviceName": "Flutter Pixel" -} -``` - -Campos: - -- `email`: `string`, obligatorio. -- `password`: `string`, obligatorio. -- `encrypted_master_key`: `string | null`, opcional. - -Respuesta ejemplo: - -```json -{ - "message": "Usuario registrado con éxito", - "accessToken": "jwt_access_token", - "refreshToken": "token_largo_aleatorio" -} -``` - -### `POST /api/auth/login` - -Inicia sesión y devuelve tokens. - -Body: - -```json -{ - "email": "maria", - "password": "123456", - "deviceName": "Flutter Pixel" -} -``` - -Campos: - -- `email`: `string`, obligatorio. -- `password`: `string`, obligatorio. -- `deviceName`: `string`, opcional. - -Respuesta ejemplo: - -```json -{ - "message": "Login exitoso", - "accessToken": "jwt_access_token", - "refreshToken": "token_largo_aleatorio", - "encrypted_master_key": "texto_cifrado" -} -``` - -### `POST /api/auth/refresh` - -Genera un nuevo `accessToken` usando un `refreshToken` válido. - -Body: - -```json -{ - "refreshToken": "token_largo_aleatorio" -} -``` - -Campos: - -- `refreshToken`: `string`, obligatorio. - -Respuesta ejemplo: - -```json -{ - "accessToken": "nuevo_jwt_access_token" -} -``` - -### `POST /api/auth/logout` - -Revoca la sesión del dispositivo actual. - -Body: - -```json -{ - "refreshToken": "token_largo_aleatorio" -} -``` - -Campos: - -- `refreshToken`: `string`, obligatorio. - -Respuesta ejemplo: - -```json -{ - "message": "Sesión cerrada en el dispositivo actual" -} -``` - -### `DELETE /api/auth/delete-all-data` - -Elimina todas las notas, categorías y refresh tokens del usuario autenticado. El usuario se conserva. - -Headers: - -- `Authorization: Bearer `: obligatorio. - -Respuesta ejemplo: - -```json -{ - "message": "Datos del usuario eliminados correctamente; se eliminaron todos los tokens." -} -``` - -### `POST /api/sync` - -Endpoint único de sincronización offline-first. - -Autenticación requerida: - -- Enviar `Authorization: Bearer ` (obligatorio). - -#### Request - -Body ejemplo: - -```json -{ - "lastSyncAt": "2026-05-18T10:00:00.000Z", + "lastSyncAt": "ISO String", "changes": { - "categories": [ - { - "id": "uuid-categoria-1", - "encrypted_name": "texto_cifrado...", - "serverVersion": 1, - "delete": false, - "updatedAt": "2026-05-18T10:05:00.000Z" - } - ], - "notes": [ - { - "id": "uuid-nota-1", - "categoryId": "uuid-categoria-1", - "title": "titulo_cifrado...", - "body": "cuerpo_cifrado...", - "serverVersion": 1, - "position": 2000, - "delete": true, - "permanentDelete": false, - "updatedAt": "2026-05-18T10:10:00.000Z" - } - ] + "categories": [ ... ], + "notes": [ ... ] } } + ``` -Campos: -- `lastSyncAt`: `string` ISO, opcional. Si no se envía, el servidor usa `1970-01-01T00:00:00.000Z`. -- `changes`: `object`, obligatorio. -- `changes.categories`: `array`, opcional. -- `changes.notes`: `array`, opcional. -#### Estructura de categoría -```json -{ - "id": "uuid", - "name": "texto_cifrado", - "serverVersion": 1, - "delete": false, - "isDirty": true, - "colorValue": 4281558681, - "iconCodePoint": 58896, - "updatedAt": "2026-05-18T10:05:00.000Z" -} -``` -Campos: +* **Descripción y Flujo de Resolución:** +1. Envuelve todo en una **Transacción SQL** para garantizar que o se guarda todo o no se guarda nada en caso de error. -- `id`: `UUID`, obligatorio. - - `name`: `string` (cifrado), obligatorio. Equivale a `encrypted_name`; ambos contienen el nombre encriptado. -- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio. -- `delete`: `boolean`, opcional, por defecto `false`. -- `isDirty`: `boolean`, opcional. El servidor lo ignora en la escritura y devuelve `false` en la respuesta de sync. -- `colorValue`: `number`, opcional. -- `iconCodePoint`: `number`, opcional. -- `updatedAt`: `ISO date string`, opcional (solo informativo para UI). -#### Estructura de nota +2. Recorre las `categories` entrantes. Si el `id` no existe, lo crea. Si existe, compara la `serverVersion`. Si la versión enviada coincide con la del servidor, actualiza los datos e incrementa la versión en el servidor. Si el servidor tiene una versión superior (conflicto), ignora el cambio del cliente y anota el ID para mandárselo de vuelta. -```json -{ - "id": "uuid", - "categoryId": "uuid-categoria", - "title": "titulo_cifrado", - "body": "cuerpo_cifrado", - "serverVersion": 1, - "position": 2000, - "delete": false, - "updatedAt": "2026-05-18T10:10:00.000Z" -} -``` -Campos: +3. Repite exactamente el mismo proceso de validación transaccional e inserción de datos para las `notes`, actualizando también la tabla intermedia de categorías mediante el array `categoryIds` entrante. -- `id`: `UUID`, obligatorio. -- `categoryId`: `UUID | null`, opcional. -- `title`: `string`, obligatorio. -- `body`: `string`, obligatorio. -- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio. -- `position`: `number`, opcional. -- `delete`: `boolean`, opcional, por defecto `false`. -- `permanentDelete`: `boolean`, opcional, por defecto `false`. Si llega en `true`, el servidor guarda la nota con `title` y `body` vacíos, `position` en `0` y `delete` en `true`. -- `updatedAt`: `ISO date string`, opcional (solo informativo para UI). -#### Response +4. Consulta a la base de datos cualquier nota o categoría cuyo `updatedAt` sea mayor al `lastSyncAt` proporcionado por el cliente, además de sumar los registros que hayan sufrido un conflicto de versión en los pasos anteriores. -Respuesta ejemplo (nuevo contrato): -```json -{ - "serverTimestamp": "2026-05-18T10:20:00.000Z", - "synced": true, - "changes": { - "categories": [], - "notes": [ - { - "id": "uuid-nota-2", - "categoryId": null, - "title": "titulo_cifrado...", - "body": "cuerpo_cifrado...", - "serverVersion": 3, - "position": 0, - "delete": false, - "permanentDelete": false, - "updatedAt": "2026-05-18T10:15:00.000Z" - } - ] - } -} -``` - -Campos de salida: - -- `serverTimestamp`: `string` ISO con la hora del servidor. -- `synced`: `boolean`. -- `changes.categories`: array con todas las categorías cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos. -- `changes.notes`: array con todas las notas cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos y las copias creadas automáticamente en caso de conflicto. - -## Reglas de sincronización - -- El cliente debe guardar su último `lastSyncAt`. -- Un borrado no elimina el registro localmente: se marca con `delete: true`. -- El servidor decide conflictos comparando `serverVersion` (no `updatedAt`). -- En categorías, solo se actualiza si `incoming.serverVersion === serverVersion`. Si no coincide, gana el servidor y no se crea duplicado. -- Si la versión entrante de una nota coincide con la versión actual del servidor, acepta el cambio y sube versión (`v -> v+1`). -- Si la versión del servidor es mayor que la entrante en una nota, el servidor crea una nueva nota duplicada con el contenido entrante para no perder datos. - -## Notas importantes - -- Las notas y categorías están pensadas para contenido cifrado del lado del cliente. -- El servidor guarda `title`, `body` y `encrypted_name`, pero no interpreta el contenido. \ No newline at end of file +5. Devuelve al cliente un objeto estructurado con las notas y categorías actualizadas del servidor y el nuevo `serverTimestamp` (momento exacto en el que acabó la sincronización). \ No newline at end of file diff --git a/src/controllers/authController.js b/src/controllers/authController.js index b333223..c41e8d0 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -132,5 +132,28 @@ const getSessionsInfo = async (req, res) => { } } +const logoutDevice = async (req, res) => { + try { + const authorizationHeader = req.headers.authorization || ''; + if (!authorizationHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authorization header missing or invalid' }); + } + + const token = authorizationHeader.slice(7).trim(); + const payload = jwt.verify(token, JWT_SECRET); + const userId = payload && payload.id; + if (!userId) return res.status(401).json({ error: 'Usuario inválido en token' }); + + + const { id } = req.body; + if (!id) return res.status(400).json({ error: 'ID del dispositivo es obligatorio' }); + + const result = await authService.logoutDevice_id(userId, id); + res.json(result); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + // Exportamos los handlers de auth. -module.exports = { register, login, refresh, logout, deleteAllData, getSessionsInfo}; \ No newline at end of file +module.exports = { register, login, refresh, logout, deleteAllData, getSessionsInfo, logoutDevice }; \ No newline at end of file diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index bd3f7a6..cd65be1 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -19,4 +19,6 @@ router.delete('/delete-all-data', authController.deleteAllData); router.get('/getSessionsInfo', authController.getSessionsInfo); +router.post('/logout-device', authController.logoutDevice); // Nueva ruta para cerrar sesión en un dispositivo específico + module.exports = router; \ No newline at end of file diff --git a/src/services/authService.js b/src/services/authService.js index 34ec17e..2b227b1 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -117,6 +117,15 @@ class AuthService { return sessions; } + + async logoutDevice_id(userId, id) { + const deletedCount = await RefreshToken.destroy({ where: { id, userId } }); + if (deletedCount === 0) { + throw new Error('No se encontró la sesión para cerrar'); + } + return { message: 'Sesión cerrada en el dispositivo especificado correctamente.' }; + } + } module.exports = new AuthService(); \ No newline at end of file