Ajustes
Hay un puñado de valores que no son contenido pero tampoco código: el nombre del sitio, su URL pública, la dirección desde la que sale el correo. Cambiarlos no debería exigir un despliegue.
Viven en Cloudflare KV, con una clave por ajuste —settings:site:<campo>, por ejemplo
settings:site:siteUrl—, y se leen desde Astro.locals.cms.settings o por la API REST. Que cada
ajuste tenga su clave es lo que hace que dos guardados a la vez de campos distintos ya no se pisen:
antes vivían todos en un único blob JSON bajo settings:site, y el segundo en escribir borraba lo
que el primero acababa de guardar. Ese blob antiguo no hace falta migrarlo: cada campo que todavía no
tenga clave propia se sigue leyendo de ahí hasta que se vuelva a guardar.
Los siete campos
Sección titulada «Los siete campos»| Campo | Por defecto | Quién lo usa |
|---|---|---|
siteName |
email.siteName del config ('Kevin CMS') |
Firma las plantillas de correo |
emailFrom |
email.from del config |
Cabecera From de todo el correo |
emailReplyTo |
email.replyTo del config |
Cabecera Reply-To. Vacío significa sin cabecera |
siteUrl |
'' |
La URL base de better-auth. Vacía significa deducirla de la petición |
favicon |
'' |
El icono del panel. Vacío significa el que trae Kevin CMS |
logo |
'' |
Nada todavía. Se guarda y se sirve, y el panel sigue pintando su propia marca |
locale |
'es' |
Nada todavía. Se guarda y se sirve |
favicon y logo son URLs, y los dos se rellenan igual desde /admin/settings: eliges un
fichero, la pantalla lo sube a .system —la carpeta reservada de la
biblioteca de medios, que no se lista ni se navega— y lo que se guarda en KV
es la URL que devuelve, de la forma /api/cms/cdn/ssd/<id>/<fichero>. Cambiar uno borra el fichero
al que el ajuste dejó de apuntar, si de verdad era suyo: lo decide la carpeta de la fila, no cómo
esté escrita la URL. Si ese borrado falla, el fichero se queda en .system y se reclama desde la
propia pantalla: lo cuenta El logo y el favicon.
El config son los valores por defecto
Sección titulada «El config son los valores por defecto»Esta es la regla que ordena todo lo demás:
cms.config.ts → valores por defecto ⊕ lo guardado en KV = ajustes efectivosLo que escribes en el bloque email de tu config es el valor de arranque. Lo que se guarda en KV lo
pisa. Y lo guardado es disperso: solo las claves que alguien editó de verdad.
Esa última parte importa más de lo que parece. Si guardara el objeto entero, la primera escritura
congelaría dentro de KV los valores que venían del config, y a partir de ahí cambiar siteName en
cms.config.ts dejaría de surtir efecto aunque nadie hubiera tocado ese campo jamás. Siendo disperso,
cada campo sigue al config hasta el día que se edita, y solo desde ese día manda KV.
Leerlos
Sección titulada «Leerlos»---export const prerender = false
const { siteName, siteUrl } = await Astro.locals.cms.settings.get()---
<footer>{siteName}</footer>get() nunca falla. Si no hay nada guardado, si el blob antiguo está corrupto, si un campo trae
el tipo equivocado o si siteUrl no es una URL válida, ese campo cae a su valor por defecto y el
problema se registra en la consola. Unos ajustes rotos no pueden tumbar el sitio entero.
Escribirlos
Sección titulada «Escribirlos»await Astro.locals.cms.settings.update({ siteName: 'Mi sitio' })Es una fusión superficial: los campos que el patch no trae conservan su valor. Guardar el formulario de ajustes generales no debe tocar campos que ese formulario ni siquiera muestra.
La validación es estricta. Solo se aceptan esos siete campos, todos los valores tienen que ser
cadenas y ninguna puede llevar caracteres de control (CONTROL_CHARACTERS): siteName firma el asunto de
los correos y un salto de línea ahí abriría una cabecera nueva. siteName tampoco puede estar vacío ni en
blanco (REQUIRED): es el título de la pantalla de entrada y de cada pestaña del panel, y un valor en blanco
ya guardado se lee como el siteName del config. emailFrom y emailReplyTo, si no están
vacíos, tienen que ser una dirección (nombre@dominio.com, INVALID_EMAIL): el nombre visible no va en
la cadena. Y un siteUrl no vacío tiene que ser un origen http(s), es decir, esquema, dominio y a lo
sumo el puerto —sin credenciales, ruta, consulta ni fragmento—, que es lo único que better-auth necesita
para construir los enlaces. favicon y logo no tienen más regla de formato: admiten cualquier cadena:
por esta vía puedes escribir /logo.svg de tu propio public/, una URL completa de otro dominio o
la de un fichero que hayas subido tú. Exigir la URL de un fichero subido impediría lo más común, y
subir el fichero es trabajo de la pantalla de Ajustes, no de la API. Y esa pantalla sube a la
biblioteca ssd, que bloquea el SVG
salvo que el config lo nombre en media.mimeTypes.
Sin esa opción, un favicon o un logo en SVG se pone por esta vía, con la ruta de tu public/. Un incumplimiento es un error
de validación con todos los problemas a la vez, no solo el primero — y por REST, un 400 con su
array issues.
La API REST
Sección titulada «La API REST»| Petición | Respuesta |
|---|---|
GET /api/cms/settings |
200 con los siete campos efectivos |
PATCH /api/cms/settings |
200 con el resultado de la fusión |
| Cualquiera de las dos sin sesión | 401 |
Cualquiera de las dos con una sesión que no puede entrar en el panel (access.admin la niega) |
403 |
PATCH con una sesión que no puede cambiar los ajustes (access.settings) |
403 |
| Cualquier otro método | 405 |
Las dos exigen sesión, sin excepción: no hay equivalente al access.read: 'public' de las
colecciones, y emailFrom no es algo que dar a quien pregunte sin identificarse. Y si declaras
access.admin en la colección de sesión, la
sesión tiene que pasarlo también: es el mismo botón que cuelga la pantalla de Ajustes del panel, y una
cuenta que el panel no deja entrar no cambia el siteUrl por aquí. Leer los ajustes sigue abierto a
quien puede entrar en el panel; cambiarlos es lo que se restringe.
Quién puede cambiarlos
Sección titulada «Quién puede cambiarlos»Cambiar la URL pública o el remitente de los correos no es de cualquier cuenta: con un emailFrom o un
siteUrl ajenos, quien lo hiciera tomaría los enlaces de verificación y de restablecimiento. Por eso
escribir los ajustes —PATCH /api/cms/settings y el formulario de /admin/settings— lo puede hacer:
- Sin declarar nada, solo una cuenta con el rol de
admin.firstUserRole(adminsi no lo declaras), que es el que recibe la primera cuenta. Una cuenta sin ese rol recibe un403, con cookie, con token Bearer o con una clave de API (la clave actúa como su dueño). - Declarando
access.settingsen la colección de las cuentas (users), quien tu función diga. Recibe{ user }y devuelve unboolean:
defineCollection({ slug: 'users', access: { // El rol `editor` también cambia los ajustes settings: ({ user }) => user?.role === 'admin' || user?.role === 'editor', }, fields: [],})En el panel, una cuenta que no puede cambiarlos sí ve la pantalla de Ajustes, con los campos en solo
lectura y sin el botón de guardar; un POST fabricado a mano recibe el mismo 403. La escritura desde el
servidor (Astro.locals.cms.settings.update()) no pasa por aquí: no hay una sesión de por medio, escribe
quien llama, que es código tuyo. El asistente de /admin que crea la primera cuenta tampoco: aún no hay
cuentas a las que preguntar.
Es un cambio respecto a versiones anteriores, donde toda cuenta con sesión podía cambiarlos. Para volver a eso,
declara solo settings: ({ user }) => user !== null; no hace falta tocar update ni delete, y abrirlos
a toda cuenta que inicia sesión entregaría las cuentas (ver
el aviso de Control de acceso). Y recuerda que sin
admin.firstUserRole ninguna cuenta tiene el rol de administración, así que nadie cambiaría los ajustes
con el defecto: ver Recuperar el acceso a los ajustes.
Recuperar el acceso a los ajustes
Sección titulada «Recuperar el acceso a los ajustes»La pantalla de Ajustes dice «Tu cuenta puede ver los ajustes pero no cambiarlos» y nadie logra guardar. Pasa en dos situaciones:
- El config no declara
admin.firstUserRoleniaccess.settings: la primera cuenta nació sin rol y, con el defecto, los ajustes son del rol de administración, que nadie tiene.cms doctorlo avisa (admin-role) y dice qué poner. - El config sí lo declara, pero la cuenta con ese rol ya no existe: una cuenta con el rol de
administración puede borrarse a sí misma mientras queden otras, y
rolees de solo lectura, así que ni el panel ni la REST lo devuelven a nadie.
Hay dos salidas, y no hace falta ninguna herramienta nueva:
-
Declarar
access.settingsen la colección de las cuentas, que no toca la base de datos. Lo más estrecho es nombrar tu cuenta; abrirlo a toda cuenta que inicia sesión devuelve el comportamiento anterior:cms.config.ts defineCollection({slug: 'users',access: {// Solo tu cuenta cambia los ajustessettings: ({ user }) => user?.email === 'tu@correo.com',// O toda cuenta que inicia sesión, como antes de #333:// settings: ({ user }) => user !== null,},fields: [],}) -
Dar el rol por SQL a la cuenta que administra. El defecto reconoce el rol
adminaunque el config no declareadmin.firstUserRole; declararlo (admin: { firstUserRole: 'admin' }) hace además que la primera cuenta de una instalación nueva lo reciba sola. El rol de la cuenta es una columna de la tabla de las cuentas; con elwranglerde tu proyecto, contra la base de datos remota:Ventana de terminal npx wrangler d1 execute DB --remote --command "UPDATE users SET role = 'admin' WHERE email = 'tu@correo.com'"DBes el nombre del binding de D1 en tuwrangler.jsoncyusersel slug de la colección de las cuentas. En desarrollo,--localen vez de--remote. Si declaraste otro rol enfirstUserRole, pon ese en lugar deadmin.
La primera salida surte efecto cuando despliegas el config nuevo. La segunda, en la siguiente petición de la cuenta: la sesión que ya estaba abierta lee el rol de la base de datos, así que no hace falta volver a entrar. En las dos, la pantalla de Ajustes vuelve a ofrecer «Guardar» al recargarla.
Por qué KV y no D1
Sección titulada «Por qué KV y no D1»Los ajustes se leen en casi todas las peticiones y no se escriben casi nunca, que es exactamente el perfil para el que sirve KV. Los documentos son al revés, y por eso viven en D1.
El precio es la consistencia eventual: una escritura tarda hasta un minuto en verse en todas partes. Es tolerable para el nombre del sitio e intolerable para un artículo que el autor acaba de guardar.
Los ajustes no están solos en ese namespace: los globals lo comparten, con el
prefijo global: en vez de settings:, y por la misma razón. El CMS nunca lee, lista ni borra
fuera de esos dos prefijos, así que el namespace puede ser el mismo que ya uses para otra cosa.
La caché
Sección titulada «La caché»Una sola, en el ámbito del isolate, con 60 segundos de vida — el mismo minuto que tarda KV en propagar, ni más ni menos. No tendría sentido prometer una frescura que KV no puede cumplir.
Lo que guarda es la promesa y no el valor, así que dos lecturas concurrentes comparten una sola consulta a KV en vez de lanzar dos.
update() sustituye la entrada en el isolate que escribe. Quien guarda ve su cambio de inmediato;
los demás esperan la propagación.
Lo que todavía no hay
Sección titulada «Lo que todavía no hay»| Cosa | Estado |
|---|---|
Credenciales de OAuth en settings:auth-providers |
❌ Llegan con los proveedores sociales |
Que locale haga algo |
❌ Se guarda y se sirve, nada más |
| Más campos que estos siete | ❌ Un modelo de contenido singular de verdad son los globals |