@bacano/sdk 1.1.13 → 1.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,9 @@
2
2
 
3
3
  `@bacano/sdk/content-management` permite que una herramienta administrativa o
4
4
  un agente autorizado consulte y mantenga **tipos, componentes, grupos, listas
5
- de opciones y entradas** de Bacano Content. Cada capacidad requiere un permiso explícito. El acceso no
6
- autoriza productos, ventas, clientes, configuración general, GraphQL, SQL ni
7
- Storage.
5
+ de opciones, entradas e imágenes** de Bacano Content. Cada capacidad requiere
6
+ un permiso explícito. El acceso no autoriza productos, ventas, clientes,
7
+ configuración general, GraphQL, SQL ni acceso directo a Storage.
8
8
 
9
9
  Este contrato es independiente del cliente público del storefront. Debe usarse
10
10
  solo desde un proceso local o servidor confiable; nunca desde el navegador ni
@@ -30,8 +30,8 @@ Scopes disponibles:
30
30
  - `content_entries:manage`: grupos, entradas en borrador y sus ciclos de vida.
31
31
  - `content_entries:publish`: publicar y modificar contenido publicado; requiere
32
32
  también `content_entries:manage`.
33
- - `content_media:upload`: reservado para una fase posterior; no se concede
34
- mientras la carga administrativa de media no esté implementada.
33
+ - `content_media:upload`: consultar imágenes de la empresa y cargar JPEG, PNG o
34
+ WebP. Requiere también `content_entries:manage`; no permite borrar archivos.
35
35
 
36
36
  No guardes el token en Git, Figma, Content, logs, variables públicas ni archivos
37
37
  que se empaqueten con el storefront.
@@ -77,6 +77,8 @@ El cliente ofrece:
77
77
  `updateOptionList(idOrKey, input)`.
78
78
  - `inspectOptionListLifecycle(idOrKey, operation)` y
79
79
  `applyOptionListLifecycle(idOrKey, operation)`.
80
+ - `listMedia(filters)` y `uploadImage(input)` para reutilizar o cargar imágenes
81
+ cuando el acceso incluya `content_media:upload`.
80
82
 
81
83
  ## Idioma, nombres y keys
82
84
 
@@ -317,8 +319,9 @@ Reglas obligatorias:
317
319
  y presentar el cambio; no se sobrescribe silenciosamente.
318
320
  3. Enviar el documento completo que debe permanecer. `updateEntry()` reemplaza
319
321
  el contenido de la entrada después de aplicar las validaciones oficiales.
320
- 4. Reutilizar únicamente `fileId` ya existentes y pertenecientes a la misma
321
- empresa. Este acceso no sube media ni acepta URLs arbitrarias.
322
+ 4. Reutilizar únicamente `fileId` pertenecientes a la misma empresa. Con
323
+ `content_media:upload`, resolverlos mediante `listMedia()` o
324
+ `uploadImage()`; nunca relacionar URLs arbitrarias.
322
325
  5. Rich text usa el JSON controlado por Bacano, nunca HTML crudo.
323
326
  6. Publicar, retirar o modificar una entrada publicada requiere
324
327
  `content_entries:publish` y aprobación humana explícita.
@@ -328,6 +331,45 @@ La API reutiliza el mismo servicio de guardado del backoffice: campos
328
331
  obligatorios, componentes anidados, repetibles, listas, referencias, rich text,
329
332
  media y pertenencia a la empresa se validan de forma autoritativa.
330
333
 
334
+ ## Imágenes
335
+
336
+ Antes de cargar, busca por nombre o texto alternativo para reutilizar la
337
+ biblioteca existente:
338
+
339
+ ```ts
340
+ import { readFile } from "node:fs/promises";
341
+
342
+ const existing = await content.listMedia({ search: "hero juguetes" });
343
+
344
+ const hero = existing.items[0] ?? await content.uploadImage({
345
+ file: await readFile("./hero-juguetes.png"),
346
+ fileName: "Hero juguetes para bebé",
347
+ altText: "Niña jugando con juguetes educativos",
348
+ });
349
+
350
+ console.log(hero.fileId, hero.width, hero.height);
351
+ ```
352
+
353
+ `uploadImage()` envía los bytes al endpoint administrativo de Bacano. El
354
+ servidor comprueba el contenido real, corrige orientación, elimina metadatos no
355
+ necesarios, normaliza a WebP y registra nombre, MIME, tamaño y dimensiones. El
356
+ límite de entrada es 10 MB y 100 megapíxeles. No se admiten animaciones,
357
+ archivos corruptos, SVG, video ni documentos en esta primera versión.
358
+
359
+ El nombre entregado debe describir el asset; Bacano lo normaliza a un filename
360
+ SEO seguro y agrega un sufijo corto para evitar colisiones. Un SHA-256 de los
361
+ bytes WebP normalizados permite reutilizar duplicados dentro de la misma
362
+ empresa. Nunca se deduplica entre empresas.
363
+
364
+ El `altText` de la biblioteca es solo un fallback. Al asociar una imagen a una
365
+ entrada, conserva el texto alternativo contextual que corresponda a ese uso;
366
+ una misma imagen puede significar cosas distintas en dos secciones. Para una
367
+ imagen decorativa el contenido final debe usar `alt=""`.
368
+
369
+ El agente no puede reemplazar ni borrar físicamente media. Una edición genera
370
+ otro asset y cambia explícitamente el `fileId` de la entrada. Esto evita romper
371
+ productos, servicios, branding u otras entradas que todavía usen el archivo.
372
+
331
373
  ## Grupos y listas de opciones
332
374
 
333
375
  Los grupos organizan entradas y usan el scope `content_entries:manage`. Las
@@ -385,12 +427,16 @@ omitas opciones existentes por accidente en una actualización.
385
427
  - Emisión, revocación y mutaciones quedan en una auditoría inmutable sin guardar
386
428
  el bearer token ni el documento completo.
387
429
  - Las respuestas del endpoint usan `no-store`, límites de tamaño y rate limit.
430
+ - Las cargas tienen un límite adicional por concesión; no aceptan importación
431
+ por URL ni exponen credenciales o endpoints internos de Storage.
388
432
  - Los tokens v2 no son compatibles con tokens anteriores y tienen como máximo
389
433
  60 minutos de vigencia.
390
434
 
391
435
  ## Límites de esta versión
392
436
 
393
- - No carga archivos ni crea media; puede relacionar media existente validada.
437
+ - La carga administrativa solo admite imágenes raster JPEG, PNG y WebP. SVG,
438
+ animaciones, video y documentos continúan en el backoffice.
439
+ - No elimina ni reemplaza físicamente archivos de la biblioteca.
394
440
  - No funciona desde el cliente público del storefront.
395
441
  - No acepta GraphQL directo ni credenciales administrativas permanentes.
396
442
  - No previsualiza borradores desde el storefront público.
package/README.md CHANGED
@@ -34,10 +34,11 @@ import { createContentManagementClient } from "@bacano/sdk/content-management";
34
34
  ```
35
35
 
36
36
  Lee [`CONTENT_TYPE_MANAGEMENT.md`](./CONTENT_TYPE_MANAGEMENT.md) antes de usarlo.
37
- La guía contiene el flujo de autorización, contratos de campos y entradas,
38
- estados, referencias, cambios destructivos y límites de seguridad. La carga de
39
- media continúa en el backoffice hasta que exista un contrato administrativo
40
- oficial para ella.
37
+ La guía contiene el flujo de autorización, contratos de campos, entradas y
38
+ media, estados, referencias, cambios destructivos y límites de seguridad. Con
39
+ el scope `content_media:upload`, un agente puede buscar imágenes existentes y
40
+ cargar JPEG, PNG o WebP mediante el contrato oficial; Bacano valida los bytes,
41
+ normaliza a WebP y registra el resultado en la biblioteca de la empresa.
41
42
 
42
43
  ### Auditoría del storefront
43
44
 
@@ -113,6 +113,35 @@ export interface ContentManagementEntryList {
113
113
  entries: ContentManagementEntry[];
114
114
  total: number;
115
115
  }
116
+ export interface ContentManagementMedia {
117
+ id: string;
118
+ fileId: string;
119
+ name: string;
120
+ mediaKind: "image";
121
+ mimeType: string;
122
+ sizeBytes: number;
123
+ width: number | null;
124
+ height: number | null;
125
+ altText: string;
126
+ createdAt: string;
127
+ }
128
+ export interface ContentManagementMediaListInput {
129
+ search?: string;
130
+ limit?: number;
131
+ offset?: number;
132
+ }
133
+ export interface ContentManagementMediaList {
134
+ items: ContentManagementMedia[];
135
+ total: number;
136
+ }
137
+ export interface ContentManagementImageUploadInput {
138
+ file: Blob | ArrayBuffer | Uint8Array;
139
+ fileName: string;
140
+ altText?: string;
141
+ }
142
+ export interface ContentManagementImageUploadResult extends ContentManagementMedia {
143
+ reused: boolean;
144
+ }
116
145
  export interface ContentManagementReferences {
117
146
  categories: Array<{
118
147
  code: string;
@@ -245,6 +274,8 @@ export interface ContentManagementClient {
245
274
  updateEntry(identifier: string, input: ContentManagementEntryInput): Promise<ContentManagementEntry>;
246
275
  inspectEntryLifecycle(identifier: string, operation: ContentManagementLifecycleOperation): Promise<ContentManagementLifecycleResult>;
247
276
  applyEntryLifecycle(identifier: string, operation: ContentManagementLifecycleOperation): Promise<ContentManagementLifecycleResult>;
277
+ listMedia(input?: ContentManagementMediaListInput): Promise<ContentManagementMediaList>;
278
+ uploadImage(input: ContentManagementImageUploadInput): Promise<ContentManagementImageUploadResult>;
248
279
  listGroups(): Promise<ContentManagementGroup[]>;
249
280
  getGroup(identifier: string): Promise<ContentManagementGroup>;
250
281
  createGroup(input: ContentManagementGroupInput): Promise<ContentManagementGroup>;
@@ -68,6 +68,24 @@ function entryListPath(input = {}) {
68
68
  const query = search.toString();
69
69
  return query ? `/entries?${query}` : "/entries";
70
70
  }
71
+ function mediaListPath(input = {}) {
72
+ const search = new URLSearchParams();
73
+ if (input.search) search.set("search", input.search);
74
+ if (input.limit !== void 0) search.set("limit", String(input.limit));
75
+ if (input.offset !== void 0) search.set("offset", String(input.offset));
76
+ const query = search.toString();
77
+ return query ? `/media?${query}` : "/media";
78
+ }
79
+ function uploadBody(file) {
80
+ if ("arrayBuffer" in file) return file;
81
+ if (file instanceof ArrayBuffer) return file;
82
+ const bytes = new Uint8Array(file.byteLength);
83
+ bytes.set(file);
84
+ return bytes.buffer;
85
+ }
86
+ function encodedHeader(value) {
87
+ return encodeURIComponent(value.trim());
88
+ }
71
89
  function createContentManagementClient(config) {
72
90
  const apiUrl = normalizeApiUrl(config.apiUrl);
73
91
  const fetchImplementation = config.fetch ?? globalThis.fetch;
@@ -98,7 +116,7 @@ function createContentManagementClient(config) {
98
116
  headers: {
99
117
  Accept: "application/json",
100
118
  Authorization: `Bearer ${await getAccessToken()}`,
101
- ...init?.body ? { "Content-Type": "application/json" } : {},
119
+ ...typeof init?.body === "string" ? { "Content-Type": "application/json" } : {},
102
120
  ...init?.headers
103
121
  }
104
122
  }
@@ -161,6 +179,26 @@ function createContentManagementClient(config) {
161
179
  ),
162
180
  inspectEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, true),
163
181
  applyEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, false),
182
+ listMedia: (input) => request(mediaListPath(input)),
183
+ uploadImage: (input) => {
184
+ const fileName = input.fileName.trim();
185
+ if (!fileName) {
186
+ throw new ContentManagementError({
187
+ message: "A descriptive image file name is required.",
188
+ code: "CONTENT_MEDIA_FILE_NAME_REQUIRED",
189
+ status: 0
190
+ });
191
+ }
192
+ return request("/media/images", {
193
+ method: "POST",
194
+ headers: {
195
+ "Content-Type": "application/octet-stream",
196
+ "X-Bacano-File-Name": encodedHeader(fileName),
197
+ ...input.altText ? { "X-Bacano-Alt-Text": encodedHeader(input.altText) } : {}
198
+ },
199
+ body: uploadBody(input.file)
200
+ });
201
+ },
164
202
  listGroups: () => request("/groups"),
165
203
  getGroup: (identifier) => request(
166
204
  `/groups/${encodeIdentifier(identifier)}`
@@ -43,6 +43,24 @@ function entryListPath(input = {}) {
43
43
  const query = search.toString();
44
44
  return query ? `/entries?${query}` : "/entries";
45
45
  }
46
+ function mediaListPath(input = {}) {
47
+ const search = new URLSearchParams();
48
+ if (input.search) search.set("search", input.search);
49
+ if (input.limit !== void 0) search.set("limit", String(input.limit));
50
+ if (input.offset !== void 0) search.set("offset", String(input.offset));
51
+ const query = search.toString();
52
+ return query ? `/media?${query}` : "/media";
53
+ }
54
+ function uploadBody(file) {
55
+ if ("arrayBuffer" in file) return file;
56
+ if (file instanceof ArrayBuffer) return file;
57
+ const bytes = new Uint8Array(file.byteLength);
58
+ bytes.set(file);
59
+ return bytes.buffer;
60
+ }
61
+ function encodedHeader(value) {
62
+ return encodeURIComponent(value.trim());
63
+ }
46
64
  function createContentManagementClient(config) {
47
65
  const apiUrl = normalizeApiUrl(config.apiUrl);
48
66
  const fetchImplementation = config.fetch ?? globalThis.fetch;
@@ -73,7 +91,7 @@ function createContentManagementClient(config) {
73
91
  headers: {
74
92
  Accept: "application/json",
75
93
  Authorization: `Bearer ${await getAccessToken()}`,
76
- ...init?.body ? { "Content-Type": "application/json" } : {},
94
+ ...typeof init?.body === "string" ? { "Content-Type": "application/json" } : {},
77
95
  ...init?.headers
78
96
  }
79
97
  }
@@ -136,6 +154,26 @@ function createContentManagementClient(config) {
136
154
  ),
137
155
  inspectEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, true),
138
156
  applyEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, false),
157
+ listMedia: (input) => request(mediaListPath(input)),
158
+ uploadImage: (input) => {
159
+ const fileName = input.fileName.trim();
160
+ if (!fileName) {
161
+ throw new ContentManagementError({
162
+ message: "A descriptive image file name is required.",
163
+ code: "CONTENT_MEDIA_FILE_NAME_REQUIRED",
164
+ status: 0
165
+ });
166
+ }
167
+ return request("/media/images", {
168
+ method: "POST",
169
+ headers: {
170
+ "Content-Type": "application/octet-stream",
171
+ "X-Bacano-File-Name": encodedHeader(fileName),
172
+ ...input.altText ? { "X-Bacano-Alt-Text": encodedHeader(input.altText) } : {}
173
+ },
174
+ body: uploadBody(input.file)
175
+ });
176
+ },
139
177
  listGroups: () => request("/groups"),
140
178
  getGroup: (identifier) => request(
141
179
  `/groups/${encodeIdentifier(identifier)}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacano/sdk",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "TypeScript SDK for building websites on the Bacano ERP",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.js",
@@ -77,7 +77,8 @@ pnpm exec bacano-figma-web validate-snapshot .bacano/designs/<proyecto>/manifest
77
77
  ## 4. Mapear a Bacano antes de programar
78
78
 
79
79
  Leer [references/bacano-mapping.md](references/bacano-mapping.md),
80
- [references/content-workflow.md](references/content-workflow.md) y
80
+ [references/content-workflow.md](references/content-workflow.md),
81
+ [references/content-media.md](references/content-media.md) y
81
82
  [references/implementation-quality.md](references/implementation-quality.md).
82
83
  Preparar una matriz ruta → frame → componente → fuente de datos → Content →
83
84
  responsive → preview → estado, y determinar si el trabajo comienza desde
@@ -103,7 +104,10 @@ blueprint, leer la sección de automatización de
103
104
  componentes, grupos, listas y entradas únicamente mediante el acceso temporal
104
105
  y los scopes oficiales. Inventariar esos recursos antes de crear otros y
105
106
  preparar entradas en borrador por defecto; la media permanece en el
106
- backoffice y publicar requiere permiso y aprobación explícitos.
107
+ backoffice salvo que el usuario conceda `content_media:upload`. Con ese scope,
108
+ buscar primero imágenes reutilizables y cargar bytes únicamente mediante
109
+ `listMedia()` y `uploadImage()`; publicar requiere permiso y aprobación
110
+ explícitos.
107
111
 
108
112
  Al proponer keys nuevas, resolver primero el idioma según
109
113
  `CONTENT_TYPE_MANAGEMENT.md`: instrucción explícita, convención existente y
@@ -129,7 +133,9 @@ lo pendiente cuando la revisión visual quede a cargo del usuario.
129
133
 
130
134
  No añadir paquetes, fuentes, iconos o assets si el proyecto ya ofrece un
131
135
  equivalente. Descargar assets mediante la fuente Figma autorizada; no incrustar
132
- URLs temporales ni construir transformaciones de Storage manualmente.
136
+ URLs temporales ni construir transformaciones de Storage manualmente. La carga
137
+ administrativa admite JPEG, PNG o WebP raster; video, documentos, SVG y
138
+ animaciones continúan bajo revisión editorial en el backoffice.
133
139
 
134
140
  ## 6. Validar diseño y contrato
135
141
 
@@ -0,0 +1,57 @@
1
+ # Imágenes de Content desde un agente
2
+
3
+ Usar este flujo solo cuando `getContext().scopes` incluya
4
+ `content_media:upload`. El token temporal continúa siendo administrativo: no se
5
+ incluye en el storefront, Figma, archivos del proyecto, logs ni respuestas al
6
+ usuario.
7
+
8
+ ## Orden obligatorio
9
+
10
+ 1. Clasificar el asset: hero desktop/mobile, card, galería, logo, decorativo u
11
+ otro uso editorial.
12
+ 2. Buscar en `listMedia()` con términos semánticos. Reutilizar únicamente cuando
13
+ sea el mismo asset, no solo una imagen parecida.
14
+ 3. Exportar desde la fuente Figma autorizada los bytes originales necesarios.
15
+ 4. Para imágenes informativas, preparar un nombre descriptivo y `altText`
16
+ contextual. No usar nombres de capas como `Frame 32` o `image_1`.
17
+ 5. Cargar con `uploadImage()`. Nunca usar GraphQL, URLs de Figma, base64 dentro
18
+ de una entrada ni endpoints directos de Nhost Storage.
19
+ 6. Guardar en Content el `fileId` devuelto y volver a consultar la entrada.
20
+ 7. Mantener en borrador hasta validar imagen, encuadre, responsive y texto
21
+ alternativo en contexto.
22
+
23
+ ```ts
24
+ import { readFile } from "node:fs/promises";
25
+ import { createContentManagementClient } from "@bacano/sdk/content-management";
26
+
27
+ const content = createContentManagementClient({
28
+ apiUrl: process.env.BACANO_API_URL!,
29
+ accessToken: process.env.BACANO_CONTENT_ACCESS_TOKEN!,
30
+ });
31
+
32
+ const matches = await content.listMedia({ search: "hero juguetes" });
33
+ const media = matches.items[0] ?? await content.uploadImage({
34
+ file: await readFile("./exports/hero-juguetes-desktop.png"),
35
+ fileName: "Hero juguetes para bebé desktop",
36
+ altText: "Niña jugando con bloques de colores",
37
+ });
38
+
39
+ console.log(media.fileId);
40
+ ```
41
+
42
+ ## Contrato de la primera versión
43
+
44
+ - Admite JPEG, PNG y WebP raster no animado.
45
+ - Máximo 10 MB de entrada y 100 megapíxeles.
46
+ - Bacano valida bytes reales, corrige orientación y normaliza a WebP.
47
+ - El nombre se convierte a un filename SEO seguro.
48
+ - Los duplicados binarios normalizados se reutilizan dentro de la misma
49
+ empresa; nunca entre empresas.
50
+ - SVG, GIF animado, video y documentos se cargan desde el backoffice.
51
+ - El agente no reemplaza ni elimina físicamente media.
52
+ - `altText` de biblioteca es fallback. El texto alternativo final pertenece al
53
+ uso dentro de la entrada; una imagen decorativa usa `alt=""` en ese contexto.
54
+
55
+ Si el asset no cabe en este contrato, reportar `BLOQUEADO` y pedir que el equipo
56
+ editorial lo prepare en la biblioteca. No convertirlo de forma improvisada ni
57
+ introducir paquetes o servicios paralelos.
@@ -155,7 +155,9 @@ usuario autoriza que el agente materialice el blueprint, usar exclusivamente
155
155
  agente**. Confirmar la empresa y los scopes, inventariar primero, crear
156
156
  componentes antes de sus consumidores y trabajar inicialmente en borrador. La
157
157
  skill puede preparar entradas cuando el usuario conceda
158
- `content_entries:manage`; no carga media ni publica sin el scope específico.
158
+ `content_entries:manage`. Si además se concede `content_media:upload`, debe
159
+ buscar y cargar imágenes por el contrato descrito en
160
+ [content-media.md](content-media.md). No publica sin el scope específico.
159
161
 
160
162
  ## 6. Cambio a Bacano real
161
163
 
@@ -189,7 +191,8 @@ escribir:
189
191
  locale; crear las faltantes en borrador y actualizar únicamente con
190
192
  `expectedUpdatedAt`.
191
193
  7. No fusionar por nombres, no usar HTML crudo y no relacionar URLs remotas. La
192
- media debe existir en Bacano y pertenecer a la empresa autorizada.
194
+ media debe existir en Bacano y pertenecer a la empresa autorizada; resolver
195
+ su `fileId` mediante `listMedia()` o `uploadImage()`.
193
196
  8. Para archivar o eliminar tipos, entradas, grupos o listas, inspeccionar
194
197
  dependencias y confirmar nuevamente. Una actualización de lista debe enviar
195
198
  todos los elementos que deben permanecer, incluidos sus IDs existentes.
@@ -198,5 +201,5 @@ escribir:
198
201
 
199
202
  Nunca exponer el token en el navegador, commit, blueprint, salida de consola o
200
203
  chat. Nunca convertir el blueprint en una importación implícita ni usar GraphQL
201
- directo. La carga de media permanece bajo gestión editorial en Bacano hasta que
202
- exista el scope y endpoint administrativo oficial.
204
+ directo. Sin `content_media:upload`, toda media permanece bajo gestión
205
+ editorial en el backoffice.