From 5c011ab7ef18d0ef3a943b1a74df1a4f5ec352e2 Mon Sep 17 00:00:00 2001 From: Jordan Diaz Date: Mon, 27 Jul 2026 18:04:05 +0000 Subject: [PATCH] refactor: create_or_update_record escribe via el server Python (un solo camino: enlace, contrasenas y metadatos de category) --- mcp-server/tools/helpers/passwordFields.js | 303 --------------------- mcp-server/tools/records/createUpdate.js | 282 ++++++++++++------- 2 files changed, 181 insertions(+), 404 deletions(-) delete mode 100644 mcp-server/tools/helpers/passwordFields.js diff --git a/mcp-server/tools/helpers/passwordFields.js b/mcp-server/tools/helpers/passwordFields.js deleted file mode 100644 index a64f4d0..0000000 --- a/mcp-server/tools/helpers/passwordFields.js +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Hashing de campos `editor_password` para escrituras directas al cmsApi. - * - * CONTEXTO / POR QUE EXISTE ESTE FICHERO - * En Acai, los campos de tipo `editor_password` (p.ej. `usuarios.clave`) se - * almacenan hasheados con SHA1. Ese hasheo lo hace el plugin PHP - * `cms/lib/plugins/editor_password/save_pre.php`, que es un hook del admin. - * Las tools MCP escriben por `action_ws=cmsApi&subaction=insert|update`, y ese - * camino NO ejecuta hooks de plugin: sin esta capa, el agente guardaria las - * contrasenas EN CLARO en la base de datos. - * - * REGLA CANONICA (fuente de verdad: `server/password_fields.py`, la misma que - * aplica el dashboard; espejo del plugin PHP save_pre.php): - * - Valor almacenado = sha1(texto_plano) en hexadecimal. - * - INSERT: hashear el valor si no esta vacio. - * - UPDATE: hashear solo si difiere del valor ya almacenado (evita - * re-hashear un hash reenviado, p.ej. tras un `get_record`). - * - Valor vacio / ausente: NO tocar el campo (se elimina del payload) para - * no borrar una contrasena existente. - * - * NOTAS Y LIMITACIONES CONOCIDAS - * 1. En UPDATE leemos el valor almacenado con una lectura extra al cmsApi, y - * solo cuando el payload trae realmente un campo password con valor. Si esa - * lectura falla (o el `num` no es numerico), caemos a la heuristica - * "si el valor entrante ya parece un sha1 (40 hex) se deja tal cual, si no - * se hashea". Consecuencia: una contrasena en claro que sea exactamente 40 - * caracteres hex se guardaria sin hashear en ese caso degradado. Se acepta - * para cumplir la prioridad "nunca doble-hashear". - * 2. En INSERT se hashea siempre (igual que el plugin PHP), asi que insertar - * un hash copiado de otro registro produciria un doble hash. Es la - * semantica canonica: en un insert no hay valor previo con el que comparar. - * 3. El tipo `editor_password` solo se conoce leyendo el schema de la tabla. - * Si el schema no se puede leer y el payload contiene un campo con nombre - * sospechoso de password, se BLOQUEA la escritura en vez de arriesgar texto - * en claro (o corromper datos hasheando un campo que no es password). - */ - -import crypto from "node:crypto"; -import { getApiClient } from "../../auth/index.js"; -import { AcaiHttpClient } from "./acaiHttpClient.js"; -import { parseIniSchema } from "../tables/iniParser.js"; - -const PASSWORD_FIELD_TYPE = "editor_password"; -const SHA1_HEX_RE = /^[a-f0-9]{40}$/i; -const SCHEMA_CACHE_TTL_MS = 60 * 1000; - -/** - * Nombres tipicos de campo password. SOLO se usan como red de seguridad - * cuando el schema no se pudo leer. Nunca deciden el hasheo cuando SI - * tenemos schema (ahi manda `type = "editor_password"`). - */ -const PASSWORD_NAME_HINTS = [ - "clave", "password", "passwd", "pwd", "pass", - "contrasena", "contraseña", "secret", -]; - -/** - * Cache de nombres de campos password por tabla. - * Clave con entorno incluido (website + mode + project_dir) para no cruzar - * datos entre proyectos/entornos distintos en el mismo proceso. - */ -const schemaCache = new Map(); - -export function sha1Hex(value) { - return crypto.createHash("sha1").update(String(value), "utf8").digest("hex"); -} - -export function looksLikeSha1(value) { - return typeof value === "string" && SHA1_HEX_RE.test(value.trim()); -} - -/** Vacio = no tocar el campo. El "0" cuenta como valor real (se hashea). */ -function isEmptyValue(value) { - if (value === undefined || value === null) return true; - return typeof value === "string" && value.trim() === ""; -} - -function looksLikePasswordName(fieldName) { - const lower = String(fieldName).toLowerCase(); - return PASSWORD_NAME_HINTS.some(hint => lower.includes(hint)); -} - -function bareTableName(tableName) { - return String(tableName).replace(/^cms_/, ""); -} - -function buildCacheKey(credentials, tableName) { - const website = credentials?.website || ""; - const mode = credentials?.mode || ""; - const projectDir = credentials?.project_dir || ""; - return `${website}|${mode}|${projectDir}|${bareTableName(tableName)}`; -} - -/** Util para tests y para invalidar tras cambios de schema. */ -export function clearPasswordFieldsCache() { - schemaCache.clear(); -} - -/** - * Devuelve los nombres de campo de tipo `editor_password` de una tabla. - * Usa la misma via que `get_table_schema`/`list_tables`: - * viewer_functions.php?action_ws=getTableSchemas + parseIniSchema. - * - * Cachea el resultado (incluida la lista vacia) para no anadir una llamada - * HTTP por cada escritura sobre la misma tabla. - * - * @returns {Promise} - */ -export async function getPasswordFieldNames({ sessionId, credentials, tableName }) { - const key = buildCacheKey(credentials, tableName); - const cached = schemaCache.get(key); - if (cached && cached.expiresAt > Date.now()) return cached.fields; - - const client = await getApiClient(sessionId); - const response = await AcaiHttpClient.postViewerFunctions(client, { - action_ws: "getTableSchemas", - tableName, - token: credentials?.token, - tokenHash: credentials?.tokenHash, - }); - - const schemas = response?.data?.schemas; - if (!schemas || typeof schemas !== "object") { - throw new Error("getTableSchemas no devolvio 'schemas'"); - } - - // Tabla sin schema (tablas de sistema, cms_*): no puede tener campos - // editor_password porque el tipo solo existe en el schema. - const fields = []; - const iniContent = schemas[`${bareTableName(tableName)}.ini.php`]; - if (iniContent) { - const parsed = parseIniSchema(iniContent); - for (const [name, definition] of Object.entries(parsed.fields || {})) { - if (definition && definition.type === PASSWORD_FIELD_TYPE) fields.push(name); - } - } - - schemaCache.set(key, { fields, expiresAt: Date.now() + SCHEMA_CACHE_TTL_MS }); - return fields; -} - -/** - * Lee el registro almacenado para poder comparar el valor previo del campo - * password. Best-effort: devuelve null si no se puede resolver. - */ -async function fetchStoredRecord({ credentials, tableName, recordId }) { - // Solo aceptamos `num` numerico: es la PK entera y asi no interpolamos - // texto arbitrario en el where de esta lectura. - const numericId = Number(recordId); - if (!Number.isInteger(numericId) || numericId <= 0) return null; - - const response = await AcaiHttpClient.postCmsApi( - credentials, - "get", - { - tableName, - where: `num = ${numericId}`, - limit: 1, - options: { uploads: false, relations: false }, - }, - credentials?.token, - credentials?.tokenHash - ); - - const rows = response?.data?.data; - return Array.isArray(rows) && rows[0] && typeof rows[0] === "object" ? rows[0] : null; -} - -/** - * Aplica la regla canonica de hasheo a los campos `editor_password` de los - * records antes de construir el payload del cmsApi. - * - * No muta los records de entrada: devuelve copias nuevas. - * - * @param {object} args - * @param {string} args.sessionId - * @param {object} args.credentials - * @param {string} args.tableName - * @param {object[]} args.records - * @param {*} [args.recordId] - presente => UPDATE; ausente => INSERT - * @returns {Promise<{records: object[], hashedFields: string[], omittedEmptyFields: string[], warnings: string[], error?: string}>} - */ -export async function applyPasswordFieldHashing({ sessionId, credentials, tableName, records, recordId }) { - const safeRecords = Array.isArray(records) ? records : []; - const result = { - records: safeRecords, - hashedFields: [], - omittedEmptyFields: [], - warnings: [], - }; - - const presentNames = new Set(); - for (const record of safeRecords) { - if (record && typeof record === "object" && !Array.isArray(record)) { - Object.keys(record).forEach(name => presentNames.add(name)); - } - } - if (presentNames.size === 0) return result; - - let passwordFields; - try { - passwordFields = await getPasswordFieldNames({ sessionId, credentials, tableName }); - } catch (error) { - // Sin schema no sabemos que campos son password. Si algun nombre huele - // a password, bloqueamos: mejor fallar que guardar texto en claro. - const suspicious = [...presentNames].filter(looksLikePasswordName); - if (suspicious.length > 0) { - return { - ...result, - error: `No se pudo leer el schema de '${tableName}' para verificar campos de contrasena (${error.message}). ` - + `La escritura se ha cancelado porque el payload incluye ${suspicious.join(", ")} y los campos ` - + `'editor_password' deben guardarse hasheados. Reintenta cuando el schema sea accesible.`, - }; - } - console.error(`[passwordFields] schema de '${tableName}' no disponible (${error.message}); sin campos sospechosos, se continua`); - return { ...result, warnings: [`Schema de '${tableName}' no verificado: ${error.message}`] }; - } - - const fieldsInPayload = passwordFields.filter(name => presentNames.has(name)); - if (fieldsInPayload.length === 0) return result; - - const isUpdate = Boolean(recordId); - - // Solo necesitamos el valor previo si algun record trae de verdad un valor - // en un campo password (los vacios se eliminan sin comparar nada). - const needsStoredValue = isUpdate && safeRecords.some(record => - record && typeof record === "object" && !Array.isArray(record) - && fieldsInPayload.some(field => field in record && !isEmptyValue(record[field])) - ); - - let storedRecord = null; - if (needsStoredValue) { - try { - storedRecord = await fetchStoredRecord({ credentials, tableName, recordId }); - } catch (error) { - console.error(`[passwordFields] no se pudo leer el registro ${recordId} de '${tableName}': ${error.message}`); - } - if (!storedRecord) { - result.warnings.push( - "No se pudo leer el valor previo de los campos de contrasena; se aplica el criterio " - + "'hashear salvo que el valor ya sea un sha1 de 40 hex'." - ); - } - } - - const hashedFields = new Set(); - const omittedEmptyFields = new Set(); - - const nextRecords = safeRecords.map(record => { - if (!record || typeof record !== "object" || Array.isArray(record)) return record; - const next = { ...record }; - - for (const field of fieldsInPayload) { - if (!(field in next)) continue; - const incoming = next[field]; - - // Vacio => no tocar el campo (no borramos la contrasena existente). - if (isEmptyValue(incoming)) { - delete next[field]; - omittedEmptyFields.add(field); - continue; - } - - const value = String(incoming); - - if (isUpdate && storedRecord) { - const stored = storedRecord[field] === undefined || storedRecord[field] === null - ? "" - : String(storedRecord[field]); - // Igual al almacenado => es el hash reenviado, se deja intacto. - if (stored !== "" && value === stored) continue; - next[field] = sha1Hex(value); - hashedFields.add(field); - continue; - } - - if (isUpdate && !storedRecord) { - // Camino degradado (ver limitacion 1 de la cabecera). - if (looksLikeSha1(value)) continue; - next[field] = sha1Hex(value); - hashedFields.add(field); - continue; - } - - // INSERT: hashear siempre que haya valor. - next[field] = sha1Hex(value); - hashedFields.add(field); - } - - return next; - }); - - if (hashedFields.size > 0) { - console.error(`[passwordFields] '${tableName}': campos hasheados (sha1) -> ${[...hashedFields].join(", ")}`); - } - - return { - ...result, - records: nextRecords, - hashedFields: [...hashedFields], - omittedEmptyFields: [...omittedEmptyFields], - }; -} diff --git a/mcp-server/tools/records/createUpdate.js b/mcp-server/tools/records/createUpdate.js index 5dfa55b..fc375a4 100644 --- a/mcp-server/tools/records/createUpdate.js +++ b/mcp-server/tools/records/createUpdate.js @@ -1,10 +1,74 @@ import { z } from "zod"; -import { withAuth, getSessionCredentials } from "../../auth/index.js"; -import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js"; -import { AcaiHttpClient } from "../helpers/acaiHttpClient.js"; +import { withAuth } from "../../auth/index.js"; +import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; import { canAccessTable } from "../helpers/accessControl.js"; -import { applyPasswordFieldHashing } from "../helpers/passwordFields.js"; +import { pythonPost } from "../helpers/pythonServerClient.js"; +import { getCurrentProjectInfo } from "../files/helpers.js"; + +// Tool: create_or_update_record +// +// TRANSPORTE: escribe SIEMPRE a traves del server Python +// (/api/cms/create-record y /api/cms/update-record), nunca contra el cmsApi de +// la web. Esos endpoints son el mismo camino que usa el dashboard, asi que la +// tool hereda gratis toda la logica de escritura que ya vive en Python: +// +// * auto-relleno y normalizacion de `enlace` (slug derivado de title/name). +// * hasheo sha1 de los campos `editor_password` (el cmsApi no ejecuta hooks +// de plugin; la regla canonica vive en server/password_fields.py). +// * metadatos de tablas `category`: regeneracion del arbol solo cuando hace +// falta (jerarquia real) y derivados calculados para las tablas planas. +// * defaults del schema en INSERT (fill_schema_defaults) y filtrado de +// campos `adminOnly` para usuarios no admin. +// +// Duplicar todo eso en JS era inviable: un solo camino de escritura. +// +// LOTES: el endpoint Python de creacion acepta UN registro, asi que un `fields` +// array se resuelve con N llamadas secuenciales. Ver BATCH_POLICY. + +// El endpoint de creacion escribe de uno en uno y NO hay transaccion que +// envuelva el lote: si la llamada k falla, las k-1 anteriores ya estan en BD. +// Politica: ABORTAR en el primer fallo y devolver los `num` ya creados, el +// indice que fallo y cuantos quedaron sin intentar. Preferimos un lote a medias +// EXPLICITO (el agente puede continuar o borrar) a seguir insertando a ciegas o +// a callarnoslo con un success:true enganoso. +const BATCH_POLICY = "abort-on-first-error"; + +// Campos que nunca deben cambiar en un registro existente. El server Python NO +// los filtra (su `autofill_enlace` en update solo normaliza el `enlace` que le +// llegue), asi que el strip se mantiene aqui: es lo que la descripcion de la +// tool le promete al agente. +const PROTECTED_UPDATE_FIELDS = ["enlace", "controlador", "precontrolador"]; + +/** + * POST al server Python normalizando el error. + * Los handlers responden {success:false, error, errorCode} con status 4xx/5xx, y + * las validaciones tempranas responden {error: "..."} con 400 — axios lanza en + * ambos casos, asi que aqui se aplanan a { ok, data, error, errorCode, status }. + */ +async function postToPython(path, body) { + try { + const data = await pythonPost(path, body); + if (data && data.success === true) return { ok: true, data }; + return { + ok: false, + error: (data && (data.error || data.message)) || "El server Python no confirmo la escritura", + errorCode: data?.errorCode, + status: 200, + }; + } catch (error) { + const payload = error?.response?.data; + const message = (payload && typeof payload === "object" && (payload.error || payload.message)) + || error?.message + || "Error desconocido escribiendo en el server Python"; + return { + ok: false, + error: typeof message === "string" ? message : JSON.stringify(message), + errorCode: payload?.errorCode, + status: error?.response?.status, + }; + } +} export function registerCreateOrUpdateRecordTool(server) { server.tool( @@ -13,9 +77,15 @@ export function registerCreateOrUpdateRecordTool(server) { Reglas clave: tablas sin prefijo 'cms_'; PK es 'num' (nunca 'id'); foreign keys con sufijo '_num'; uploads son arrays — NO los envíes en 'fields', sube después con 'upload_record_image'; fechas en formato YYYY-MM-DD HH:mm:ss; checkboxes como 1/0 (números). -Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates. +Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates. -Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se hashean automáticamente con SHA1 antes de guardarse — envía la contraseña en texto plano y NO la hashees tú. Su valor no se puede leer/descifrar después (solo verás el hash), así que no intentes recuperar contraseñas existentes ni reenviarlas. Si envías el campo vacío ('' o null) se omite del guardado y la contraseña actual se mantiene.`, +Enlace: NO hace falta que lo inventes al crear. Si la tabla tiene campo 'enlace' y no lo envías, se genera un slug legible a partir de 'title' o 'name' (y si no hay ninguno, uno aleatorio); si lo envías, se normaliza a la forma /.../. El valor final lo decide el servidor, así que si necesitas la URL del registro léela después con 'get_record'. + +Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se hashean automáticamente con SHA1 en el servidor antes de guardarse — envía la contraseña en texto plano y NO la hashees tú. Su valor no se puede leer/descifrar después (solo verás el hash), así que no intentes recuperar contraseñas existentes ni reenviarlas. Si envías el campo vacío ('' o null) se omite del guardado y la contraseña actual se mantiene. + +Alta múltiple ('fields' como array): los registros se crean UNO A UNO y no hay transacción. Si uno falla, se aborta ahí: la respuesta te dice qué 'num' se llegaron a crear (createdIds), en qué índice falló y cuántos quedaron sin intentar. Los ya creados NO se revierten — decide tú si reintentas el resto o los borras. + +Campos restringidos: los campos marcados como 'adminOnly' en el schema se descartan silenciosamente si el usuario del proyecto no es admin (solo aplica en producción).`, withAuthParams({ tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"), recordId: z.any().optional().describe("'num' del registro a actualizar. Omitir para crear nuevo. NO se usa cuando 'fields' es array."), @@ -23,7 +93,7 @@ Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se ha tableSchema: z.any().describe("Schema de la tabla para validar tipos antes de enviar (opcional)."), }), { readOnlyHint: false, destructiveHint: false }, - withAuth(async ({ tableName, recordId, fields }, extra) => { + withAuth(async ({ tableName, recordId, fields }, _extra) => { try { // Validate required parameters const validationError = validateRequired({ tableName, fields }, ['tableName', 'fields'], 'create_or_update_record'); @@ -58,115 +128,126 @@ Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se ha }; } - // Protect critical fields during updates — these should never be changed by AI - const PROTECTED_UPDATE_FIELDS = ['enlace', 'controlador', 'precontrolador']; - if (recordId) { - // On update: strip protected fields silently - recordsArray.forEach(record => { - PROTECTED_UPDATE_FIELDS.forEach(f => { - if (f in record) delete record[f]; + // Un array vacio no es un alta de 0 registros: es una llamada sin + // sentido. Antes acababa en un insert vacio; ahora se corta aqui + // para no devolver un success enganoso. + if (recordsArray.length === 0) { + return handleToolError( + "Error: 'fields' is an empty array — there is nothing to create.", + 'create_or_update_record', + { tableName } + ); + } + + const { projectSlug } = getCurrentProjectInfo(); + const isNewRecord = !recordId; + + // ---------- UPDATE: un registro, una llamada ---------- + if (!isNewRecord) { + // Protege los campos criticos: se eliminan en silencio (contrato + // publico de la tool). Python no hace este strip. + const record = { ...recordsArray[0] }; + const stripped = PROTECTED_UPDATE_FIELDS.filter(f => f in record); + stripped.forEach(f => { delete record[f]; }); + + // El endpoint exige `fields` no vacio; si el strip lo dejo seco + // devolvemos un error accionable en vez del generico de Python. + if (Object.keys(record).length === 0) { + return handleToolError( + `Nothing to update: after stripping protected fields (${PROTECTED_UPDATE_FIELDS.join(', ')}) there are no fields left. ` + + `Those fields cannot be modified on an existing record.`, + 'create_or_update_record', + { tableName, recordId, strippedFields: stripped } + ); + } + + const res = await postToPython("/api/cms/update-record", { + project: projectSlug, + table: tableName, + num: recordId, + fields: record, + }); + + if (!res.ok) { + return handleToolError(res.error, 'create_or_update_record', { + tableName, + recordId, + errorCode: res.errorCode, + httpStatus: res.status, }); - }); - } + } - // Process enlace field for new records only - let processedRecords = recordsArray; - if (!recordId) { - processedRecords = recordsArray.map(record => { - let enlaceValue = record.enlace; - - if (!enlaceValue) { - // Generate random enlace if not provided to ensure uniqueness - enlaceValue = '/' + Math.random().toString(36).substring(2, 10) + '/'; - } else { - // Ensure format /.../ - enlaceValue = String(enlaceValue); - if (!enlaceValue.startsWith('/')) enlaceValue = '/' + enlaceValue; - if (!enlaceValue.endsWith('/')) enlaceValue = enlaceValue + '/'; - } - - return { ...record, enlace: enlaceValue }; - }); - } - - const credentials = await getSessionCredentials(extra.sessionId); - - // Campos de contraseña (type = "editor_password"): el cmsApi no ejecuta - // hooks de plugin, así que el hasheo sha1 que haría save_pre.php hay que - // aplicarlo aquí. Regla canónica en server/password_fields.py. - const passwordResult = await applyPasswordFieldHashing({ - sessionId: extra.sessionId, - credentials, - tableName, - records: processedRecords, - recordId, - }); - if (passwordResult.error) { return { - content: [{ type: "text", text: JSON.stringify({ success: false, error: passwordResult.error }, null, 2) }], - isError: true, + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + message: `Record ${recordId} updated successfully`, + tableName, + recordIds: recordId, + recordsCount: 1, + strippedFields: stripped.length > 0 ? stripped : undefined, + // El server responde skipped cuando el filtrado + // (adminOnly / password vacia) dejo el UPDATE sin columnas. + skipped: res.data?.skipped === true ? true : undefined, + skippedReason: res.data?.skipped === true + ? "El servidor descartó todos los campos enviados (adminOnly o contraseña vacía): no se escribió nada." + : undefined, + }, null, 2) + }], }; } - processedRecords = passwordResult.records; - // Prepare payload for CMS API - const recordPayload = { - tableName: tableName, - records: processedRecords, - functions: [], - options: {} - }; + // ---------- INSERT: N registros, N llamadas ---------- + const createdIds = []; + for (let i = 0; i < recordsArray.length; i++) { + const res = await postToPython("/api/cms/create-record", { + project: projectSlug, + table: tableName, + fields: recordsArray[i], + }); - // Determine action: insert for new records, update for existing - const isNewRecord = !recordId; - let response; + if (!res.ok) { + // BATCH_POLICY: abortar y reportar el estado real del lote. + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: res.error, + errorCode: res.errorCode, + httpStatus: res.status, + tableName, + batchPolicy: BATCH_POLICY, + failedIndex: i, + createdIds, + createdCount: createdIds.length, + notAttemptedCount: recordsArray.length - i - 1, + hint: createdIds.length > 0 + ? `Los ${createdIds.length} registro(s) anteriores YA se crearon (num: ${createdIds.join(', ')}) y NO se han revertido. Corrige el registro del índice ${i} y reintenta solo los que faltan, o bórralos con delete_record.` + : `No se creó ningún registro. Corrige el registro del índice ${i} y reintenta.`, + }, null, 2) + }], + isError: true, + }; + } - if (isNewRecord) { - // Insert new record(s) - response = await AcaiHttpClient.postCmsApi( - credentials, - 'insert', - recordPayload, - credentials.token, - credentials.tokenHash - ); - } else { - // Update existing record (only single record, not array) - response = await AcaiHttpClient.postCmsApi( - credentials, - 'update', - { - ...recordPayload, - where: `num = ${recordId}` - }, - credentials.token, - credentials.tokenHash - ); + createdIds.push(res.data.num); } - // Check for API errors - const apiError = handleApiResponse(response.data, 'create_or_update_record'); - if (apiError) return apiError; - return { content: [{ type: "text", text: JSON.stringify({ success: true, - message: isNewRecord - ? `${isArray ? recordsArray.length : 1} record(s) created successfully` - : `Record ${recordId} updated successfully`, - tableName: tableName, - recordIds: response.data?.data || (recordId || 'new'), - recordsCount: isArray ? recordsArray.length : 1, - createdIds: response.data?.data, - // Transparencia sobre el tratamiento de contraseñas (nunca devolvemos el valor) - passwordFieldsHashed: passwordResult.hashedFields.length > 0 ? passwordResult.hashedFields : undefined, - passwordFieldsSkipped: passwordResult.omittedEmptyFields.length > 0 - ? `Campos de contraseña vacíos, omitidos para no borrar el valor actual: ${passwordResult.omittedEmptyFields.join(', ')}` + message: `${recordsArray.length} record(s) created successfully`, + tableName, + recordIds: isArray ? createdIds : createdIds[0], + recordsCount: recordsArray.length, + createdIds, + suggestion: !isArray + ? `Puedes verificar el registro con get_record({ tableName: "${tableName}", recordId: ${JSON.stringify(createdIds[0])} }) — ahí verás el 'enlace' definitivo que generó el servidor.` : undefined, - passwordWarnings: passwordResult.warnings.length > 0 ? passwordResult.warnings : undefined, - suggestion: isNewRecord && !isArray ? `You can verify the record by fetching: ${credentials.web_url}${processedRecords[0].enlace}` : undefined }, null, 2) }], }; @@ -176,4 +257,3 @@ Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se ha }) ); } -