Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol

Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { exec } from './db.mjs';
import { config } from './config.mjs';
/**
* Registra una acción en la bitácora. Nunca lanza: si el registro falla,
* la petición del usuario no se cae por eso.
*/
export async function registrar(req, { accion, entidad = '', entidad_id = '', resultado = 'ok', detalle = '' }) {
const u = req.usuario ?? null;
try {
await exec(
`INSERT INTO bitacora (usuario_id, actor, rol, accion, entidad, entidad_id, resultado, detalle, ip_origen)
VALUES (?,?,?,?,?,?,?,?,?)`,
[
u?.id ?? null,
u?.nombre ?? 'anónimo',
u?.rol_clave ?? '',
accion,
String(entidad).slice(0, 48),
String(entidad_id).slice(0, 48),
resultado,
String(detalle).slice(0, 500),
ip(req),
],
);
} catch (err) {
console.error('[bitacora] no se pudo registrar:', err.message);
}
}
/**
* Origen de la petición. X-Forwarded-For lo pone quien llama, así que solo se
* cree cuando se declara explícitamente que hay un proxy de confianza delante
* (CONFIAR_PROXY=true). Si no, la bitácora registraría la IP que el cliente
* quiera y dejaría de servir para auditar.
*/
export function ip(req) {
let cruda = req.socket?.remoteAddress || '';
if (config.confiarProxy) {
const xff = req.headers['x-forwarded-for'];
const primera = (Array.isArray(xff) ? xff[0] : xff)?.split(',')[0]?.trim();
if (primera) cruda = primera;
}
return String(cruda).replace('::ffff:', '').slice(0, 64);
}