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.
2. No hay borradores ni versiones
Sección titulada «2. No hay borradores ni versiones»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.
3. No hay localización
Sección titulada «3. No hay localización»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
textynumber, fichas enselect), 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.updateoaccess.deleteno 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 unAccessError(un500por REST) en el momento de escribir. Enaccess.readsí valen. Filtra por un campo del propio documento. - No hay índice sobre el valor y no se puede ordenar por ellos. Un
whererecorre los valores de los documentos candidatos, ysortes unQueryError. Tampoco admitenuniqueniindex, 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 unarraycon un subcampo o unjson. textareaconhasManyno existe todavía (INVALID_HAS_MANY). Usa unarraycon un subcampotextarea.- Convertir un campo normal en
hasMany(o al revés) no es aditivo, ycms db:migratese niega a generar esa migración: ver Convertir un campo en hasMany. Renombrar unhasManycon la pistacreateborra sus valores. - En TypeScript, un campo declarado con un
typeque es una unión ('text' | 'number') en un objeto sin anotar ya no se asigna aConfigField: 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
useAsTitlede texto en el que buscar (text,textareaoselect), 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.readlo 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. ConsetNull(por defecto) el elemento desaparece de la lista sin avisar y no se renumera: si el campo esrequiredo tieneminRows, el documento puede quedar fuera de esas reglas hasta que se guarde de nuevo. Conrestrict, el borrado se rechaza (RESTRICTED) y nombra el campo.onDelete: 'cascade'no existe en unhasMany(INVALID_HAS_MANY). - El borrado de un fichero que está en una galería (
uploadconhasMany) se rechaza (REFERENCED), igual que si lo apuntara unuploadsuelto. La respuesta es un400de validación, no un409: el CMS no tiene ningún409hoy. - Un
uploadconhasManyapunta a una sola colección. Varios destinos solo valen en unrelationship(INVALID_HAS_MANYsi pones una lista entode unupload). - 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 rutacampo.Ndel elemento) y no se guarda nada. - No se puede ordenar por ellos, ni filtrar por un id suelto en un campo con varios destinos.
sortes unQueryError; en un campo con varios destinos elwherese escribe por colección (related.categories) y la barra de filtros del panel solo ofreceexists. - Quitar un destino de
topierde las referencias a él, y cambiar de destino o convertir un campo suelto enhasMany(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.
6. No hay GraphQL, ni va a haberlo
Sección titulada «6. No hay GraphQL, ni va a haberlo»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
413antes 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 ficheroEse 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 campouploadenseñ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
POSTreenviado, 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
groupcomofieldset, unpointcomo 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 conupload: editar un fichero deja sus grupos como estaban. nearywithinno existen, ni índices espaciales. Unpointse filtra solo conexists, y no admiteindexniunique. Para buscar por cercanía, guarda además la latitud y la longitud en dos camposnumber, que sí se filtran por rango, o calcula la distancia en tu código.- No hay
arrayniblocksdentro 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 applyal 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
metaTitleaseo.metaTitlepide 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_titley un gruposeocon untitleson la misma columna ydefineConfiglanza unConfigErrorque la nombra; en un global no hay columnas y no hace falta.
Dónde está el detalle
Sección titulada «Dónde está el detalle»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 bibliotecassdy 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,usernisiblingData.