Medios privados
Un archivo privado no tiene URL pública. Cómo funciona la visibilidad, cómo un token de recurso autoriza la firma y cómo tu backend entrega al lector una URL que caduca en cinco minutos.
Un archivo privado vive en otro bucket, uno que no está ligado a ningún host público. Ninguna URL sirve sus bytes al mundo. La única forma de leerlo es una URL firmada de vida corta que tu backend pide a cmssy, para un lector del que tu backend ha decidido que tiene derecho.
Ese reparto es todo el diseño. cmssy decide si quien llama puede firmar; tú decides quién es el lector. cmssy nunca conoce a tus usuarios finales, y no lo necesita.
Hacer privado un archivo
Súbelo como privado o cambia uno existente desde la biblioteca de medios. El cambio copia el objeto al otro bucket, comprueba que la copia llegó entera, actualiza la biblioteca y solo entonces borra la copia antigua; en ese orden, para que un fallo en cualquier punto deje el archivo legible en vez de varado.
Si solo falla el último paso, obtienes:
The file is now private, but the old public copy could not be removed. Run this again to clear it.Repetir la operación es seguro y es justo lo que limpia el resto.
Dos cosas antes de cambiar un archivo que ya está en una página:
- La URL pública deja de funcionar. Todo lo que la sostenga -una página cacheada, un marcador, otro sitio que la enlaza- se rompe al instante.
- La entrega empieza a devolver
url: null. Un bloque que renderiza su imagen sin condiciones renderizará una rota.
Qué devuelve la entrega
{
"id": "6a6495e44d1ee7dedcae1f52",
"url": null,
"visibility": "private",
"alt": "Portada del informe trimestral",
"width": 1600,
"height": 900
}Todo menos los bytes sigue llegando: el id, las dimensiones, el texto alternativo. Eso es lo que hace posible la degradación elegante: sabes que hay una imagen, conoces su forma y sabes que aquí no te corresponde.
const src = mediaUrl(content.src);
if (!src) return null;Una transformación sobre una referencia privada no hace nada, porque no hay URL que transformar.
La generación de páginas con IA tampoco ofrece nunca un archivo privado al modelo. Su dirección almacenada apunta a un bucket sin host público, así que ofrecerla solo haría que el modelo escribiera una URL muerta en la página.
Tokens de recurso
La firma la autoriza un token de recurso: una credencial que pertenece al workspace y no a una persona, y que hace exactamente una cosa: emitir URLs firmadas para los archivos privados de ese workspace.
Deliberadamente no es un token de API. Un token de API autentica a un usuario, y todo lo que ese usuario puede hacer se deriva de él; entregarlo al servidor de un consumidor concederiía el conjunto entero para permitir una sola operación.
Crea uno en Ajustes → Headless, o por GraphQL:
mutation {
assetToken {
create(name: "storefront", expiresAt: "2027-01-01T00:00:00Z") {
id
prefix
token
}
}
}El token se muestra una vez y nunca más: cmssy guarda solo un hash. Lo que ves después es el prefix: csa_ más siete caracteres, suficiente para distinguir dos tokens cuando decides cuál revocar. expiresAt es opcional y conviene ponerlo.
assetToken { list } los devuelve con lastUsedAt, así que un token que nada ha usado en meses salta a la vista. assetToken { delete(id: "...") } revoca uno de inmediato. Tanto la emisión como la revocación quedan en el registro de auditoría, a nombre de quien las hizo.
Solo del lado del servidor. En un bundle de navegador, este token concede la firma de todos los archivos privados del workspace. Su sitio es una variable de entorno de servidor: nunca NEXT_PUBLIC_, nunca un componente cliente, nunca una constante incrustada en el build.
Emitir una URL firmada
POST https://api.cmssy.io/media/{assetId}/sign
Authorization: Bearer csa_...{
"url": "https://...",
"expiresAt": "2026-08-09T18:35:00.000Z"
}La URL vale cinco minutos. Trátala por petición: emítela cuando un lector pide la página, entrégala y déjala caducar.
Un recurso público responde a la misma llamada con su URL de CDN y expiresAt: null, así que un consumidor nunca tiene que ramificar por visibilidad antes de preguntar.
Los fallos merecen leerse con precisión:
401: no hay token, o el token es inválido o ha caducado.404: no existe ese recurso en el workspace de este token. Un archivo de otra persona responde 404 y no 403 a propósito: un 403 confirmaría que el id existe, lo que convertiría el endpoint en un oráculo de consulta para otros inquilinos.429: 120 firmas por minuto por token y 300 por minuto por dirección que llama. El límite por dirección se cobra antes incluso de mirar el token, porque validarlo cuesta una comparación bcrypt, y eso no es algo que un desconocido deba poder gastar a tu cuenta.
Cada firma se contabiliza contra el workspace. Ningún plan la limita hoy; se cuenta para que se vea.
Cómo conectarlo
Pon el token en tu backend y una ruta delante. En la ruta vive el derecho de acceso -un nivel de membresía, una compra, una suscripción- igual que en todo lo demás que ya proteges:
// app/api/asset/[id]/route.ts
export async function GET(request: Request, { params }) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const signed = await fetch(`${process.env.CMSSY_API_HOST}/media/${id}/sign`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CMSSY_ASSET_TOKEN}` },
});
if (!signed.ok) return new Response("Not found", { status: 404 });
const { url } = await signed.json();
return Response.redirect(url, 307);
}Redirige en vez de devolver la URL. Un enlace firmado dentro de tu HTML se queda ahí los cinco minutos que sigue siendo válido: en una caché de CDN, en un código fuente compartido, en el «ver código fuente» de alguien.
Ojo con el host: CMSSY_API_URL apunta al endpoint de GraphQL, mientras que la firma está un nivel por encima. Guarda el host de la API en su propia variable en vez de operar con cadenas sobre la otra.
Siguientes pasos
- Medios: la biblioteca, el campo de medios, transformaciones y borrado.
- Esquema de bloque y tipos de campo:
fields.mediaentre los demás.