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 diez, 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.

1. Sin access, cualquier cuenta con sesión lo puede todo

Sección titulada «1. Sin access, cualquier cuenta con sesión lo puede todo»

Y hay una consecuencia que conviene tener presente al migrar: la API local es anónima por defecto. Astro.locals.cms ya no lee y escribe como el sistema: una llamada que no dice quién la hace —sin { user } y sin overrideAccess— es la de un visitante, y lanza UnauthorizedError en toda escritura y en toda lectura que no sea de una colección o un global con read: 'public'. Donde el servidor decide qué mostrar, la llamada pasa overrideAccess: true.

Lo que el control de acceso todavía no hace, comprobado, está en Lo que el control de acceso no cubre: con funciones declaradas, una subida anónima se lee entera antes de contestar el 401; la biblioteca ssd sigue siendo pública para la API local y su acceso por funciones no existe todavía; y una cuenta tiene un solo role, sin una lista de roles.

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.

4. El rich text no tiene tablas ni estilos a medida

Sección titulada «4. El rich text no tiene tablas ni estilos a medida»

El campo richText cubre el formato de un texto: negrita, cursiva, subrayado, tachado, código en línea, títulos de nivel 1 a 6, listas (también de tareas), enlaces, citas, bloques de código, líneas horizontales e imágenes. Lo que no tiene:

  • Tablas, notas al pie, comentarios ni edición colaborativa.
  • Color, fuentes ni alineación: el formato no admite estilos en línea, y la API rechaza un texto o un bloque que los lleve.
  • Nodos propios declarados en la configuración. El formato tiene los suyos, y la versión 1 no los expone.
  • Migración automática. El markdown que ya tengas lo conviertes tú con un script, como explica la guía.

Las imágenes externas dependen de quien las sirve, y el paquete no las descarga. Ver la guía del texto enriquecido.

5. Los campos array y blocks se guardan y se leen, con límites

Sección titulada «5. Los campos array y blocks se guardan y se leen, con límites»

Se pueden declarar —una lista repetible de subcampos, un constructor de bloques por página—, se validan, create, update y delete las guardan y find, findOne, findByID y depth las leen: cada fila vive en una tabla hija y una lista que llega reemplaza las filas de la anterior. El panel edita las dos listas fila a fila y deja reordenarlas arrastrando. No se puede filtrar ni ordenar por sus subcampos (QueryError), y leerla cuesta una sentencia por tabla hija en cada lectura. Tampoco caben dentro de un group, que agrupa subcampos fijos y no se repite, ni una dentro de otra. Ver array y blocks. La guía Construir páginas con blocks las declara, las edita en el panel y las pinta en Astro. json sigue siendo lo que guarda cualquier cosa serializable en una sola columna, anidamiento incluido.

En un global la lista es el JSON de sus filas en una clave de KV, con las mismas reglas y sin tabla hija. Se guarda entera, y como KV no tiene escrituras condicionales no hay If-Match: dos guardados a la vez de la misma lista son last-write-wins sobre toda ella. Una clave de KV admite 25 MiB, y en un blocks los 2 000 000 bytes de cada tipo de bloque se suman en la misma clave. Y como update() valida el documento fundido entero, una fila guardada que ya no cumple el config (una clave que quitaste, un tipo de bloque retirado, un maxRows más bajo) impide guardar el global hasta que la corriges desde la propia lista; nada se descarta en silencio.

Tienen sus propias limitaciones, que conviene conocer al diseñar: una lista admite como mucho 1000 filas (100 por defecto), un solo nivel de anidamiento, y un guardado de un documento con muchas listas puede pasar de las 50 consultas por invocación del plan Free de D1 (cms db:generate lo avisa; ver el límite de sentencias): un update cuesta 3T + 2 sentencias y un delete 2T + 2, con T las tablas hijas, vengan 10 filas o 1000. Cada tabla hija de una lista cabe en 2 000 000 bytes de JSON, y quien actualiza una lista la reemplaza entera: sin If-Match, dos guardados a la vez sobre la misma lista son last-write-wins sobre toda ella, y el panel no manda If-Match. Y las tablas hijas no llevan clave foránea al documento: un DELETE a mano contra D1, o un onDelete: 'cascade' de otra colección que se lleve el documento, deja filas huérfanas.

Los campos con hasMany —un text, un number o un select que guardan una lista de valores sueltos— son una tabla hija más y heredan todo lo de arriba (se reemplazan enteros, T sentencias más por guardado), y añaden lo suyo:

  • El panel los edita como una lista ordenada (filas con su input en text y number, fichas en select), que necesita JavaScript: sin él el campo guarda lo que traía, pero no se puede editar. Un valor guardado que no es el JSON de un array se enseña como JSON para poder arreglarlo. Guardar un documento sin tocar la lista deja los valores como estaban.
  • Una condición de access.update o access.delete no puede nombrarlos. Se vuelve a comprobar después de que la escritura reemplace los valores que lee, y dejaría el guardado a medias: se rechaza con un AccessError (un 500 por REST) en el momento de escribir. En access.read sí valen. Filtra por un campo del propio documento.
  • No hay índice sobre el valor y no se puede ordenar por ellos. Un where recorre los valores de los documentos candidatos, y sort es un QueryError. Tampoco admiten unique ni index, y los duplicados dentro de un mismo campo se guardan.
  • Un global no admite hasMany (INVALID_HAS_MANY): vive en KV y no tiene tablas hijas. Usa un array con un subcampo o un json.
  • textarea con hasMany no existe todavía (INVALID_HAS_MANY). Usa un array con un subcampo textarea.
  • Convertir un campo normal en hasMany (o al revés) no es aditivo, y cms db:migrate se niega a generar esa migración: ver Convertir un campo en hasMany. Renombrar un hasMany con la pista create borra sus valores.
  • En TypeScript, un campo declarado con un type que es una unión ('text' | 'number') en un objeto sin anotar ya no se asigna a ConfigField: anota el tipo (: ConfigField) o declara el literal.

Los campos relationship y upload con hasMany —varios documentos o ficheros en un campo, con uno o varios destinos— son también una tabla hija (posts_related) con una columna por colección destino, y añaden lo suyo:

  • El panel los edita como una lista ordenada, con buscador, botones y teclado (cómo), y se reordena arrastrando su asa, con los botones o con Alt + ↑ / ↓. Si el valor no es el JSON de un array, o el destino no tiene un useAsTitle de texto en el que buscar (text, textarea o select), el campo vuelve al JSON de su array (ids, o { "relationTo": …, "value": … }), para poder leerlo y arreglarlo. Guardar sin tocar la lista deja los elementos como estaban.
  • Un elemento que el panel no puede leer se ve «No disponible», y se queda en el valor hasta que lo quites tú: el documento ya no existe, está en la carpeta reservada de la biblioteca, o tu access.read lo niega. El panel de detalle de Medios no conoce tus permisos sobre los destinos, así que ahí un destino que no puedes leer se ofrece igual y su buscador se queda en el mensaje del servidor.
  • Borrar un destino los afecta según onDelete. Con setNull (por defecto) el elemento desaparece de la lista sin avisar y no se renumera: si el campo es required o tiene minRows, el documento puede quedar fuera de esas reglas hasta que se guarde de nuevo. Con restrict, el borrado se rechaza (RESTRICTED) y nombra el campo. onDelete: 'cascade' no existe en un hasMany (INVALID_HAS_MANY).
  • El borrado de un fichero que está en una galería (upload con hasMany) se rechaza (REFERENCED), igual que si lo apuntara un upload suelto. La respuesta es un 400 de validación, no un 409: el CMS no tiene ningún 409 hoy.
  • Un upload con hasMany apunta a una sola colección. Varios destinos solo valen en un relationship (INVALID_HAS_MANY si pones una lista en to de un upload).
  • Las referencias a un documento que no existe se detectan al guardar por la clave foránea, no antes: el guardado falla entero (INVALID_RELATION, con la ruta campo.N del elemento) y no se guarda nada.
  • No se puede ordenar por ellos, ni filtrar por un id suelto en un campo con varios destinos. sort es un QueryError; en un campo con varios destinos el where se escribe por colección (related.categories) y la barra de filtros del panel solo ofrece exists.
  • Quitar un destino de to pierde las referencias a él, y cambiar de destino o convertir un campo suelto en hasMany (o al revés) no es aditivo: ver Tablas hijas de un hasMany de relaciones.

Pasar un json que ya tienes a un array o a blocks no copia nada: la migración que genera cms db:migrate crea la tabla hija y quita la columna, y las filas que había en ella se pierden salvo que añadas tú la copia en SQL. La receta, probada, está en Pasar un json que ya tenías. En un global basta con guardar de nuevo.

La única API sobre HTTP es la API REST, con sus seis endpoints por colección. Los endpoints propios no se declaran en el config: son rutas de Astro. 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 del almacén 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.

Lo que sí recrea una tabla: cambiar el tipo de un campo, el onDelete de una relación, o quitar una relación o un upload. Lo que ya no la recrea: marcar o desmarcar required, y añadir, quitar o reordenar las opciones de un select, que no llevan NOT NULL ni CHECK desde esta versión (por qué).

Una consecuencia: un campo required que declares en users lo hace cumplir la API del CMS (create y update), pero no el alta de better-auth, que escribe la fila sin pasar por la validación del CMS: esa fila puede nacer con el campo vacío hasta que se actualice por la API.

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.

Con un perfil s3 el camino es el mismo —los bytes pasan por tu Worker—, así que el techo no cambia: lo que sí cambia es lo que el servicio de destino admita por parte (5 MiB como mínimo, 5 GiB como máximo, 10.000 partes), que cabe con holgura en los trozos de 25 a 95 MiB del CMS. Ver Almacenamiento de ficheros.

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, el asistente y las dos pantallas de recuperar la contraseña topan mucho antes: 64 KB por envío. Son las únicas pantallas a las que se llega sin sesión y ninguna acepta ficheros —un correo, una o dos contraseñas y unos pocos campos de texto—, así que darles el presupuesto de una subida deja la 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, los de recuperar la contraseña 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/api-key/create (acuña credenciales de larga vida) 10 1 hora IP
/api/auth/request-password-reset, /send-verification-email y /forget-password, y también POST /admin/forgot (el mismo contador) 3 1 hora IP
El resto de /api/auth/* (incluidas las lecturas y los borrados de claves), y también POST /admin/reset/:token (el mismo contador) 60 60 s IP
POST /admin/account con action=password (cambiar la contraseña) 5 300 s usuario
POST /admin/account con action=profile, revoke-session o api-key-revoke (el resto de la cuenta, un solo cubo) 60 60 s usuario
POST /admin/account con action=api-key-create o api-key-regenerate (acuñan credenciales; un solo cubo) 10 1 hora usuario
POST de administrar cuentas ajenas (user-create, user-password, user-ban, user-role…; un solo cubo) 30 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, la de recuperar la contraseña o la de la cuenta—, con Retry-After y sin delatar si el correo existe.

Las dos pantallas de recuperar la contraseña no usan un contador propio como el login: hablan con better-auth por su mismo handler, así que cuentan en el mismo cubo que la API. Tres peticiones de enlace por hora y por IP son tres entre las dos puertas, no tres en cada una. Tampoco descuentan del contador del login: quien ha fallado cinco veces la contraseña y viene a pedir otra no se encuentra el límite del login por hacer justo lo que se le aconsejaba.

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.

Las claves de API del panel tienen tres límites propios, a propósito:

  • Un nombre o un plazo inválido no cuesta intento: se rechaza antes de contar. Un envío que acuña, sí.
  • Recargar la respuesta que enseña la clave acuña otra: es un POST reenviado, y el navegador avisa. Está acotado por el tope de 10 por hora, y la pantalla lo advierte. No hay redirección que lo evite porque la clave no puede viajar en una URL, una cookie ni un registro.
  • No hay botón de copiar (necesitaría JavaScript, y estas pantallas no cargan ninguna isla nueva) ni un máximo de claves por cuenta: el tope de 10 por hora es el único freno. Revocar tiene su cubo aparte para que cortar una clave comprometida no espere una hora.

Las peticiones a la REST con una clave de API no pasan por ninguno de estos contadores: el límite por clave de better-auth está apagado (su valor de serie, 10 peticiones al día, no sirve para una API), y la REST no tiene límite propio. Si una clave se filtra, se revoca; para un tope por petición, la regla de Cloudflare de más abajo.

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/*, /admin/login y /admin/forgot corta antes de que la petición llegue al Worker.

10. group y point guardan sus subcampos en columnas, y tienen sus límites

Sección titulada «10. group y point guardan sus subcampos en columnas, y tienen sus límites»

Un group no es un objeto en una columna: cada subcampo es una columna de la tabla, <grupo>_<campo>, y un point son dos, <campo>_lng y <campo>_lat. Eso da índices, unique y filtros por subcampo, y trae esto:

  • El listado del panel no los trata como columna, filtro ni orden. El editor sí los pinta (un group como fieldset, un point como longitud y latitud), pero la tabla de documentos, la barra de filtros y el orden son de campos de un valor del primer nivel; la API local y la REST sí filtran por subcampo. La biblioteca de medios tampoco pinta los grupos de una colección con upload: editar un fichero deja sus grupos como estaban.
  • near y within no existen, ni índices espaciales. Un point se filtra solo con exists, y no admite index ni unique. Para buscar por cercanía, guarda además la latitud y la longitud en dos campos number, que sí se filtran por rango, o calcula la distancia en tu código.
  • No hay array ni blocks dentro de un grupo. Ver el punto 5 y el épico #27.
  • Cada subcampo cuenta para el límite de columnas de D1, que es de 100 por tabla. Kevin CMS no lo comprueba al validar el config: un grupo grande lo descubre wrangler d1 migrations apply al aplicar la migración.
  • Un grupo de un global es una sola clave de KV, así que dos guardados simultáneos de subcampos distintos del mismo grupo se pisan. Ver Globals.
  • Convertir un campo que ya existe en un subcampo no es aditivo. Añadir un grupo sí lo es; mover metaTitle a seo.metaTitle pide una pista de renombrado o una copia a mano. Ver Convertir un campo en un group.
  • El choque de nombres aplanados solo se comprueba en colecciones. Un campo seo_title y un grupo seo con un title son la misma columna y defineConfig lanza un ConfigError que la nombra; en un global no hay columnas y no hace falta.

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 — pantalla de verificación, 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.
  • Control de acceso — el coste de leer el cuerpo antes del 401, la biblioteca ssd y las fugas que una consulta no cierra.
  • Globals — versiones, 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.