feat: habilita modo desarrollo con bypass de auth y seed de demo

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent 7e8b9c3d7a
commit ca616a786a

@ -1,17 +1,34 @@
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';
import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const handle: Handle = async ({ event, resolve }) => {
// Bypass de auth en desarrollo (opcional)
const bypass = isDev() && DEV_BYPASS_AUTH;
if (bypass) {
const qp = event.url.searchParams.get('__as')?.trim();
const current = event.cookies.get('dev_as') || '';
const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER);
if (qp && qp.length && qp !== current) {
event.cookies.set('dev_as', user, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isProd(),
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
event.locals.userId = user;
}
// 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) {
if (!bypass && sid) {
try {
const db = await getDb();
const hash = await sha256Hex(sid);
@ -97,5 +114,11 @@ export const handle: Handle = async ({ event, resolve }) => {
} catch {
// Ignorar si la implementación de Response no permite set()
}
// Indicador de bypass en respuestas (útil en dev)
try {
if (bypass) {
response.headers.set('X-Dev-Auth', 'bypass');
}
} catch {}
return response;
};

@ -1,6 +1,6 @@
import { mkdirSync } from 'fs';
import { mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
import { resolveDbAbsolutePath } from './env';
import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env';
function applyDefaultPragmas(instance: any): void {
try {
@ -17,11 +17,13 @@ function applyDefaultPragmas(instance: any): void {
}
/**
* Abre la BD compartida sin ejecutar migraciones (las realiza el proceso del bot).
* Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
* inicializa el esquema (migraciones) y siembra datos de demo.
* Nota: uso de import dinámico de 'bun:sqlite' para que el build con Node no falle.
*/
async function openDb(filename: string = 'tasks.db'): Promise<any> {
const absolutePath = resolveDbAbsolutePath(filename);
const firstCreate = !existsSync(absolutePath);
// Crear directorio padre si no existe
try {
@ -33,6 +35,27 @@ async function openDb(filename: string = 'tasks.db'): Promise<any> {
const { Database } = await import('bun:sqlite');
const instance = new Database(absolutePath);
applyDefaultPragmas(instance);
// Auto-inicialización y seed sólo en desarrollo y primer arranque
if (firstCreate && isDev() && DEV_AUTOSEED_DB) {
try {
const dbModule = await import('../../../../../src/db');
if (typeof (dbModule as any).initializeDatabase === 'function') {
(dbModule as any).initializeDatabase(instance);
}
} catch (e) {
console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev:', e);
}
try {
const seed = await import('./dev-seed');
if (typeof (seed as any).seedDev === 'function') {
await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
}
} catch (e) {
console.warn('[web/db] No se pudo realizar el seed de datos de demo:', e);
}
}
return instance;
}

@ -0,0 +1,109 @@
/**
* Seed mínimo de datos de demo para desarrollo.
* Inserta usuarios, grupos permitidos, membresías, preferencias y tareas variadas.
*/
function toIsoYmd(d: Date): string {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addDays(base: Date, days: number): Date {
return new Date(base.getTime() + days * 24 * 3600 * 1000);
}
export async function seedDev(db: any, defaultUser: string): Promise<void> {
try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {}
// Si ya hay tareas, asumimos BD poblada
try {
const row = db.query(`SELECT COUNT(*) AS c FROM tasks`).get() as any;
if (row && Number(row.c || 0) > 0) return;
} catch {}
const now = new Date();
const today = toIsoYmd(now);
const tomorrow = toIsoYmd(addDays(now, 1));
const nextWeek = toIsoYmd(addDays(now, 7));
const overdue = toIsoYmd(addDays(now, -1));
const inTwoDays = toIsoYmd(addDays(now, 2));
const otherUser = '5550001111';
// Usuarios
db.prepare(`
INSERT OR IGNORE INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(defaultUser);
db.prepare(`
INSERT OR IGNORE INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(otherUser);
// Grupos
db.prepare(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-1', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run('g-1@g.us', 'Grupo Alpha');
db.prepare(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-1', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run('g-2@g.us', 'Grupo Beta');
// Allowed groups
try {
db.prepare(`
INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
VALUES (?, 'Alpha', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL)
`).run('g-1@g.us');
db.prepare(`
INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
VALUES (?, 'Beta', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL)
`).run('g-2@g.us');
} catch {}
// Membresías (usuario por defecto activo en ambos grupos)
db.prepare(`
INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run('g-1@g.us', defaultUser);
db.prepare(`
INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run('g-2@g.us', defaultUser);
// Preferencias del usuario
try {
db.prepare(`
INSERT OR REPLACE INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, 'daily', '08:30', NULL, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(defaultUser);
} catch {}
// Crear tareas diversas
const insertTask = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?)
`);
const assignStmt = db.prepare(`
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)
`);
const tasksToCreate = [
{ desc: 'Tarea sin responsable (hoy) - Grupo Alpha', due: today, group: 'g-1@g.us', assignTo: null, completed: 0, completedAt: null },
{ desc: 'Tarea sin responsable (mañana) - Grupo Alpha', due: tomorrow, group: 'g-1@g.us', assignTo: null, completed: 0, completedAt: null },
{ desc: 'Tarea sin responsable (próx. semana) - Grupo Beta', due: nextWeek, group: 'g-2@g.us', assignTo: null, completed: 0, completedAt: null },
{ desc: 'Mi tarea personal (en 2 días)', due: inTwoDays, group: null, assignTo: defaultUser, completed: 0, completedAt: null },
{ desc: 'Mi tarea (vencida ayer) - Grupo Alpha', due: overdue, group: 'g-1@g.us', assignTo: defaultUser, completed: 0, completedAt: null },
{ desc: 'Tarea completada reciente', due: today, group: null, assignTo: defaultUser, completed: 1, completedAt: new Date().toISOString().replace('T',' ').replace('Z','') }
];
db.transaction(() => {
for (const t of tasksToCreate) {
const res = insertTask.run(t.desc, t.due, t.group, defaultUser, t.completed, t.completedAt);
const id = Number((res as any)?.lastInsertRowid ?? 0);
if (t.assignTo && id > 0) {
assignStmt.run(id, t.assignTo, defaultUser);
}
}
})();
}

@ -6,14 +6,15 @@ 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)
* 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp)
*/
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) : '/app/data';
const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
return resolve(join(dataDir, filename));
}
@ -25,6 +26,13 @@ export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) *
export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
export const isProd = () => NODE_ENV === 'production';
export const isDev = () => NODE_ENV === 'development';
// Flags de desarrollo (solo en entornos no productivos)
const toBool = (v: string) => ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase());
export const DEV_BYPASS_AUTH = toBool(env.DEV_BYPASS_AUTH || '');
export const DEV_DEFAULT_USER = (env.DEV_DEFAULT_USER || 'demo').trim();
export const DEV_AUTOSEED_DB = toBool(env.DEV_AUTOSEED_DB || '');
// ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado)
const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12);

@ -2,6 +2,7 @@
import '$lib/styles/tokens.css';
import '$lib/styles/base.css';
import favicon from '$lib/assets/favicon.svg';
const dev = import.meta.env.DEV;
</script>
<svelte:head>
@ -9,4 +10,28 @@
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
{#if dev}
<div
role="status"
aria-live="polite"
style="
position: fixed;
bottom: 8px;
left: 8px;
right: 8px;
margin: 0 auto;
max-width: 960px;
background: rgba(255, 221, 87, 0.95);
color: #111;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
z-index: 9999;
">
Modo desarrollo: auth bypass activo
</div>
{/if}
<slot />

@ -2,7 +2,7 @@ import type { RequestHandler } from './$types';
import { redirect } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
import { sessionIdleTtlMs, isProd } from '$lib/server/env';
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
@ -20,6 +20,9 @@ function escapeHtml(s: string): string {
// 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) => {
if (isDev() && DEV_BYPASS_AUTH) {
throw redirect(303, '/app');
}
const token = event.url.searchParams.get('token')?.trim();
if (!token) {
console.warn('[web/login] Solicitud sin token');
@ -80,6 +83,9 @@ export const GET: RequestHandler = async (event) => {
// POST: canje real del token (uso único). Crea sesión y redirige a /app.
export const POST: RequestHandler = async (event) => {
if (isDev() && DEV_BYPASS_AUTH) {
throw redirect(303, '/app');
}
const form = await event.request.formData();
const token = String(form.get('token') || '').trim();
if (!token) {

Loading…
Cancel
Save