Semana 15 — Seguridad, privacidad, ética y accesibilidad
Objetivos
- Hacer un modelo de amenazas de una aplicación pequeña (activos, actores, puntos de entrada, fronteras de confianza) y ubicar sus riesgos en el OWASP Top 10.
- Distinguir autenticación de autorización, y explicar y corregir un XSS almacenado y un control de acceso roto.
- Aplicar a un producto los principios de la Ley 29733 de protección de datos personales y su reglamento, empezando por la finalidad y la proporcionalidad.
- Evaluar una página con los criterios de WCAG 2.2 que más se incumplen (texto alternativo, contraste, uso del color, teclado, etiquetas, mensajes de estado).
- Elegir una licencia con el cliente, revisar las licencias de las dependencias y explicar qué se sabe y qué no sobre la procedencia del código generado.
Sesión A — Seguridad y privacidad desde el diseño
Lecturas:
- OWASP, Top 10:2025 (y la página del proyecto).
- Ley N.° 29733, Ley de Protección de Datos Personales, el título preliminar (disposiciones generales) y el título I (principios rectores, arts. 4 a 12).
Lecturas complementarias:
- Reglamento de la Ley N.° 29733 (Decreto Supremo N.° 016-2024-JUS) y las normas de la Autoridad Nacional de Protección de Datos Personales.
- OWASP Cheat Sheet Series, Cross Site Scripting Prevention y Authorization.
- OWASP, Application Security Verification Standard (ASVS).
Temas:
- Seguridad desde el diseño: las propiedades que se protegen (confidencialidad, integridad, disponibilidad) se deciden al diseñar, no al final. Modelo de amenazas en cuatro preguntas: ¿qué estamos construyendo?, ¿qué puede salir mal?, ¿qué vamos a hacer al respecto?, ¿lo hicimos bien? Para Kallpa: activos (reservas, datos de los vecinos, la caja del día), actores (vecino, administración, cualquiera en internet), puntos de entrada (cada ruta de la API y la página) y la frontera de confianza (todo lo que llega del navegador es entrada no confiable).
- OWASP Top 10:2025: A01 Broken Access Control sigue primero; entran A03 Software Supply Chain Failures (semana 12) y A10 Mishandling of Exceptional Conditions (errores que dejan el sistema en un estado inseguro o revelan información). El Top 10 es una lista de concientización, no un estándar de verificación; para eso está ASVS.
- Autenticación (¿quién eres?) frente a autorización (¿qué puedes hacer?). Control de acceso roto: el servidor confía en un dato que manda el cliente, como un
memberIden el cuerpo o un número de reserva en la URL. Si los identificadores son correlativos, basta sumar uno para ver o tocar lo de otra persona (insecure direct object reference). La regla: cada solicitud se autoriza en el servidor, contra la identidad de la sesión, y por defecto se niega. - XSS (cross-site scripting): texto controlado por un usuario termina interpretado como HTML o JavaScript en la página de otro. Almacenado (se guarda en el servidor y se muestra a todos), reflejado (vuelve en la misma respuesta) o basado en el DOM. La defensa principal es codificar la salida según el contexto: en el DOM,
textContentocreateElementen lugar deinnerHTMLcon texto del usuario. Una Content Security Policy que no permita scripts en línea es una segunda capa, no un reemplazo. - Inyección SQL: se previene con sentencias preparadas y parámetros, siempre. Un SQL armado con texto fijo del programa (nombres de tabla constantes) no es inyectable; una herramienta de análisis estático lo puede marcar igual, y decidir si es un falso positivo es parte de la auditoría.
- Privacidad desde el diseño y la Ley 29733: un dato personal es cualquier información que identifica o hace identificable a una persona (nombre, celular, DNI). Principios de la ley (título I): legalidad, consentimiento, finalidad, proporcionalidad, calidad, seguridad, disposición de recurso y nivel de protección adecuado. En la práctica: se recolecta solo lo necesario para una finalidad que se informó, solo lo ven quienes lo necesitan, se protege, y las personas pueden ejercer sus derechos de acceso, rectificación, cancelación y oposición. El reglamento vigente es el de 2024 (Decreto Supremo N.° 016-2024-JUS), que reemplazó al de 2013.
- Error común: “nadie va a encontrar esa ruta”. Una ruta de la API que la página no muestra es tan pública como la página: cualquiera la llama con
curl. Lo que una respuesta devuelve es lo que se publica. - Con IA: el código generado repite los patrones más comunes de lo que aprendió, incluidos los inseguros (
innerHTMLcon datos del usuario, SQL concatenado, secretos en el código). Y una funcionalidad con LLM agrega su propia superficie (semana 14). La revisión de seguridad no cambia: se busca por dónde entra el texto no confiable y a dónde llega.
Sesión B — Ética, accesibilidad y licencias · ✂️ procedencia del código generado
Lecturas:
- W3C, Pautas de Accesibilidad para el Contenido Web (WCAG) (resumen en español) y la referencia rápida de WCAG 2.2.
- SWEG, cap. 4, Engineering for Equity.
Lecturas complementarias:
- W3C, WCAG 2.2 (la norma completa) y Easy Checks: A First Review of Web Accessibility.
- WebAIM, Contrast Checker.
- choosealicense.com y su apéndice comparativo; la lista de licencias SPDX.
Temas:
- Ética en ingeniería de software: quien construye decide, aunque no lo note, quién puede usar el producto y a quién perjudica. SWEG cap. 4: los productos fallan a los grupos que no estaban representados en quienes los diseñaron y probaron. Preguntas para el proyecto: ¿quién no puede usar esto (sin smartphone, sin datos, con una discapacidad, sin leer bien)?, ¿qué pasa con los datos si el cliente los pierde o los filtra?, ¿qué haría falta para que el cliente pueda seguir sin el equipo?
- Accesibilidad: WCAG 2.2 organiza sus criterios en cuatro principios (perceptible, operable, comprensible, robusto) con tres niveles (A, AA, AAA); AA es el que piden la mayoría de las normas. Los criterios que más se incumplen en una página pequeña: 1.1.1 contenido no textual (texto alternativo), 1.3.1 información y relaciones (etiquetas
<label>unidas a su campo), 1.4.1 uso del color (el color no puede ser la única forma de transmitir algo), 1.4.3 contraste mínimo (4.5:1 para texto normal, 3:1 para texto grande), 2.1.1 teclado (todo se puede hacer sin mouse), 4.1.2 nombre, función y valor, y 4.1.3 mensajes de estado (que un lector de pantalla anuncie “reserva confirmada”). - Cómo se evalúa: herramientas automáticas (Lighthouse, el inspector de accesibilidad del navegador) encuentran una parte de los problemas, como contrastes y campos sin nombre; el resto se encuentra usando la página solo con el teclado y con un lector de pantalla (NVDA en Windows, VoiceOver en macOS). Primero se usa HTML semántico (
<button>,<label>,<fieldset>); ARIA es para lo que el HTML no puede expresar. - Error común: dar por accesible una página porque Lighthouse le da un puntaje alto. Las herramientas automáticas no pueden saber si el orden del foco tiene sentido, si un texto alternativo describe la imagen o si un control que se ve como botón se puede activar con el teclado; eso solo se sabe usando la página como la usaría esa persona.
- Licencias: sin licencia, el código es de su autor y nadie más tiene permiso de usarlo o modificarlo, aunque esté público en GitHub (los términos de GitHub solo permiten verlo y hacer fork). Permisivas (MIT, BSD, Apache-2.0, esta última con una concesión de patentes): se puede usar y redistribuir conservando el aviso. Copyleft (GPL): si se distribuye un trabajo derivado, se distribuye con la misma licencia; AGPL extiende esa obligación a quien ofrece el software por la red. La licencia del proyecto se acuerda con el cliente y se declara con su identificador SPDX en
package.jsony en un archivoLICENSE. - Las licencias de las dependencias: el lockfile de la semana 12 registra la licencia de cada paquete. Kallpa no tiene dependencias de producción, pero las herramientas que fijaron como devDependencies traen paquetes con licencias MIT, Apache-2.0, BSD, ISC y BlueOak-1.0.0, todas permisivas. Una dependencia GPL en un producto que se distribuye cambia las obligaciones del proyecto entero.
- ✂️ Procedencia del código generado: un modelo puede reproducir fragmentos de código que vio al entrenarse, con su licencia, sin decirlo. Quién es titular del código generado y en qué condiciones depende de la ley de cada país y de los términos de la herramienta, y es una discusión abierta. Lo que el equipo sí controla: registrar qué se generó y con qué (el registro de uso de IA), revisar y entender ese código como si fuera propio, sospechar de bloques largos que parecen copiados (comentarios ajenos, nombres de otro proyecto) y no pegar en una herramienta código o datos del cliente sin su permiso.
Laboratorio — Auditoría de seguridad y accesibilidad de Kallpa
Guía: code/week-15/template-auditoria.md. Referencias: OWASP, Top 10:2025 y XSS Prevention Cheat Sheet; W3C, Easy Checks; WebAIM, Contrast Checker; MDN, innerHTML (sección Security considerations).
En parejas, sobre su repositorio de Kallpa, con make seed para empezar con los datos de ejemplo. Cada hallazgo va en docs/auditoria.md con el template: qué es, cómo se reproduce (los comandos exactos), categoría (OWASP o criterio WCAG), severidad y la corrección propuesta. Las pruebas de ataque se hacen solo contra su propia copia, nunca contra un sistema que no es suyo. Con el servidor corriendo (make start), los curl van en otra shell del contenedor; el navegador, en su computadora.
- Modelo de amenazas: una tabla con los activos, los actores y cada ruta de la API (README de Kallpa), y para cada ruta qué datos devuelve o modifica y quién debería poder llamarla. Marquen dónde está la frontera de confianza.
- Control de acceso: con
curl, intenten hacer lo que un vecino no debería poder hacer con las reservas o los datos de otro. Por ejemplo, para una fecha de los próximos días:
Sigan conD=$(TZ=America/Lima date -d '+2 days' +%F) curl -s localhost:3000/api/members | jq curl -s localhost:3000/api/members/1/bookings | jqPOST /api/bookingsyPOST /api/bookings/:id/cancel. Anoten cada caso con su comando y su respuesta. - XSS: encuentren en
public/app.jspor dónde llega a la página un texto que escribe un usuario. Pruébenlo con un nombre de equipo inofensivo (<b>prueba</b>) y después con<img src=x onerror=alert(1)>, reservando concurl(-d "{\"courtId\":3,\"memberId\":1,\"date\":\"$D\",\"startHour\":8,\"teamName\":\"...\"}"), y abran la disponibilidad de esa cancha y ese día en el navegador. Corríjanlo en una rama, con una prueba o un procedimiento manual escrito que demuestre que el nombre se muestra como texto. - Datos personales y Ley 29733: hagan el inventario de los datos personales que guarda Kallpa y de cada lugar por donde salen (rutas de la API, la página, los logs del servidor). Para cada uno: finalidad, quién lo necesita ver y si la respuesta actual es proporcional. Propongan los cambios mínimos para cumplir los principios de finalidad y proporcionalidad.
- Cabeceras:
curl -s -D - -o /dev/null localhost:3000/muestra las cabeceras de la página. Comparen con lo que recomienda OWASP (Content-Security-Policy, X-Content-Type-Options, Referrer-Policy) y agreguen una CSP que no rompa la página; comprueben en la consola del navegador que no hay violaciones. - Accesibilidad, en el navegador de su computadora: hagan una reserva completa solo con el teclado (Tab, Shift+Tab, Enter, Espacio) y anoten dónde se traban. Corran Lighthouse (pestaña Lighthouse de las DevTools de Chrome) sobre
http://localhost:3000. Revisen con el Contrast Checker de WebAIM cada combinación de color de texto y fondo depublic/styles.css, y busquen información que solo se transmite con color. Si pueden, prueben con NVDA o VoiceOver si se anuncia el resultado de una reserva. - Licencias: con el
package-lock.jsonde la semana 12, cuenten las licencias de las dependencias:
¿Alguna obliga a algo más que conservar el aviso? ¿Qué licencia le pondrían a Kallpa y por qué?jq -r '.packages | to_entries[] | select(.key != "") | .value.license' package-lock.json | sort | uniq -c | sort -rn - Prioricen: ordenen los hallazgos por severidad y corrijan los dos más graves en pull requests separados, con revisión. El resto queda como issues en el repositorio, enlazados desde
docs/auditoria.md.
Entregas
- No hay entregas esta semana. P5 (
projects/p5-producto/README.md), publicado en la semana 14, incluye la revisión de seguridad del producto con el OWASP Top 10, la licencia acordada con el cliente y el tratamiento de los datos personales: apliquen hoy lo mismo a su proyecto. Entrega en semana 16.
Preguntas de repaso
- ¿Qué diferencia hay entre autenticación y autorización? Den un ejemplo de Kallpa en el que agregar un inicio de sesión no basta para cerrar el problema de control de acceso.
- Expliquen cómo funciona un XSS almacenado en una aplicación como Kallpa, desde que alguien escribe el texto hasta que se ejecuta en la computadora de otra persona. ¿Por qué
textContentlo evita? ¿Qué agrega una CSP? - Según el principio de proporcionalidad de la Ley 29733, ¿qué datos de los vecinos necesita ver otro vecino para reservar una cancha? ¿Y la administración?
- Elijan tres criterios de WCAG 2.2 y digan, para cada uno, cómo lo comprobarían en una página y si una herramienta automática lo detectaría.
- Una herramienta de IA genera para su proyecto una función de 80 líneas que incluye un comentario en inglés con el nombre de otro proyecto. ¿Qué harían antes de aceptarla? ¿Qué escribirían en el registro de uso de IA?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (8)Pruebas (5)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// Contraste según WCAG 2.2: luminancia relativa y razón entre dos colores.
// `node contrast.ts` imprime las combinaciones de public/styles.css de Kallpa.
export function relativeLuminance(hex: string): number {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (match === null) throw new Error(`Color inválido: ${hex}`);
const [r, g, b] = match.slice(1).map((part) => {
const channel = parseInt(part, 16) / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function contrastRatio(first: string, second: string): number {
const [darker, lighter] = [relativeLuminance(first), relativeLuminance(second)].sort((a, b) => a - b);
return (lighter + 0.05) / (darker + 0.05);
}
// El mínimo de 1.4.3: 3:1 solo para texto grande (18 pt, o 14 pt en negrita).
export function passesAA(ratio: number, largeText: boolean): boolean {
return ratio >= (largeText ? 3 : 4.5);
}
export type ColorPair = { where: string; text: string; background: string; largeText: boolean };
// Texto y fondo de cada regla de kallpa/public/styles.css. El fondo de la página
// no está declarado: WCAG asume blanco. El h1 mide 2em (más de 18 pt).
export const KALLPA_PAIRS: readonly ColorPair[] = [
{ where: 'body', text: '#222222', background: '#ffffff', largeText: false },
{ where: 'header h1', text: '#0b6e4f', background: '#ffffff', largeText: true },
{ where: '.hint', text: '#b5b5b5', background: '#ffffff', largeText: false },
{ where: '.mine .cancelled', text: '#b5b5b5', background: '#ffffff', largeText: false },
{ where: '.slot.free', text: '#ffffff', background: '#43a047', largeText: false },
{ where: '.slot.taken', text: '#ffffff', background: '#e53935', largeText: false },
{ where: '#message.ok', text: '#43a047', background: '#ffffff', largeText: false },
{ where: '#message.error', text: '#e53935', background: '#ffffff', largeText: false },
];
if (import.meta.main) {
for (const pair of KALLPA_PAIRS) {
const ratio = contrastRatio(pair.text, pair.background);
const verdict = passesAA(ratio, pair.largeText) ? 'cumple' : 'NO cumple';
console.log(
`${pair.where.padEnd(17)} ${pair.text} sobre ${pair.background} ${ratio.toFixed(2).padStart(5)}:1 ${verdict}`,
);
}
}// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint, sin información de tipos.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'solution/', 'licencias/'] },
js.configs.recommended,
tseslint.configs.recommended,
);// Dos reglas de forma, para ver qué encuentra (y qué no) un análisis estático:
// HTML armado con innerHTML y SQL armado con un template. Miran el código, no
// de dónde viene el dato: quien revisa decide si cada aviso es real.
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig({
files: ['**/*.ts', '**/*.js'],
languageOptions: { parser: tseslint.parser },
linterOptions: { reportUnusedDisableDirectives: 'off' },
rules: {
'no-restricted-syntax': [
'error',
{
selector: "AssignmentExpression[left.property.name='innerHTML']",
message: 'innerHTML interpreta HTML: de dónde viene el texto?',
},
{
selector:
"CallExpression[callee.property.name=/^(prepare|exec)$/] > TemplateLiteral[expressions.length>0]",
message: 'SQL armado con un template: de dónde viene cada ${...}?',
},
],
},
});# Auditoría de seguridad y accesibilidad
Equipo: ... · Fecha: ... · Commit auditado: ...
Copien este archivo a `docs/auditoria.md` en su repositorio y llénenlo mientras
hacen el laboratorio de la [semana 15](../../weeks/week-15.md). Sirve también
para `docs/seguridad.md` de P5: las categorías y los estados son los mismos.
**Reglas.** Las pruebas de ataque se hacen solo contra su propia copia, nunca
contra un sistema que no es suyo. Escriban los comandos exactos y peguen la
respuesta real: un hallazgo que otra persona no puede reproducir no sirve.
## Cómo llenar un hallazgo
Cada hallazgo es una fila de la tabla de la sección 9 y, si hace falta detalle,
una subsección. Tiene estos campos:
- **Qué es:** el problema en una frase, y quién resulta perjudicado.
- **Cómo se reproduce:** los comandos exactos y la respuesta que obtuvieron.
- **Categoría:** de OWASP (`A01:2025 Broken Access Control`, `A05:2025 Injection`,
...) o un criterio de WCAG (`WCAG 2.1.1 Teclado`).
- **Severidad:** según la escala de abajo, con una frase que la justifique.
- **Corrección propuesta:** el cambio mínimo, y cómo se comprobaría.
| Severidad | Qué significa |
|---|---|
| Crítica | Cualquiera, sin cuenta ni conocimientos especiales, lee o cambia datos de otras personas, o deja el servicio caído. |
| Alta | Hace falta algo más (que la víctima abra una página, conocer un dato) y el daño es el mismo; o una persona no puede completar la tarea principal. |
| Media | El daño es limitado, o las condiciones para que ocurra son poco probables; o la tarea se puede hacer con mucha dificultad. |
| Baja | Mejora una defensa que no es la principal, o molesta sin impedir nada. |
## 1. Modelo de amenazas
**Qué estamos construyendo.**
Activos (lo que hay que proteger, y qué propiedad importa: confidencialidad,
integridad, disponibilidad):
| Activo | Dónde vive | Propiedad que más importa | Por qué |
|---|---|---|---|
| | | | |
Actores (quién usa o ataca el sistema, y qué debería poder hacer):
| Actor | Qué debería poder hacer | Qué no debería poder hacer |
|---|---|---|
| | | |
Puntos de entrada (una fila por cada ruta de la API, y la página):
| Método y ruta | Qué datos devuelve o modifica | Quién debería poder llamarla | Quién puede hoy |
|---|---|---|---|
| | | | |
Dónde está la frontera de confianza (qué lado controlan ustedes y qué lado no):
**Qué puede salir mal.** Para cada entrada, qué pasa si quien llama miente.
**Qué vamos a hacer al respecto.** Sale de la sección 9.
**Lo hicimos bien.** Qué prueba o procedimiento lo demuestra:
## 2. Control de acceso
Cada intento con su comando y su respuesta.
```sh
D=$(TZ=America/Lima date -d '+2 days' +%F)
```
| Lo que intentamos | Comando | Respuesta | Qué debería pasar |
|---|---|---|---|
| | | | |
## 3. XSS
Por dónde llega a la página un texto que escribe un usuario (archivo y línea de
cada lugar):
Con `<b>prueba</b>`, lo que se vio:
Con `<img src=x onerror=alert(1)>`, lo que se vio:
Corrección, en la rama: ...
La prueba o el procedimiento manual que demuestra que el nombre se muestra como
texto:
1.
2.
## 4. Datos personales y Ley 29733
*Esto es un ejercicio de curso, no asesoría legal. Lo que decidan aquí no
reemplaza la opinión de un abogado ni la de la Autoridad Nacional de Protección
de Datos Personales.*
Citen el artículo que usan: Ley N.° 29733 y su reglamento (Decreto Supremo
N.° 016-2024-JUS). Lo que no sepan cómo resolver, anótenlo como pregunta para
el cliente.
| Dato personal | Dónde se guarda | Por dónde sale (rutas, página, logs) | Finalidad | Quién lo necesita ver | ¿La respuesta actual es proporcional? (art. 7) | Cuándo se borra |
|---|---|---|---|---|---|---|
| | | | | | | |
Cambios mínimos que proponen para cumplir finalidad (art. 6) y proporcionalidad
(art. 7):
Preguntas para el cliente:
## 5. Cabeceras
```sh
curl -s -D - -o /dev/null localhost:3000/
```
| Cabecera | Antes | Después | Por qué ese valor |
|---|---|---|---|
| Content-Security-Policy | | | |
| X-Content-Type-Options | | | |
| Referrer-Policy | | | |
Lo que muestra la consola del navegador con la CSP puesta (una violación se ve
distinta de un 404):
## 6. Accesibilidad
**Con el teclado.** Una reserva completa con Tab, Shift+Tab, Enter y Espacio.
Dónde se trabaron:
**Lighthouse.** Versión, puntaje y qué avisó. Qué problema de los que encontraron
con el teclado **no** avisó:
**Contraste.** Una fila por cada combinación de `public/styles.css`.
| Selector | Texto | Fondo | Razón | Mínimo (1.4.3) | ¿Cumple? |
|---|---|---|---|---|---|
| | | | | | |
**Información que solo se transmite con color:**
**Lector de pantalla** (NVDA, VoiceOver, si pudieron). ¿Se anuncia el resultado
de una reserva?
## 7. Licencias
```sh
jq -r '.packages | to_entries[] | select(.key != "") | .value.license' package-lock.json | sort | uniq -c | sort -rn
```
Resultado:
¿Alguna obliga a algo más que conservar el aviso?
Qué licencia le pondrían a Kallpa y por qué:
## 8. OWASP Top 10:2025
| Categoría | Qué miramos en nuestra app | Estado | Evidencia o pendiente |
|---|---|---|---|
| A01:2025 Broken Access Control | | | |
| A02:2025 Security Misconfiguration | | | |
| A03:2025 Software Supply Chain Failures | | | |
| A04:2025 Cryptographic Failures | | | |
| A05:2025 Injection | | | |
| A06:2025 Insecure Design | | | |
| A07:2025 Authentication Failures | | | |
| A08:2025 Software or Data Integrity Failures | | | |
| A09:2025 Security Logging and Alerting Failures | | | |
| A10:2025 Mishandling of Exceptional Conditions | | | |
Estados: `Cumple`, `Parcial` (qué falta y por qué se acepta por ahora), `No cumple`
o `No aplica` (con la justificación).
## 9. Hallazgos, de más grave a menos grave
| # | Qué es | Cómo se reproduce | Categoría | Severidad | Corrección propuesta | Pull request o issue |
|---|---|---|---|---|---|---|
| 1 | | | | | | |
| 2 | | | | | | |
Corrijan los dos más graves en pull requests separados, con revisión. El resto
queda como issues, enlazados desde esta tabla.// Utilidades de las pruebas: levantar una Kallpa de verdad y correr herramientas.
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { request } from 'node:http';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
export const HERE = import.meta.dirname;
export const KALLPA = resolve(HERE, '../../kallpa');
export type RunResult = { code: number; output: string };
export function run(command: string, args: string[], cwd: string = HERE): RunResult {
// Sin NODE_TEST_CONTEXT: si no, un node --test hijo no corre sus archivos.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' });
return { code: result.status ?? -1, output: `${result.stdout}${result.stderr}` };
}
// Una copia nueva de Kallpa en build/<name>, con un commit base.
export function freshKallpa(name: string): string {
const dir = resolve(HERE, 'build', name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(resolve(HERE, 'build'), { recursive: true });
cpSync(KALLPA, dir, { recursive: true });
rmSync(resolve(dir, 'data'), { recursive: true, force: true });
mkdirSync(resolve(dir, 'data'));
const base = ['-c', 'user.name=Kallpa', '-c', 'user.email=kallpa@example.com', '-c', 'init.defaultBranch=main'];
run('git', [...base, 'init', '-q'], dir);
run('git', [...base, 'add', '-A'], dir);
run('git', [...base, 'commit', '-qm', 'Kallpa publicada'], dir);
return dir;
}
export function applyPatch(dir: string, patch: string): RunResult {
return run('git', ['apply', resolve(HERE, patch)], dir);
}
async function freePort(): Promise<number> {
return new Promise((done) => {
const probe = createServer();
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
probe.close(() => done(port));
});
});
}
export type LiveKallpa = {
url: string;
port: number;
process: ChildProcess;
output: () => string;
stop: () => Promise<void>;
};
// Una Kallpa de verdad (node src/main.ts) en un puerto libre, con una base de
// datos temporal y los datos de ejemplo alrededor de hoy.
export async function startKallpa(dir: string = KALLPA): Promise<LiveKallpa> {
const data = mkdtempSync(join(tmpdir(), 'kallpa-'));
const port = await freePort();
const child = spawn('node', ['src/main.ts'], {
cwd: dir,
env: { ...process.env, PORT: String(port), KALLPA_DB: join(data, 'kallpa.db') },
});
let output = '';
child.stdout.on('data', (chunk) => (output += String(chunk)));
child.stderr.on('data', (chunk) => (output += String(chunk)));
const url = `http://127.0.0.1:${port}`;
for (let attempt = 0; attempt < 100 && !output.includes('escuchando'); attempt++) {
await new Promise((done) => setTimeout(done, 50));
}
return {
url,
port,
process: child,
output: () => output,
stop: async () => {
if (child.exitCode === null && child.signalCode === null) {
const closed = new Promise((done) => child.once('close', done));
child.kill();
await closed;
}
rmSync(data, { recursive: true, force: true });
},
};
}
// Un pedido con la ruta tal cual: fetch la normalizaría y no llegaría '//'.
export function rawGet(port: number, path: string): Promise<'respondió' | 'conexión cortada'> {
return new Promise((done) => {
const pending = request({ host: '127.0.0.1', port, path, method: 'GET', agent: false }, (res) => {
res.resume();
done('respondió');
});
pending.on('error', () => done('conexión cortada'));
pending.end();
});
}
// Una fecha a `days` días de hoy en Lima (AAAA-MM-DD).
export function limaDatePlus(days: number): string {
const moved = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return moved.toLocaleDateString('en-CA', { timeZone: 'America/Lima' });
}
// Un POST con cuerpo JSON. Devuelve el código y el cuerpo sin tipar: quien
// llama revisa lo que espera, como haría cualquier cliente HTTP.
export async function postJson(url: string, body?: unknown, headers: Record<string, string> = {}) {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, body: (await response.json()) as Record<string, never> };
}// Un servidor de ejemplo delante de las rutas de Kallpa, para separar dos
// preguntas: quién eres (autenticación) y qué puedes hacer (autorización).
// Los tokens fijos están solo para el ejemplo: un inicio de sesión de verdad
// emite un token aleatorio al comprobar la identidad de la persona.
import http, { type IncomingMessage } from 'node:http';
import { Readable } from 'node:stream';
import type { DatabaseSync } from 'node:sqlite';
import { systemClock, type Clock } from '../../../kallpa/src/domain/clock.ts';
import { HttpError, readJson, sendError } from '../../../kallpa/src/http/json.ts';
import { buildRouter } from '../../../kallpa/src/http/server.ts';
import type { Notifier } from '../../../kallpa/src/notifications/notifier.ts';
import { BookingService } from '../../../kallpa/src/services/booking-service.ts';
export type Session = { memberId: number; role: 'vecino' | 'administracion' };
export type Sessions = ReadonlyMap<string, Session>;
// La decisión de autorización: una función pura, fácil de probar. Lo que no
// está en la lista se niega, también las rutas que se agreguen mañana.
export type BookingOwner = (bookingId: number) => number | undefined;
export function isAllowed(session: Session, method: string, path: string, ownerOf: BookingOwner): boolean {
const parts = path.split('/').filter(Boolean);
const route = `${method} /${parts.join('/')}`;
if (session.role === 'administracion') return true;
if (route === 'GET /api/courts') return true;
if (method === 'GET' && /^\/api\/courts\/\d+\/availability$/.test(path)) return true;
if (route === 'POST /api/bookings') return true;
if (method === 'GET' && /^\/api\/members\/\d+\/bookings$/.test(path)) {
return Number(parts[2]) === session.memberId;
}
if (method === 'POST' && /^\/api\/bookings\/\d+\/cancel$/.test(path)) {
return ownerOf(Number(parts[2])) === session.memberId;
}
return false;
}
export type GuardOptions = {
db: DatabaseSync;
sessions: Sessions;
authorize: boolean;
clock?: Clock;
notifier: Notifier;
};
export function createGuardedServer(options: GuardOptions): http.Server {
const service = new BookingService(options.db);
const router = buildRouter({ service, clock: options.clock ?? systemClock, notifier: options.notifier });
const ownerOf: BookingOwner = (id) => service.findBooking(id)?.member_id;
return http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const session = options.sessions.get(tokenOf(req));
if (session === undefined) throw new HttpError(401, 'UNAUTHENTICATED', 'Inicia sesión.');
const method = req.method ?? 'GET';
if (options.authorize && !isAllowed(session, method, url.pathname, ownerOf)) {
throw new HttpError(403, 'FORBIDDEN', 'No puedes hacer esto.');
}
const match = router.match(method, url.pathname);
if (match.kind !== 'found') throw new HttpError(404, 'NOT_FOUND', 'No existe la ruta.');
// El miembro de una reserva sale de la sesión; si la autorización está
// activa, lo que mande el navegador en memberId no cuenta.
const fromSession = options.authorize && method === 'POST' && url.pathname === '/api/bookings';
const request = fromSession ? await withMemberId(req, session.memberId) : req;
await match.handler({ req: request, res, url, params: match.params });
} catch (error) {
sendError(res, error instanceof HttpError ? error : new HttpError(500, 'INTERNAL_ERROR', 'Error inesperado.'));
}
});
}
function tokenOf(req: IncomingMessage): string {
return (req.headers.authorization ?? '').replace(/^Bearer /, '');
}
// readJson solo itera el cuerpo, así que le basta un stream con el cuerpo nuevo.
async function withMemberId(req: IncomingMessage, memberId: number): Promise<IncomingMessage> {
const body = await readJson(req);
const replaced = typeof body === 'object' && body !== null ? { ...body, memberId } : body;
return Readable.from([Buffer.from(JSON.stringify(replaced))]) as unknown as IncomingMessage;
}{
"name": "licencias-ejemplo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "licencias-ejemplo",
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/node": "26.6.6",
"eslint": "10.14.0",
"fast-check": "4.10.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1"
}
},
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
"integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.5.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.5.1",
"keyv": "^5.6.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
"integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.6.0"
}
},
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
"integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/type-utils": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.72.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
"integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.72.1",
"@typescript-eslint/types": "^8.72.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.72.1",
"@typescript-eslint/tsconfig-utils": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/cacheable": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
"integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.2.0",
"@cacheable/utils": "^2.5.0",
"hookified": "^1.15.0",
"keyv": "^5.6.0",
"qified": "^0.10.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.14.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
"integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
"@humanfs/node": "^0.16.8",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "11.1.5 || >11.1.6 <12",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.6",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-check": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
"integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "11.1.5",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
"integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^6.1.23"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
"version": "6.1.23",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
"integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cacheable": "^2.5.0",
"flatted": "^3.4.2",
"hookified": "^1.15.0"
}
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true,
"license": "ISC"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/hashery": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^1.15.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"dev": true,
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qified": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qified/node_modules/hookified": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz",
"integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.72.1",
"@typescript-eslint/parser": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/utils": "8.72.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}{
"name": "licencias-ejemplo",
"private": true,
"description": "Las devDependencies de la semana 12, para contar sus licencias",
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/node": "26.6.6",
"eslint": "10.14.0",
"fast-check": "4.10.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1"
}
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Lo que el apunte dice de public/ y de WCAG, leído del código fuente: lo que se
// comprueba aquí es la página tal como está publicada. Usarla con el teclado o
// con un lector de pantalla no se puede automatizar con estas pruebas.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { KALLPA } from './tools.ts';
const html = readFileSync(resolve(KALLPA, 'public/index.html'), 'utf8');
const app = readFileSync(resolve(KALLPA, 'public/app.js'), 'utf8');
const css = readFileSync(resolve(KALLPA, 'public/styles.css'), 'utf8');
test('1.3.1 y 4.1.2: cinco campos y ninguna etiqueta', () => {
const controls = html.match(/<(select|input)\b[^>]*>/g) ?? [];
assert.equal(controls.length, 5);
assert.equal(html.includes('<label'), false);
assert.equal(html.includes('aria-label'), false);
assert.match(html, /<input id="team" placeholder="Nombre del equipo">/);
});
test('2.1.1: las horas son div con un listener de clic, sin tabindex ni teclas', () => {
assert.match(app, /<div class="slot free" data-hour/);
assert.equal(app.includes('tabindex'), false);
assert.equal(app.includes('keydown'), false);
assert.match(app, /slotsBox\.addEventListener\('click'/);
});
test('4.1.3: el mensaje de resultado no tiene role ni aria-live', () => {
assert.match(html, /<p id="message"><\/p>/);
assert.equal(html.includes('role='), false);
assert.equal(html.includes('aria-live'), false);
});
test('1.4.1: la hora elegida solo cambia el color del borde', () => {
assert.match(css, /\.slot\.selected \{\s*border-color: #fdd835;\s*\}/);
});
test('1.1.1: la página no tiene imágenes, así que hoy no hay texto alternativo que revisar', () => {
assert.equal(/<img\b/.test(html) || /<img\b/.test(app), false);
});// Agregar un inicio de sesión no cierra el control de acceso: hace falta
// autorizar cada solicitud contra la identidad de la sesión.
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { after, before, describe, test } from 'node:test';
import { fixedClock } from '../../kallpa/src/domain/clock.ts';
import type { BookingConfirmation } from '../../kallpa/src/notifications/notifier.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../kallpa/src/store/seed.ts';
import { createGuardedServer, isAllowed, type Session } from './ejemplos/acceso.ts';
import { postJson } from './tools.ts';
const ROSA = 1;
const LUIS = 2;
const NOW = '2026-03-09T15:00:00Z'; // lunes 10:00 en Lima
const sessions = new Map<string, Session>([
['token-rosa', { memberId: ROSA, role: 'vecino' }],
['token-luis', { memberId: LUIS, role: 'vecino' }],
['token-admin', { memberId: 0, role: 'administracion' }],
]);
const as = (token: string) => ({ authorization: `Bearer ${token}` });
async function startServer(authorize: boolean) {
const db = openDatabase(':memory:');
seedCatalog(db);
const sent: BookingConfirmation[] = [];
const server = createGuardedServer({
db,
sessions,
authorize,
clock: fixedClock(NOW),
notifier: { bookingConfirmed: (c) => sent.push(c) },
});
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
sent,
close: () =>
new Promise<void>((done) => {
server.closeAllConnections();
server.close(() => {
db.close();
done();
});
}),
};
}
type Running = Awaited<ReturnType<typeof startServer>>;
const booking = (memberId: number, startHour: number) => ({
courtId: 1,
memberId,
date: '2026-03-10',
startHour,
teamName: 'Real Barranco',
});
describe('solo autenticación: hay sesión, pero el problema sigue', () => {
let app: Running;
before(async () => (app = await startServer(false)));
after(() => app.close());
test('sin token, 401', async () => {
assert.equal((await fetch(`${app.url}/api/members`)).status, 401);
});
test('con la sesión de Rosa se cancela una reserva de Luis', async () => {
const created = await postJson(`${app.url}/api/bookings`, booking(LUIS, 8), as('token-luis'));
const id = (created.body.booking as { id: number }).id;
const cancelled = await postJson(`${app.url}/api/bookings/${id}/cancel`, undefined, as('token-rosa'));
assert.equal(cancelled.status, 200);
});
test('con la sesión de Rosa se reserva a nombre de Luis y se usa su descuento', async () => {
const own = await postJson(`${app.url}/api/bookings`, booking(ROSA, 9), as('token-rosa'));
const other = await postJson(`${app.url}/api/bookings`, booking(LUIS, 10), as('token-rosa'));
assert.equal((own.body.booking as { priceCents: number }).priceCents, 9000);
assert.equal((other.body.booking as { priceCents: number }).priceCents, 8100);
});
test('con la sesión de Rosa se leen los celulares de todos', async () => {
const response = await fetch(`${app.url}/api/members`, { headers: as('token-rosa') });
const { members } = (await response.json()) as { members: { phone: string }[] };
assert.equal(response.status, 200);
assert.equal(members.length, 10);
assert.ok(members.every((m) => /^9\d{8}$/.test(m.phone)));
});
});
describe('autenticación y autorización', () => {
let app: Running;
before(async () => (app = await startServer(true)));
after(() => app.close());
test('Rosa no cancela la reserva de Luis; Luis sí', async () => {
const created = await postJson(`${app.url}/api/bookings`, booking(LUIS, 8), as('token-luis'));
const id = (created.body.booking as { id: number }).id;
const denied = await postJson(`${app.url}/api/bookings/${id}/cancel`, undefined, as('token-rosa'));
assert.equal(denied.status, 403);
const allowed = await postJson(`${app.url}/api/bookings/${id}/cancel`, undefined, as('token-luis'));
assert.equal(allowed.status, 200);
});
test('el memberId que manda el navegador no cuenta: la reserva queda a nombre de la sesión', async () => {
const created = await postJson(`${app.url}/api/bookings`, booking(LUIS, 9), as('token-rosa'));
const made = created.body.booking as { memberId: number; priceCents: number };
assert.equal(made.memberId, ROSA);
assert.equal(made.priceCents, 9000);
assert.equal(app.sent.at(-1)?.memberName, 'Rosa Quispe Mamani');
});
test('Rosa ve sus reservas, no las de Luis', async () => {
const own = await fetch(`${app.url}/api/members/${ROSA}/bookings`, { headers: as('token-rosa') });
const other = await fetch(`${app.url}/api/members/${LUIS}/bookings`, { headers: as('token-rosa') });
assert.equal(own.status, 200);
assert.equal(other.status, 403);
});
test('la lista de vecinos y la caja del día, solo para la administración', async () => {
const paths = ['/api/members', '/api/reports/daily?date=2026-03-10'];
for (const path of paths) {
assert.equal((await fetch(app.url + path, { headers: as('token-rosa') })).status, 403);
assert.equal((await fetch(app.url + path, { headers: as('token-admin') })).status, 200);
}
});
});
describe('isAllowed niega por defecto', () => {
const rosa: Session = { memberId: ROSA, role: 'vecino' };
const nobody = () => undefined;
test('una ruta que nadie listó se niega', () => {
assert.equal(isAllowed(rosa, 'GET', '/api/ruta-de-manana', nobody), false);
assert.equal(isAllowed(rosa, 'DELETE', '/api/courts', nobody), false);
});
test('una reserva que no existe no tiene owner: se niega', () => {
assert.equal(isAllowed(rosa, 'POST', '/api/bookings/999/cancel', nobody), false);
});
});// Lo que el apunte dice de la Kallpa publicada, comprobado contra el servidor de
// verdad. Estas pruebas pasan porque las debilidades están ahí: una corrección
// en la copia de ustedes haría fallar la prueba correspondiente, y está bien.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { after, before, describe, test } from 'node:test';
import { KALLPA, limaDatePlus, postJson, rawGet, run, startKallpa, type LiveKallpa } from './tools.ts';
type Booking = { id: number; memberId: number; priceCents: number; teamName: string; status: string };
const DATE = limaDatePlus(2);
const booking = (courtId: number, memberId: number, startHour: number, teamName: string) => ({
courtId,
memberId,
date: DATE,
startHour,
teamName,
});
describe('control de acceso roto (A01:2025)', () => {
let app: LiveKallpa;
before(async () => (app = await startKallpa()));
after(() => app.stop());
test('GET /api/members publica nombre, celular y plan de los diez vecinos', async () => {
const { members } = (await (await fetch(`${app.url}/api/members`)).json()) as {
members: { id: number; name: string; phone: string; plan: string }[];
};
assert.equal(members.length, 10);
assert.deepEqual(Object.keys(members[0]).sort(), ['id', 'name', 'phone', 'plan']);
assert.ok(members.some((m) => m.phone === '987654321' && m.name === 'Rosa Quispe Mamani'));
});
test('las reservas de cualquier vecino se leen cambiando un número en la URL', async () => {
for (let id = 1; id <= 10; id++) {
assert.equal((await fetch(`${app.url}/api/members/${id}/bookings`)).status, 200);
}
});
test('se reserva a nombre de un socio y sale con su descuento; el aviso le llega a él', async () => {
const regular = await postJson(`${app.url}/api/bookings`, booking(1, 1, 7, 'Prueba'));
const socio = await postJson(`${app.url}/api/bookings`, booking(1, 2, 8, 'Prueba'));
const [a, b] = [regular.body.booking as Booking, socio.body.booking as Booking];
assert.equal(b.memberId, 2);
assert.equal(b.priceCents, a.priceCents * 0.9);
await new Promise((done) => setTimeout(done, 50));
assert.match(app.output(), /\[whatsapp\] para 912345678: Hola Luis Huamán Torres/);
});
test('cualquiera cancela una reserva de otra persona con solo su número', async () => {
const made = await postJson(`${app.url}/api/bookings`, booking(1, 3, 9, 'Prueba'));
const { id } = made.body.booking as Booking;
const cancelled = await postJson(`${app.url}/api/bookings/${id}/cancel`);
assert.equal(cancelled.status, 200);
assert.equal((cancelled.body.booking as Booking).status, 'cancelled');
});
test('los números de reserva son correlativos', async () => {
const first = await postJson(`${app.url}/api/bookings`, booking(1, 1, 10, 'Prueba'));
const second = await postJson(`${app.url}/api/bookings`, booking(1, 1, 11, 'Prueba'));
assert.equal((second.body.booking as Booking).id, (first.body.booking as Booking).id + 1);
});
test('la caja del día se lee sin identificarse', async () => {
const report = await fetch(`${app.url}/api/reports/daily?date=${limaDatePlus(0)}`);
assert.equal(report.status, 200);
assert.ok('revenueCents' in ((await report.json()) as object));
});
});
describe('inyección (A05:2025)', () => {
let app: LiveKallpa;
before(async () => (app = await startKallpa()));
after(() => app.stop());
const availabilityAt = async (courtId: number) => {
const res = await fetch(`${app.url}/api/courts/${courtId}/availability?date=${DATE}`);
return ((await res.json()) as { slots: { hour: number; teamName?: string }[] }).slots;
};
test('el servidor guarda y devuelve el HTML tal cual: el peligro está en cómo se muestra', async () => {
const payload = '<img src=x onerror=alert(1)>';
assert.ok(payload.length <= 40);
const made = await postJson(`${app.url}/api/bookings`, booking(3, 1, 8, payload));
assert.equal(made.status, 200);
const slots = await availabilityAt(3);
assert.equal(slots.find((s) => s.hour === 8)?.teamName, payload);
});
test('una inyección SQL queda como texto: las sentencias preparadas la neutralizan', async () => {
const payload = "x' OR 1=1; DROP TABLE bookings; --";
const made = await postJson(`${app.url}/api/bookings`, booking(3, 1, 9, payload));
assert.equal((made.body.booking as Booking).teamName, payload);
const slots = await availabilityAt(3);
assert.equal(slots.find((s) => s.hour === 9)?.teamName, payload);
});
});
describe('cabeceras (A02:2025)', () => {
let app: LiveKallpa;
before(async () => (app = await startKallpa()));
after(() => app.stop());
test('la página no manda ninguna de las cabeceras que recomienda OWASP', async () => {
const { headers } = await fetch(`${app.url}/`);
for (const name of ['content-security-policy', 'x-content-type-options', 'referrer-policy']) {
assert.equal(headers.get(name), null, name);
}
});
});
describe('condiciones excepcionales (A10:2025)', () => {
test('un pedido a // apaga el servidor: el error ocurre fuera del try', async () => {
const app = await startKallpa();
try {
const answer = await rawGet(app.port, '//');
assert.equal(answer, 'conexión cortada');
await new Promise((done) => setTimeout(done, 200));
assert.notEqual(app.process.exitCode, null, 'el proceso debería haber terminado');
assert.match(app.output(), /TypeError: Invalid URL/);
} finally {
await app.stop();
}
});
test('una ruta mal codificada responde 500 sin revelar nada, y el servidor sigue', async () => {
const app = await startKallpa();
try {
const response = await fetch(`${app.url}/api/courts/%E0/availability?date=${DATE}`);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), {
error: { code: 'INTERNAL_ERROR', message: 'Ocurrió un error inesperado.' },
});
assert.equal((await fetch(`${app.url}/api/courts`)).status, 200);
} finally {
await app.stop();
}
});
});
describe('datos personales en los logs (A09:2025)', () => {
test('el aviso escribe celular, nombre y horario en la salida del servidor', async () => {
const app = await startKallpa();
try {
await postJson(`${app.url}/api/bookings`, booking(1, 1, 7, 'Prueba'));
await new Promise((done) => setTimeout(done, 50));
assert.match(app.output(), /\[whatsapp\] para 987654321: Hola Rosa Quispe Mamani, tu reserva de Fulbito 1/);
} finally {
await app.stop();
}
});
});
describe('lo que NO es una debilidad', () => {
test('los archivos estáticos salen de una lista fija: no se llega a src/', async () => {
const app = await startKallpa();
try {
for (const path of ['/../src/main.ts', '/data/kallpa.db', '/src/main.ts', '/.git/config']) {
assert.equal((await fetch(app.url + path)).status, 404, path);
}
} finally {
await app.stop();
}
});
test('el SQL armado con texto solo usa texto fijo del programa', () => {
const source = readFileSync(resolve(KALLPA, 'src/seed.ts'), 'utf8');
assert.match(source, /count\('courts'\)/);
assert.match(source, /SELECT COUNT\(\*\) AS n FROM \$\{table\}/);
});
});
describe('análisis estático: eslint.sinks.js', () => {
const report = (): { rel: string; line: number; message: string }[] => {
const lint = run('eslint', ['--no-config-lookup', '-c', 'sinks.config.js', 'src', 'public', '-f', 'json'], 'build/kallpa');
const files = JSON.parse(lint.output) as { filePath: string; messages: { line: number; message: string }[] }[];
return files.flatMap((file) =>
file.messages.map((m) => ({ rel: file.filePath.replace(/^.*build\/kallpa\//, ''), line: m.line, message: m.message })),
);
};
test('encuentra los cuatro innerHTML de app.js y dos templates de SQL', () => {
const found = report();
const inner = found.filter((f) => f.message.startsWith('innerHTML')).map((f) => `${f.rel}:${f.line}`);
assert.deepEqual(inner, ['public/app.js:36', 'public/app.js:41', 'public/app.js:47', 'public/app.js:58']);
const sql = found.filter((f) => f.message.startsWith('SQL')).map((f) => `${f.rel}:${f.line}`);
assert.deepEqual(sql.sort(), ['src/seed.ts:18', 'src/store/database.ts:54']);
});
});import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { KALLPA_PAIRS, contrastRatio, passesAA, relativeLuminance } from './contrast.ts';
import { KALLPA } from './tools.ts';
test('los extremos: negro sobre blanco es 21:1 y un color consigo mismo, 1:1', () => {
assert.equal(relativeLuminance('#000000'), 0);
assert.equal(relativeLuminance('#ffffff'), 1);
assert.equal(contrastRatio('#000000', '#ffffff'), 21);
assert.equal(contrastRatio('#43a047', '#43a047'), 1);
});
test('el orden de los colores no cambia la razón', () => {
assert.equal(contrastRatio('#222222', '#b5b5b5'), contrastRatio('#b5b5b5', '#222222'));
});
test('los valores de Kallpa que cita el apunte', () => {
const ratio = (text: string, background: string) => Number(contrastRatio(text, background).toFixed(2));
assert.equal(ratio('#b5b5b5', '#ffffff'), 2.05);
assert.equal(ratio('#ffffff', '#43a047'), 3.3);
assert.equal(ratio('#ffffff', '#e53935'), 4.23);
assert.equal(ratio('#222222', '#ffffff'), 15.91);
assert.equal(ratio('#0b6e4f', '#ffffff'), 6.25);
});
test('de las ocho combinaciones de Kallpa, seis no cumplen 1.4.3', () => {
const failing = KALLPA_PAIRS.filter((p) => !passesAA(contrastRatio(p.text, p.background), p.largeText));
assert.deepEqual(
failing.map((p) => p.where),
['.hint', '.mine .cancelled', '.slot.free', '.slot.taken', '#message.ok', '#message.error'],
);
});
test('4.23:1 no alcanza en texto normal, pero sí en texto grande', () => {
assert.equal(passesAA(4.23, false), false);
assert.equal(passesAA(4.23, true), true);
});
test('1.4.11: el borde de la hora elegida no llega a 3:1 con lo que tiene al lado', () => {
assert.equal(Number(contrastRatio('#fdd835', '#ffffff').toFixed(2)), 1.4);
assert.equal(Number(contrastRatio('#fdd835', '#43a047').toFixed(2)), 2.37);
});
test('los colores de la tabla son los de styles.css', () => {
const css = readFileSync(resolve(KALLPA, 'public/styles.css'), 'utf8');
for (const color of ['#222', '#0b6e4f', '#b5b5b5', '#43a047', '#e53935']) {
assert.ok(css.includes(color), `${color} no está en styles.css`);
}
});// Las licencias de un lockfile real con las herramientas de la semana 12
// (licencias/package-lock.json), contadas igual que en el paso 7 del laboratorio.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { HERE, KALLPA, run } from './tools.ts';
type Lock = { packages: Record<string, { license?: string; dev?: boolean }> };
const lock = JSON.parse(readFileSync(resolve(HERE, 'licencias/package-lock.json'), 'utf8')) as Lock;
const installed = Object.entries(lock.packages).filter(([key]) => key !== '');
test('el lockfile tiene 100 paquetes con estas licencias', () => {
const counts = new Map<string, number>();
for (const [, pkg] of installed) counts.set(String(pkg.license), (counts.get(String(pkg.license)) ?? 0) + 1);
assert.equal(installed.length, 100);
assert.deepEqual(
Object.fromEntries(counts),
{ MIT: 74, 'Apache-2.0': 13, 'BSD-2-Clause': 6, ISC: 5, 'BlueOak-1.0.0': 1, 'BSD-3-Clause': 1 },
);
});
test('todos son de desarrollo: ninguno llega al producto', () => {
assert.ok(installed.every(([, pkg]) => pkg.dev === true));
});
test('ninguna es copyleft', () => {
assert.ok(installed.every(([, pkg]) => !/GPL/.test(String(pkg.license))));
});
test('el comando del laboratorio da lo mismo', () => {
const filter = '.packages | to_entries[] | select(.key != "") | .value.license';
const { output } = run('jq', ['-r', filter, 'licencias/package-lock.json']);
assert.equal(output.trim().split('\n').filter((line) => line === 'MIT').length, 74);
});
test('Kallpa todavía no declara licencia', () => {
const manifest = JSON.parse(readFileSync(resolve(KALLPA, 'package.json'), 'utf8')) as Record<string, unknown>;
assert.equal('license' in manifest, false);
assert.equal(manifest.dependencies, undefined);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 15: seguridad, privacidad, accesibilidad y licencias. Se usa dentro del
# contenedor del curso.
#
# make copia Kallpa en build/kallpa, para analizarla sin tocar la
# original
# make test revisa los tipos (tsc), corre ESLint y las pruebas
# (*.test.ts), que comprueban lo que dice el apunte, y las del
# laboratorio si está la carpeta solution/
# make contraste la razón de contraste de cada combinación de styles.css
# make sinks los innerHTML y los templates de SQL de Kallpa
# make licencias cuenta las licencias de licencias/package-lock.json
# make clean borra build/
#
# Las pruebas levantan la Kallpa del curso (../../kallpa) con una base de datos
# temporal, sin modificarla. La guía del laboratorio es template-auditoria.md.
KALLPA ?= ../../kallpa
.PHONY: all test contraste sinks licencias clean
all: build/kallpa
build/kallpa:
mkdir -p build
cp -r $(KALLPA) $@
rm -f $@/data/*.db $@/data/*.db-*
cp eslint.sinks.js $@/sinks.config.js
test: all
tsc -p .
eslint .
node --test "*.test.ts"
@if [ -d solution ]; then node --test "solution/*.test.ts"; fi
contraste:
@node contrast.ts
sinks: all
@cd build/kallpa && (eslint --no-config-lookup -c sinks.config.js src public || true)
licencias:
@jq -r '.packages | to_entries[] | select(.key != "") | .value.license' licencias/package-lock.json | sort | uniq -c | sort -rn
clean:
rm -rf build{
"name": "week-15",
"private": true,
"type": "module"
}{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["*.ts", "ejemplos"],
"exclude": ["build", "solution"]
}