Prompt para integrar Limbo
Un prompt autocontenido para que tu agente de IA migre tu integración (API legacy a /api/v1 y component-limbo 4.x a limbo-component-v5) o implemente Limbo desde cero. Decide el modo por tu repositorio y tu petición, y pregunta si no queda claro.
Cómo usarlo
- Abre el repositorio que consume Limbo en tu agente de IA.
- Pulsa «Copiar prompt» y pégalo como primera instrucción, seguido de lo que quieres («migra a v1», «integra Limbo en este proyecto»…).
- Revisa el entregable del agente (modo elegido, diff, peticiones al administrador, checklist de PRE) antes de aplicar nada.
Complementa a Migrar a v1 y Migrar a la 5.0.
# Integración con Limbo: migrar a /api/v1 y limbo-component-v5, o implementarlo desde cero
## 1. Rol y objetivo
Eres un agente que integra ESTE repositorio con Limbo (plataforma DAM de Lefebvre): según el caso, migras una integración existente (API legacy a `/api/v1`, component-limbo 4.x a `limbo-component-v5`) o la implementas desde cero. En ambos casos usas siempre la versión más nueva: `/api/v1` y `limbo-component-v5`.
- La API legacy (rutas `/api/…` sin `v1/` y `/auth/token`) lleva la cabecera `Sunset` con fecha 2027-05-01 (las rutas con sucesor llevan además `Deprecation` y `Link: <ruta-v1>; rel="successor-version"`). No llevan `Sunset` ni `Deprecation`: `/api/atenea/*` (salvo su `/proxy`), `GET /api/assets/n/{filename}`, `POST /auth/validate` y `/component-auth/*`. Sigue funcionando hasta entonces con los mismos cuerpos y códigos; la retirada no es automática, la anuncia el equipo de Limbo.
- component-limbo 4.x sigue en `/cdn/component-limbo/` y en el paquete npm `limbo-component`. La 5.x vive en `/cdnv1/component-limbo/` y en el paquete npm `limbo-component-v5`, y habla con `/api/v1`.
- Objetivo: dejar este repositorio hablando con `/api/v1` y, si usa el componente, con `limbo-component-v5`. Primero decide el modo (sección 3); después aplica los bloques que correspondan.
## 2. Reglas
- Usa solo rutas de la tabla de equivalencias de este documento (Bloques A y B) y las que cita el Bloque C. Si una ruta legacy del repositorio no aparece, dilo en el entregable y no inventes su equivalente.
- Código nuevo, siempre en la versión más nueva: nunca `/auth/token`, `/api/assets…` sin `v1/`, `limbo-component` ni `/cdn/component-limbo/`, aunque el repositorio los use en otro sitio.
- El `sk_` nunca va en código de navegador ni en el repositorio: solo en variables de entorno del backend.
- Cambios mínimos y solo en el código que habla con Limbo. La migración puede hacerse endpoint a endpoint: la legacy sigue funcionando hasta la retirada.
- No resuelvas lo que exige a un administrador de Limbo (scopes, `allowedOrigins`, acceso cross-portal, crear API keys): lístalo en el entregable.
- Pregunta antes de cambiar contratos públicos de la propia aplicación (rutas, formatos de respuesta que consumen terceros).
## 3. Paso 0: modo e inventario
Busca en el repositorio, excluyendo `node_modules`, `vendor`, `dist` y `.git`:
- `/auth/token`
- `/api/` no seguido de `v1/`
- `/portals/me/presets`
- `limbo-component`
- `/cdn/component-limbo`
- `/api/atenea/proxy` y `/api/external/proxy`
- `Limbo.configure`
- `tokenProvider`
- `includePortals`
Decide el modo con el inventario y con lo que pide el usuario (si es explícito, manda la petición):
| Inventario | Modo | Qué haces |
| --- | --- | --- |
| Llamadas legacy o `limbo-component` 4.x | Migrar | Bloque A si llama a la API directamente, Bloque B si usa el componente (ambos si hace las dos cosas) |
| Ninguna coincidencia y el usuario quiere añadir Limbo | Desde cero | Bloque C |
| Solo `/api/v1` o `limbo-component-v5` | Ya migrado | Nada que migrar; si falta una pieza nueva, Bloque C solo para ella |
| Legacy en una parte y una función nueva en otra | Ambos | Migra lo legacy (A/B) e implementa lo nuevo (C) |
**Si no queda claro, pregunta antes de tocar nada** (una sola pregunta corta, por ejemplo: «¿Migro lo que ya hay a v1/5.0, implemento Limbo desde cero, o ambas cosas?»). No queda claro cuando: la petición no dice si migrar o implementar y el inventario no lo resuelve (el repositorio solo menciona Limbo en comentarios o documentación, no en código); o hay código legacy y el usuario solo pidió una función nueva sin decir si quiere migrar lo anterior.
Si los logs del cliente guardan cabeceras de respuesta, `Deprecation` y `Link: <ruta-v1>; rel="successor-version"` señalan la ruta v1 de cada llamada legacy.
## 4. Bloque A: API (legacy a /api/v1)
### 4.1 Qué cambia
| | Legacy | v1 |
| --- | --- | --- |
| Prefijo | `/api/…`, `/auth/token` | `/api/v1/…` |
| Token | Clave pública del portal o `pk_` de una API key | Solo la `pk_` de una API key |
| Éxito | `{success, data}` | `{success, data, meta}` |
| Errores | `{success: false, error: {code, message}}` | `application/problem+json` |
| Paginación | `page` + `limit`, en `data.pagination` | `page` + `per_page`, en `meta.pagination` |
| Altas | 200 | 201 con `Location` |
| Scopes | Los de la API key, salvo `portal:admin` (nunca se emite) | Un scope por endpoint; `portal:admin` solo si se pide |
| Límite | Solo token y proxies | 600 por minuto y portal, con `X-RateLimit-*` (`image-convert`: 600 por minuto e IP, presupuesto propio) |
| Reintentos | — | `Idempotency-Key` en los POST de creación |
### 4.2 Pedir el token (el cambio que más rompe)
- Solo vale la `pk_` de una API key. La clave pública del portal, que la legacy aceptaba, da 401 `invalid_credentials` en v1 (igual que una `pk_` desconocida, una key o un portal inactivos, o un `sk_` incorrecto: no se distinguen). Si el repositorio usa la del portal, hace falta una API key: pídela al administrador (ver entregable) y usa su `pk_`.
- Antes: `POST /auth/token` con `{"public_key": "<pk_del_portal>"}`. Ahora: `POST /api/v1/auth/token` con `{"public_key": "<pk_de_la_api_key>"}`.
- El `sk_` va en el campo `api_key` del cuerpo y solo desde el servidor. Con una cabecera `Origin` (un navegador) da 403 `sk_not_allowed_from_browser` sin mirar la clave: si se vio en un navegador, hay que considerarla expuesta y rotarla.
- `scopes` es opcional y subconjunto de los de la key. Sin él, el token lleva todos salvo `portal:admin`, que solo se emite si se pide y se envía un `api_key` válido (sin él, 403 `portal_admin_requires_api_key`).
- `portal:admin` y la legacy (D-18): `POST /auth/token` emite los scopes de la key sin `portal:admin`, siempre (si es el único, el token sale con `scopes: []`). Con ese token dan 403 `POST /api/portals/{portalId}/images`, `POST /portals/me/presets/aspect`, `PUT /portals/me/presets/aspect/{id}` y `DELETE /portals/me/presets/aspect/{id}` (y los equivalentes de `/size`) y cualquier escritura bajo `/api/portal*`. Salidas: `GET /api/portals/{portalId}/images` con los mismos parámetros en la query, o los presets por `/api/v1/portals/me/presets/**` con un token v1 pedido con `api_key` (`sk_`) y `scopes: ["portal:admin"]`. Un token v1 con `portal:admin` sí se acepta en las rutas legacy.
- Respuesta: `token`, `token_type`, `expires_in`, `scopes`, `portal` y `policies`, con `Cache-Control: no-store`. Exige `Content-Type: application/json` (415 si no).
- Que lo pida el backend; el navegador solo debe recibir el JWT.
403 al pedir el token desde un navegador: el `detail` del problema empieza por un código.
| `detail` empieza por | Qué hacer |
| --- | --- |
| `sk_not_allowed_from_browser` | Se envía el `sk_` desde el navegador. Que lo pida el backend (`tokenProvider` en el componente) y rotar esa clave. |
| `origin_not_allowed` | El `Origin` no está en los `allowedOrigins` del portal: un administrador debe añadirlo (esquema, host y puerto exactos). |
### 4.3 Tabla de equivalencias: legacy a v1 (y scope)
Assets
| Legacy | v1 | Scope |
| --- | --- | --- |
| `GET /api/assets` | `GET /api/v1/assets` | `asset:read` |
| `POST /api/assets` | `POST /api/v1/assets` | `asset:write` |
| `GET /api/assets/{id}` | `GET /api/v1/assets/{id}` | `asset:read` |
| `GET /api/assets/f/{folderSlug}/{filename}` | `GET /api/v1/assets/f/{folderSlug}/{filename}` | `asset:read` |
| `PATCH /api/assets/{id}` | `PATCH /api/v1/assets/{id}` | `asset:write` |
| `DELETE /api/assets/{id}` | `DELETE /api/v1/assets/{id}` | `asset:delete` |
| `POST /api/assets/{id}/restore` | `POST /api/v1/assets/{id}/restore` | `asset:write` |
| `GET /api/assets/n/{filename}` | Sin equivalente (no lleva `Deprecation`) | — |
Variantes
| Legacy | v1 | Scope |
| --- | --- | --- |
| `GET /api/assets/{id}/variants` | `GET /api/v1/assets/{id}/variants` (paginado) | `variant:read` |
| `POST /api/assets/{id}/variants` | `POST /api/v1/assets/{id}/variants` (202 por defecto) | `variant:write` |
| `POST /api/assets/{id}/variants/upload` | `POST /api/v1/assets/{id}/variants/upload` | `variant:write` |
| `PATCH /api/assets/{assetId}/variants/{variantId}` | `PATCH /api/v1/assets/{id}/variants/{variantId}` | `variant:write` |
| `DELETE /api/assets/{assetId}/variants/{variantId}` | `DELETE /api/v1/assets/{id}/variants/{variantId}` | `variant:delete` |
| `POST /api/variants/from-source` | `POST /api/v1/variants/from-source` | `asset:write` |
| `GET /api/image-convert` | `GET /api/v1/image-convert` | Pública |
Carpetas y tags
| Legacy | v1 | Scope |
| --- | --- | --- |
| `GET /api/folders`, `GET /api/folders/{id}` | `GET /api/v1/folders`, `GET /api/v1/folders/{id}` | `folder:read` o `asset:read` |
| `GET /api/folders/{id}/assets` | `GET /api/v1/folders/{id}/assets` | `folder:read` o `asset:read` |
| `POST /api/folders` | `POST /api/v1/folders` | `folder:write` |
| `PUT` y `PATCH /api/folders/{id}` | `PATCH /api/v1/folders/{id}` (no hay `PUT`) | `folder:write` |
| `DELETE /api/folders/{id}` | `DELETE /api/v1/folders/{id}` | `folder:write` |
| `GET /api/tags` | `GET /api/v1/tags` | `asset:read` |
Autenticación, portal y presets
| Legacy | v1 | Scope |
| --- | --- | --- |
| `POST /auth/token` | `POST /api/v1/auth/token` | Pública. La legacy no emite `portal:admin`; para ese scope, v1 con `api_key` |
| `GET /api/portals/me/cross-portal-access` | `GET /api/v1/portals/me/cross-portal-access` | `asset:read` |
| `GET /portals/me/presets` | `GET /api/v1/portals/me/presets`; el perfil y las políticas, en `GET /api/v1/portals/me` (nuevo) | `portal:read` |
| `POST /portals/me/presets/aspect` y `/size` | `POST /api/v1/portals/me/presets/aspect` y `/size` | `portal:admin` (token v1 pedido con `sk_`; con el token legacy da 403) |
| `PUT /portals/me/presets/aspect/{id}` y `/size/{id}` | `PATCH /api/v1/portals/me/presets/aspect/{id}` y `/size/{id}` | `portal:admin` |
| `DELETE /portals/me/presets/aspect/{id}` y `/size/{id}` | `DELETE /api/v1/portals/me/presets/aspect/{id}` y `/size/{id}` | `portal:admin` |
| `GET /api/metrics/usage`, `GET /api/metrics/endpoints` | `GET /api/v1/metrics/usage`, `GET /api/v1/metrics/endpoints` | `portal:read` |
IA, vídeo y stock
| Legacy | v1 | Scope |
| --- | --- | --- |
| `GET /api/ai/services` | `GET /api/v1/ai/services` | `ai:read` |
| `GET` y `POST /api/ai/params` | `GET /api/v1/ai/params` (solo GET) | `ai:read` |
| `POST /api/ai/generate` | `POST /api/v1/ai/generate` | `ai:generate` |
| `GET /api/ai/status/{jobId}` | `GET /api/v1/ai/jobs/{id}` | `ai:read` |
| `GET /api/ai/video/services`, `/params`, `/capabilities` | `GET /api/v1/ai/video/services`, `/params`, `/capabilities` | `ai:read` |
| `POST /api/ai/video/generate` | `POST /api/v1/ai/video/generate` | `ai:generate` |
| `GET /api/ai/video/status/{jobUuid}` | `GET /api/v1/ai/video/jobs/{id}` | `ai:read` |
| `GET /api/stock/services` | `GET /api/v1/stock/services` | `stock:search` |
| `POST /api/stock/search` | `GET /api/v1/stock/search` (ahora con query) | `stock:search` |
| `POST /api/stock/download` | `POST /api/v1/stock/download` | `stock:download` |
Contenido externo
| Legacy | v1 | Scope |
| --- | --- | --- |
| `GET /api/external/portals`, `/sources` y `GET /api/portals` | `GET /api/v1/external/portals` | `external:read` |
| `GET /api/external/portals/{slug}` | `GET /api/v1/external/portals/{slug}` | `external:read` |
| `POST /api/external/portals/{slug}/refresh` | `POST /api/v1/external/portals/{slug}/refresh` | `portal:admin` |
| `GET /api/external/media` | `GET /api/v1/external/media` | `external:read` |
| `GET /api/external/articles`, `POST /api/external/search` | `GET /api/v1/external/articles` (`portal` obligatorio) | `external:read` |
| `GET /api/external/articles/{id}` | `GET /api/v1/external/articles/{id}` | `external:read` |
| `GET` y `POST /api/portals/{portalId}/images` | `GET /api/v1/external/articles?portal={portalId}`. Mientras se migra, usa `GET /api/portals/{portalId}/images` con los mismos parámetros en la query: el `POST` exige `portal:admin` y da 403 con el token legacy | `external:read` |
Sin v1
| Legacy | Qué hacer | Scope |
| --- | --- | --- |
| `POST /api/pipeline/upload` | Usa `POST /api/v1/assets` | `asset:write` |
| `POST /api/pipeline/{id}/variants` | Usa `POST /api/v1/assets/{id}/variants` | `variant:write` |
| `POST /api/pipeline/{id}/generate-master-webp` | Sin sucesor: el master WebP lo genera la subida | — |
| `/api/atenea/*` | Sin v1: usa los endpoints de IA, stock y contenido externo de las tablas anteriores | — |
| `GET /api/atenea/proxy`, `GET /api/external/proxy` | `POST /api/v1/proxy/sign` y `GET /api/v1/proxy` con la URL firmada (ver 4.7) | `external:read`, `stock:search`, `ai:read` o `ai:generate` para firmar; el `GET` es público |
### 4.4 Errores
Decide por el estado HTTP y por el código de máquina que abre `detail` (`write_access_denied`, `folder_not_empty`…), no por el texto, que es fijo y no repite datos internos. Los 5xx llevan siempre `An unexpected error occurred.`. La validación (422) añade `errors[]` con `field` y `message`. En v1 un fichero ausente o inválido es 422 (la legacy daba 400).
Antes (legacy, 404):
```
{ "success": false, "error": { "code": "asset_not_found", "message": "Asset not found" } }
```
Ahora (v1, 404):
```
Content-Type: application/problem+json
{
"type": "https://limbo.lefebvre.es/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "Asset not found.",
"instance": "/api/v1/assets/3f1c9a52-7d0e-4b8a-9c11-0a52e6d4b7f1"
}
```
### 4.5 Paginación
`limit` pasa a `per_page` (máximo 100) y `limit` da 422. El array ya no está anidado en `data.data`.
Antes (legacy): `GET /api/assets?page=2&limit=20`
```
{ "success": true,
"data": { "data": [ … ], "pagination": { "page": 2, "limit": 20, "pages": 3, "total": 45 } } }
```
Ahora (v1): `GET /api/v1/assets?page=2&per_page=20`
```
{ "success": true,
"data": [ … ],
"meta": { "pagination": { "current_page": 2, "total_pages": 3, "total_items": 45,
"items_per_page": 20, "has_next_page": true, "has_prev_page": true } } }
```
### 4.6 Límite de peticiones y reintentos
- La v1 permite 600 peticiones por minuto y portal (ventana deslizante), y el token 120 por minuto e IP. Las respuestas llevan `X-RateLimit-Limit`, `X-RateLimit-Remaining` y `X-RateLimit-Reset` (timestamp Unix del primer instante en que se aceptará la siguiente petición; mientras queden peticiones es «ahora», no el fin de la ventana).
- El 429 añade `Retry-After` en segundos reales (de 1 a 60): úsalo en vez de esperar un minuto fijo. En la legacy el 429 decía siempre 60 s.
- `Idempotency-Key` es nuevo y opcional en los POST de creación (assets, subida y generación de variantes, carpetas, presets, generación de IA y descarga de stock). Añádelo a los reintentos automáticos. Reglas: el cuerpo se compara byte a byte (en multipart, el nombre y el hash de cada fichero), así que el mismo JSON re-serializado con otro orden de claves o espaciado da 422: guarda y reenvía los bytes originales. Misma clave para reintentar la misma operación (también tras un 429, un corte de red o un 5xx); clave nueva para cualquier operación distinta, incluso con el mismo fichero. 409 significa que la primera sigue en curso.
### 4.7 Proxies de imagen firmados
Los proxies legacy (`GET /api/atenea/proxy`, `GET /api/external/proxy`) servían `<img src>` sin JWT. En v1 la autorización es una firma:
1. `POST /api/v1/proxy/sign` con `{"source": "external" | "atenea", "urls": [...]}` (1 a 50 URLs absolutas `http(s)`, hasta 2048 caracteres, sin `usuario@`). Responde `data: [{url, proxy_url, expires_at}]` en el mismo orden. Exige uno de `external:read`, `stock:search`, `ai:read` o `ai:generate`, para ambos `source`. Firma una vez por página, no por imagen: cuenta para el límite de 600 por minuto y portal.
2. `GET {proxy_url}`, público y sin JWT. Responde la imagen con `Access-Control-Allow-Origin: *`.
`expires_at` (segundos Unix) está alineado a la hora: la URL vale entre 1 y 2 horas y el navegador la cachea. Renueva la firma antes de que caduque. El `fetch` de una URL firmada debe ir sin `credentials` ni `Authorization`: con `Access-Control-Allow-Origin: *` el navegador rechaza las peticiones con credenciales.
| Estado en `GET /api/v1/proxy` | Causa |
| --- | --- |
| 400 `invalid_request` | Falta `source`, `url`, `portal`, `expires` o `signature` |
| 403 `invalid_signature` | Firma alterada, o `url`, `source`, `portal` o `expires` distintos de los firmados. Pide una firma nueva; no edites la URL |
| 403 `expired_signature` | Pasó `expires_at`: vuelve a firmar |
| 429 | Límite por IP; respeta `Retry-After` |
| 502 | El origen no respondió o no devolvió contenido (el `detail` se enmascara en todos los 5xx) |
### 4.8 Diferencias de comportamiento
- Assets: `include_portals` (no `includePortals`); sin filtro `portal_id`. Un asset borrado da 404 en GET y PATCH; borrar dos veces o restaurar uno no borrado da 409. delete y restore devuelven `id` (no `asset_id`). Renombrar es solo por `name`.
- Escritura ajena: un asset que no puedes leer da 404, idéntico a uno inexistente; uno legible de otro portal da 403 `write_access_denied`. Aplica también a generar y subir variantes.
- Subida: solo JPEG, PNG, WebP y GIF. Una URL pasa por una guarda anti-SSRF (IPs privadas rechazadas, 200 MB máximo, mensaje de error fijo).
- Variantes: nombre `[A-Za-z0-9][A-Za-z0-9_-]{0,63}` sin transformar; formatos webp, jpeg y png; máximo 20 por asset; claves desconocidas dan 422; `Content-Type` incorrecto, 415. Generar es asíncrono por defecto (202). El `PATCH` de una variante solo acepta `name` (también en la legacy): cambiar `width`, `height`, `output_format`, `quality` o `crop_params` da 400 `REGENERATION_NOT_SUPPORTED`; para otra medida, crea una variante nueva.
- Cross-portal: respuesta en snake_case (`self_portal`, `read_access`, `asset_count`); la legacy usa camelCase.
- Presets: `PATCH` en vez de `PUT`; borrar responde 200 `{id, status}` (no 204); el `id` lo genera el servidor; máximo 100 por tipo.
- Carpetas: solo `PATCH`. Borrar una con assets da 409 `folder_not_empty` salvo con `?force=true`; el borrado correcto es 204.
- IA y vídeo: `generate` espera internamente y puede responder 202 con `job_id`; `async` y `query` dan 422. Los jobs creados desde la legacy no se ven en v1 (su `jobs/{id}` da 404): no mezcles una creación legacy con un sondeo v1.
- Stock: la búsqueda pasa de `POST` con cuerpo a `GET` con query (5–200 caracteres) y paginación.
- Externo: `portal` es obligatorio en `articles` (1 a 10 slugs); un portal desconocido o inactivo es 404. `external:read` lo concede un administrador.
- Image-convert: sin `json=true` (400 `invalid_request`); límite propio de 600 por minuto e IP. La legacy no tiene límite.
- Métricas: un rango de fechas inválido es 422 y no se recorta en silencio (máximo 365 días).
### 4.9 Si todavía no se migra todo: lo que ya cambió en la legacy
- `/api/atenea/*` (salvo `/proxy`), `/api/external/*` (salvo `/proxy`) y `/api/metrics/*` exigen `Authorization: Bearer <JWT>` válido (cualquier scope); sin él, 401.
- `POST /auth/token` legacy: 429 `rate_limited` (120 por minuto e IP) y 403 `origin_not_allowed` si el `Origin` no está en los `allowedOrigins` del portal (lista vacía = sin restricción).
- Los errores de la legacy ya no repiten el texto de las excepciones (D-05/D-31): llevan un texto fijo y el detalle va al log. Decide siempre por `error.code`, nunca por el texto.
- La legacy bloquea IPs privadas al descargar por URL, comprueba propiedad al generar y subir variantes, borra de verdad las variantes y rechaza nombres con `..`, `/` o `\` y formatos como avif.
- El `PATCH` de variante legacy solo admite `name` (400 `REGENERATION_NOT_SUPPORTED` para lo demás).
- El token legacy ya no lleva `portal:admin` (ver 4.2).
## 5. Bloque B: componente (4.x a limbo-component-v5)
`Limbo.configure()` no cambia; cambian la clave, sus scopes y de dónde se carga el componente. Los dos canales conviven, así que migrar es cambiar una URL o un paquete cuando la clave esté lista.
### 5.1 La clave y sus scopes
- Usa la `pk_` de una API key en `publicKey`. La clave pública del portal da 401 `invalid_credentials` en v1.
- La key necesita los scopes de las pestañas que se usen. Sin el scope de una pestaña, esa pestaña recibe 403 `Missing required scope: X` (el componente lo muestra y nombra el scope que falta). La v1 exige además `folder:read`, `folder:write` y `external:read`, que la legacy no pedía.
| Función del componente | Scopes de la key |
| --- | --- |
| Galería, subida, edición y borrado de assets; etiquetas | `asset:read`, `asset:write`, `asset:delete` |
| Recortes y variantes | `variant:read`, `variant:write`, `variant:delete` |
| Carpetas | `folder:read`, `folder:write` |
| Portales externos | `external:read` |
| Imágenes y vídeo con IA | `ai:read`, `ai:generate` |
| Banco de imágenes (stock) | `stock:search`, `stock:download` |
Pide solo los de las pestañas que use la aplicación.
### 5.2 El token desde el backend
- Con `authMode: 'jwt'` y un `tokenProvider`: el backend pasa de `POST /auth/token` a `POST /api/v1/auth/token` con `{ "public_key": "<pk_de_la_api_key>" }` y lee el token de `data.token` (la v1 envuelve la respuesta en `{ success, data }`).
- El `sk_` no hace falta para el componente (solo para emitir `portal:admin`) y nunca debe llegar al navegador.
- Con `authMode: 'session'` no se toca el backend: si la `pk_` sigue siendo la del portal, el componente obtiene el token con un único reintento contra la legacy y lo avisa con `console.warn`. Es un puente hasta cambiar la clave, no una configuración final: la ruta legacy tiene fecha de retirada.
### 5.3 Cambiar la URL o el paquete
CDN (usa URLs absolutas del host de Limbo que ya use el repositorio):
```
<!-- Antes (4.x, API legacy) -->
<link rel="stylesheet" href="{host}/cdn/component-limbo/latest/limbo.css">
<script src="{host}/cdn/component-limbo/latest/limbo.min.js"></script>
<!-- Ahora (5.x, /api/v1), versión exacta -->
<link rel="stylesheet" href="https://limbo.lefebvre.es/cdnv1/component-limbo/v5.0.0/limbo.css">
<script src="https://limbo.lefebvre.es/cdnv1/component-limbo/v5.0.0/limbo.min.js"></script>
```
({host} es el host que ya usaba el repositorio para `/cdn/component-limbo/`; la versión 5.x es `5.0.0`.)
npm:
- Cambia la dependencia `limbo-component` por `limbo-component-v5` (`npm install limbo-component-v5`, o `"limbo-component-v5": "^5.0.0"` en `package.json`) y los imports a `'limbo-component-v5'` y `'limbo-component-v5/css'`. `limbo-component` se queda en la 4.x.
- En ESM y CommonJS el componente no empaqueta React, React DOM ni `cropperjs`: la aplicación aporta `react` y `react-dom` (18 o superior) y npm instala `cropperjs`. El UMD (`limbo.min.js` del CDN) lo lleva todo.
### 5.4 Qué más cambia
- Errores: la API responde `application/problem+json` y el componente lo entiende. Si la aplicación intercepta las peticiones, el formato de error es el de 4.4.
- Proxies firmados: las miniaturas de portales externos, stock e IA pasan por `POST /api/v1/proxy/sign` y `GET /api/v1/proxy`, sin credenciales en el `fetch`. No hay que configurar nada más que el scope; si un proxy inverso o la CSP bloquean esas rutas, ábrelas (ver 4.7).
- `apiBaseUrl` (opcional): URL absoluta `http(s)`, sin barra final, de otra instancia de la API (un entorno de pruebas). Manda sobre `prod`; un valor no válido lanza un error de configuración.
```
Limbo.configure({
publicKey: '<pk_de_la_api_key>',
prod: false,
apiBaseUrl: 'https://tu-entorno-de-pruebas.example' // opcional
});
```
- Vídeo con IA: un parámetro no declarado por el modelo, `disabled` o de tipo `array` no se pinta ni se envía. Los jobs de vídeo que la 4.x guardó en `localStorage` se descartan (dan 404 en v1).
## 6. Bloque C: implementar desde cero
Flujo mínimo: clave, token desde el backend, subir, leer y mostrar. Lo demás (carpetas, variantes avanzadas, presets, Atenea, portales externos) se añade solo si el usuario lo pide, con las guías de la sección 8. Escribe solo lo que la aplicación necesite.
### 6.1 Lo que da el administrador de Limbo
- Una API key del portal: su `pk_` (pública) y su `sk_` (privada). Si no existen, no inventes valores: déjalos como variables de entorno vacías y pídelos en el entregable.
- Scopes de la key según lo que haga la aplicación: `asset:read` y `asset:write` para subir y mostrar, `variant:write` para generar variantes; para el componente, los de la tabla de 5.1.
- Los orígenes exactos de la aplicación en `allowedOrigins` del portal si el navegador llama a la API (el componente lo hace).
- Variables de entorno del backend: host de Limbo, `pk_` y `sk_`. El `sk_` nunca va al navegador ni al repositorio.
### 6.2 Token desde el backend
```
POST {host}/api/v1/auth/token
Content-Type: application/json
{ "public_key": "<pk_de_la_api_key>", "api_key": "<sk_de_la_api_key>" }
# 200: { "success": true, "data": { "token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scopes": [...] } }
```
- Cachea el token en el backend hasta poco antes de `expires_in` (3600 s por defecto) y pide otro si una llamada da 401.
- Sin `scopes` en la petición, el token lleva todos los de la key menos `portal:admin`; pide solo los que uses.
- Si hay navegador, el backend expone un endpoint propio (por ejemplo `/api/limbo-token`) que devuelve `{ token, expires_in }` a los usuarios autenticados de la aplicación.
### 6.3 Subir, leer y listar
Todas las llamadas llevan `Authorization: Bearer <token>` y devuelven `{ success, data }`; los errores, `application/problem+json` (ver 4.4).
```
# Subir (scope asset:write): multipart, `file` obligatorio; `folder` y `tags` (separados por comas) opcionales
POST {host}/api/v1/assets
Idempotency-Key: <uuid> # opcional: repetir la petición no crea un duplicado
-F "file=@portada.jpg" -F "folder=campana-otono" -F "tags=portada,otono"
# 201 con Location: /api/v1/assets/{id} y data.id, data.url, data.width, data.height, data.tags
# Leer (asset:read)
GET {host}/api/v1/assets/{id}
# data.url (variante WebP) y data.url_original: URLs firmadas de /media/…?a=, sin caducidad por defecto
# Listar (asset:read)
GET {host}/api/v1/assets?page=1&per_page=20
# data: [...], meta.pagination: { current_page, total_pages, total_items, items_per_page, has_next_page, has_prev_page }
# Variante (variant:write)
POST {host}/api/v1/assets/{id}/variants
{ "async": false, "variants": [{ "name": "card", "width": 400, "height": 300, "format": "webp" }] }
# 201 si async es false; por defecto 202 y estado queued
```
- Guarda el `id` del asset en la base de datos de la aplicación.
- La URL de `/media/…?a=` se sirve sin `Authorization`: puede ir directamente en un `<img src>`. No la recompongas a mano ni quites `?a=`.
- Límite de 600 peticiones por minuto: ante un 429, espera `Retry-After` segundos (ver 4.6).
### 6.4 Componente (solo si los usuarios van a elegir o subir imágenes)
- Instálalo con `limbo-component-v5` (npm) o con el CDN de la 5.x (5.3). En ESM la aplicación aporta `react` y `react-dom` (18 o superior).
- Configura una sola vez, con el token desde el backend (6.2), y crea la instancia al montar:
```
import Limbo from 'limbo-component-v5';
import 'limbo-component-v5/css';
Limbo.configure({
publicKey: '<pk_de_la_api_key>',
prod: true,
authMode: 'jwt',
tokenProvider: async () => {
const res = await fetch('/api/limbo-token');
return await res.json(); // string o { token, expires_in }
}
});
const instance = Limbo.create({
container: '#limbo-app',
mode: 'embed', // 'embed', 'modal' o 'button'
modeUI: 'full', // 'full', 'gallery-only', 'upload-only', 'crop-only'
callbacks: { onSelect: (data) => { /* data.assetId; URL firmada en data.master.urlSigned */ } }
});
// Al desmontar: instance.destroy()
```
- Guarda `data.assetId` de `onSelect` y `onUpload`. Para formularios, `data-limbo-input-file` con `Limbo.configureAutoInputs` rellena un input con el resultado.
- Una pestaña sin su scope da 403 `Missing required scope: X`: añádelo a la petición al administrador.
### 6.5 Qué no hacer
- No uses `/auth/token`, `/api/assets…` sin `v1/`, `limbo-component` ni `/cdn/component-limbo/`.
- No uses la clave pública del portal en `publicKey`: v1 solo acepta la `pk_` de una API key.
- No pongas el `sk_` en código de navegador ni en el repositorio.
## 7. Entregable
Cuando termines, devuelve:
1. El modo elegido (migrar, desde cero, ambos o ya migrado) y la evidencia del inventario que lo justifica.
2. El diff por fichero (y los ficheros nuevos, si los hay).
3. Peticiones al administrador de Limbo: crear la API key si hace falta; `php bin/console limbo:grant-scope <api-key-id> <scope>` por cada scope que falte; los orígenes exactos para `allowedOrigins`; los accesos cross-portal necesarios.
4. Variables de entorno nuevas (por ejemplo, la `pk_` de la API key y, solo si el backend la usa, el `sk_`).
5. Checklist de prueba en PRE: sin `Deprecation` en las rutas migradas, sin 401 ni 403 en la consola, sin el aviso de reintento legacy del componente, y recorrido de las pestañas usadas. En modo desde cero: la subida responde 201, el listado 200 y la imagen se muestra desde `data.url`.
6. Cómo volver atrás: si migraste, restaurar la URL (o la versión de npm) y la clave anteriores (la 4.x y la legacy siguen sirviendo, no hay nada que revertir en la API); si implementaste desde cero, retirar el módulo nuevo y las variables de entorno.
## 8. Referencias (públicas, sin login)
Este prompt es autocontenido. Si puedes leer URLs, estas guías amplían el detalle; si alguna contradice a este prompt, manda la guía.
- Inicio rápido de la API: https://limbo.lefebvre.es/docs/api/quickstart
- Assets: https://limbo.lefebvre.es/docs/api/assets
- Variantes: https://limbo.lefebvre.es/docs/api/variants
- Migrar a v1: https://limbo.lefebvre.es/docs/api/migrate-v1
- Migrar a la 5.0: https://limbo.lefebvre.es/docs/component/migrate-v5
- Instalación del componente: https://limbo.lefebvre.es/docs/component/installation
- Autenticación de la API: https://limbo.lefebvre.es/docs/api/authentication
- Recetas de la API: https://limbo.lefebvre.es/docs/api/recipes
- Recetas del componente: https://limbo.lefebvre.es/docs/component/recipes