From 5e762dbdf750547dbc4c49b112f7dd05355e3a29 Mon Sep 17 00:00:00 2001 From: borja Date: Thu, 9 Oct 2025 10:49:04 +0200 Subject: [PATCH 001/203] =?UTF-8?q?docs:=20a=C3=B1ade=20plan=20detallado?= =?UTF-8?q?=20para=20interfaz=20web=20con=20SvelteKit=20y=20bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/plan-interfaz-web.md | 256 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/plan-interfaz-web.md diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md new file mode 100644 index 0000000..9570fd6 --- /dev/null +++ b/docs/plan-interfaz-web.md @@ -0,0 +1,256 @@ +# Plan de implementación: Interfaz Web (SvelteKit) + Bot/Webhook (separado) + +Este documento define el plan para añadir una interfaz web al sistema, manteniendo el bot/webhook existente como proceso independiente y compartiendo la misma base de datos SQLite (WAL). El objetivo es proporcionar al usuario un acceso seguro, rápido y cómodo a sus tareas, configuración y feeds de calendario, con especial atención a la seguridad y a la mínima fricción. + +## 1) Decisiones fijadas + +- Arquitectura: dos procesos (apps/bot y apps/web). SvelteKit para la web (SSR, rutas de API, cookies). +- Acceso: enlace mágico por DM con token de 10 minutos, de un solo uso. Sin “recordarme”. +- Sesión: cookie de sesión (HttpOnly, SameSite=Lax, Secure en prod) + expiración por inactividad de servidor de 2 horas. +- Orden por defecto: tareas por creación descendente (más recientes primero). +- ICS: + - Horizonte temporal: 12 meses. + - Excluir tareas sin fecha. + - Feeds: + - B (por usuario+grupo, solo tareas sin responsable) como opción por defecto, con autogeneración. + - C (personal multigrupo, solo sin responsable) opcional para power users. +- Monorepo: estructura apps/bot y apps/web. Posible “shared” en el futuro para reutilizar utilidades. + +## 2) Alcance funcional (MVP) + +- Mis tareas: lista (orden creación desc), filtros (abiertas, vencen pronto), búsqueda por texto simple. +- Tareas de mis grupos: solo grupos permitidos y en los que el usuario está activo; sección destacada de “sin responsable”. +- Preferencias de recordatorios: ver y modificar frecuencia (daily/weekly/weekdays/off) y hora. Visualización de próximo recordatorio según TZ. +- Autenticación: comando /t web que devuelve URL con token. Canje en /login y cookie de sesión. +- Integraciones: + - ICS personal (solo “mis tareas” con due_date). + - ICS por usuario+grupo (solo sin responsable), autogenerados (sin clic de creación). + - ICS personal multigrupo opcional (solo sin responsable). + +## 3) Arquitectura técnica + +- apps/web (SvelteKit): + - SSR + endpoints (rutas +server.ts) para login, APIs, ICS. + - Gestión de cookies/sesión en hooks.server.ts. + - UI con Svelte (sin framework adicional salvo CSS utilitario si se desea). +- apps/bot: + - Se mantiene el webhook/servicios actuales. + - Emisión de tokens de login: puede implementarse desde el bot (insertando en DB) o delegarse al web (si el bot solo notifica al usuario la URL base + token emitido por la web). Para MVP: el bot crea el token directamente en DB y envía la URL. +- Concurrencia DB: + - SQLite en modo WAL con PRAGMA busy_timeout para ambos procesos. Reutilizar convenciones actuales de PRAGMA. + +## 4) Autenticación y sesiones + +- Emisión de token (bot): + - En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario. + - Devolver URL del tipo: https://app.example.com/login?token=XYZ +- Canje (web): + - Validar hash y caducidad; si ok, invalidar token (marcar usado). + - Crear sesión en DB (web_sessions) y emitir cookie de sesión (solo cookie de sesión, sin persistencia en disco). + - Redirigir a /app (sin token en la URL). +- Expiración: + - Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token /t web. +- Seguridad: + - Cookies: HttpOnly, SameSite=Lax, Secure (en prod), path acotado. + - Rate limit en /login para evitar bruteforce de tokens. + - Redirección inmediata tras canje para evitar fugas por Referer. + +## 5) Calendario ICS + +- Contenido: + - Solo tareas con due_date, dentro de los próximos 12 meses. + - Título con id y descripción, notas con URL a la tarea (opcional). +- Feeds: + - Personal (usuario): “mis tareas” con due_date. + - Por usuario+grupo (B, por defecto): “sin responsable” del grupo. + - Autogeneración: “perezosa” (on-demand) al solicitar listado de feeds en la UI o al primer acceso a la URL; o proactiva (sembrado) mediante tarea que cree un token por cada par usuario+grupo activo. Recomendado: perezosa, con garantía de existencia al cargar la página “Integraciones”. + - Un feed activo por usuario+grupo (regla de unicidad). Rotación manual por el usuario (revocar y crear nuevo). + - Personal multigrupo (C, opcional): “sin responsable” agregadas de todos los grupos en los que el usuario esté activo. Un único token; revocable. +- Seguridad y control: + - Tokens largos, no caducan por tiempo (estilo ICS), siempre revocables. + - Para B/C: revocación automática al detectar que el usuario dejó de ser miembro activo del grupo (B) o de todos los grupos (C), en conciliaciones de miembros. + - Rate limit de peticiones por token/IP (ligero) y soporte de ETag/Last-Modified para minimizar carga. + +## 6) Estructura del repo (monorepo) + +- apps/bot: código actual del webhook, servicios, schedulers, migraciones. +- apps/web: SvelteKit (adapter-node). +- data/: carpeta con la base de datos SQLite compartida (ya existente). +- docs/: documentación (este archivo). +- Opcional futuro: packages/shared para extraer utilidades (normalizeWhatsAppId, métricas, tipos, etc.). + +## 7) Migraciones de base de datos (nuevas tablas) + +- web_tokens + - id, user_id, token_hash, created_at, expires_at, used_at (nullable), metadata (JSON opcional). + - Índices: (user_id), (expires_at), (token_hash único). +- web_sessions + - id (session_id), user_id, session_hash, created_at, last_seen_at, expires_at (idle cutoff), user_agent, ip (opcional). + - Índices: (user_id), (expires_at), (session_hash único). +- calendar_tokens + - id, type (‘personal’, ‘group’, ‘aggregate’), user_id, group_id (nullable), token_hash, created_at, revoked_at (nullable), last_used_at. + - Unicidad: (type, user_id, group_id) activa (si revoked_at IS NULL). + - Índices: (user_id), (group_id), (token_hash único). + +Notas: +- Guardar siempre hashes de tokens (no tokens en claro). +- Para autogeneración perezosa: crear on-demand si no existe registro activo para (user_id, group_id). + +## 8) Endpoints (apps/web) + +- Autenticación: + - GET /login?token=… (canjea token, crea sesión, redirige a /app) + - POST /api/logout (revoca sesión actual) +- APIs (todas requieren sesión válida): + - GET /api/me/tasks?status=open&search=...&page=...&limit=... + - GET /api/me/groups (grupos en los que está activo; solo allowed) + - GET /api/groups/:id/tasks?unassignedFirst=true (respeta gating y membresía) + - GET /api/me/preferences + - POST /api/me/preferences (actualiza frecuencia/hora) + - GET /api/integrations/feeds + - Genera automáticamente (si faltan) tokens B por cada grupo activo del usuario. + - Devuelve URLs para: ICS personal (mis tareas), ICS por grupo (B), y opcional ICS multigrupo (C). + - POST /api/integrations/feeds/rotate { type, groupId? } (revoca y recrea token) +- ICS (no requieren sesión; usan token en la URL): + - GET /ics/personal/:token.ics + - GET /ics/group/:token.ics + - GET /ics/aggregate/:token.ics + +## 9) UI (apps/web) + +- Páginas: + - /app (dashboard): “Mis tareas” por defecto. + - /app/groups: lista de grupos del usuario; en cada uno, “sin responsable” prominente. + - /app/preferences: frecuencia y hora de recordatorios; vista “próximo recordatorio”. + - /app/integrations: enlaces ICS + - Autogenerados: mostrar directamente botones “Copiar” y breve guía (Google/Apple/Outlook). + - Rotar/revocar: botones por feed. Avisar que rotar invalida suscripción previa. +- Interacciones: + - Filtros rápidos, búsqueda, paginación liviana. + - Estado de sesión (2h de inactividad): al expirar, mostrar mensaje con instrucción de enviar /t web. + +## 10) Seguridad + +- Tokens: + - Aleatorios criptográficos; hash en DB; TTL 10 min (web_tokens); uso único. + - calendar_tokens sin TTL (estilo ICS), siempre revocables. +- Cookies: HttpOnly, SameSite=Lax, Secure en prod, path acotado. No almacenar PII en cookies. +- CSRF: bajo riesgo con SameSite y API same-origin; añadir token anti-CSRF a mutaciones como defensa en profundidad. +- Cabeceras: + - X-Frame-Options: DENY, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Content-Security-Policy básica. + - Robots: noindex, nofollow. +- Gating: + - Todas las consultas filtran por user_id y validan AllowedGroups + membresía activa (group_members). +- Logs: nunca registrar tokens en claro; solo hashes o IDs. + +## 11) Observabilidad y límites + +- Métricas (via Metrics): + - web_tokens_issued_total, web_tokens_redeemed_total, web_login_success_total, web_login_failed_total + - web_sessions_active, web_api_requests_total{route=…}, ics_requests_total{type=…} + - ics_tokens_revoked_total, ics_tokens_created_total +- Rate limiting: + - Emisión de token /t web (en el bot) y /login (web). + - ICS por token/IP (p. ej., 4 req/min). +- Caching ICS: + - ETag/Last-Modified y Cache-Control: public, max-age=300 (suave), para que los clientes no abusen. + +## 12) DevOps y despliegue + +- Entornos: + - WEB_BASE_URL, COOKIE_SECRET, SESSION_IDLE_TTL_MIN=120, ICS_HORIZON_MONTHS=12, ICS_RATE_LIMIT, etc. + - Reutilizar EVOLUTION_API_* donde aplique (si se consulta API desde web). +- Build: + - SvelteKit con adapter-node; ejecución con Bun o Node en producción. +- Reverse proxy: + - apps/web servido en app.example.com (o /app). + - apps/bot mantiene su endpoint de webhook. Compartir .env según necesidad. +- Schedulers: + - Permanecen en el proceso del bot. apps/web no arranca ningún scheduler. + +## 13) Plan de trabajo por etapas + +Etapa 0 — Preparación +- Crear estructura apps/web (SvelteKit con adapter-node). +- Configurar ESLint/Prettier y CI mínimos (lint, build). +- Asegurar que la web abre la misma DB (PRAGMAs coherentes). + +Etapa 1 — Autenticación +- Migraciones: web_tokens, web_sessions. +- Bot: emisión de token de 10 min (hash, rate limit) en /t web. +- Web: endpoint /login, cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h. +- Páginas de error/expiración. + +Etapa 2 — Lectura de datos (MVP) +- APIs: /api/me/tasks, /api/me/groups, /api/groups/:id/tasks, /api/me/preferences (GET). +- UI: “Mis tareas” y “Grupos” (solo lectura). +- Orden de creación desc, filtros básicos, búsqueda. + +Etapa 3 — Preferencias +- APIs: GET/POST /api/me/preferences. +- UI: edición de frecuencia/hora y vista del próximo recordatorio. + +Etapa 4 — ICS +- Migraciones: calendar_tokens. +- APIs/UI Integraciones: autogeneración perezosa de feeds B (por usuario+grupo) y C (multigrupo opcional). +- Endpoints ICS: personal, group (B), aggregate (C), con horizonte 12 meses y solo con due_date. +- Revocación/rotación manual. Revocación automática al perder membresía (cron en bot o check dinámico). + +Etapa 5 — Pulido y observabilidad +- Métricas, rate limits, ETag/Last-Modified en ICS. +- CSP y cabeceras de seguridad. +- UX: copiar enlace, avisos claros, vacíos de estado. + +Etapa 6 — Evolutivos (posteriores) +- Edición de tareas (claim/unassign, fechas). +- Búsqueda avanzada y atajos. +- Notificaciones (SSE/polling). +- Panel admin (opcional). + +## 14) Pruebas + +- Unit: + - Emisión/canje de token, expiración, cookie y expiración por inactividad. + - Autorización de endpoints (gating, membresía). + - Generación ICS y filtros (due_date, horizonte). +- Integración: + - Flujo end-to-end: /t web → /login → /app. + - Listado de feeds y autogeneración B. +- Regresión: + - Aislar que schedulers solo corran en el bot. + +## 15) Riesgos y mitigaciones + +- Fuga de enlace de ICS: + - Tokens largos, revocación sencilla, métricas y rate-limit. Evitar ICS compartido de grupo. B y C permiten revocar por usuario. +- Doble arranque de tareas en web: + - Flag para no iniciar schedulers en apps/web. +- Concurrencia SQLite: + - WAL + busy_timeout ya configurados; operaciones ICE (lectura) mayoritarias en web. +- Fricción de login: + - /t web es rápido; expiración 10 min adecuada; mensajes claros si expira. + +## 16) Variables de entorno (propuestas, apps/web) + +- WEB_BASE_URL +- COOKIE_SECRET +- SESSION_IDLE_TTL_MIN=120 +- ICS_HORIZON_MONTHS=12 +- ICS_RATE_LIMIT_PER_MIN=4 +- NODE_ENV / BUN_ENV +- (Opcional) METRICS_ENABLED + +## 17) Métricas (nombres sugeridos) + +- web_tokens_issued_total +- web_tokens_redeemed_total +- web_login_success_total +- web_login_failed_total +- web_sessions_active +- web_api_requests_total{route} +- ics_tokens_created_total{type} +- ics_tokens_revoked_total{type} +- ics_requests_total{type} +- ics_rate_limit_hits_total + +Fin del documento. From e647ad7c3a1ab54e8654b2a6270cd437452c660f Mon Sep 17 00:00:00 2001 From: borja Date: Thu, 9 Oct 2025 15:51:43 +0200 Subject: [PATCH 002/203] =?UTF-8?q?feat:=20a=C3=B1adir=20DATA=5FDIR=20para?= =?UTF-8?q?=20DB=20compartida=20y=20configurar=20Bun=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- DEVTASKS_STAGE0_COMMANDS.txt | 3 +++ README.md | 1 + bunfig.toml | 1 + docs/operations.md | 1 + src/db.ts | 15 ++++++++++----- 5 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 DEVTASKS_STAGE0_COMMANDS.txt create mode 100644 bunfig.toml diff --git a/DEVTASKS_STAGE0_COMMANDS.txt b/DEVTASKS_STAGE0_COMMANDS.txt new file mode 100644 index 0000000..0c6ab6b --- /dev/null +++ b/DEVTASKS_STAGE0_COMMANDS.txt @@ -0,0 +1,3 @@ +bun install +bun create svelte@latest apps/web +cd apps/web && bun add -d @sveltejs/adapter-node diff --git a/README.md b/README.md index 3194a9b..cc7d1f0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Variables clave: - METRICS_ENABLED, PORT. - Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST. - Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS. +- DATA_DIR: directorio raíz para la base de datos SQLite compartida (por defecto ./data). Consulta: - docs/operations.md para operación, endpoints y variables de entorno. diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..6b2f890 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1 @@ +workspaces = ["apps/*"] diff --git a/docs/operations.md b/docs/operations.md index c094ef6..f38d810 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -20,6 +20,7 @@ Variables de entorno (principales) - ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222' - ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' - NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' +- DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data). Endpoints operativos - GET /metrics diff --git a/src/db.ts b/src/db.ts index 2c25bb8..ac63fb0 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,7 +1,7 @@ import { Database } from 'bun:sqlite'; import { normalizeWhatsAppId } from './utils/whatsapp'; import { mkdirSync } from 'fs'; -import { join } from 'path'; +import { join, resolve } from 'path'; import { Migrator } from './db/migrator'; function applyDefaultPragmas(instance: Database): void { @@ -18,15 +18,20 @@ function applyDefaultPragmas(instance: Database): void { } } -// Function to get a database instance. Defaults to 'data/tasks.db' + // Function to get a database instance. Defaults to 'data/tasks.db' export function getDb(filename: string = 'tasks.db'): Database { + // Determine base directory for the database (env DATA_DIR or default './data'), resolve to absolute + const dataDir = process?.env?.DATA_DIR ? String(process.env.DATA_DIR) : 'data'; + const dirPath = resolve(dataDir); + // Try to create data directory if it doesn't exist (ignore if already exists) try { - mkdirSync('data', { recursive: true }); + mkdirSync(dirPath, { recursive: true }); } catch (err) { - if (err.code !== 'EEXIST') throw err; // Only ignore "already exists" errors + if ((err as any)?.code !== 'EEXIST') throw err; // Only ignore "already exists" errors } - const instance = new Database(join('data', filename)); + + const instance = new Database(join(dirPath, filename)); applyDefaultPragmas(instance); return instance; } From 664cf26b573ce00de1812a33ffeeb243fb6fabbc Mon Sep 17 00:00:00 2001 From: borja Date: Thu, 9 Oct 2025 15:55:21 +0200 Subject: [PATCH 003/203] =?UTF-8?q?a=C3=B1ade=20apps/web=20y=20le=20instal?= =?UTF-8?q?a=20sveltekit=20encima=20con=20bun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/.gitignore | 23 +++ apps/web/.npmrc | 1 + apps/web/README.md | 38 +++++ apps/web/bun.lock | 253 ++++++++++++++++++++++++++++ apps/web/package.json | 24 +++ apps/web/src/app.d.ts | 13 ++ apps/web/src/app.html | 11 ++ apps/web/src/lib/assets/favicon.svg | 1 + apps/web/src/lib/index.ts | 1 + apps/web/src/routes/+layout.svelte | 11 ++ apps/web/src/routes/+page.svelte | 2 + apps/web/static/robots.txt | 3 + apps/web/svelte.config.js | 18 ++ apps/web/tsconfig.json | 19 +++ apps/web/vite.config.ts | 6 + 15 files changed, 424 insertions(+) create mode 100644 apps/web/.gitignore create mode 100644 apps/web/.npmrc create mode 100644 apps/web/README.md create mode 100644 apps/web/bun.lock create mode 100644 apps/web/package.json create mode 100644 apps/web/src/app.d.ts create mode 100644 apps/web/src/app.html create mode 100644 apps/web/src/lib/assets/favicon.svg create mode 100644 apps/web/src/lib/index.ts create mode 100644 apps/web/src/routes/+layout.svelte create mode 100644 apps/web/src/routes/+page.svelte create mode 100644 apps/web/static/robots.txt create mode 100644 apps/web/svelte.config.js create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/apps/web/bun.lock b/apps/web/bun.lock new file mode 100644 index 0000000..2b1a9d8 --- /dev/null +++ b/apps/web/bun.lock @@ -0,0 +1,253 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "web", + "devDependencies": { + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/adapter-node": "^5.3.3", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "typescript": "^5.9.2", + "vite": "^7.1.7", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="], + + "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], + + "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tCtHJ2BlhSoK4cCs25NMXfV7EALKr0jyasmqVCq3y9cBrKdmJhtsy1iTz36Xhk/O+pDJbzawxF4K6ZblqCnITQ=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ=="], + + "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.3.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-SRDVuFBkmpKGsA9b0wYaCrrSChq2Yv5Dv8g7WiZcs8E69vdQNRamN0DzQV9/rEixvuRkojATLADNeQ+6FeyVNQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.46.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-J1fd80WokLzIm6EAV7z7C2+/C02qVAX645LZomARARTRJkbbJSY1Jln3wtBZYibUB8c9/5Z6xqLAV39VdbtWCQ=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="], + + "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "svelte": ["svelte@5.39.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw=="], + + "svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="], + + "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..01f0e36 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/adapter-node": "^5.3.3", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "typescript": "^5.9.2", + "vite": "^7.1.7" + } +} diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/apps/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/apps/web/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/apps/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/apps/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 0000000..20f8d04 --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children?.()} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte new file mode 100644 index 0000000..cc88df0 --- /dev/null +++ b/apps/web/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/apps/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js new file mode 100644 index 0000000..1295460 --- /dev/null +++ b/apps/web/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..a5567ee --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..bbf8c7d --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); From 122beb766360535c6c6e755c8f4d9626760a1472 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 19:07:05 +0200 Subject: [PATCH 004/203] =?UTF-8?q?feat:=20a=C3=B1adir=20WEB=5FBASE=5FURL?= =?UTF-8?q?=20como=20variable=20de=20entorno=20y=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .env.example | 1 + README.md | 1 + docs/operations.md | 1 + 3 files changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 7b7c3d6..7341e51 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ PORT=3007 # Entorno NODE_ENV="production" # production | development | test TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fechas +WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p.ej. https://wtask.org en producción) # Sincronización de grupos (opcional) # Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms). diff --git a/README.md b/README.md index cc7d1f0..1e2e05b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Variables clave: - REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60). - ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY. - METRICS_ENABLED, PORT. +- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web). - Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST. - Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS. - DATA_DIR: directorio raíz para la base de datos SQLite compartida (por defecto ./data). diff --git a/docs/operations.md b/docs/operations.md index f38d810..3ed7b8b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,6 +21,7 @@ Variables de entorno (principales) - ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' - NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' - DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data). +- WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para /t web. Ej.: WEB_BASE_URL='https://wtask.org' Endpoints operativos - GET /metrics From 3d6c4465478f0de92fd06c4b16535a4b23345f59 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 19:29:46 +0200 Subject: [PATCH 005/203] feat: dar prioridad a DB_PATH sobre DATA_DIR en la BD SQLite Co-authored-by: aider (openrouter/openai/gpt-5) --- .env.example | 1 + docs/operations.md | 1 + src/db.ts | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7341e51..b9f62fc 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ PORT=3007 NODE_ENV="production" # production | development | test TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fechas WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p.ej. https://wtask.org en producción) +# DB_PATH="./data/tasks.db" # Si se define, ignora DATA_DIR y usa esta ruta exacta # Sincronización de grupos (opcional) # Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms). diff --git a/docs/operations.md b/docs/operations.md index 3ed7b8b..3b1960b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,6 +21,7 @@ Variables de entorno (principales) - ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' - NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' - DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data). +- DB_PATH: ruta absoluta o relativa al archivo SQLite; si se define, tiene prioridad sobre DATA_DIR. Ej.: DB_PATH='./data/tasks.db' - WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para /t web. Ej.: WEB_BASE_URL='https://wtask.org' Endpoints operativos diff --git a/src/db.ts b/src/db.ts index ac63fb0..8258c50 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,7 +1,7 @@ import { Database } from 'bun:sqlite'; import { normalizeWhatsAppId } from './utils/whatsapp'; import { mkdirSync } from 'fs'; -import { join, resolve } from 'path'; +import { join, resolve, dirname } from 'path'; import { Migrator } from './db/migrator'; function applyDefaultPragmas(instance: Database): void { @@ -20,6 +20,22 @@ function applyDefaultPragmas(instance: Database): void { // Function to get a database instance. Defaults to 'data/tasks.db' export function getDb(filename: string = 'tasks.db'): Database { + // Prioridad 1: DB_PATH (ruta completa al archivo). Si está definida, se usa tal cual. + const dbPathEnv = process?.env?.DB_PATH ? String(process.env.DB_PATH).trim() : ''; + if (dbPathEnv) { + const absolutePath = resolve(dbPathEnv); + // Crear directorio padre si no existe + try { + mkdirSync(dirname(absolutePath), { recursive: true }); + } catch (err) { + if ((err as any)?.code !== 'EEXIST') throw err; // Solo ignorar "ya existe" + } + const instance = new Database(absolutePath); + applyDefaultPragmas(instance); + return instance; + } + + // Prioridad 2: DATA_DIR + filename (comportamiento actual) // Determine base directory for the database (env DATA_DIR or default './data'), resolve to absolute const dataDir = process?.env?.DATA_DIR ? String(process.env.DATA_DIR) : 'data'; const dirPath = resolve(dataDir); From f6672ec52be554962607172e3a683b086e5416e8 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 19:49:38 +0200 Subject: [PATCH 006/203] =?UTF-8?q?feat:=20agrega=20migraci=C3=B3n=20v10?= =?UTF-8?q?=20para=20web=5Ftokens=20y=20web=5Fsessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db/migrations/index.ts | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 72cf0c2..9813e49 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -288,5 +288,46 @@ export const migrations: Migration[] = [ ON allowed_groups (status); `); } + }, + { + version: 10, + name: 'web-auth-tables', + checksum: 'v10-web-auth-2025-10-12', + up: (db: Database) => { + db.exec(`PRAGMA foreign_keys = ON;`); + + // Tokens de login web (válidos 10 min, de un solo uso) + db.exec(` + CREATE TABLE IF NOT EXISTS web_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + expires_at TEXT NOT NULL, + used_at TEXT NULL, + metadata TEXT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_web_tokens_user ON web_tokens (user_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_web_tokens_expires ON web_tokens (expires_at);`); + + // Sesiones de la web (idle timeout gestionado por la app web) + db.exec(` + CREATE TABLE IF NOT EXISTS web_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + session_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + expires_at TEXT NOT NULL, + user_agent TEXT NULL, + ip TEXT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_user ON web_sessions (user_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);`); + } } ]; From 1e188c2e967eb86fffdf97db789b1529fe93823f Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 20:09:54 +0200 Subject: [PATCH 007/203] build: establecer DATA_DIR por defecto y declarar volumen /app/data Co-authored-by: aider (openrouter/openai/gpt-5) --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 5fd2580..7d055b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ FROM oven/bun:1.1 as base # Install basic debugging tools RUN apt-get update && apt-get install -y curl netcat sqlite3 WORKDIR /app +# Default data dir for SQLite; can be overridden by DB_PATH at runtime (DB_PATH has priority) +ENV DATA_DIR=/app/data # Create data directory with proper permissions RUN mkdir -p /app/data && chown -R bun:bun /app/data @@ -22,6 +24,8 @@ HEALTHCHECK --start-period=30s --interval=30s --timeout=3s --retries=3 \ # Server runs on port from environment variable EXPOSE ${PORT:-3007} +# Declare volume for persistent data by default +VOLUME ["/app/data"] # Make script executable COPY startup.sh ./ From c8c4fdd9270a87659868947e21927977d11c6778 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:03:13 +0200 Subject: [PATCH 008/203] =?UTF-8?q?feat:=20a=C3=B1adir=20soporte=20para=20?= =?UTF-8?q?/t=20web=20con=20tokens=20de=20login=20y=20util=20crypto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/command.ts | 56 ++++++++ src/utils/crypto.ts | 20 +++ tests/unit/services/command.web-login.test.ts | 131 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/utils/crypto.ts create mode 100644 tests/unit/services/command.web-login.test.ts diff --git a/src/services/command.ts b/src/services/command.ts index 370ed15..d00fe4a 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -9,6 +9,7 @@ import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; +import { randomTokenBase64Url, sha256Hex } from '../utils/crypto'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -955,6 +956,61 @@ export class CommandService { }]; } + // Enlace de acceso a la web (/t web) + if (action === 'web') { + // Solo por DM + if (isGroupId(context.groupId)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.' + }]; + } + + const base = (process.env.WEB_BASE_URL || '').trim(); + if (!base) { + return [{ + recipient: context.sender, + message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).' + }]; + } + + const ensured = ensureUserExists(context.sender, this.dbInstance); + if (!ensured) { + throw new Error('No se pudo asegurar el usuario'); + } + + const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); + const now = new Date(); + const nowIso = toIso(now); + const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos + + // Invalidar tokens vigentes (uso único) + this.dbInstance.prepare(` + UPDATE web_tokens + SET used_at = ? + WHERE user_id = ? + AND used_at IS NULL + AND expires_at > ? + `).run(nowIso, ensured, nowIso); + + // Generar nuevo token y guardar solo el hash + const token = randomTokenBase64Url(32); + const tokenHash = await sha256Hex(token); + + this.dbInstance.prepare(` + INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata) + VALUES (?, ?, ?, NULL) + `).run(ensured, tokenHash, expiresIso); + + try { Metrics.inc('web_tokens_issued_total'); } catch {} + + const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString(); + return [{ + recipient: context.sender, + message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".` + }]; + } + if (action !== 'nueva') { return [{ recipient: context.sender, diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..c7b355c --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,20 @@ +/** + * Utilidades criptográficas sin dependencias externas. + * - randomTokenBase64Url: genera un token aleatorio (base64url, sin relleno). + * - sha256Hex: calcula SHA-256 y devuelve en hex. + */ + +export function randomTokenBase64Url(bytes: number = 32): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + const b64 = Buffer.from(arr).toString('base64'); + // base64url (RFC 4648) sin padding + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const hashBuf = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(hashBuf); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/tests/unit/services/command.web-login.test.ts b/tests/unit/services/command.web-login.test.ts new file mode 100644 index 0000000..c296bcc --- /dev/null +++ b/tests/unit/services/command.web-login.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { CommandService } from '../../../src/services/command'; +import { sha256Hex } from '../../../src/utils/crypto'; +import { Metrics } from '../../../src/services/metrics'; + +const envBackup = { ...process.env }; +let memdb: Database; + +describe('CommandService - /t web (emisión de token de login)', () => { + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + TZ: 'Europe/Madrid', + WEB_BASE_URL: 'https://app.example.test' + }; + Metrics.reset?.(); + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (CommandService as any).dbInstance = memdb; + }); + + afterEach(() => { + process.env = envBackup; + try { memdb.close(); } catch {} + }); + + test('DM feliz: devuelve URL con token y persiste hash en web_tokens', async () => { + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', // DM (no @g.us) + message: '/t web', + mentions: [] + }); + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(1); + expect(res[0].recipient).toBe('34600123456'); + expect(res[0].message).toContain('https://app.example.test'); + + const m = res[0].message.match(/https?:\/\/\S+/); + expect(m).toBeTruthy(); + const url = new URL(m![0]); + expect(url.pathname).toBe('/login'); + const token = url.searchParams.get('token') || ''; + expect(token.length).toBeGreaterThan(0); + + const hash = await sha256Hex(token); + const row = memdb.prepare(` + SELECT user_id, token_hash, used_at, expires_at + FROM web_tokens + WHERE user_id = ? AND token_hash = ? + `).get('34600123456', hash) as any; + + expect(row).toBeTruthy(); + expect(row.user_id).toBe('34600123456'); + expect(row.token_hash).toBe(hash); + expect(row.used_at).toBeNull(); + // expires_at debe ser en el futuro + const now = new Date(); + const exp = new Date(String(row.expires_at).replace(' ', 'T') + 'Z'); + expect(exp.getTime()).toBeGreaterThan(now.getTime() + 9 * 60 * 1000 - 10 * 1000); // ~>= 9min50s + expect(Metrics.get('web_tokens_issued_total')).toBe(1); + }); + + test('En grupo: responde que debe usarse por privado y no inserta token', async () => { + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '123@g.us', + message: '/t web', + mentions: [] + }); + expect(res.length).toBe(1); + expect(res[0].recipient).toBe('34600123456'); + expect(res[0].message.toLowerCase()).toContain('privado'); + + const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any; + expect(Number(cnt.c)).toBe(0); + }); + + test('Sin WEB_BASE_URL: error claro y no inserta token', async () => { + delete process.env.WEB_BASE_URL; + + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(res.length).toBe(1); + expect(res[0].message).toContain('no está configurada'); + const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any; + expect(Number(cnt.c)).toBe(0); + }); + + test('Token vigente: se invalida y se emite uno nuevo (queda solo 1 activo)', async () => { + // Primera emisión + { + const r1 = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(r1.length).toBe(1); + } + + // Segunda emisión (debe invalidar el anterior) + { + const r2 = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(r2.length).toBe(1); + } + + const counts = memdb.prepare(` + SELECT + SUM(CASE WHEN used_at IS NULL THEN 1 ELSE 0 END) AS active, + COUNT(*) AS total + FROM web_tokens + WHERE user_id = ? + `).get('34600123456') as any; + + expect(Number(counts.total)).toBeGreaterThanOrEqual(2); + expect(Number(counts.active)).toBe(1); + }); +}); From 6082574a529a91135fad166e7ee624196d855d7f Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:37:29 +0200 Subject: [PATCH 009/203] chore: silenciar logs del migrador en test para reducir ruido Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db/migrator.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/db/migrator.ts b/src/db/migrator.ts index fab6b21..f4dfabc 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -3,11 +3,16 @@ import { mkdirSync, appendFileSync } from 'fs'; import { join } from 'path'; import { migrations, type Migration } from './migrations'; +const MIGRATIONS_LOG_LEVEL = (process.env.MIGRATIONS_LOG_LEVEL || '').toLowerCase(); +const MIGRATIONS_QUIET = process.env.NODE_ENV === 'test' || MIGRATIONS_LOG_LEVEL === 'silent'; + function nowIso(): string { return new Date().toISOString().replace('T', ' ').replace('Z', ''); } function logEvent(level: 'info' | 'error', event: string, data: any = {}) { + // En modo test o nivel 'silent', no registrar eventos para evitar ruido + if (MIGRATIONS_QUIET) return; try { mkdirSync('data', { recursive: true }); } catch {} @@ -64,10 +69,10 @@ function backupDatabaseIfNeeded(db: Database): string | null { try { // VACUUM INTO hace copia consistente del estado actual db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`); - console.log(`ℹ️ Backup de base de datos creado en: ${backupPath}`); + if (!MIGRATIONS_QUIET) console.log(`ℹ️ Backup de base de datos creado en: ${backupPath}`); return backupPath; } catch (e) { - console.warn('⚠️ No se pudo crear el backup con VACUUM INTO (continuando de todos modos):', e); + if (!MIGRATIONS_QUIET) console.warn('⚠️ No se pudo crear el backup con VACUUM INTO (continuando de todos modos):', e); return null; } } @@ -100,7 +105,7 @@ export const Migrator = { const jmRow = db.query(`PRAGMA journal_mode`).get() as any; const journalMode = jmRow ? (jmRow.journal_mode || jmRow.value || jmRow.mode || 'unknown') : 'unknown'; const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0; - console.log(`ℹ️ Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`); + if (!MIGRATIONS_QUIET) console.log(`ℹ️ Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`); try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {} if (applied.size === 0 && allowBaseline && detectExistingSchema(db)) { @@ -109,14 +114,14 @@ export const Migrator = { db.transaction(() => { insertMigrationRow(db, v1); })(); - console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)'); + if (!MIGRATIONS_QUIET) console.log('ℹ️ Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)'); try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {} // Recalcular pendientes pending.splice(0, pending.length, ...migrations.filter(m => m.version > 1)); } if (pending.length === 0) { - console.log('ℹ️ No hay migraciones pendientes'); + if (!MIGRATIONS_QUIET) console.log('ℹ️ No hay migraciones pendientes'); try { logEvent('info', 'no_pending', {}); } catch {} return; } @@ -127,7 +132,7 @@ export const Migrator = { } for (const mig of pending) { - console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`); + if (!MIGRATIONS_QUIET) console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`); try { try { logEvent('info', 'apply_start', { version: mig.version, name: mig.name, checksum: mig.checksum }); } catch {} const t0 = Date.now(); @@ -141,7 +146,7 @@ export const Migrator = { insertMigrationRow(db, mig); })(); const ms = Date.now() - t0; - console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`); + if (!MIGRATIONS_QUIET) console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`); try { logEvent('info', 'apply_success', { version: mig.version, name: mig.name, checksum: mig.checksum, duration_ms: ms }); } catch {} } catch (e) { console.error(`❌ Error aplicando migración v${mig.version}:`, e); From bd0fda224842cb1d72b7a1783ebd83333ce61ec9 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:45:43 +0200 Subject: [PATCH 010/203] =?UTF-8?q?fix:=20endurece=20GroupSyncService=20y?= =?UTF-8?q?=20activa=20m=C3=A9tricas=20en=20tests=20web-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/group-sync.ts | 22 ++++++++++++------- tests/unit/services/command.web-login.test.ts | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 2f241a1..1ce6b54 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -427,7 +427,7 @@ export class GroupSyncService { // Aprender mapping alias→número si vienen ambos if (rawId && rawJid) { - IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); + try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {} } if (typeof p.isAdmin === 'boolean') { @@ -443,10 +443,13 @@ export class GroupSyncService { if (!norm) continue; result.push({ userId: norm, isAdmin }); } - const resolved = (() => { + let resolved: Array<{ userId: string; isAdmin: boolean }>; + try { const map = IdentityService.resolveMany(result.map(r => r.userId)); - return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); - })(); + resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); + } catch { + resolved = result; + } return resolved; } // Si no viene en el formato esperado, caemos al plan B @@ -512,7 +515,7 @@ export class GroupSyncService { // Aprender mapping alias→número si vienen ambos if (rawId && rawJid) { - IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); + try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {} } if (typeof p.isAdmin === 'boolean') { @@ -529,10 +532,13 @@ export class GroupSyncService { if (!norm) continue; result.push({ userId: norm, isAdmin }); } - const resolved = (() => { + let resolved: Array<{ userId: string; isAdmin: boolean }>; + try { const map = IdentityService.resolveMany(result.map(r => r.userId)); - return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); - })(); + resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin })); + } catch { + resolved = result; + } return resolved; } diff --git a/tests/unit/services/command.web-login.test.ts b/tests/unit/services/command.web-login.test.ts index c296bcc..3b03f0d 100644 --- a/tests/unit/services/command.web-login.test.ts +++ b/tests/unit/services/command.web-login.test.ts @@ -14,7 +14,8 @@ describe('CommandService - /t web (emisión de token de login)', () => { ...envBackup, NODE_ENV: 'test', TZ: 'Europe/Madrid', - WEB_BASE_URL: 'https://app.example.test' + WEB_BASE_URL: 'https://app.example.test', + METRICS_ENABLED: 'true' }; Metrics.reset?.(); memdb = new Database(':memory:'); From 90771eb34a48f9ea1d188d5effe7932c4dbfb6a1 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:49:02 +0200 Subject: [PATCH 011/203] =?UTF-8?q?fix:=20usar=20d=C3=ADgitos=20del=20jid?= =?UTF-8?q?=20como=20fallback=20al=20normalizar=20WhatsApp=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/group-sync.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 1ce6b54..4342349 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -439,7 +439,11 @@ export class GroupSyncService { } } - const norm = normalizeWhatsAppId(jid); + let norm = normalizeWhatsAppId(jid); + if (!norm) { + const digits = (jid || '').replace(/\D+/g, ''); + norm = digits || null; + } if (!norm) continue; result.push({ userId: norm, isAdmin }); } @@ -528,7 +532,11 @@ export class GroupSyncService { } } - const norm = normalizeWhatsAppId(jid); + let norm = normalizeWhatsAppId(jid); + if (!norm) { + const digits = (jid || '').replace(/\D+/g, ''); + norm = digits || null; + } if (!norm) continue; result.push({ userId: norm, isAdmin }); } From ae35ae2db3d762bbade1b8883a207c0330ea162c Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:56:14 +0200 Subject: [PATCH 012/203] =?UTF-8?q?docs:=20actualizar=20documentaci=C3=B3n?= =?UTF-8?q?=20con=20migraciones,=20DB=5FPATH=20y=20login=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .env.example | 3 +++ README.md | 5 ++++- docs/operations.md | 2 ++ docs/plan-interfaz-web.md | 6 +++--- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index b9f62fc..8809474 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p. # METRICS_ENABLED=true # METRICS_FORMAT=prom # prom|json +# Migrador (opcional) +# MIGRATIONS_LOG_LEVEL="silent" # Silencia logs del migrador (en test ya se silencian automáticamente) + # Control de acceso por grupos (multicomunidad) # Modo: off|discover|enforce (por defecto off) # GROUP_GATING_MODE=discover diff --git a/README.md b/README.md index 1e2e05b..ce9aef9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - Control de acceso por grupos: modos off, discover y enforce; aprobación y bloqueo por admins. - Sincronización de grupos y miembros con cachés y schedulers configurables. - Alias de identidad con normalización de IDs. +- Acceso web por token mágico (/t web), tokens de 10 min de un solo uso (migración y emisión implementadas). - Métricas listas para Prometheus en el endpoint /metrics. - Rate limiting por usuario para evitar abuso. - Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.). @@ -26,7 +27,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - No es un framework general de bots ni un CRM. - No conecta directamente con WhatsApp: requiere Evolution API. - No gestiona flujos conversacionales complejos ni multimedia avanzada. -- No incluye panel web; la interacción es vía WhatsApp. +- Panel web en desarrollo (MVP de login en marcha); hoy la interacción principal es vía WhatsApp. - Está optimizado para un despliegue por comunidad/instancia (no multi-tenant masivo). ## Cómo funciona (alto nivel) @@ -74,6 +75,7 @@ Variables clave: - WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web). - Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST. - Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS. +- DB_PATH: ruta al archivo SQLite. Tiene prioridad sobre DATA_DIR y permite aislar BD por rama/entorno. Ej.: DB_PATH='./data/tasks.db' - DATA_DIR: directorio raíz para la base de datos SQLite compartida (por defecto ./data). Consulta: @@ -91,6 +93,7 @@ Consulta: - Nombre provisional: “Taskbot”. - Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0). +- Progreso Etapa 1 (autenticación web): migración v10 (web_tokens/web_sessions) y comando /t web implementados; pendiente /login en la web y gestión de sesión (idle 2h). - Roadmap y contribuciones: pendientes de publicación. ## Enlaces diff --git a/docs/operations.md b/docs/operations.md index 3b1960b..ac48605 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -22,6 +22,7 @@ Variables de entorno (principales) - NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' - DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data). - DB_PATH: ruta absoluta o relativa al archivo SQLite; si se define, tiene prioridad sobre DATA_DIR. Ej.: DB_PATH='./data/tasks.db' +- MIGRATIONS_LOG_LEVEL: 'silent' para silenciar logs del migrador (en test se silencian automáticamente). - WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para /t web. Ej.: WEB_BASE_URL='https://wtask.org' Endpoints operativos @@ -34,6 +35,7 @@ Arranque y servicios - Valida entorno (logs de variables presentes/faltantes). - Aplica migraciones up-only. - Inicia HTTP y (según entorno) schedulers. + - En tests, el migrador silencia logs; puede forzarse en cualquier entorno con MIGRATIONS_LOG_LEVEL='silent'. Schedulers - GroupSyncService.startGroupsScheduler() y .startMembersScheduler() diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index 9570fd6..fee7b98 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -176,9 +176,9 @@ Etapa 0 — Preparación - Asegurar que la web abre la misma DB (PRAGMAs coherentes). Etapa 1 — Autenticación -- Migraciones: web_tokens, web_sessions. -- Bot: emisión de token de 10 min (hash, rate limit) en /t web. -- Web: endpoint /login, cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h. +- Migraciones: web_tokens, web_sessions. — HECHO +- Bot: emisión de token de 10 min (hash, rate limit) en /t web. — HECHO +- Web: endpoint /login, cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h. — SIGUIENTE - Páginas de error/expiración. Etapa 2 — Lectura de datos (MVP) From 32f1011fcfd52abf1780c0f931775c44b65861e8 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 21:56:57 +0200 Subject: [PATCH 013/203] docs: agregar DB_PATH y notas de CapRover en Datos y backups Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/operations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/operations.md b/docs/operations.md index ac48605..d869a53 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -49,6 +49,8 @@ Datos y backups - Data path: data/tasks.db (por defecto). - Migraciones con backup opcional (withBackup=false por defecto en initializeDatabase). - Recomendación: planificar copia de seguridad periódica del directorio data/ y retención externa. +- DB_PATH permite aislar BD por rama/entorno sin tocar DATA_DIR; útil para pruebas en CapRover. +- En Docker/CapRover, el volumen por defecto es /app/data. Para persistencia, usa rutas de DB_PATH dentro de ese directorio (p. ej., /app/data/tasks-next.db). Métricas de referencia - sync_runs_total, identity_alias_resolved_total, contadores/gauges específicos de colas y limpieza. From 9347d86065e6fbdae002ac9bab93ec3be61f8e08 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 22:19:34 +0200 Subject: [PATCH 014/203] feat: prepara web con bun:sqlite y soporte env/db/hooks Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/hooks.server.ts | 14 ++++++++++++ apps/web/src/lib/server/db.ts | 39 ++++++++++++++++++++++++++++++++++ apps/web/src/lib/server/env.ts | 27 +++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 apps/web/src/hooks.server.ts create mode 100644 apps/web/src/lib/server/db.ts create mode 100644 apps/web/src/lib/server/env.ts diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 0000000..ea643aa --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -0,0 +1,14 @@ +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + // Handler mínimo (sin sesión aún). Añadimos cabeceras de seguridad básicas. + const response = await resolve(event); + try { + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('Referrer-Policy', 'no-referrer'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + } catch { + // Ignorar si la implementación de Response no permite set() + } + return response; +}; diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts new file mode 100644 index 0000000..8077e66 --- /dev/null +++ b/apps/web/src/lib/server/db.ts @@ -0,0 +1,39 @@ +import { Database } from 'bun:sqlite'; +import { mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { resolveDbAbsolutePath } from './env'; + +function applyDefaultPragmas(instance: Database): void { + try { + instance.exec(`PRAGMA busy_timeout = 5000;`); + // Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo) + instance.query(`PRAGMA journal_mode = WAL`).get(); + instance.exec(`PRAGMA synchronous = NORMAL;`); + instance.exec(`PRAGMA wal_autocheckpoint = 1000;`); + // Asegurar claves foráneas siempre activas + instance.exec(`PRAGMA foreign_keys = ON;`); + } catch (e) { + console.warn('[web/db] No se pudieron aplicar PRAGMAs (WAL, busy_timeout...):', e); + } +} + +/** + * Abre la BD compartida sin ejecutar migraciones (las realiza el proceso del bot). + */ +export function openDb(filename: string = 'tasks.db'): Database { + const absolutePath = resolveDbAbsolutePath(filename); + + // Crear directorio padre si no existe + try { + mkdirSync(dirname(absolutePath), { recursive: true }); + } catch (err: any) { + if (err?.code !== 'EEXIST') throw err; + } + + const instance = new Database(absolutePath); + applyDefaultPragmas(instance); + return instance; +} + +// Instancia por defecto +export const db = openDb(); diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts new file mode 100644 index 0000000..0ce9dfd --- /dev/null +++ b/apps/web/src/lib/server/env.ts @@ -0,0 +1,27 @@ +import { join, resolve } from 'path'; + +const env = process.env; + +/** + * Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida. + * Prioridad: + * 1) DB_PATH (ruta completa al archivo) + * 2) DATA_DIR + filename (por defecto ./data/tasks.db) + */ +export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string { + const dbPathEnv = (env.DB_PATH || '').trim(); + if (dbPathEnv) { + return resolve(dbPathEnv); + } + const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : 'data'; + return resolve(join(dataDir, filename)); +} + +export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim(); +export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim(); + +const SESSION_IDLE_TTL_MIN = Number(env.SESSION_IDLE_TTL_MIN || 120); +export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * 60 * 1000; + +export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase(); +export const isProd = () => NODE_ENV === 'production'; From 6d7d203465b79b2ba2953328ff0d03db01b63b0c Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 22:30:02 +0200 Subject: [PATCH 015/203] feat: agregar canje de token magico en GET /login y crear sesion Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/server/crypto.ts | 19 +++++++ apps/web/src/routes/login/+server.ts | 85 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 apps/web/src/lib/server/crypto.ts create mode 100644 apps/web/src/routes/login/+server.ts diff --git a/apps/web/src/lib/server/crypto.ts b/apps/web/src/lib/server/crypto.ts new file mode 100644 index 0000000..0f3c250 --- /dev/null +++ b/apps/web/src/lib/server/crypto.ts @@ -0,0 +1,19 @@ +/** + * Genera un token aleatorio en base64url (sin padding). + */ +export function randomTokenBase64Url(bytes: number = 32): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + const b64 = Buffer.from(arr).toString('base64'); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +/** + * Calcula SHA-256 en hexadecimal (minúsculas). + */ +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const hashBuf = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(hashBuf); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts new file mode 100644 index 0000000..2395b7a --- /dev/null +++ b/apps/web/src/routes/login/+server.ts @@ -0,0 +1,85 @@ +import type { RequestHandler } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; +import { sessionIdleTtlMs, isProd } from '$lib/server/env'; + +function toIsoSql(d: Date): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +export const GET: RequestHandler = async (event) => { + const token = event.url.searchParams.get('token')?.trim(); + if (!token) { + return new Response('Falta el token', { status: 400 }); + } + + // Hash del token (no loguear el valor en claro) + const tokenHash = await sha256Hex(token); + const nowIso = toIsoSql(new Date()); + + // Intentar canjear el token: marcarlo como usado si está vigente y no usado + const res = db + .prepare( + `UPDATE web_tokens + SET used_at = ? + WHERE token_hash = ? + AND used_at IS NULL + AND expires_at > ?` + ) + .run(nowIso, tokenHash, nowIso); + + const changes = Number(res?.changes || 0); + if (changes < 1) { + return new Response('Enlace inválido o caducado', { status: 400 }); + } + + // Recuperar el user_id asociado + const row = db + .prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`) + .get(tokenHash) as { user_id: string } | null; + + const userId = row?.user_id?.trim(); + if (!userId) { + return new Response('Token canjeado pero usuario no encontrado', { status: 500 }); + } + + // Crear sesión + const sessionToken = randomTokenBase64Url(32); + const sessionHash = await sha256Hex(sessionToken); + const sessionId = randomTokenBase64Url(16); + const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs)); + + // Datos de agente e IP (best-effort) + const userAgent = event.request.headers.get('user-agent') || null; + let ip: string | null = null; + try { + // SvelteKit 2: getClientAddress en adapters compatibles + // @ts-ignore + if (typeof event.getClientAddress === 'function') { + // @ts-ignore + ip = event.getClientAddress() || null; + } + } catch {} + if (!ip) { + const fwd = event.request.headers.get('x-forwarded-for'); + ip = fwd ? fwd.split(',')[0].trim() : null; + } + + db.prepare( + `INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip); + + // Cookie de sesión + event.cookies.set('sid', sessionToken, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isProd(), + maxAge: Math.floor(sessionIdleTtlMs / 1000) + }); + + // Redirigir a /app + throw redirect(303, '/app'); +}; From 4d5fa36e3ac478cfd2e7840f5f76f1090a97928d Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 22:33:51 +0200 Subject: [PATCH 016/203] hasta el paso 2 de la etapa 1 de la cosa web --- apps/web/bun.lock | 13 +++++++++++++ apps/web/package.json | 1 + 2 files changed, 14 insertions(+) diff --git a/apps/web/bun.lock b/apps/web/bun.lock index 2b1a9d8..252b829 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -8,6 +8,7 @@ "@sveltejs/adapter-node": "^5.3.3", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/bun": "^1.3.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", "typescript": "^5.9.2", @@ -146,10 +147,16 @@ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -158,6 +165,8 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -166,6 +175,8 @@ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -242,6 +253,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], diff --git a/apps/web/package.json b/apps/web/package.json index 01f0e36..3f2bc90 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@sveltejs/adapter-node": "^5.3.3", "@sveltejs/kit": "^2.43.2", "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@types/bun": "^1.3.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", "typescript": "^5.9.2", From 94bbb5ad1b78e129ebff875f290cf255a1321df4 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 22:46:16 +0200 Subject: [PATCH 017/203] =?UTF-8?q?build:=20usar=20adapter-node=20y=20a?= =?UTF-8?q?=C3=B1adir=20start=20en=20apps/web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/package.json | 1 + apps/web/svelte.config.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3f2bc90..5b63bea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", + "start": "bun ./build/index.js", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 1295460..e0a641e 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ From 296ab169f1b1b768b85a97d96f53345df1d23a99 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 22:49:42 +0200 Subject: [PATCH 018/203] =?UTF-8?q?refactor:=20cargar=20bun:sqlite=20din?= =?UTF-8?q?=C3=A1micamente=20y=20a=C3=B1adir=20getDb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/server/db.ts | 19 ++++++++++++++----- apps/web/src/routes/login/+server.ts | 4 +++- apps/web/vite.config.ts | 10 +++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts index 8077e66..dc8cfa4 100644 --- a/apps/web/src/lib/server/db.ts +++ b/apps/web/src/lib/server/db.ts @@ -1,9 +1,8 @@ -import { Database } from 'bun:sqlite'; import { mkdirSync } from 'fs'; import { dirname } from 'path'; import { resolveDbAbsolutePath } from './env'; -function applyDefaultPragmas(instance: Database): void { +function applyDefaultPragmas(instance: any): void { try { instance.exec(`PRAGMA busy_timeout = 5000;`); // Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo) @@ -19,8 +18,9 @@ function applyDefaultPragmas(instance: Database): void { /** * Abre la BD compartida sin ejecutar migraciones (las realiza el proceso del bot). + * Nota: uso de import dinámico de 'bun:sqlite' para que el build con Node no falle. */ -export function openDb(filename: string = 'tasks.db'): Database { +async function openDb(filename: string = 'tasks.db'): Promise { const absolutePath = resolveDbAbsolutePath(filename); // Crear directorio padre si no existe @@ -30,10 +30,19 @@ export function openDb(filename: string = 'tasks.db'): Database { if (err?.code !== 'EEXIST') throw err; } + const { Database } = await import('bun:sqlite'); const instance = new Database(absolutePath); applyDefaultPragmas(instance); return instance; } -// Instancia por defecto -export const db = openDb(); +let _db: any | null = null; + +/** + * Devuelve una única instancia compartida (lazy) de la BD. + */ +export async function getDb(filename: string = 'tasks.db'): Promise { + if (_db) return _db; + _db = await openDb(filename); + return _db; +} diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 2395b7a..6f6fba2 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { redirect } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; +import { getDb } from '$lib/server/db'; import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto'; import { sessionIdleTtlMs, isProd } from '$lib/server/env'; @@ -18,6 +18,8 @@ export const GET: RequestHandler = async (event) => { const tokenHash = await sha256Hex(token); const nowIso = toIsoSql(new Date()); + const db = await getDb(); + // Intentar canjear el token: marcarlo como usado si está vigente y no usado const res = db .prepare( diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bbf8c7d..245ce0c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,5 +2,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()] + plugins: [sveltekit()], + ssr: { + // Evita que Node intente resolver el esquema 'bun:' durante el build SSR + external: ['bun:sqlite'] + }, + optimizeDeps: { + // No prebundlear 'bun:sqlite' en dev + exclude: ['bun:sqlite'] + } }); From c97a805d43eb8199de17e94f39b65a8a0e7f9ce2 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 23:19:57 +0200 Subject: [PATCH 019/203] feat: gestiona rutas con proxy en Bun para /webhook y /metrics Co-authored-by: aider (openrouter/openai/gpt-5) --- Dockerfile | 27 +++++++++++++++++++++------ proxy.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ startup.sh | 17 ++++++++++++++--- 3 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 proxy.ts diff --git a/Dockerfile b/Dockerfile index 7d055b6..f3fcaea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,20 +10,35 @@ ENV DATA_DIR=/app/data # Create data directory with proper permissions RUN mkdir -p /app/data && chown -R bun:bun /app/data -# Install dependencies first (better layer caching) +# Install bot dependencies first (better layer caching) COPY package.json bun.lock ./ RUN bun install -# Copy only necessary files +# Prepare and install web dependencies +COPY apps/web/package.json apps/web/ +WORKDIR /app/apps/web +RUN bun install + +# Copy sources +WORKDIR /app COPY src/ ./src/ COPY index.ts ./ +COPY apps/web/ /app/apps/web/ +COPY proxy.ts ./ + +# Build the web app +WORKDIR /app/apps/web +RUN bun run build + +# Return to root workdir +WORKDIR /app -# More forgiving health check during debugging +# More forgiving health check during debugging (router en 3000) HEALTHCHECK --start-period=30s --interval=30s --timeout=3s --retries=3 \ - CMD curl -f http://localhost:${PORT:-3007}/health || exit 0 + CMD curl -f http://localhost:3000/health || exit 0 -# Server runs on port from environment variable -EXPOSE ${PORT:-3007} +# Expose router port +EXPOSE 3000 # Declare volume for persistent data by default VOLUME ["/app/data"] diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..1b2fa39 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,44 @@ +const BOT_ORIGIN = 'http://127.0.0.1:3007'; +const WEB_ORIGIN = 'http://127.0.0.1:3008'; + +function shouldRouteToBot(pathname: string): boolean { + if (pathname === '/metrics' || pathname.startsWith('/metrics/')) return true; + if (pathname === '/webhook' || pathname.startsWith('/webhook/')) return true; + return false; +} + +function buildForwardHeaders(req: Request): Headers { + const headers = new Headers(req.headers); + try { + const proto = headers.get('x-forwarded-proto') || 'https'; + const fwdFor = headers.get('x-forwarded-for'); + headers.set('x-forwarded-proto', proto); + headers.set('x-forwarded-for', fwdFor ? `${fwdFor}, 127.0.0.1` : '127.0.0.1'); + } catch {} + return headers; +} + +Bun.serve({ + port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000), + fetch: async (req) => { + const url = new URL(req.url); + const targetOrigin = shouldRouteToBot(url.pathname) ? BOT_ORIGIN : WEB_ORIGIN; + const targetUrl = targetOrigin + url.pathname + url.search; + + const init: RequestInit = { + method: req.method, + headers: buildForwardHeaders(req), + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body, + redirect: 'manual', + }; + + try { + const res = await fetch(targetUrl, init); + // Devuelve la respuesta tal cual (incluye Set-Cookie, Location, etc.) + return res; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return new Response(`Proxy error: ${msg}\n`, { status: 502 }); + } + }, +}); diff --git a/startup.sh b/startup.sh index 1610d46..f188e53 100644 --- a/startup.sh +++ b/startup.sh @@ -1,7 +1,18 @@ #!/bin/bash +set -euo pipefail -# Wait for server to be ready +# Arranca el bot en segundo plano (puerto 3007 por defecto) +BOT_PORT="${BOT_PORT:-3007}" +PORT="$BOT_PORT" bun run index.ts & + +# Arranca la web (SvelteKit) en segundo plano en el puerto 3008 +WEB_PORT="${WEB_PORT:-3008}" +pushd apps/web >/dev/null +PORT="$WEB_PORT" bun ./build/index.js & +popd >/dev/null + +# Pequeña espera para evitar condiciones de carrera sleep 1 -# Start the main process -exec bun run index.ts +# Arranca el router en primer plano en el puerto 3000 (o $PORT si viene de CapRover) +exec bun proxy.ts From 5616ee83956635176e3765b7ec0e7937e1ee7e28 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 23:38:41 +0200 Subject: [PATCH 020/203] fix: normalizar DB_PATH y DATA_DIR a rutas absolutas y esperar tablas Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/lib/server/env.ts | 2 +- startup.sh | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 0ce9dfd..68f090c 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -13,7 +13,7 @@ export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string { if (dbPathEnv) { return resolve(dbPathEnv); } - const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : 'data'; + const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : '/app/data'; return resolve(join(dataDir, filename)); } diff --git a/startup.sh b/startup.sh index f188e53..0866318 100644 --- a/startup.sh +++ b/startup.sh @@ -1,10 +1,51 @@ #!/bin/bash set -euo pipefail +# Normalizar rutas de DB a absolutas para que bot y web apunten al mismo archivo +if [ -n "${DB_PATH:-}" ]; then + # readlink -f resuelve a ruta absoluta y elimina ./, ../, etc. + DB_PATH="$(readlink -f "$DB_PATH")" || true + export DB_PATH +else + DATA_DIR="${DATA_DIR:-/app/data}" + DATA_DIR="$(readlink -f "$DATA_DIR")" || true + export DATA_DIR +fi + +# Determinar archivo de base de datos para esperas +if [ -n "${DB_PATH:-}" ]; then + DB_FILE="$DB_PATH" +else + DB_FILE="${DATA_DIR:-/app/data}/tasks.db" +fi + # Arranca el bot en segundo plano (puerto 3007 por defecto) BOT_PORT="${BOT_PORT:-3007}" PORT="$BOT_PORT" bun run index.ts & +# Esperar a que exista el archivo de DB (máx ~30s) +echo "[startup] Esperando a que exista la base de datos en: $DB_FILE" +for i in $(seq 1 150); do + if [ -f "$DB_FILE" ]; then break; fi + sleep 0.2 +done + +# Esperar a que las tablas de auth estén creadas por las migraciones del bot (máx ~30s) +if command -v sqlite3 >/dev/null 2>&1; then + echo "[startup] Verificando tablas de autenticación (web_tokens, web_sessions)..." + for i in $(seq 1 150); do + if [ -f "$DB_FILE" ]; then + HAS_AUTH=$(sqlite3 "$DB_FILE" "SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('web_tokens','web_sessions') LIMIT 1;" || true) + if [ "$HAS_AUTH" = "1" ]; then + break + fi + fi + sleep 0.2 + done +else + echo "[startup] sqlite3 no disponible; se omite verificación de tablas (continuando)." +fi + # Arranca la web (SvelteKit) en segundo plano en el puerto 3008 WEB_PORT="${WEB_PORT:-3008}" pushd apps/web >/dev/null From cdfd16f2fa34deb75cd1f54aed02ffe1dc3d72b6 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 23:44:00 +0200 Subject: [PATCH 021/203] fix: usa strftime('now') en SQL y simplifica canje de token Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/login/+server.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 6f6fba2..b5c82b3 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -16,20 +16,19 @@ export const GET: RequestHandler = async (event) => { // Hash del token (no loguear el valor en claro) const tokenHash = await sha256Hex(token); - const nowIso = toIsoSql(new Date()); const db = await getDb(); - // Intentar canjear el token: marcarlo como usado si está vigente y no usado + // Intentar canjear el token: marcarlo como usado si está vigente y no usado (usar tiempo de SQLite) const res = db .prepare( `UPDATE web_tokens - SET used_at = ? + SET used_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE token_hash = ? AND used_at IS NULL - AND expires_at > ?` + AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')` ) - .run(nowIso, tokenHash, nowIso); + .run(tokenHash); const changes = Number(res?.changes || 0); if (changes < 1) { From b7c8e37a8541a7cd16093fe92dc428a397410d7b Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 12 Oct 2025 23:47:16 +0200 Subject: [PATCH 022/203] =?UTF-8?q?feat:=20a=C3=B1adir=20logs=20y=20endpoi?= =?UTF-8?q?nt=20de=20salud=20en=20proxy=20y=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/login/+server.ts | 2 ++ proxy.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index b5c82b3..78a3bc4 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -11,6 +11,7 @@ function toIsoSql(d: Date): string { export const GET: RequestHandler = async (event) => { const token = event.url.searchParams.get('token')?.trim(); if (!token) { + console.warn('[web/login] Solicitud sin token'); return new Response('Falta el token', { status: 400 }); } @@ -32,6 +33,7 @@ export const GET: RequestHandler = async (event) => { const changes = Number(res?.changes || 0); if (changes < 1) { + console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.'); return new Response('Enlace inválido o caducado', { status: 400 }); } diff --git a/proxy.ts b/proxy.ts index 1b2fa39..42e58d2 100644 --- a/proxy.ts +++ b/proxy.ts @@ -14,6 +14,8 @@ function buildForwardHeaders(req: Request): Headers { const fwdFor = headers.get('x-forwarded-for'); headers.set('x-forwarded-proto', proto); headers.set('x-forwarded-for', fwdFor ? `${fwdFor}, 127.0.0.1` : '127.0.0.1'); + const host = headers.get('host') || ''; + if (!host) headers.set('host', 'localhost'); } catch {} return headers; } @@ -22,7 +24,14 @@ Bun.serve({ port: Number(process.env.PORT || process.env.ROUTER_PORT || 3000), fetch: async (req) => { const url = new URL(req.url); - const targetOrigin = shouldRouteToBot(url.pathname) ? BOT_ORIGIN : WEB_ORIGIN; + + // Health local para el contenedor (evita 404 en healthcheck) + if (url.pathname === '/health') { + return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }); + } + + const routeToBot = shouldRouteToBot(url.pathname); + const targetOrigin = routeToBot ? BOT_ORIGIN : WEB_ORIGIN; const targetUrl = targetOrigin + url.pathname + url.search; const init: RequestInit = { @@ -32,12 +41,18 @@ Bun.serve({ redirect: 'manual', }; + const started = Date.now(); try { const res = await fetch(targetUrl, init); + const ms = Date.now() - started; + try { + console.log(`[proxy] ${req.method} ${url.pathname}${url.search} -> ${routeToBot ? 'bot' : 'web'} ${res.status} (${ms}ms)`); + } catch {} // Devuelve la respuesta tal cual (incluye Set-Cookie, Location, etc.) return res; } catch (err) { const msg = err instanceof Error ? err.message : String(err); + console.error(`[proxy] ${req.method} ${url.pathname}${url.search} -> ERROR: ${msg}`); return new Response(`Proxy error: ${msg}\n`, { status: 502 }); } }, From ccbc9413d82ef6b6482afc0f42d23ddbe51642cf Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:00:06 +0200 Subject: [PATCH 023/203] =?UTF-8?q?feat:=20gestionar=20sesi=C3=B3n=20web?= =?UTF-8?q?=20con=20idle=20timeout=20y=20ruta=20/app=20protegida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/app.d.ts | 7 ++- apps/web/src/hooks.server.ts | 67 ++++++++++++++++++++++- apps/web/src/routes/+page.svelte | 2 + apps/web/src/routes/api/logout/+server.ts | 28 ++++++++++ apps/web/src/routes/app/+page.server.ts | 11 ++++ apps/web/src/routes/app/+page.svelte | 7 +++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/routes/api/logout/+server.ts create mode 100644 apps/web/src/routes/app/+page.server.ts create mode 100644 apps/web/src/routes/app/+page.svelte diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index da08e6d..4bc652b 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,13 +1,14 @@ // See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +/* See https://svelte.dev/docs/kit/types#app.d.ts */ declare global { namespace App { + interface Locals { + userId?: string | null; + } // interface Error {} - // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } - export {}; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index ea643aa..e342e33 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,8 +1,73 @@ import type { Handle } from '@sveltejs/kit'; +import { getDb } from '$lib/server/db'; +import { sha256Hex } from '$lib/server/crypto'; +import { isProd, sessionIdleTtlMs } from '$lib/server/env'; + +function toIsoSql(d: Date): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} export const handle: Handle = async ({ event, resolve }) => { - // Handler mínimo (sin sesión aún). Añadimos cabeceras de seguridad básicas. + // Sesión por cookie 'sid' + const sid = event.cookies.get('sid'); + if (sid) { + try { + const db = await getDb(); + const hash = await sha256Hex(sid); + + // Validar sesión vigente + const row = db + .prepare( + `SELECT user_id FROM web_sessions + WHERE session_hash = ? + AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now') + LIMIT 1` + ) + .get(hash) as { user_id: string } | undefined; + + if (row?.user_id) { + event.locals.userId = row.user_id; + + // Renovar expiración por inactividad y last_seen_at + const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs)); + try { + db.prepare( + `UPDATE web_sessions + SET last_seen_at = strftime('%Y-%m-%d %H:%M:%f','now'), + expires_at = ? + WHERE session_hash = ?` + ).run(newExpIso, hash); + } catch { + // Si no existe last_seen_at en el esquema, al menos renovar expires_at + try { + db.prepare( + `UPDATE web_sessions + SET expires_at = ? + WHERE session_hash = ?` + ).run(newExpIso, hash); + } catch {} + } + + // Refrescar cookie (idle) + event.cookies.set('sid', sid, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isProd(), + maxAge: Math.floor(sessionIdleTtlMs / 1000) + }); + } else { + // Sesión inválida/expirada + event.cookies.delete('sid', { path: '/' }); + } + } catch { + // En caso de error de DB, no romper la request; continuar sin sesión + } + } + const response = await resolve(event); + + // Cabeceras de seguridad básicas try { response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('Referrer-Policy', 'no-referrer'); diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index cc88df0..1744fbe 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,2 +1,4 @@

Welcome to SvelteKit

Visit svelte.dev/docs/kit to read the documentation

+ +

Ir al panel

diff --git a/apps/web/src/routes/api/logout/+server.ts b/apps/web/src/routes/api/logout/+server.ts new file mode 100644 index 0000000..9b1f832 --- /dev/null +++ b/apps/web/src/routes/api/logout/+server.ts @@ -0,0 +1,28 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; +import { sha256Hex } from '$lib/server/crypto'; + +export const POST: RequestHandler = async (event) => { + const sid = event.cookies.get('sid'); + if (sid) { + try { + const db = await getDb(); + const hash = await sha256Hex(sid); + // Intentar borrar; si falla, expirar + try { + db.prepare(`DELETE FROM web_sessions WHERE session_hash = ?`).run(hash); + } catch { + db.prepare( + `UPDATE web_sessions + SET expires_at = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE session_hash = ?` + ).run(hash); + } + } catch { + // Ignorar errores de DB en logout + } + } + // Limpiar cookie + event.cookies.delete('sid', { path: '/' }); + return new Response(null, { status: 204 }); +}; diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts new file mode 100644 index 0000000..57a97a1 --- /dev/null +++ b/apps/web/src/routes/app/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async (event) => { + const userId = event.locals.userId ?? null; + if (!userId) { + // No hay sesión: redirigir a la home + throw redirect(303, '/'); + } + return { userId }; +}; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte new file mode 100644 index 0000000..4470e52 --- /dev/null +++ b/apps/web/src/routes/app/+page.svelte @@ -0,0 +1,7 @@ + + +

Panel

+

Sesión iniciada como: {data.userId}

+

Esta es una página protegida. La cookie de sesión se renueva con cada visita (idle timeout).

From 472cd3eef80b06eb6a817e08f507abc7869589f4 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:06:37 +0200 Subject: [PATCH 024/203] =?UTF-8?q?feat:=20a=C3=B1ade=20p=C3=A1gina=20inte?= =?UTF-8?q?rmedia=20de=20login=20y=20flujo=20de=20canje=20de=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/login/+server.ts | 57 ++++++++++++++++++++++++++-- docs/plan-interfaz-web.md | 7 ++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 78a3bc4..785108f 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -8,6 +8,17 @@ function toIsoSql(d: Date): string { return d.toISOString().replace('T', ' ').replace('Z', ''); } +function escapeHtml(s: string): string { + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +// GET: página intermedia para evitar que los “link preview bots” canjeen el token. +// Muestra un formulario que hace POST automático para canjear el token. export const GET: RequestHandler = async (event) => { const token = event.url.searchParams.get('token')?.trim(); if (!token) { @@ -15,12 +26,52 @@ export const GET: RequestHandler = async (event) => { return new Response('Falta el token', { status: 400 }); } - // Hash del token (no loguear el valor en claro) - const tokenHash = await sha256Hex(token); + const html = ` + + + + Accediendo… + + + + + + +
+ + +
+ + +`; + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store, max-age=0' + } + }); +}; + +// POST: canje real del token (uso único). Crea sesión y redirige a /app. +export const POST: RequestHandler = async (event) => { + const form = await event.request.formData(); + const token = String(form.get('token') || '').trim(); + if (!token) { + console.warn('[web/login] POST sin token'); + return new Response('Falta el token', { status: 400 }); + } + const tokenHash = await sha256Hex(token); const db = await getDb(); - // Intentar canjear el token: marcarlo como usado si está vigente y no usado (usar tiempo de SQLite) + // Intentar canjear el token (un solo uso, no caducado) const res = db .prepare( `UPDATE web_tokens diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index fee7b98..1fdae28 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -45,9 +45,10 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario. - Devolver URL del tipo: https://app.example.com/login?token=XYZ - Canje (web): - - Validar hash y caducidad; si ok, invalidar token (marcar usado). - - Crear sesión en DB (web_sessions) y emitir cookie de sesión (solo cookie de sesión, sin persistencia en disco). - - Redirigir a /app (sin token en la URL). + - GET /login muestra una página intermedia con formulario (y auto-submit JS) para evitar que los “link preview bots” canjeen el token. + - POST /login valida hash y caducidad; si ok, invalida el token (marcar usado). + - Crea sesión en DB (web_sessions) y emite cookie de sesión (solo cookie de sesión, sin persistencia en disco). + - Redirige a /app (sin token en la URL). - Expiración: - Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token /t web. - Seguridad: From 58bf44db88402a99ca5be72a0981b7d29fd1a6a9 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:20:31 +0200 Subject: [PATCH 025/203] =?UTF-8?q?feat:=20a=C3=B1adir=20gate=20de=20JS=20?= =?UTF-8?q?en=20/login=20para=20evitar=20canje=20prematuro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/login/+server.ts | 56 +++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routes/login/+server.ts b/apps/web/src/routes/login/+server.ts index 785108f..9b4f397 100644 --- a/apps/web/src/routes/login/+server.ts +++ b/apps/web/src/routes/login/+server.ts @@ -17,8 +17,8 @@ function escapeHtml(s: string): string { .replaceAll("'", '''); } -// GET: página intermedia para evitar que los “link preview bots” canjeen el token. -// Muestra un formulario que hace POST automático para canjear el token. +// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS. +// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic. export const GET: RequestHandler = async (event) => { const token = event.url.searchParams.get('token')?.trim(); if (!token) { @@ -26,26 +26,45 @@ export const GET: RequestHandler = async (event) => { return new Response('Falta el token', { status: 400 }); } + // Nonce para "gate de JS" + const nonce = randomTokenBase64Url(18); + const html = ` - Accediendo… + Acceder - + - -
- - -
+
+

Acceso seguro

+

Para continuar, pulsa “Continuar”. Si no funciona, asegúrate de abrir este enlace en tu navegador.

+
+ + + +
+ +
`; @@ -68,6 +87,14 @@ export const POST: RequestHandler = async (event) => { return new Response('Falta el token', { status: 400 }); } + // Validación del "gate de JS": cookie + nonce deben coincidir + const nonce = String(form.get('nonce') || '').trim(); + const loginIntent = event.cookies.get('login_intent') || ''; + if (!nonce || !loginIntent || nonce !== loginIntent) { + console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.'); + return new Response('Solicitud inválida', { status: 400 }); + } + const tokenHash = await sha256Hex(token); const db = await getDb(); @@ -134,6 +161,9 @@ export const POST: RequestHandler = async (event) => { maxAge: Math.floor(sessionIdleTtlMs / 1000) }); + // Eliminar cookie de intento (ya no es necesaria) + event.cookies.delete('login_intent', { path: '/' }); + // Redirigir a /app throw redirect(303, '/app'); }; From 8a807d8af384a866930ddcae48add5112d9d92ce Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:28:33 +0200 Subject: [PATCH 026/203] chore: desactiva checkOrigin CSRF de SvelteKit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/svelte.config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index e0a641e..97bf987 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -11,7 +11,10 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + csrf: { + checkOrigin: false + } } }; From da025326b5d4c7aa03db537b2f96d8090fe92cb9 Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:38:10 +0200 Subject: [PATCH 027/203] =?UTF-8?q?feat:=20a=C3=B1adir=20endpoint=20/api/m?= =?UTF-8?q?e/tasks=20y=20mostrar=20tareas=20en=20app=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/api/me/tasks/+server.ts | 115 ++++++++++++++++++++ apps/web/src/routes/app/+page.server.ts | 23 +++- apps/web/src/routes/app/+page.svelte | 45 +++++++- 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/api/me/tasks/+server.ts diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts new file mode 100644 index 0000000..65f8a06 --- /dev/null +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -0,0 +1,115 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +export const GET: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const url = new URL(event.request.url); + const search = (url.searchParams.get('search') || '').trim(); + const status = (url.searchParams.get('status') || 'open').trim().toLowerCase(); + const page = clamp(parseInt(url.searchParams.get('page') || '1', 10) || 1, 1, 100000); + const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100); + + // Por ahora solo "open" + if (status !== 'open') { + return new Response('Bad Request', { status: 400 }); + } + + const offset = (page - 1) * limit; + + const db = await getDb(); + + // Construir filtros dinámicos + const whereParts = [ + `a.user_id = ?`, + `COALESCE(t.completed, 0) = 0`, + `t.completed_at IS NULL` + ]; + const params: any[] = [userId]; + + if (search) { + whereParts.push(`t.description LIKE ?`); + params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); + } + + // Total + const totalRow = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM tasks t + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE ${whereParts.join(' AND ')}` + ) + .get(...params) as any; + const total = Number(totalRow?.cnt || 0); + + // Items + const itemsRows = db + .prepare( + `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code + FROM tasks t + INNER JOIN task_assignments a ON a.task_id = t.id + WHERE ${whereParts.join(' AND ')} + ORDER BY + CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, + t.due_date ASC, + t.id ASC + LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as any[]; + + const items = itemsRows.map((r) => ({ + id: Number(r.id), + description: String(r.description || ''), + due_date: r.due_date ? String(r.due_date) : null, + group_id: r.group_id ? String(r.group_id) : null, + display_code: r.display_code != null ? Number(r.display_code) : null, + assignees: [] as string[] + })); + + // Cargar asignados de todas las tareas recuperadas (si hay) + if (items.length > 0) { + const ids = items.map((it) => it.id); + const placeholders = ids.map(() => '?').join(','); + const assignRows = db + .prepare( + `SELECT task_id, user_id + FROM task_assignments + WHERE task_id IN (${placeholders}) + ORDER BY assigned_at ASC` + ) + .all(...ids) as any[]; + + const map = new Map(); + for (const row of assignRows) { + const tid = Number(row.task_id); + const uid = String(row.user_id); + if (!map.has(tid)) map.set(tid, []); + map.get(tid)!.push(uid); + } + for (const it of items) { + it.assignees = map.get(it.id) || []; + } + } + + const body = { + items, + page, + limit, + total, + hasMore: offset + items.length < total + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts index 57a97a1..be1fc7e 100644 --- a/apps/web/src/routes/app/+page.server.ts +++ b/apps/web/src/routes/app/+page.server.ts @@ -7,5 +7,26 @@ export const load: PageServerLoad = async (event) => { // No hay sesión: redirigir a la home throw redirect(303, '/'); } - return { userId }; + + // Cargar "mis tareas" desde la API interna + let tasks: Array<{ + id: number; + description: string; + due_date: string | null; + group_id: string | null; + display_code: number | null; + assignees: string[]; + }> = []; + + try { + const res = await event.fetch('/api/me/tasks?limit=20'); + if (res.ok) { + const json = await res.json(); + tasks = Array.isArray(json?.items) ? json.items : []; + } + } catch { + // Ignorar errores y dejar lista vacía + } + + return { userId, tasks }; }; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index 4470e52..e1bfec9 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -1,7 +1,48 @@

Panel

Sesión iniciada como: {data.userId}

-

Esta es una página protegida. La cookie de sesión se renueva con cada visita (idle timeout).

+ +
+ +
+ +

Mis tareas (abiertas)

+{#if data.tasks.length === 0} +

No tienes tareas abiertas.

+{:else} +
    + {#each data.tasks as t} +
  • + #{t.display_code ?? t.id} — {t.description} + {#if t.due_date} + (vence: {t.due_date}) + {/if} + {#if t.assignees?.length} + — asignados: {t.assignees.join(', ')} + {/if} +
  • + {/each} +
+{/if} + +

La cookie de sesión se renueva con cada visita (idle timeout).

From 5637c90d2d76bfe08c4fa0c9ab11c88ef50bb43e Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:45:59 +0200 Subject: [PATCH 028/203] fix: evita refrescar cookie durante /api/logout y limpia cookie Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/hooks.server.ts | 21 ++++++++++++--------- apps/web/src/routes/api/logout/+server.ts | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index e342e33..a9d7854 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -9,6 +9,7 @@ function toIsoSql(d: Date): string { export const handle: Handle = async ({ event, resolve }) => { // Sesión por cookie 'sid' + const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/'); const sid = event.cookies.get('sid'); if (sid) { try { @@ -48,17 +49,19 @@ export const handle: Handle = async ({ event, resolve }) => { } catch {} } - // Refrescar cookie (idle) - event.cookies.set('sid', sid, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: isProd(), - maxAge: Math.floor(sessionIdleTtlMs / 1000) - }); + // Refrescar cookie (idle) excepto durante /api/logout + if (!isLogout) { + event.cookies.set('sid', sid, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: isProd(), + maxAge: Math.floor(sessionIdleTtlMs / 1000) + }); + } } else { // Sesión inválida/expirada - event.cookies.delete('sid', { path: '/' }); + event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() }); } } catch { // En caso de error de DB, no romper la request; continuar sin sesión diff --git a/apps/web/src/routes/api/logout/+server.ts b/apps/web/src/routes/api/logout/+server.ts index 9b1f832..52d84b0 100644 --- a/apps/web/src/routes/api/logout/+server.ts +++ b/apps/web/src/routes/api/logout/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; +import { isProd } from '$lib/server/env'; export const POST: RequestHandler = async (event) => { const sid = event.cookies.get('sid'); @@ -22,7 +23,7 @@ export const POST: RequestHandler = async (event) => { // Ignorar errores de DB en logout } } - // Limpiar cookie - event.cookies.delete('sid', { path: '/' }); + // Limpiar cookie (asegurar mismos atributos que al crearla) + event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() }); return new Response(null, { status: 204 }); }; From d84fd9a7728f894e7721363808ca364b00ca137a Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 00:49:57 +0200 Subject: [PATCH 029/203] =?UTF-8?q?feat:=20cerrar=20sesi=C3=B3n=20por=20PO?= =?UTF-8?q?ST=20con=20redirecci=C3=B3n=20a=20/=20y=20formulario=20en=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/api/logout/+server.ts | 5 ++++- apps/web/src/routes/app/+page.svelte | 10 +++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/api/logout/+server.ts b/apps/web/src/routes/api/logout/+server.ts index 52d84b0..abd444e 100644 --- a/apps/web/src/routes/api/logout/+server.ts +++ b/apps/web/src/routes/api/logout/+server.ts @@ -1,4 +1,5 @@ import type { RequestHandler } from './$types'; +import { redirect } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { sha256Hex } from '$lib/server/crypto'; import { isProd } from '$lib/server/env'; @@ -25,5 +26,7 @@ export const POST: RequestHandler = async (event) => { } // Limpiar cookie (asegurar mismos atributos que al crearla) event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() }); - return new Response(null, { status: 204 }); + + // Redirigir a home para que el navegador navegue sin depender de JS + throw redirect(303, '/'); }; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index e1bfec9..f0283bd 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -11,19 +11,15 @@ }>; }; - async function logout() { - try { - await fetch('/api/logout', { method: 'POST' }); - } catch {} - location.href = '/'; - }

Panel

Sesión iniciada como: {data.userId}

- +
+ +

Mis tareas (abiertas)

From 4ceb64877fcf054cdd8fa6a6013fabd58d797b0b Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 13 Oct 2025 01:15:23 +0200 Subject: [PATCH 030/203] docs: actualiza README.md y docs/operations.md y plan-interfaz-web.md Co-authored-by: aider (openrouter/openai/gpt-5) --- README.md | 8 +++++--- docs/operations.md | 15 ++++++++++++--- docs/plan-interfaz-web.md | 21 +++++++++++++-------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ce9aef9..16e2820 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - Control de acceso por grupos: modos off, discover y enforce; aprobación y bloqueo por admins. - Sincronización de grupos y miembros con cachés y schedulers configurables. - Alias de identidad con normalización de IDs. -- Acceso web por token mágico (/t web), tokens de 10 min de un solo uso (migración y emisión implementadas). +- Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso. - Métricas listas para Prometheus en el endpoint /metrics. - Rate limiting por usuario para evitar abuso. - Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.). @@ -27,7 +27,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - No es un framework general de bots ni un CRM. - No conecta directamente con WhatsApp: requiere Evolution API. - No gestiona flujos conversacionales complejos ni multimedia avanzada. -- Panel web en desarrollo (MVP de login en marcha); hoy la interacción principal es vía WhatsApp. +- Panel web en progreso: login operativo y vista básica de tareas; la interacción principal sigue siendo WhatsApp. - Está optimizado para un despliegue por comunidad/instancia (no multi-tenant masivo). ## Cómo funciona (alto nivel) @@ -38,6 +38,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen 4. Las respuestas se encolan y envían a través de Evolution API. 5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento. 6. Las métricas se exponen en /metrics (Prometheus o JSON). +7. Un proxy interno en Bun sirve web y bot bajo el mismo dominio: /webhook y /metrics → bot; el resto → web. ## Uso básico @@ -93,7 +94,8 @@ Consulta: - Nombre provisional: “Taskbot”. - Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0). -- Progreso Etapa 1 (autenticación web): migración v10 (web_tokens/web_sessions) y comando /t web implementados; pendiente /login en la web y gestión de sesión (idle 2h). +- Progreso Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun. +- Primer MVP de lectura: endpoint /api/me/tasks y listado básico en /app. - Roadmap y contribuciones: pendientes de publicación. ## Enlaces diff --git a/docs/operations.md b/docs/operations.md index d869a53..17cee69 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,7 +6,9 @@ Variables de entorno (principales) - EVOLUTION_API_KEY: API key para peticiones salientes (contacts, etc.). - WEBHOOK_URL: URL pública del webhook (puede usarse para auto-registro/config). - WHATSAPP_COMMUNITY_ID: comunidad cuyos grupos se sincronizan. -- PORT: puerto HTTP (por defecto 3007). +- PORT: puerto HTTP del proxy interno (por defecto 3000). +- BOT_PORT: puerto interno del bot (por defecto 3007). +- WEB_PORT: puerto interno de la web SvelteKit (por defecto 3008). - NODE_ENV: 'development' | 'test' | 'production'. - METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test). Ej.: METRICS_ENABLED='true' - RATE_LIMIT_PER_MIN: tokens por minuto por usuario (default 15). @@ -29,9 +31,14 @@ Endpoints operativos - GET /metrics - 200 si Metrics.enabled() y formato Prometheus por defecto. - 404 si métricas deshabilitadas; 405 si método no permitido. +- GET /health + - 200 siempre (proxy interno), útil para healthcheck del contenedor. Arranque y servicios -- src/server.ts::start() +- src/server.ts::start() (bot) +- proxy.ts y startup.sh (contenedor único con Bun): + - El proxy escucha en PORT (3000 por defecto) y enruta /webhook y /metrics → BOT_PORT; el resto → WEB_PORT. + - startup.sh normaliza DB_PATH/DATA_DIR a absolutas, arranca bot, espera tablas web_tokens/web_sessions y arranca la web antes del proxy. - Valida entorno (logs de variables presentes/faltantes). - Aplica migraciones up-only. - Inicia HTTP y (según entorno) schedulers. @@ -46,7 +53,8 @@ Schedulers - Tarea diaria; borra miembros inactivos según retención. Datos y backups -- Data path: data/tasks.db (por defecto). +- Data path: /app/data/tasks.db (por defecto). +- startup.sh normaliza DB_PATH y DATA_DIR a rutas absolutas para que bot y web apunten al mismo archivo, y espera a que existan web_tokens/web_sessions antes de iniciar la web. - Migraciones con backup opcional (withBackup=false por defecto en initializeDatabase). - Recomendación: planificar copia de seguridad periódica del directorio data/ y retención externa. - DB_PATH permite aislar BD por rama/entorno sin tocar DATA_DIR; útil para pruebas en CapRover. @@ -66,6 +74,7 @@ Métricas de referencia Buenas prácticas - No arrancar schedulers en test salvo que FORCE_SCHEDULERS='true'. - Validar nuevas env en src/server.ts::validateEnv() y documentarlas aquí. +- En apps/web, kit.csrf.checkOrigin=false debido al proxy interno; considerar alternativas si se elimina el proxy. Formato de fechas en comandos - Se aceptan únicamente YYYY-MM-DD y YY-MM-DD (YY se expande a 20YY). diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index 1fdae28..9155209 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -4,7 +4,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie ## 1) Decisiones fijadas -- Arquitectura: dos procesos (apps/bot y apps/web). SvelteKit para la web (SSR, rutas de API, cookies). +- Arquitectura: dos procesos (apps/bot y apps/web) ejecutándose en la misma app de CapRover, con un proxy interno en Bun (puerto 3000) que enruta /webhook y /metrics al bot (3007) y el resto a la web (3008). SvelteKit para la web (SSR, rutas de API, cookies). - Acceso: enlace mágico por DM con token de 10 minutos, de un solo uso. Sin “recordarme”. - Sesión: cookie de sesión (HttpOnly, SameSite=Lax, Secure en prod) + expiración por inactividad de servidor de 2 horas. - Orden por defecto: tareas por creación descendente (más recientes primero). @@ -45,8 +45,8 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario. - Devolver URL del tipo: https://app.example.com/login?token=XYZ - Canje (web): - - GET /login muestra una página intermedia con formulario (y auto-submit JS) para evitar que los “link preview bots” canjeen el token. - - POST /login valida hash y caducidad; si ok, invalida el token (marcar usado). + - GET /login muestra una página intermedia sin auto-submit; requiere interacción mínima. Un script establece una cookie efímera login_intent y habilita el botón. + - POST /login valida hash y caducidad y comprueba la cookie login_intent; si ok, invalida el token (marcar usado). - Crea sesión en DB (web_sessions) y emite cookie de sesión (solo cookie de sesión, sin persistencia en disco). - Redirige a /app (sin token en la URL). - Expiración: @@ -55,6 +55,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - Cookies: HttpOnly, SameSite=Lax, Secure (en prod), path acotado. - Rate limit en /login para evitar bruteforce de tokens. - Redirección inmediata tras canje para evitar fugas por Referer. + - CSRF: checkOrigin desactivado en SvelteKit debido al proxy interno que reenvía las peticiones (mismo dominio). ## 5) Calendario ICS @@ -100,7 +101,8 @@ Notas: ## 8) Endpoints (apps/web) - Autenticación: - - GET /login?token=… (canjea token, crea sesión, redirige a /app) + - GET /login?token=… (página intermedia con gate de JS) + - POST /login (canjea token, crea sesión, redirige a /app) - POST /api/logout (revoca sesión actual) - APIs (todas requieren sesión válida): - GET /api/me/tasks?status=open&search=...&page=...&limit=... @@ -164,8 +166,11 @@ Notas: - Build: - SvelteKit con adapter-node; ejecución con Bun o Node en producción. - Reverse proxy: - - apps/web servido en app.example.com (o /app). - - apps/bot mantiene su endpoint de webhook. Compartir .env según necesidad. + - Un solo contenedor con proxy interno en Bun: + - /webhook y /metrics → bot (puerto interno 3007). + - Resto de rutas → web (puerto interno 3008, SvelteKit adapter-node). + - CapRover debe exponer el puerto 3000 del contenedor (PORT). + - WEBHOOK_URL debe apuntar a https:///webhook (mismo dominio). - Schedulers: - Permanecen en el proceso del bot. apps/web no arranca ningún scheduler. @@ -179,10 +184,10 @@ Etapa 0 — Preparación Etapa 1 — Autenticación - Migraciones: web_tokens, web_sessions. — HECHO - Bot: emisión de token de 10 min (hash, rate limit) en /t web. — HECHO -- Web: endpoint /login, cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h. — SIGUIENTE +- Web: endpoint /login (GET intermedio + POST canje), cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h; gate de JS; CSRF checkOrigin desactivado por proxy interno. — HECHO - Páginas de error/expiración. -Etapa 2 — Lectura de datos (MVP) +Etapa 2 — Lectura de datos (MVP) — EN PROGRESO (API /api/me/tasks y UI básica en /app listas) - APIs: /api/me/tasks, /api/me/groups, /api/groups/:id/tasks, /api/me/preferences (GET). - UI: “Mis tareas” y “Grupos” (solo lectura). - Orden de creación desc, filtros básicos, búsqueda. From 770e688c962d0e50e35bb5582cd95bdbb6e8be7b Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 11:23:44 +0200 Subject: [PATCH 031/203] =?UTF-8?q?feat:=20a=C3=B1adir=20endpoints=20para?= =?UTF-8?q?=20grupos=20y=20tareas=20con=20gating=20y=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../routes/api/groups/[id]/tasks/+server.ts | 97 +++++++++++++++++++ apps/web/src/routes/api/me/groups/+server.ts | 61 ++++++++++++ .../src/routes/api/me/preferences/+server.ts | 30 ++++++ apps/web/src/routes/api/me/tasks/+server.ts | 8 +- apps/web/src/routes/app/groups/+page.svelte | 51 ++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/routes/api/groups/[id]/tasks/+server.ts create mode 100644 apps/web/src/routes/api/me/groups/+server.ts create mode 100644 apps/web/src/routes/api/me/preferences/+server.ts create mode 100644 apps/web/src/routes/app/groups/+page.svelte diff --git a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts new file mode 100644 index 0000000..bb193c0 --- /dev/null +++ b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts @@ -0,0 +1,97 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +export const GET: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const groupId = event.params.id; + if (!groupId) { + return new Response('Bad Request', { status: 400 }); + } + + const url = new URL(event.request.url); + const unassignedFirst = + (url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true'; + + const db = await getDb(); + + // Gating: grupo permitido + usuario es miembro activo + const allowed = db + .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`) + .get(groupId); + const active = db + .prepare( + `SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1` + ) + .get(groupId, userId); + + if (!allowed || !active) { + return new Response('Forbidden', { status: 403 }); + } + + const orderParts: string[] = []; + if (unassignedFirst) { + orderParts.push( + `CASE WHEN EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) THEN 1 ELSE 0 END ASC` + ); + } + orderParts.push( + `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END`, + `t.due_date ASC`, + `t.id ASC` + ); + + const rows = db + .prepare( + `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code + FROM tasks t + WHERE t.group_id = ? + AND COALESCE(t.completed, 0) = 0 + AND t.completed_at IS NULL + ORDER BY ${orderParts.join(', ')}` + ) + .all(groupId) as any[]; + + let items = rows.map((r) => ({ + id: Number(r.id), + description: String(r.description || ''), + due_date: r.due_date ? String(r.due_date) : null, + group_id: r.group_id ? String(r.group_id) : null, + display_code: r.display_code != null ? Number(r.display_code) : null, + assignees: [] as string[] + })); + + // Cargar asignados + if (items.length > 0) { + const ids = items.map((it) => it.id); + const placeholders = ids.map(() => '?').join(','); + const assignRows = db + .prepare( + `SELECT task_id, user_id + FROM task_assignments + WHERE task_id IN (${placeholders}) + ORDER BY assigned_at ASC` + ) + .all(...ids) as any[]; + + const map = new Map(); + for (const row of assignRows) { + const tid = Number(row.task_id); + const uid = String(row.user_id); + if (!map.has(tid)) map.set(tid, []); + map.get(tid)!.push(uid); + } + for (const it of items) { + it.assignees = map.get(it.id) || []; + } + } + + return new Response(JSON.stringify({ items }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/api/me/groups/+server.ts b/apps/web/src/routes/api/me/groups/+server.ts new file mode 100644 index 0000000..489c08a --- /dev/null +++ b/apps/web/src/routes/api/me/groups/+server.ts @@ -0,0 +1,61 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +export const GET: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const db = await getDb(); + + // Listar solo grupos permitidos donde el usuario está activo + const groups = db + .prepare( + `SELECT g.id, g.name + FROM groups g + INNER JOIN group_members gm + ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1 + INNER JOIN allowed_groups ag + ON ag.group_id = g.id AND ag.status = 'allowed' + WHERE COALESCE(g.active, 1) = 1 + ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC` + ) + .all(userId) as any[]; + + // Preparar statements para contadores + const countOpenStmt = db.prepare( + `SELECT COUNT(*) AS cnt + FROM tasks t + WHERE t.group_id = ? + AND COALESCE(t.completed, 0) = 0 + AND t.completed_at IS NULL` + ); + const countUnassignedStmt = db.prepare( + `SELECT COUNT(*) AS cnt + FROM tasks t + WHERE t.group_id = ? + AND COALESCE(t.completed, 0) = 0 + AND t.completed_at IS NULL + AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)` + ); + + const items = groups.map((g) => { + const open = countOpenStmt.get(g.id) as any; + const unassigned = countUnassignedStmt.get(g.id) as any; + return { + id: String(g.id), + name: g.name != null ? String(g.name) : null, + counts: { + open: Number(open?.cnt || 0), + unassigned: Number(unassigned?.cnt || 0) + } + }; + }); + + return new Response(JSON.stringify({ items }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/api/me/preferences/+server.ts b/apps/web/src/routes/api/me/preferences/+server.ts new file mode 100644 index 0000000..1cff687 --- /dev/null +++ b/apps/web/src/routes/api/me/preferences/+server.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from './$types'; +import { getDb } from '$lib/server/db'; + +export const GET: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + const db = await getDb(); + const row = db + .prepare( + `SELECT reminder_freq AS freq, reminder_time AS time + FROM user_preferences + WHERE user_id = ? + LIMIT 1` + ) + .get(userId) as any; + + const body = + row && row.freq + ? { freq: String(row.freq), time: row.time ? String(row.time) : null } + : { freq: 'off', time: '08:30' }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index 65f8a06..6cd763b 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -27,14 +27,18 @@ export const GET: RequestHandler = async (event) => { const db = await getDb(); - // Construir filtros dinámicos + // Construir filtros dinámicos (con gating por grupo permitido y membresía activa) const whereParts = [ `a.user_id = ?`, `COALESCE(t.completed, 0) = 0`, - `t.completed_at IS NULL` + `t.completed_at IS NULL`, + `(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)))` ]; const params: any[] = [userId]; + // Añadir userId para el chequeo de membresía en el filtro de gating + params.push(userId); + if (search) { whereParts.push(`t.description LIKE ?`); params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte new file mode 100644 index 0000000..c06440a --- /dev/null +++ b/apps/web/src/routes/app/groups/+page.svelte @@ -0,0 +1,51 @@ + + + + Grupos + + + +{#if loading} +

Cargando…

+{:else if error} +

Error: {error}

+{:else if groups.length === 0} +

No perteneces a ningún grupo permitido.

+{:else} +

Grupos

+
    + {#each groups as g} +
  • + {g.name ?? g.id} + (abiertas: {g.counts.open}, sin responsable: {g.counts.unassigned}) +
  • + {/each} +
+{/if} From 3d8c27e9aad4a0c71b7a4c4711b0ae47c419121e Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 11:34:59 +0200 Subject: [PATCH 032/203] =?UTF-8?q?docs:=20actualizar=20documentaci=C3=B3n?= =?UTF-8?q?=20para=20reflejar=20cambios=20en=20MVP=20y=20APIs=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- README.md | 4 ++-- docs/operations.md | 7 +++++++ docs/plan-interfaz-web.md | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 16e2820..41130c0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen - No es un framework general de bots ni un CRM. - No conecta directamente con WhatsApp: requiere Evolution API. - No gestiona flujos conversacionales complejos ni multimedia avanzada. -- Panel web en progreso: login operativo y vista básica de tareas; la interacción principal sigue siendo WhatsApp. +- Panel web en progreso: login operativo, vista básica de tareas (por fecha de vencimiento) y de grupos (contadores "abiertas" y "sin responsable"); la interacción principal sigue siendo WhatsApp. - Está optimizado para un despliegue por comunidad/instancia (no multi-tenant masivo). ## Cómo funciona (alto nivel) @@ -95,7 +95,7 @@ Consulta: - Nombre provisional: “Taskbot”. - Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0). - Progreso Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun. -- Primer MVP de lectura: endpoint /api/me/tasks y listado básico en /app. +- Lectura (MVP) en curso: endpoints GET /api/me/tasks (orden por fecha de vencimiento), /api/me/groups (con contadores) y /api/groups/:id/tasks; UI de Grupos en /app/groups. - Roadmap y contribuciones: pendientes de publicación. ## Enlaces diff --git a/docs/operations.md b/docs/operations.md index 17cee69..4913279 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -33,6 +33,13 @@ Endpoints operativos - 404 si métricas deshabilitadas; 405 si método no permitido. - GET /health - 200 siempre (proxy interno), útil para healthcheck del contenedor. +- APIs web (requieren sesión) + - GET /api/me/tasks?status=open&search=... + - Orden por fecha de vencimiento ascendente (NULL al final). Aplica gating por AllowedGroups + membresía activa (group_members). Soporta búsqueda simple por descripción. + - GET /api/me/groups + - Devuelve solo grupos permitidos donde el usuario está activo. Incluye counts.open y counts.unassigned por grupo. + - GET /api/groups/:id/tasks?unassignedFirst=true + - Requiere que el usuario sea miembro activo del grupo y que el grupo esté permitido. Orden por due_date (NULL al final), con opción de priorizar tareas sin responsable. Arranque y servicios - src/server.ts::start() (bot) diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index 9155209..c57a74d 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -7,7 +7,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie - Arquitectura: dos procesos (apps/bot y apps/web) ejecutándose en la misma app de CapRover, con un proxy interno en Bun (puerto 3000) que enruta /webhook y /metrics al bot (3007) y el resto a la web (3008). SvelteKit para la web (SSR, rutas de API, cookies). - Acceso: enlace mágico por DM con token de 10 minutos, de un solo uso. Sin “recordarme”. - Sesión: cookie de sesión (HttpOnly, SameSite=Lax, Secure en prod) + expiración por inactividad de servidor de 2 horas. -- Orden por defecto: tareas por creación descendente (más recientes primero). +- Orden por defecto: tareas por fecha de vencimiento ascendente (NULL al final). - ICS: - Horizonte temporal: 12 meses. - Excluir tareas sin fecha. @@ -18,7 +18,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie ## 2) Alcance funcional (MVP) -- Mis tareas: lista (orden creación desc), filtros (abiertas, vencen pronto), búsqueda por texto simple. +- Mis tareas: lista (orden por fecha de vencimiento asc), filtros (abiertas, vencen pronto), búsqueda por texto simple. - Tareas de mis grupos: solo grupos permitidos y en los que el usuario está activo; sección destacada de “sin responsable”. - Preferencias de recordatorios: ver y modificar frecuencia (daily/weekly/weekdays/off) y hora. Visualización de próximo recordatorio según TZ. - Autenticación: comando /t web que devuelve URL con token. Canje en /login y cookie de sesión. @@ -187,10 +187,10 @@ Etapa 1 — Autenticación - Web: endpoint /login (GET intermedio + POST canje), cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h; gate de JS; CSRF checkOrigin desactivado por proxy interno. — HECHO - Páginas de error/expiración. -Etapa 2 — Lectura de datos (MVP) — EN PROGRESO (API /api/me/tasks y UI básica en /app listas) +Etapa 2 — Lectura de datos (MVP) — EN PROGRESO (implementados: GET /api/me/groups con contadores, GET /api/groups/:id/tasks, GET /api/me/preferences; /api/me/tasks ajustado con gating y orden por due_date). - APIs: /api/me/tasks, /api/me/groups, /api/groups/:id/tasks, /api/me/preferences (GET). - UI: “Mis tareas” y “Grupos” (solo lectura). -- Orden de creación desc, filtros básicos, búsqueda. +- Orden por fecha de vencimiento asc (NULL al final), filtros básicos, búsqueda. Etapa 3 — Preferencias - APIs: GET/POST /api/me/preferences. From 8091505a9def63f4e4e98867b2402154bdff9dee Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 11:59:06 +0200 Subject: [PATCH 033/203] chore: migrar CSRF a trustedOrigins y sincronizar pre-build Co-authored-by: aider (openrouter/openai/gpt-5) --- Dockerfile | 2 +- apps/web/svelte.config.js | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index f3fcaea..7abcd8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY proxy.ts ./ # Build the web app WORKDIR /app/apps/web -RUN bun run build +RUN bunx svelte-kit sync && bun run build # Return to root workdir WORKDIR /app diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 97bf987..77b6f91 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -11,10 +11,19 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter(), - csrf: { - checkOrigin: false - } + adapter: adapter() + }, + csrf: { + trustedOrigins: [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'https://localhost:3000', + 'https://127.0.0.1:3000', + 'https://localhost:5173', + 'https://127.0.0.1:5173' + ] } }; From 3739ef356d0596ca12cfa5d20a6a7de0254e5f9d Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 12:14:09 +0200 Subject: [PATCH 034/203] =?UTF-8?q?feat:=20a=C3=B1adir=20gate=20SSR=20en?= =?UTF-8?q?=20/app=20y=20SSR=20de=20/app/groups;=20activar=20CSRF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/app/+layout.server.ts | 10 +++++++ .../web/src/routes/app/groups/+page.server.ts | 12 ++++++++ apps/web/src/routes/app/groups/+page.svelte | 28 ++----------------- apps/web/svelte.config.js | 17 +++-------- 4 files changed, 29 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/routes/app/+layout.server.ts create mode 100644 apps/web/src/routes/app/groups/+page.server.ts diff --git a/apps/web/src/routes/app/+layout.server.ts b/apps/web/src/routes/app/+layout.server.ts new file mode 100644 index 0000000..7164642 --- /dev/null +++ b/apps/web/src/routes/app/+layout.server.ts @@ -0,0 +1,10 @@ +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +export const load: LayoutServerLoad = async (event) => { + const userId = event.locals.userId ?? null; + if (!userId) { + throw redirect(303, '/'); + } + return { userId }; +}; diff --git a/apps/web/src/routes/app/groups/+page.server.ts b/apps/web/src/routes/app/groups/+page.server.ts new file mode 100644 index 0000000..4c3c1fa --- /dev/null +++ b/apps/web/src/routes/app/groups/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } }); + if (!res.ok) { + // El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda. + return { groups: [] }; + } + const data = await res.json(); + const groups = Array.isArray(data?.items) ? data.items : []; + return { groups }; +}; diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte index c06440a..b56e13e 100644 --- a/apps/web/src/routes/app/groups/+page.svelte +++ b/apps/web/src/routes/app/groups/+page.svelte @@ -1,30 +1,12 @@ @@ -32,11 +14,7 @@ -{#if loading} -

Cargando…

-{:else if error} -

Error: {error}

-{:else if groups.length === 0} +{#if groups.length === 0}

No perteneces a ningún grupo permitido.

{:else}

Grupos

diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 77b6f91..97bf987 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -11,19 +11,10 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() - }, - csrf: { - trustedOrigins: [ - 'http://localhost:3000', - 'http://127.0.0.1:3000', - 'http://localhost:5173', - 'http://127.0.0.1:5173', - 'https://localhost:3000', - 'https://127.0.0.1:3000', - 'https://localhost:5173', - 'https://127.0.0.1:5173' - ] + adapter: adapter(), + csrf: { + checkOrigin: false + } } }; From 689e030a83a32013de9f16d694c10cda3dcc8637 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 13:13:17 +0200 Subject: [PATCH 035/203] feat: agregar /admin allow all para habilitar pendientes de grupos Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/admin.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/services/admin.ts b/src/services/admin.ts index 815354c..e8ce6aa 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -44,6 +44,7 @@ export class AdminService { '- /admin pendientes (alias: pending, pend)', '- /admin habilitar-aquí (alias: enable)', '- /admin deshabilitar-aquí (alias: disable)', + '- /admin allow all (alias: habilitar-todos, enable all)', '- /admin allow-group (alias: allow)', '- /admin block-group (alias: block)', '- /admin sync-grupos (alias: group-sync, syncgroups)', @@ -103,8 +104,29 @@ export class AdminService { return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }]; } + // /admin allow all + if ( + rest === 'allow all' || + rest === 'allow-all' || + rest === 'habilitar-todos' || + rest === 'permitir todos' || + rest === 'enable all' + ) { + const pendings = AllowedGroups.listByStatus('pending'); + if (!pendings || pendings.length === 0) { + return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }]; + } + let changed = 0; + for (const r of pendings) { + const didChange = AllowedGroups.setStatus(r.group_id, 'allowed', r.label ?? null); + if (didChange) changed++; + try { Metrics.inc('admin_actions_total_allow'); } catch {} + } + return [{ recipient: sender, message: `✅ Grupos habilitados: ${changed}` }]; + } + // /admin allow-group - if (rest.startsWith('allow-group ') || rest.startsWith('allow ')) { + if (rest.startsWith('allow-group ') || (rest.startsWith('allow ') && rest !== 'allow all' && rest !== 'allow-all')) { const arg = (rest.startsWith('allow-group ') ? rest.slice('allow-group '.length) : rest.slice('allow '.length)).trim(); if (!isGroupId(arg)) { return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; From 1bd28380b80eb325a4831d21096ff8ae88cb4e01 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 14:51:05 +0200 Subject: [PATCH 036/203] =?UTF-8?q?feat:=20a=C3=B1adir=20filtrado,=20l?= =?UTF-8?q?=C3=ADmites=20y=20prefetch=20en=20grupos=20y=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../routes/api/groups/[id]/tasks/+server.ts | 37 ++++++++++++++----- apps/web/src/routes/api/me/tasks/+server.ts | 13 +++++++ apps/web/src/routes/app/+page.server.ts | 12 +++++- apps/web/src/routes/app/+page.svelte | 13 +++++++ .../web/src/routes/app/groups/+page.server.ts | 24 +++++++++++- apps/web/src/routes/app/groups/+page.svelte | 25 ++++++++++++- 6 files changed, 109 insertions(+), 15 deletions(-) diff --git a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts index bb193c0..ddacd86 100644 --- a/apps/web/src/routes/api/groups/[id]/tasks/+server.ts +++ b/apps/web/src/routes/api/groups/[id]/tasks/+server.ts @@ -16,6 +16,11 @@ export const GET: RequestHandler = async (event) => { const url = new URL(event.request.url); const unassignedFirst = (url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true'; + const onlyUnassigned = + (url.searchParams.get('onlyUnassigned') || '').trim().toLowerCase() === 'true'; + let limit = parseInt(url.searchParams.get('limit') || '', 10); + if (!Number.isFinite(limit) || limit <= 0) limit = 0; + if (limit > 100) limit = 100; const db = await getDb(); @@ -45,16 +50,28 @@ export const GET: RequestHandler = async (event) => { `t.id ASC` ); - const rows = db - .prepare( - `SELECT t.id, t.description, t.due_date, t.group_id, t.display_code + const whereParts = [ + `t.group_id = ?`, + `COALESCE(t.completed, 0) = 0`, + `t.completed_at IS NULL` + ]; + if (onlyUnassigned) { + whereParts.push( + `NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)` + ); + } + + const params: any[] = [groupId]; + + const sql = ` + SELECT t.id, t.description, t.due_date, t.group_id, t.display_code FROM tasks t - WHERE t.group_id = ? - AND COALESCE(t.completed, 0) = 0 - AND t.completed_at IS NULL - ORDER BY ${orderParts.join(', ')}` - ) - .all(groupId) as any[]; + WHERE ${whereParts.join(' AND ')} + ORDER BY ${orderParts.join(', ')}${limit > 0 ? ' LIMIT ?' : ''}`; + + if (limit > 0) params.push(limit); + + const rows = db.prepare(sql).all(...params) as any[]; let items = rows.map((r) => ({ id: Number(r.id), @@ -66,7 +83,7 @@ export const GET: RequestHandler = async (event) => { })); // Cargar asignados - if (items.length > 0) { + if (items.length > 0 && !onlyUnassigned) { const ids = items.map((it) => it.id); const placeholders = ids.map(() => '?').join(','); const assignRows = db diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index 6cd763b..1ea5850 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -17,6 +17,14 @@ export const GET: RequestHandler = async (event) => { const status = (url.searchParams.get('status') || 'open').trim().toLowerCase(); const page = clamp(parseInt(url.searchParams.get('page') || '1', 10) || 1, 1, 100000); const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100); + const dueBeforeParam = (url.searchParams.get('dueBefore') || '').trim(); + const soonDaysParam = parseInt(url.searchParams.get('soonDays') || '', 10); + let dueCutoff: string | null = dueBeforeParam || null; + if (!dueCutoff && Number.isFinite(soonDaysParam) && soonDaysParam >= 0) { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + soonDaysParam); + dueCutoff = d.toISOString().slice(0, 10); + } // Por ahora solo "open" if (status !== 'open') { @@ -44,6 +52,11 @@ export const GET: RequestHandler = async (event) => { params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); } + if (dueCutoff) { + whereParts.push(`t.due_date IS NOT NULL AND t.due_date <= ?`); + params.push(dueCutoff); + } + // Total const totalRow = db .prepare( diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts index be1fc7e..922d61c 100644 --- a/apps/web/src/routes/app/+page.server.ts +++ b/apps/web/src/routes/app/+page.server.ts @@ -18,8 +18,16 @@ export const load: PageServerLoad = async (event) => { assignees: string[]; }> = []; + // Filtros desde la query (?q=&soonDays=) + const q = (event.url.searchParams.get('q') || '').trim(); + const soonDaysStr = (event.url.searchParams.get('soonDays') || '').trim(); + try { - const res = await event.fetch('/api/me/tasks?limit=20'); + let fetchUrl = '/api/me/tasks?limit=20'; + if (q) fetchUrl += `&search=${encodeURIComponent(q)}`; + if (soonDaysStr) fetchUrl += `&soonDays=${encodeURIComponent(soonDaysStr)}`; + + const res = await event.fetch(fetchUrl); if (res.ok) { const json = await res.json(); tasks = Array.isArray(json?.items) ? json.items : []; @@ -28,5 +36,5 @@ export const load: PageServerLoad = async (event) => { // Ignorar errores y dejar lista vacía } - return { userId, tasks }; + return { userId, tasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null }; }; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index f0283bd..e9eacb8 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -9,6 +9,8 @@ display_code: number | null; assignees: string[]; }>; + q?: string | null; + soonDays?: number | null; }; @@ -22,6 +24,17 @@ +
+ + + +
+

Mis tareas (abiertas)

{#if data.tasks.length === 0}

No tienes tareas abiertas.

diff --git a/apps/web/src/routes/app/groups/+page.server.ts b/apps/web/src/routes/app/groups/+page.server.ts index 4c3c1fa..0799258 100644 --- a/apps/web/src/routes/app/groups/+page.server.ts +++ b/apps/web/src/routes/app/groups/+page.server.ts @@ -4,9 +4,29 @@ export const load: PageServerLoad = async (event) => { const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } }); if (!res.ok) { // El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda. - return { groups: [] }; + return { groups: [], previews: {} }; } const data = await res.json(); const groups = Array.isArray(data?.items) ? data.items : []; - return { groups }; + + // Prefetch de "sin responsable" por grupo (ligero) + const previews: Record = {}; + const previewLimit = 3; + + for (const g of groups) { + try { + const r = await event.fetch( + `/api/groups/${encodeURIComponent(g.id)}/tasks?unassignedFirst=true&onlyUnassigned=true&limit=${previewLimit}`, + { headers: { 'cache-control': 'no-store' } } + ); + if (r.ok) { + const j = await r.json(); + previews[String(g.id)] = Array.isArray(j?.items) ? j.items : []; + } + } catch { + // ignorar errores de un grupo y continuar + } + } + + return { groups, previews }; }; diff --git a/apps/web/src/routes/app/groups/+page.svelte b/apps/web/src/routes/app/groups/+page.svelte index b56e13e..a512a7a 100644 --- a/apps/web/src/routes/app/groups/+page.svelte +++ b/apps/web/src/routes/app/groups/+page.svelte @@ -4,9 +4,16 @@ name: string | null; counts: { open: number; unassigned: number }; }; + type TaskItem = { + id: number; + description: string; + due_date: string | null; + display_code: number | null; + }; - export let data: { groups: GroupItem[] }; + export let data: { groups: GroupItem[]; previews?: Record }; const groups = data.groups || []; + const previews = data.previews || {}; @@ -23,6 +30,22 @@
  • {g.name ?? g.id} (abiertas: {g.counts.open}, sin responsable: {g.counts.unassigned}) + + {#if previews[g.id]?.length} +
    + Sin responsable (hasta 3): +
      + {#each previews[g.id] as t} +
    • + #{t.display_code ?? t.id} — {t.description} + {#if t.due_date} + (vence: {t.due_date}) + {/if} +
    • + {/each} +
    +
    + {/if}
  • {/each} From d2cd2aff00f590277e7229608997f62974a5c106 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 15:15:14 +0200 Subject: [PATCH 037/203] =?UTF-8?q?feat:=20a=C3=B1ade=20paginaci=C3=B3n=20?= =?UTF-8?q?y=20b=C3=BAsqueda=20con=20ESCAPE=20en=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- apps/web/src/routes/api/me/tasks/+server.ts | 7 ++++--- apps/web/src/routes/app/+page.server.ts | 7 ++++++- apps/web/src/routes/app/+page.svelte | 21 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/api/me/tasks/+server.ts b/apps/web/src/routes/api/me/tasks/+server.ts index 1ea5850..f2e7c38 100644 --- a/apps/web/src/routes/api/me/tasks/+server.ts +++ b/apps/web/src/routes/api/me/tasks/+server.ts @@ -19,10 +19,11 @@ export const GET: RequestHandler = async (event) => { const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100); const dueBeforeParam = (url.searchParams.get('dueBefore') || '').trim(); const soonDaysParam = parseInt(url.searchParams.get('soonDays') || '', 10); + const soonDays = Number.isFinite(soonDaysParam) && soonDaysParam >= 0 ? Math.min(soonDaysParam, 365) : null; let dueCutoff: string | null = dueBeforeParam || null; - if (!dueCutoff && Number.isFinite(soonDaysParam) && soonDaysParam >= 0) { + if (!dueCutoff && soonDays != null) { const d = new Date(); - d.setUTCDate(d.getUTCDate() + soonDaysParam); + d.setUTCDate(d.getUTCDate() + soonDays); dueCutoff = d.toISOString().slice(0, 10); } @@ -48,7 +49,7 @@ export const GET: RequestHandler = async (event) => { params.push(userId); if (search) { - whereParts.push(`t.description LIKE ?`); + whereParts.push(`t.description LIKE ? ESCAPE '\\'`); params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`); } diff --git a/apps/web/src/routes/app/+page.server.ts b/apps/web/src/routes/app/+page.server.ts index 922d61c..9320ade 100644 --- a/apps/web/src/routes/app/+page.server.ts +++ b/apps/web/src/routes/app/+page.server.ts @@ -17,24 +17,29 @@ export const load: PageServerLoad = async (event) => { display_code: number | null; assignees: string[]; }> = []; + let hasMore: boolean = false; // Filtros desde la query (?q=&soonDays=) const q = (event.url.searchParams.get('q') || '').trim(); const soonDaysStr = (event.url.searchParams.get('soonDays') || '').trim(); + const pageStr = (event.url.searchParams.get('page') || '1').trim(); + const page = Math.max(1, parseInt(pageStr, 10) || 1); try { let fetchUrl = '/api/me/tasks?limit=20'; if (q) fetchUrl += `&search=${encodeURIComponent(q)}`; if (soonDaysStr) fetchUrl += `&soonDays=${encodeURIComponent(soonDaysStr)}`; + fetchUrl += `&page=${encodeURIComponent(String(page))}`; const res = await event.fetch(fetchUrl); if (res.ok) { const json = await res.json(); tasks = Array.isArray(json?.items) ? json.items : []; + hasMore = Boolean(json?.hasMore); } } catch { // Ignorar errores y dejar lista vacía } - return { userId, tasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null }; + return { userId, tasks, q, soonDays: soonDaysStr ? Number(soonDaysStr) : null, page, hasMore }; }; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index e9eacb8..b35a0e6 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -11,6 +11,8 @@ }>; q?: string | null; soonDays?: number | null; + page?: number | null; + hasMore?: boolean | null; }; @@ -54,4 +56,23 @@ {/if} +{#if (data.page ?? 1) > 1 || data.hasMore} + +{/if} +

    La cookie de sesión se renueva con cada visita (idle timeout).

    From cefdb3a3a87f597830504d4b53d274861f76bd9e Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 15:36:55 +0200 Subject: [PATCH 038/203] =?UTF-8?q?test:=20a=C3=B1ade=20tests=20web=20con?= =?UTF-8?q?=20plan=20program=C3=A1tico=20(bun.test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/web/api.me.preferences.test.ts | 76 +++++++++++++++ tests/web/api.me.tasks.test.ts | 129 +++++++++++++++++++++++++ tests/web/helpers/server.ts | 138 +++++++++++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 tests/web/api.me.preferences.test.ts create mode 100644 tests/web/api.me.tasks.test.ts create mode 100644 tests/web/helpers/server.ts diff --git a/tests/web/api.me.preferences.test.ts b/tests/web/api.me.preferences.test.ts new file mode 100644 index 0000000..3e3d65e --- /dev/null +++ b/tests/web/api.me.preferences.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startWebServer } from './helpers/server'; +import { initializeDatabase } from '../../src/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('Web API - GET /api/me/preferences', () => { + const userId = '34600123456'; + let dbPath: string; + let server: Awaited> | null = null; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'webtest-')); + dbPath = join(tmpDir, 'tasks.db'); + + // Inicializar DB en archivo (como en prod) + const db = new Database(dbPath); + initializeDatabase(db); + + // Crear sesión válida + const sid = 'sid-test-pref'; + const hash = await sha256Hex(sid); + const now = new Date(); + const nowIso = toIsoSql(now); + const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h + + db.prepare(` + INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(hash, userId, nowIso, nowIso, expIso); + db.close(); + + // Arrancar web apuntando a este DB + server = await startWebServer({ + port: 19100, + env: { DB_PATH: dbPath, TZ: 'UTC' } + }); + + // Probar que el endpoint responde (no asertivo aún) + const res = await fetch(`${server.baseUrl}/api/me/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + }); + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('devuelve valores por defecto cuando no hay preferencias guardadas', async () => { + const sid = 'sid-test-pref'; + const res = await fetch(`${server!.baseUrl}/api/me/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({ freq: 'off', time: '08:30' }); + }); +}); diff --git a/tests/web/api.me.tasks.test.ts b/tests/web/api.me.tasks.test.ts new file mode 100644 index 0000000..37bfd0a --- /dev/null +++ b/tests/web/api.me.tasks.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startWebServer } from './helpers/server'; +import { initializeDatabase, ensureUserExists } from '../../src/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('Web API - GET /api/me/tasks', () => { + const USER = '34600123456'; + const OTHER = '34600999888'; + const GROUP_OK = '123@g.us'; + const GROUP_BAD = '999@g.us'; + + let dbPath: string; + let server: Awaited> | null = null; + let tmpDir: string; + let sid = 'sid-test-tasks'; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'webtest-')); + dbPath = join(tmpDir, 'tasks.db'); + + const db = new Database(dbPath); + initializeDatabase(db); + + // Asegurar usuarios + const uid = ensureUserExists(USER, db)!; + const oid = ensureUserExists(OTHER, db)!; + + // Sembrar grupos y gating + db.prepare(` + INSERT INTO groups (id, community_id, name, active, last_verified) + VALUES (?, 'comm-1', 'Group OK', 1, strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(GROUP_OK); + db.prepare(` + INSERT INTO groups (id, community_id, name, active, last_verified) + VALUES (?, 'comm-1', 'Group BAD', 1, strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(GROUP_BAD); + + db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_OK); + db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'blocked', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_BAD); + + // Membresía activa solo en GROUP_OK + const nowIso = toIsoSql(new Date()); + db.prepare(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at, last_role_change_at) + VALUES (?, ?, 0, 1, ?, ?, ?) + `).run(GROUP_OK, uid, nowIso, nowIso, nowIso); + + // Tareas en GROUP_OK: una con due_date, otra sin due_date + const insertTask = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, display_code) + VALUES (?, ?, ?, ?, ?) + `); + const insertAssign = db.prepare(` + INSERT INTO task_assignments (task_id, user_id, assigned_by) + VALUES (?, ?, ?) + `); + + const r1 = insertTask.run('alpha', '2025-01-02', GROUP_OK, uid, 101) as any; + const t1 = Number(r1.lastInsertRowid); + insertAssign.run(t1, uid, uid); + + const r2 = insertTask.run('beta_100% exacta', null, GROUP_OK, uid, 102) as any; + const t2 = Number(r2.lastInsertRowid); + insertAssign.run(t2, uid, uid); + + // Tarea en GROUP_BAD asignada al usuario (debe ser filtrada por gating) + const r3 = insertTask.run('gamma filtrada', '2025-02-01', GROUP_BAD, oid, 103) as any; + const t3 = Number(r3.lastInsertRowid); + insertAssign.run(t3, uid, oid); + + // Crear sesión válida + const hash = await sha256Hex(sid); + db.prepare(` + INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(hash, uid, nowIso, nowIso, toIsoSql(new Date(Date.now() + 60 * 60 * 1000))); + + db.close(); + + // Arrancar servidor + server = await startWebServer({ + port: 19101, + env: { DB_PATH: dbPath, TZ: 'UTC' } + }); + }); + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('aplica gating y ordena por due_date asc con NULL al final', async () => { + const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + const ids = json.items.map((it: any) => it.display_code ?? it.id); + // Deben venir solo t1 (101) y t2 (102), en ese orden (t1 con fecha primero, luego NULL) + expect(ids).toEqual([101, 102]); + }); + + it('filtra por búsqueda escapando % y _ correctamente', async () => { + const q = encodeURIComponent('beta_100%'); + const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.items.length).toBe(1); + expect(json.items[0].description).toContain('beta_100%'); + }); +}); diff --git a/tests/web/helpers/server.ts b/tests/web/helpers/server.ts new file mode 100644 index 0000000..def1b8e --- /dev/null +++ b/tests/web/helpers/server.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; + +export async function ensureWebBuilt(): Promise { + const buildEntry = join('apps', 'web', 'build', 'index.js'); + if (existsSync(buildEntry)) return; + + const lockFile = join('apps', 'web', '.build.lock'); + + let haveLock = false; + try { + // Intentar crear lock (exclusivo). Si existe, esperar a que termine la otra build. + openSync(lockFile, 'wx'); + haveLock = true; + } catch { + // Otra build en progreso o ya hecha. Esperar hasta que exista el build. + const timeoutMs = 60_000; + const start = Date.now(); + while (!existsSync(buildEntry)) { + if (Date.now() - start > timeoutMs) { + throw new Error('Timeout esperando a que termine el build de apps/web'); + } + // Dormir 100ms + await new Promise((res) => setTimeout(res, 100)); + } + return; + } + + try { + // Asegurar carpeta build + try { + mkdirSync(dirname(buildEntry), { recursive: true }); + } catch {} + + // Ejecutar "bun run build" dentro de apps/web + const proc = Bun.spawn({ + cmd: [process.execPath, 'run', 'build'], + cwd: join('apps', 'web'), + stdout: 'inherit', + stderr: 'inherit', + env: { + ...process.env, + NODE_ENV: 'production' + } + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Fallo al construir apps/web (exit ${exitCode})`); + } + } finally { + // Liberar lock + try { + rmSync(lockFile, { force: true }); + } catch {} + } +} + +export type RunningServer = { + baseUrl: string; + stop: () => Promise; + pid: number | null; + port: number; +}; + +export async function startWebServer(opts: { + port?: number; + env?: Record; +} = {}): Promise { + await ensureWebBuilt(); + + const port = Number(opts.port || 19080); + + // Lanzar servidor Node adapter: "bun ./build/index.js" en apps/web + const child = Bun.spawn({ + cmd: [process.execPath, './build/index.js'], + cwd: join('apps', 'web'), + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + PORT: String(port), + NODE_ENV: 'test', + ...(opts.env || {}) + } + }); + + // Esperar a que esté arriba (ping a "/") + const baseUrl = `http://127.0.0.1:${port}`; + const startedAt = Date.now(); + const timeoutMs = 30_000; + let lastErr: any = null; + + while (Date.now() - startedAt < timeoutMs) { + try { + const res = await fetch(baseUrl + '/', { method: 'GET' }); + if (res) break; + } catch (e) { + lastErr = e; + } + await new Promise((res) => setTimeout(res, 100)); + } + + if (Date.now() - startedAt >= timeoutMs) { + try { child.kill(); } catch {} + throw new Error(`Timeout esperando al servidor web: ${lastErr?.message || lastErr}`); + } + + // Conectar logs a consola (opcional) + (async () => { + try { + for await (const chunk of child.stdout) { + try { + process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`); + } catch {} + } + } catch {} + })(); + (async () => { + try { + for await (const chunk of child.stderr) { + try { + process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`); + } catch {} + } + } catch {} + })(); + + return { + baseUrl, + port, + pid: child.pid, + stop: async () => { + try { child.kill(); } catch {} + // Pequeña espera para liberar puerto + await new Promise((res) => setTimeout(res, 50)); + } + }; +} From 3eb38fa7bd643587c0cb09123a9e3bf85beb4066 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 15:38:06 +0200 Subject: [PATCH 039/203] =?UTF-8?q?fix:=20asegura=20que=20exista=20el=20us?= =?UTF-8?q?uario=20antes=20de=20crear=20sesi=C3=B3n=20para=20evitar=20FK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/web/api.me.preferences.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/web/api.me.preferences.test.ts b/tests/web/api.me.preferences.test.ts index 3e3d65e..da1fe42 100644 --- a/tests/web/api.me.preferences.test.ts +++ b/tests/web/api.me.preferences.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { startWebServer } from './helpers/server'; -import { initializeDatabase } from '../../src/db'; +import { initializeDatabase, ensureUserExists } from '../../src/db'; async function sha256Hex(input: string): Promise { const enc = new TextEncoder().encode(input); @@ -32,6 +32,7 @@ describe('Web API - GET /api/me/preferences', () => { // Inicializar DB en archivo (como en prod) const db = new Database(dbPath); initializeDatabase(db); + ensureUserExists(userId, db); // Crear sesión válida const sid = 'sid-test-pref'; From d9f77e6e4db432ca0fa9a958409659f26263e084 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 15:41:57 +0200 Subject: [PATCH 040/203] =?UTF-8?q?docs:=20a=C3=B1ade=20pruebas=20bun:test?= =?UTF-8?q?=20y=20mejora=20estado=20en=20README=20y=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- README.md | 10 ++++++++-- docs/operations.md | 5 +++-- docs/plan-interfaz-web.md | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 41130c0..1178f08 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,18 @@ Consulta: - Migraciones up-only al arranque; logging de eventos de migración. - Copias de seguridad: respaldar el directorio data/ y planificar retención. +## Pruebas (bun:test) +- Suite web implementada con build programático: los tests construyen apps/web (adapter-node) una única vez, arrancan el servidor en un puerto efímero y hacen peticiones HTTP reales. +- Sin dependencias externas: bun:test, bun:sqlite y helpers propios. +- Cobertura actual: endpoints /api/me/tasks (gating, orden, búsqueda con ESCAPE, soonDays y paginación), /api/me/preferences (valores por defecto) y helpers de servidor para build/arranque. +- Ejecución: bun test tests/web + ## Estado y licencia - Nombre provisional: “Taskbot”. - Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0). -- Progreso Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun. -- Lectura (MVP) en curso: endpoints GET /api/me/tasks (orden por fecha de vencimiento), /api/me/groups (con contadores) y /api/groups/:id/tasks; UI de Grupos en /app/groups. +- Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun. +- Etapa 2 (lectura de datos - MVP): completada. GET /api/me/tasks (orden por due_date asc con NULL al final, búsqueda con ESCAPE, filtros soonDays/dueBefore, paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app (Mis tareas, filtros/búsqueda/paginación) y /app/groups (bloque “sin responsable” con prefetch). - Roadmap y contribuciones: pendientes de publicación. ## Enlaces diff --git a/docs/operations.md b/docs/operations.md index 4913279..157f560 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -35,11 +35,11 @@ Endpoints operativos - 200 siempre (proxy interno), útil para healthcheck del contenedor. - APIs web (requieren sesión) - GET /api/me/tasks?status=open&search=... - - Orden por fecha de vencimiento ascendente (NULL al final). Aplica gating por AllowedGroups + membresía activa (group_members). Soporta búsqueda simple por descripción. + - Orden por fecha de vencimiento ascendente (NULL al final). Aplica gating por AllowedGroups + membresía activa (group_members). Búsqueda por descripción con LIKE y ESCAPE '\'. Filtros dueBefore y soonDays (en días). Paginación page/limit y campos hasMore/total en la respuesta. - GET /api/me/groups - Devuelve solo grupos permitidos donde el usuario está activo. Incluye counts.open y counts.unassigned por grupo. - GET /api/groups/:id/tasks?unassignedFirst=true - - Requiere que el usuario sea miembro activo del grupo y que el grupo esté permitido. Orden por due_date (NULL al final), con opción de priorizar tareas sin responsable. + - Requiere que el usuario sea miembro activo del grupo y que el grupo esté permitido. Orden por due_date (NULL al final); admite parámetros unassignedFirst, onlyUnassigned y limit (clamp a 100). Arranque y servicios - src/server.ts::start() (bot) @@ -82,6 +82,7 @@ Buenas prácticas - No arrancar schedulers en test salvo que FORCE_SCHEDULERS='true'. - Validar nuevas env en src/server.ts::validateEnv() y documentarlas aquí. - En apps/web, kit.csrf.checkOrigin=false debido al proxy interno; considerar alternativas si se elimina el proxy. +- Tests web con bun:test: construcción programática de apps/web (build/), arranque real del servidor y peticiones HTTP reales; ver tests/web/helpers/server.ts. Formato de fechas en comandos - Se aceptan únicamente YYYY-MM-DD y YY-MM-DD (YY se expande a 20YY). diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index c57a74d..c8b99fa 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -215,6 +215,8 @@ Etapa 6 — Evolutivos (posteriores) ## 14) Pruebas +Implementado: suite web con bun:test y build programático (helpers en tests/web/helpers/server.ts). Los tests arrancan la web real (adapter-node) y ejercitan endpoints y páginas vía HTTP, usando una base SQLite temporal. + - Unit: - Emisión/canje de token, expiración, cookie y expiración por inactividad. - Autorización de endpoints (gating, membresía). From 4a34b4b53d71de3a88aa6f24b4ff7c2dc8beb895 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 15:44:07 +0200 Subject: [PATCH 041/203] docs: actualizar Etapa 2 a COMPLETADA en plan-interfaz-web Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/plan-interfaz-web.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan-interfaz-web.md b/docs/plan-interfaz-web.md index c8b99fa..c2dabef 100644 --- a/docs/plan-interfaz-web.md +++ b/docs/plan-interfaz-web.md @@ -187,7 +187,7 @@ Etapa 1 — Autenticación - Web: endpoint /login (GET intermedio + POST canje), cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h; gate de JS; CSRF checkOrigin desactivado por proxy interno. — HECHO - Páginas de error/expiración. -Etapa 2 — Lectura de datos (MVP) — EN PROGRESO (implementados: GET /api/me/groups con contadores, GET /api/groups/:id/tasks, GET /api/me/preferences; /api/me/tasks ajustado con gating y orden por due_date). +Etapa 2 — Lectura de datos (MVP) — COMPLETADA: GET /api/me/tasks (orden por due_date asc con NULL al final; búsqueda con ESCAPE; filtros soonDays/dueBefore; paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app con filtros/búsqueda/paginación y /app/groups con prefetch "sin responsable". - APIs: /api/me/tasks, /api/me/groups, /api/groups/:id/tasks, /api/me/preferences (GET). - UI: “Mis tareas” y “Grupos” (solo lectura). - Orden por fecha de vencimiento asc (NULL al final), filtros básicos, búsqueda. From 01e1d31f009db4ea154399b2d88cf1d11ea37481 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 17:07:49 +0200 Subject: [PATCH 042/203] =?UTF-8?q?feat:=20a=C3=B1adir=20POST=20/api/me/pr?= =?UTF-8?q?eferences=20con=20validaci=C3=B3n=20y=20upsert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../src/routes/api/me/preferences/+server.ts | 97 +++++++++++++++++++ tests/web/api.me.preferences.test.ts | 89 +++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/apps/web/src/routes/api/me/preferences/+server.ts b/apps/web/src/routes/api/me/preferences/+server.ts index 1cff687..1121764 100644 --- a/apps/web/src/routes/api/me/preferences/+server.ts +++ b/apps/web/src/routes/api/me/preferences/+server.ts @@ -28,3 +28,100 @@ export const GET: RequestHandler = async (event) => { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }); }; + +export const POST: RequestHandler = async (event) => { + // Requiere sesión + const userId = event.locals.userId ?? null; + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + let payload: any = null; + try { + payload = await event.request.json(); + } catch { + return new Response('Bad Request', { status: 400 }); + } + + const freqRaw = String(payload?.freq || '').trim().toLowerCase(); + const timeRaw = payload?.time == null ? null : String(payload.time).trim(); + + const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']); + if (!allowed.has(freqRaw)) { + return new Response(JSON.stringify({ error: 'freq inválida' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + + function normalizeTime(input: string): string | null { + const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || ''); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (!Number.isFinite(h) || !Number.isFinite(min)) return null; + if (h < 0 || h > 23 || min < 0 || min > 59) return null; + const hh = String(h).padStart(2, '0'); + const mm = String(min).padStart(2, '0'); + return `${hh}:${mm}`; + } + + const db = await getDb(); + + let timeToSave: string | null = null; + + if (freqRaw === 'off') { + // Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30' + if (timeRaw && timeRaw.length > 0) { + const norm = normalizeTime(timeRaw); + if (!norm) { + return new Response(JSON.stringify({ error: 'hora inválida' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + timeToSave = norm; + } else { + const row = db + .prepare( + `SELECT reminder_time AS time + FROM user_preferences + WHERE user_id = ? + LIMIT 1` + ) + .get(userId) as any; + timeToSave = row?.time ? String(row.time) : '08:30'; + } + } else { + // daily/weekly/weekdays: si no se especifica hora, usar '08:30' + if (!timeRaw || timeRaw.length === 0) { + timeToSave = '08:30'; + } else { + const norm = normalizeTime(timeRaw); + if (!norm) { + return new Response(JSON.stringify({ error: 'hora inválida' }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); + } + timeToSave = norm; + } + } + + // Upsert preferencia (mantener last_reminded_on intacto) + db.prepare( + `INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + reminder_time = excluded.reminder_time, + updated_at = excluded.updated_at` + ).run(userId, freqRaw, timeToSave, userId); + + const responseBody = { freq: freqRaw, time: timeToSave }; + + return new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } + }); +}; diff --git a/tests/web/api.me.preferences.test.ts b/tests/web/api.me.preferences.test.ts index da1fe42..29356d7 100644 --- a/tests/web/api.me.preferences.test.ts +++ b/tests/web/api.me.preferences.test.ts @@ -74,4 +74,93 @@ describe('Web API - GET /api/me/preferences', () => { const json = await res.json(); expect(json).toEqual({ freq: 'off', time: '08:30' }); }); + + it('POST /api/me/preferences - flujo completo', async () => { + const sid = 'sid-test-pref'; + const base = server!.baseUrl; + + // 1) daily sin hora -> usa default '08:30' + let res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'daily' }) + }); + expect(res.status).toBe(200); + let json = await res.json(); + expect(json).toEqual({ freq: 'daily', time: '08:30' }); + + // GET refleja lo guardado + res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'daily', time: '08:30' }); + + // 2) weekly con hora '7:5' → normaliza a '07:05' + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'weekly', time: '7:5' }) + }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'weekly', time: '07:05' }); + + // 3) weekdays con hora inválida → 400 + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'weekdays', time: '25:00' }) + }); + expect(res.status).toBe(400); + + // 4) freq inválida → 400 + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'foo', time: '08:00' }) + }); + expect(res.status).toBe(400); + + // 5) off sin hora → conserva última ('07:05') + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'off' }) + }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'off', time: '07:05' }); + + // GET refleja off con hora conservada + res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'off', time: '07:05' }); + + // 6) weekdays con hora válida + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'weekdays', time: '18:45' }) + }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'weekdays', time: '18:45' }); + + // 7) daily con hora '6:7' -> normaliza '06:07' + res = await fetch(`${base}/api/me/preferences`, { + method: 'POST', + headers: { 'content-type': 'application/json', Cookie: `sid=${sid}` }, + body: JSON.stringify({ freq: 'daily', time: '6:7' }) + }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'daily', time: '06:07' }); + + // GET final + res = await fetch(`${base}/api/me/preferences`, { headers: { Cookie: `sid=${sid}` } }); + expect(res.status).toBe(200); + json = await res.json(); + expect(json).toEqual({ freq: 'daily', time: '06:07' }); + }); }); From 94ad9119f41f3809d93bb86156aa236b9bf0ad47 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 17:09:27 +0200 Subject: [PATCH 043/203] fix: deshabilitar retorno temprano en tests para reconstruir web Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/web/helpers/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/web/helpers/server.ts b/tests/web/helpers/server.ts index def1b8e..e649ce7 100644 --- a/tests/web/helpers/server.ts +++ b/tests/web/helpers/server.ts @@ -3,7 +3,8 @@ import { join, dirname } from 'path'; export async function ensureWebBuilt(): Promise { const buildEntry = join('apps', 'web', 'build', 'index.js'); - if (existsSync(buildEntry)) return; + const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test'; + if (existsSync(buildEntry) && !isTest) return; const lockFile = join('apps', 'web', '.build.lock'); From 1744f317b83d30b7e64614c0ee421073683cf787 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 17:17:40 +0200 Subject: [PATCH 044/203] =?UTF-8?q?feat:=20a=C3=B1adir=20p=C3=A1gina=20/ap?= =?UTF-8?q?p/preferences=20para=20gestionar=20recordatorios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../routes/app/preferences/+page.server.ts | 108 ++++++++++++ .../src/routes/app/preferences/+page.svelte | 161 ++++++++++++++++++ tests/web/app.preferences.page.test.ts | 79 +++++++++ 3 files changed, 348 insertions(+) create mode 100644 apps/web/src/routes/app/preferences/+page.server.ts create mode 100644 apps/web/src/routes/app/preferences/+page.svelte create mode 100644 tests/web/app.preferences.page.test.ts diff --git a/apps/web/src/routes/app/preferences/+page.server.ts b/apps/web/src/routes/app/preferences/+page.server.ts new file mode 100644 index 0000000..ec148ee --- /dev/null +++ b/apps/web/src/routes/app/preferences/+page.server.ts @@ -0,0 +1,108 @@ +import type { PageServerLoad } from './$types'; +import { getDb } from '$lib/server/db'; +import { redirect } from '@sveltejs/kit'; + +function ymdInTZ(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value || ''; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +function hmInTZ(d: Date, tz: string): string { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + hour12: false + }).formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value || ''; + return `${get('hour')}:${get('minute')}`; +} + +function weekdayShortInTZ(d: Date, tz: string): string { + return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d); +} + +function normalizeTime(input: string): string | null { + const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || ''); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (!Number.isFinite(h) || !Number.isFinite(min)) return null; + if (h < 0 || h > 23 || min < 0 || min > 59) return null; + const hh = String(h).padStart(2, '0'); + const mm = String(min).padStart(2, '0'); + return `${hh}:${mm}`; +} + +function computeNextReminder( + freq: 'off' | 'daily' | 'weekly' | 'weekdays', + time: string | null, + now: Date, + tz: string +): string | null { + if (freq === 'off' || !time) return null; + + const nowHM = hmInTZ(now, tz); + const [nowH, nowM] = String(nowHM).split(':'); + const [cfgH, cfgM] = String(time).split(':'); + const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0); + const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0); + + const allowDay = (w: string) => { + if (freq === 'daily') return true; + if (freq === 'weekly') return w === 'Mon'; + // weekdays + return w !== 'Sat' && w !== 'Sun'; + }; + + for (let offset = 0; offset < 14; offset++) { + const cand = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000); + const wd = weekdayShortInTZ(cand, tz); + if (!allowDay(wd)) continue; + + if (offset === 0 && nowMin >= cfgMin) { + // hoy ya pasó la hora → buscar siguiente día permitido + continue; + } + const ymd = ymdInTZ(cand, tz); + return `${ymd} ${normalizeTime(time)}`; + } + return null; +} + +export const load: PageServerLoad = async ({ locals }) => { + const userId = locals.userId ?? null; + if (!userId) { + throw redirect(302, '/login'); + } + + const db = await getDb(); + const row = db + .prepare( + `SELECT reminder_freq AS freq, reminder_time AS time + FROM user_preferences + WHERE user_id = ? + LIMIT 1` + ) + .get(userId) as any; + + const pref = + row && row.freq + ? { freq: String(row.freq) as 'off' | 'daily' | 'weekly' | 'weekdays', time: row.time ? String(row.time) : null } + : { freq: 'off' as const, time: '08:30' as string }; + + const tz = (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid'; + const next = computeNextReminder(pref.freq, pref.time, new Date(), tz); + + return { + pref, + tz, + next + }; +}; diff --git a/apps/web/src/routes/app/preferences/+page.svelte b/apps/web/src/routes/app/preferences/+page.svelte new file mode 100644 index 0000000..c871259 --- /dev/null +++ b/apps/web/src/routes/app/preferences/+page.svelte @@ -0,0 +1,161 @@ + + +
    +

    Preferencias de recordatorios

    + +
    +
    + + +

    + - Diario: cada día a la hora indicada. - Laborables: solo lunes a viernes. - Semanal: los lunes. +

    +
    + +
    + + +

    Zona horaria: {data.tz}

    +
    + + {#if errorMsg} +
    {errorMsg}
    + {/if} + {#if successMsg} +
    {successMsg}
    + {/if} + + +
    + +
    +

    Próximo recordatorio

    +
      +
    • Servidor: {serverNext ?? '—'}
    • +
    • Calculado ahora: {clientNext ?? '—'}
    • +
    +
    +
    diff --git a/tests/web/app.preferences.page.test.ts b/tests/web/app.preferences.page.test.ts new file mode 100644 index 0000000..a6aee4f --- /dev/null +++ b/tests/web/app.preferences.page.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startWebServer } from './helpers/server'; +import { initializeDatabase, ensureUserExists } from '../../src/db'; + +async function sha256Hex(input: string): Promise { + const enc = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', enc); + const bytes = new Uint8Array(buf); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function toIsoSql(d = new Date()): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('Web UI - /app/preferences', () => { + const userId = '34600123456'; + let dbPath: string; + let server: Awaited> | null = null; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'webtest-')); + dbPath = join(tmpDir, 'tasks.db'); + + // Inicializar DB en archivo (como en prod) + const db = new Database(dbPath); + initializeDatabase(db); + ensureUserExists(userId, db); + + // Crear sesión válida + const sid = 'sid-test-pref-ui'; + const hash = await sha256Hex(sid); + const now = new Date(); + const nowIso = toIsoSql(now); + const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h + + db.prepare(` + INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(hash, userId, nowIso, nowIso, expIso); + db.close(); + + // Arrancar web apuntando a este DB + server = await startWebServer({ + port: 19110, + env: { DB_PATH: dbPath, TZ: 'UTC' } + }); + }); + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('renderiza el formulario con valores por defecto y muestra próximo recordatorio', async () => { + const sid = 'sid-test-pref-ui'; + const res = await fetch(`${server!.baseUrl}/app/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(html).toContain('Preferencias de recordatorios'); + // select de frecuencia y opción 'off' presente + expect(html).toContain(''); + // input type="time" con valor por defecto + expect(html).toContain('type="time"'); + expect(html).toContain('08:30'); + // bloque de "Próximo recordatorio" + expect(html).toContain('Próximo recordatorio'); + }); +}); From f6b6ab7e6c4e208ff6172a984b9a29bac92febcc Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 13 Oct 2025 17:19:28 +0200 Subject: [PATCH 045/203] =?UTF-8?q?test:=20adaptar=20verificaci=C3=B3n=20d?= =?UTF-8?q?e=20la=20opci=C3=B3n=20off=20en=20/app/preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/web/app.preferences.page.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/web/app.preferences.page.test.ts b/tests/web/app.preferences.page.test.ts index a6aee4f..939aa96 100644 --- a/tests/web/app.preferences.page.test.ts +++ b/tests/web/app.preferences.page.test.ts @@ -69,7 +69,7 @@ describe('Web UI - /app/preferences', () => { expect(html).toContain('Preferencias de recordatorios'); // select de frecuencia y opción 'off' presente - expect(html).toContain(''); + expect(html).toContain('