Ir al contenido

Limitaciones de la versión 1

Esta página existe para que decidas antes de construir sobre Kevin CMS, no después. Son nueve, y cada una está comprobada contra el código, no contra una hoja de ruta.

Si alguna de ellas es un requisito de tu proyecto, la versión 1 no te sirve. Es mejor saberlo ahora.

Y hay una consecuencia menos evidente: la API local no comprueba access en absoluto. Astro.locals.cms lee y escribe siempre. Ese control vive solo en la capa REST, así que una página tuya que exponga una escritura tiene que poner su propia guarda.

Guardar publica. No hay copia de trabajo, ni historial, ni «volver a la versión de ayer»: lo que escribes en el editor es lo que hay en la base en cuanto pulsas guardar, y lo anterior no se guarda en ninguna parte.

Lo que sí puedes hacer es un campo select con draft y published y filtrar por él en tus consultas, que es lo que hace Tu primera colección. Es un estado que tú controlas, no un sistema de borradores: el documento es uno solo, y editarlo cambia lo que ve todo el mundo. Ver El editor.

Vale igual para los globals, y ahí con un matiz más: un global vive en Cloudflare KV, así que además de no tener versiones no tiene escrituras condicionales —no hay ifMatch ni If-Match, y entre dos guardados simultáneos del mismo campo gana el último— ni consistencia inmediata: el cambio tarda hasta un minuto en verse fuera del isolate que lo guardó. Es el mismo trato de los ajustes.

Un documento tiene un idioma: el que escribiste. No hay campos por idioma, ni versiones traducidas de un mismo documento, ni rutas por locale.

El ajuste locale de los ajustes del sitio existe, se guarda y se sirve, pero hoy no hace nada. El panel está en español y tampoco se traduce. Ver Lo que todavía no hay.

No existe un tipo de campo de texto enriquecido. Los tipos son nuevetext, textarea, number, checkbox, date, select, json, relationship y upload— y el texto largo se escribe como Markdown dentro de un textarea, que tu plantilla convierte a HTML al pintarlo.

Eso significa que quien escribe teclea Markdown a pelo: no hay barra de herramientas, ni negrita con ⌘B, ni previsualización. Si tu redacción no va a aceptar eso, es un problema real y no un detalle. Ver Fuera de la versión 1.

No se puede declarar una lista repetible de subcampos, ni un constructor de bloques por página. Para estructuras a medida está json: entra cualquier cosa serializable y vuelve tal cual, anidamiento incluido.

El precio es que json se tipa como unknown, así que lo estrechas tú, y que el panel lo edita como texto validado, no como un formulario con sus campos. Una lista de tres enlaces cabe perfectamente; un modelo de contenido entero, no.

La única API sobre HTTP es la API REST, con sus seis endpoints por colección. No está previsto añadir GraphQL: dentro de una plantilla de Astro la API local ya llega tipada desde el codegen y sin salto de red, que es la mitad del problema que GraphQL resuelve.

Tampoco hay transformaciones de imagen. Los ficheros salen de R2 tal y como entraron; para redimensionar y recortar está Cloudflare Images.

7. No se puede recrear una tabla a la que apunte una relación

Sección titulada «7. No se puede recrear una tabla a la que apunte una relación»

Cambiar el tipo de un campo obliga a SQLite a recrear la tabla: copia las filas a una tabla nueva, borra la vieja y renombra. Ese DROP TABLE es el problema, y sólo cuando otra tabla apunta a la que se recrea.

Por eso cms db:migrate se planta: cuando la migración que acaba de generar recrea una tabla a la que apunta alguna clave foránea, borra esa migración y sale con error, nombrando las tablas que perderían filas. Recrear una tabla que sólo es hija —posts, por ejemplo— es inofensivo y se genera con normalidad.

La salida es escribir esa migración a mano, respaldando y restaurando las filas de las hijas: Recrear una tabla a mano.

8. El techo de un fichero no son los 5 TiB de R2

Sección titulada «8. El techo de un fichero no son los 5 TiB de R2»

R2 guarda objetos de hasta 4,995 TiB. Kevin CMS no te va a dejar subir uno: los bytes entran por tu Worker, y lo que limita es el camino, no el almacén. La propia documentación de R2 lo dice en su nota al pie: si hay un Worker en medio, manda el límite de petición del Worker.

Son dos límites, y los dos son de la cuenta, no del plan de Workers:

  • El cuerpo de una petición: 100 MB en Free y en Pro, 200 MB en Business, 500 MB en Enterprise. El borde contesta 413 antes de que la petición llegue a tu código.
  • La memoria: 128 MB por isolate, compartidos entre las peticiones que corran a la vez. Pasarse no es una excepción que puedas capturar, es el error 1102 Worker exceeded resource limits.

De ahí sale la cuenta. R2 admite 10.000 partes por objeto y cada parte viaja en su propia petición, así que con partes de 95 MiB —por debajo de los 100 MB del plan más bajo—:

10.000 partes × 95 MiB = 950.000 MiB ≈ 928 GiB ≈ 996 GB por fichero

Ese es el techo de la plataforma para la subida por trozos. Encima está tu política, maxFileSize, que por defecto son 5 GiB: lo que admite una colección es el menor de los dos, así que sin tocar nada te topas ahí y no en los 928 GiB.

Por la ruta de un solo golpe el techo es otro y mucho más bajo: 25 MB, porque el envío se carga entero en memoria y son los 128 MB del isolate los que mandan, no el cuerpo de petición de tu plan. Subir maxFileSize no mueve ese número: lo que compra es capacidad por trozos.

Y hay dos cosas más que conviene tener claras antes de contar con esto:

  • No hay cliente que trocee. El servidor sirve las cuatro acciones; partir el fichero, mandar los trozos, reintentar el que falle y enseñar un porcentaje lo escribes tú.
  • El panel sigue subiendo de un golpe, con el mismo tope de 25 MB por envío —todos los ficheros del formulario juntos—, porque el formulario carga el envío entero en memoria. Es el mismo número que la ruta REST de un golpe y por la misma razón, y tampoco se mueve al subir maxFileSize: la mediateca y el campo upload enseñan el límite que de verdad va a aplicar. Que la API por trozos admita mucho más que el panel es esperado: son dos caminos distintos.
  • El login y el asistente topan mucho antes: 64 KB por envío. Son las dos únicas pantallas a las que se llega sin sesión y ninguna acepta ficheros —un correo, una contraseña y unos pocos campos de texto—, así que darles el presupuesto de una subida deja la única puerta anónima mucho más holgada de lo que necesita. No se configura, y solo se nota si automatizas el login con un cuerpo enorme: entonces el formulario contesta que el envío se pasa del máximo.

9. El límite de intentos es por IP fuera de la sesión, y por usuario dentro

Sección titulada «9. El límite de intentos es por IP fuera de la sesión, y por usuario dentro»

/api/auth/*, el formulario de /admin/login y las escrituras de /admin/account tienen un techo de intentos, con el contador en una tabla de D1 (rateLimit) para que sobreviva al reciclado de los isolates de Workers. Los números:

Ruta Máximo Ventana Clave
/api/auth/sign-in/email, y también POST /admin/login y /admin/setup (contador aparte) 5 300 s IP
/api/auth/sign-up/email 3 1 hora IP
/api/auth/request-password-reset, /send-verification-email y /forget-password 3 1 hora IP
El resto de /api/auth/* 60 60 s IP
POST /admin/account con action=password (cambiar la contraseña) 5 300 s usuario
POST /admin/account con action=profile o revoke-session (el resto de la cuenta, un solo cubo) 60 60 s usuario

La API contesta el 429 de better-auth tal cual, con la cabecera X-Retry-After; el panel pinta «Demasiados intentos» sobre la misma pantalla —la de login o la de la cuenta—, con Retry-After y sin delatar si el correo existe.

La cuenta se cuenta por usuario y no por IP, porque ahí siempre hay sesión: una IP compartida —una oficina, una VPN— no comparte cubo entre dos cuentas, y una sesión robada que rota de IP no esquiva el techo. Además, un cambio de contraseña con la actual equivocada ya no paga el hash de la nueva: la actual se verifica contra D1 antes de llamar a better-auth, así que cada fallo cuesta una sola verificación, no llega a better-auth y nunca escribe en la tabla account. Los 5 son intentos, no fallos: tres equivocadas y una correcta dentro de la ventana pasan.

Lo que no hay: fuera de la sesión la clave es la IP —la de cf-connecting-ip—, así que un atacante con muchas IP puede seguir intentando contra la misma cuenta. No hay tope por dirección de correo, ni bloqueo de cuenta tras N fallos, y una IPv6 comparte cubo con todo su /64, que es el defecto de better-auth. Dentro de la sesión no hay tope por IP además del de por usuario: quien tenga muchas sesiones válidas sondea cada una a su ritmo. Quien quiera un techo más duro que el del paquete lo pone delante: una regla de rate limiting de Cloudflare sobre /api/auth/* y /admin/login corta antes de que la petición llegue al Worker.

Cada una de estas páginas mantiene su propia tabla de lo que le falta, y esa es la que manda:

  • El administrador — proveedores sociales, invitar a un segundo usuario, pantalla de cuenta, barra de progreso al subir.
  • Autenticación — pantallas de verificación y restablecimiento, invitaciones por correo, Google y GitHub.
  • Ajustes — credenciales de OAuth, y más campos que los seis que hay.
  • Ficheros — lo que no cubre la subida a R2.
  • Globals — versiones, access como función, localización, y lo que un documento único no hace nunca.
  • Hooks — lo que un hook no tiene — no hay beforeOperation, beforeRead, afterError, hooks de auth, user ni siblingData.