refactor: create_or_update_record escribe via el server Python (un solo camino: enlace, contrasenas y metadatos de category)
This commit is contained in:
@@ -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<string[]>}
|
|
||||||
*/
|
|
||||||
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],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,74 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
import { withAuth } from "../../auth/index.js";
|
||||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { canAccessTable } from "../helpers/accessControl.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) {
|
export function registerCreateOrUpdateRecordTool(server) {
|
||||||
server.tool(
|
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).
|
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({
|
withAuthParams({
|
||||||
tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"),
|
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."),
|
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)."),
|
tableSchema: z.any().describe("Schema de la tabla para validar tipos antes de enviar (opcional)."),
|
||||||
}),
|
}),
|
||||||
{ readOnlyHint: false, destructiveHint: false },
|
{ readOnlyHint: false, destructiveHint: false },
|
||||||
withAuth(async ({ tableName, recordId, fields }, extra) => {
|
withAuth(async ({ tableName, recordId, fields }, _extra) => {
|
||||||
try {
|
try {
|
||||||
// Validate required parameters
|
// Validate required parameters
|
||||||
const validationError = validateRequired({ tableName, fields }, ['tableName', 'fields'], 'create_or_update_record');
|
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
|
// Un array vacio no es un alta de 0 registros: es una llamada sin
|
||||||
const PROTECTED_UPDATE_FIELDS = ['enlace', 'controlador', 'precontrolador'];
|
// sentido. Antes acababa en un insert vacio; ahora se corta aqui
|
||||||
if (recordId) {
|
// para no devolver un success enganoso.
|
||||||
// On update: strip protected fields silently
|
if (recordsArray.length === 0) {
|
||||||
recordsArray.forEach(record => {
|
return handleToolError(
|
||||||
PROTECTED_UPDATE_FIELDS.forEach(f => {
|
"Error: 'fields' is an empty array — there is nothing to create.",
|
||||||
if (f in record) delete record[f];
|
'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 {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: passwordResult.error }, null, 2) }],
|
content: [{
|
||||||
isError: true,
|
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
|
// ---------- INSERT: N registros, N llamadas ----------
|
||||||
const recordPayload = {
|
const createdIds = [];
|
||||||
tableName: tableName,
|
for (let i = 0; i < recordsArray.length; i++) {
|
||||||
records: processedRecords,
|
const res = await postToPython("/api/cms/create-record", {
|
||||||
functions: [],
|
project: projectSlug,
|
||||||
options: {}
|
table: tableName,
|
||||||
};
|
fields: recordsArray[i],
|
||||||
|
});
|
||||||
|
|
||||||
// Determine action: insert for new records, update for existing
|
if (!res.ok) {
|
||||||
const isNewRecord = !recordId;
|
// BATCH_POLICY: abortar y reportar el estado real del lote.
|
||||||
let response;
|
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) {
|
createdIds.push(res.data.num);
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API errors
|
|
||||||
const apiError = handleApiResponse(response.data, 'create_or_update_record');
|
|
||||||
if (apiError) return apiError;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: isNewRecord
|
message: `${recordsArray.length} record(s) created successfully`,
|
||||||
? `${isArray ? recordsArray.length : 1} record(s) created successfully`
|
tableName,
|
||||||
: `Record ${recordId} updated successfully`,
|
recordIds: isArray ? createdIds : createdIds[0],
|
||||||
tableName: tableName,
|
recordsCount: recordsArray.length,
|
||||||
recordIds: response.data?.data || (recordId || 'new'),
|
createdIds,
|
||||||
recordsCount: isArray ? recordsArray.length : 1,
|
suggestion: !isArray
|
||||||
createdIds: response.data?.data,
|
? `Puedes verificar el registro con get_record({ tableName: "${tableName}", recordId: ${JSON.stringify(createdIds[0])} }) — ahí verás el 'enlace' definitivo que generó el servidor.`
|
||||||
// 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(', ')}`
|
|
||||||
: undefined,
|
: 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)
|
}, null, 2)
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@@ -176,4 +257,3 @@ Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se ha
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user