create-panal-agent 0.1.2 → 0.2.0

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.
package/dist/index.js CHANGED
@@ -2,20 +2,26 @@
2
2
  /**
3
3
  * create-panal-agent — genera un agente de Panal listo para funcionar.
4
4
  *
5
- * npx create-panal-agent mi-agente
5
+ * npx create-panal-agent mi-agente [--lang es] [--no-input]
6
6
  *
7
- * Copia la plantilla, le pone el nombre, y genera una wallet dedicada nueva
8
- * para el agente. Después son tres pasos: instalar, publicar el endpoint y
9
- * registrarse.
7
+ * Copia la plantilla, le pone el nombre, genera una wallet dedicada nueva y
8
+ * escribe la documentación del proyecto en el idioma de quien lo ejecuta.
9
+ * Después son tres pasos: instalar, publicar el endpoint y registrarse.
10
10
  *
11
11
  * Por qué existe: antes, poner un agente en Panal significaba leer una guía de
12
12
  * 529 líneas, montar un VPS y mantenerlo encendido. Eso no es un alta, es un
13
13
  * trabajo — y explica por qué el marketplace tenía cinco agentes.
14
+ *
15
+ * Por qué en diez idiomas: el alta es lo primero que ve un desarrollador, y en
16
+ * ella van las tres cosas que salen caras si se malinterpretan —la clave
17
+ * privada, el endpoint público y el gas—. Un aviso que no se entiende es un
18
+ * aviso que no existe.
14
19
  */
15
20
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
16
21
  import { dirname, join, resolve } from 'node:path';
17
22
  import { fileURLToPath } from 'node:url';
18
23
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
24
+ import { CATALOG, LANGS, fill, isLang, resolveLang } from './i18n.js';
19
25
  const HERE = dirname(fileURLToPath(import.meta.url));
20
26
  /** La plantilla se empaqueta junto al binario (ver `files` del package.json). */
21
27
  const TEMPLATE = resolve(HERE, '..', 'template');
@@ -34,33 +40,81 @@ const RENAMES = {
34
40
  _gitignore: '.gitignore',
35
41
  };
36
42
  const c = {
37
- bold: (s) => `${s}`,
38
- dim: (s) => `${s}`,
39
- green: (s) => `${s}`,
40
- yellow: (s) => `${s}`,
43
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
44
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
45
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
46
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
41
47
  };
42
48
  function fail(msg) {
43
49
  console.error(`\n${msg}\n`);
44
50
  process.exit(1);
45
51
  }
52
+ /**
53
+ * Se admite `--lang es` y `--lang=es`. No es capricho: la primera forma es la
54
+ * que teclea una persona y la segunda la que escriben los scripts.
55
+ */
56
+ function parseArgs(argv) {
57
+ const args = { name: null, lang: null, noInput: false, help: false, version: false };
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ if (a === '--help' || a === '-h')
61
+ args.help = true;
62
+ else if (a === '--version' || a === '-v')
63
+ args.version = true;
64
+ else if (a === '--no-input' || a === '--yes' || a === '-y')
65
+ args.noInput = true;
66
+ else if (a === '--lang')
67
+ args.lang = argv[++i] ?? '';
68
+ else if (a.startsWith('--lang='))
69
+ args.lang = a.slice('--lang='.length);
70
+ else if (!a.startsWith('-') && args.name === null)
71
+ args.name = a;
72
+ }
73
+ return args;
74
+ }
46
75
  /** Nombre válido de carpeta y de paquete npm. */
47
- function validateName(raw) {
48
- const name = raw.trim().replace(/^\.\//, '');
76
+ function validateName(raw, t) {
77
+ const name = (raw ?? '').trim().replace(/^\.\//, '');
49
78
  if (!name)
50
- fail('Dile cómo se llama tu agente: npx create-panal-agent mi-agente');
51
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
52
- fail(`"${name}" no vale como nombre.\n` +
53
- 'Usa minúsculas, números y guiones: mi-agente, traductor-tecnico, resumidor.');
54
- }
79
+ fail(t.errNoName);
80
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
81
+ fail(fill(t.errBadName, { name }));
55
82
  return name;
56
83
  }
84
+ /**
85
+ * Selector interactivo. Solo aparece cuando hay una persona delante: sin TTY
86
+ * —en CI, en un Dockerfile, tras una tubería— preguntar cuelga el proceso, así
87
+ * que en ese caso se cae al inglés sin ruido.
88
+ */
89
+ async function askLang() {
90
+ const { createInterface } = await import('node:readline/promises');
91
+ console.log('');
92
+ LANGS.forEach((l, i) => {
93
+ console.log(` ${c.bold(String(i + 1).padStart(2))} ${l.label}${i === 0 ? c.dim(' (default)') : ''}`);
94
+ });
95
+ console.log('');
96
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
97
+ try {
98
+ // La pregunta va en inglés porque todavía no sabemos en qué idioma leer.
99
+ const answer = (await rl.question(`${CATALOG.en.pickLang} [1-${LANGS.length}] `)).trim();
100
+ const n = Number(answer);
101
+ if (Number.isInteger(n) && n >= 1 && n <= LANGS.length)
102
+ return LANGS[n - 1].code;
103
+ if (isLang(answer.toLowerCase()))
104
+ return answer.toLowerCase();
105
+ return 'en';
106
+ }
107
+ finally {
108
+ rl.close();
109
+ }
110
+ }
57
111
  /** Copia la plantilla sustituyendo los marcadores. */
58
- function copyTemplate(dest, name) {
112
+ function copyTemplate(dest, name, t) {
59
113
  cpSync(TEMPLATE, dest, { recursive: true });
60
114
  for (const [from, to] of Object.entries(RENAMES)) {
61
115
  const src = join(dest, from);
62
116
  if (!existsSync(src))
63
- fail(`La plantilla está incompleta: falta ${from}. El paquete está mal instalado.`);
117
+ fail(fill(t.errTemplateMissing, { name: from }));
64
118
  renameSync(src, join(dest, to));
65
119
  }
66
120
  const walk = (dir) => {
@@ -78,38 +132,93 @@ function copyTemplate(dest, name) {
78
132
  };
79
133
  walk(dest);
80
134
  }
81
- function main() {
82
- const name = validateName(process.argv[2] ?? '');
83
- const dest = resolve(process.cwd(), name);
84
- if (existsSync(dest) && readdirSync(dest).length > 0) {
85
- fail(`La carpeta ${name}/ ya existe y no está vacía. Elige otro nombre o bórrala.`);
135
+ /**
136
+ * El `.env.example` se escribe aquí, no se copia: sus comentarios explican qué
137
+ * es cada clave y son justo lo que hay que entender antes de pegar una clave
138
+ * privada en un servidor.
139
+ */
140
+ function writeEnvExample(dest, t) {
141
+ const bloque = (texto) => texto
142
+ .split('\n')
143
+ .map((l) => `# ${l}`)
144
+ .join('\n');
145
+ const contenido = [
146
+ bloque(t.env.key),
147
+ 'AGENT_PRIVATE_KEY=',
148
+ '',
149
+ bloque(t.env.port),
150
+ 'PORT=8787',
151
+ '',
152
+ bloque(t.env.model),
153
+ '# OpenAI https://api.openai.com/v1 gpt-4o-mini',
154
+ '# DeepSeek https://api.deepseek.com/v1 deepseek-chat',
155
+ '# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile',
156
+ 'LLM_BASE_URL=https://api.deepseek.com/v1',
157
+ 'LLM_API_KEY=',
158
+ 'LLM_MODEL=deepseek-chat',
159
+ '',
160
+ bloque(t.env.rpc),
161
+ 'RPC_URL=',
162
+ '',
163
+ bloque(t.env.data),
164
+ 'DATA_DIR=./data',
165
+ '',
166
+ ].join('\n');
167
+ writeFileSync(join(dest, '.env.example'), contenido, 'utf8');
168
+ return contenido;
169
+ }
170
+ async function main() {
171
+ const args = parseArgs(process.argv.slice(2));
172
+ // --help y --version salen antes que nada: son las dos cosas que se piden
173
+ // cuando aún no se sabe qué hace el comando.
174
+ const pedido = resolveLang(args.lang, process.env);
175
+ if (args.lang && !pedido)
176
+ fail(fill(CATALOG.en.errBadLang, { name: args.lang }));
177
+ if (args.version) {
178
+ const pkg = JSON.parse(readFileSync(resolve(HERE, '..', 'package.json'), 'utf8'));
179
+ console.log(pkg.version);
180
+ return;
86
181
  }
182
+ if (args.help) {
183
+ console.log(`\n${(pedido ? CATALOG[pedido] : CATALOG.en).usage}\n`);
184
+ return;
185
+ }
186
+ // Sin idioma decidido: se pregunta si hay alguien delante, y si no, inglés.
187
+ const interactivo = !args.noInput && process.stdin.isTTY === true && process.stdout.isTTY === true;
188
+ const lang = pedido ?? (interactivo ? await askLang() : 'en');
189
+ const t = CATALOG[lang];
190
+ const name = validateName(args.name, t);
191
+ const dest = resolve(process.cwd(), name);
192
+ if (existsSync(dest) && readdirSync(dest).length > 0)
193
+ fail(fill(t.errDirExists, { name }));
87
194
  if (!existsSync(TEMPLATE))
88
- fail(`No encuentro la plantilla en ${TEMPLATE}. El paquete está mal instalado.`);
195
+ fail(fill(t.errTemplateMissing, { name: TEMPLATE }));
89
196
  mkdirSync(dest, { recursive: true });
90
- copyTemplate(dest, name);
197
+ copyTemplate(dest, name, t);
198
+ const envExample = writeEnvExample(dest, t);
91
199
  // Wallet dedicada, generada aquí. La alternativa —"crea una wallet y pega la
92
200
  // clave"— es donde la gente acaba pegando la de su MetaMask personal, que es
93
201
  // exactamente lo que no debe vivir en un servidor.
94
202
  const privateKey = generatePrivateKey();
95
203
  const address = privateKeyToAccount(privateKey).address;
96
- const envExample = readFileSync(join(dest, '.env.example'), 'utf8');
97
204
  writeFileSync(join(dest, '.env'), envExample.replace('AGENT_PRIVATE_KEY=', `AGENT_PRIVATE_KEY=${privateKey}`), 'utf8');
98
- console.log(`\n${c.green('✓')} ${c.bold(name)} creado.\n`);
99
- console.log(` Wallet del agente: ${c.bold(address)}`);
100
- console.log(c.dim(` Su clave está en ${name}/.env, que ya está en el .gitignore.\n`));
101
- console.log(c.bold('Lo que falta:\n'));
102
- console.log(` ${c.bold('1.')} Instalar y darle algo de MON para el gas`);
103
- console.log(c.dim(` cd ${name} && npm install`));
104
- console.log(c.dim(` manda ~0.5 MON a ${address}\n`));
105
- console.log(` ${c.bold('2.')} Escribir lo que hace tu agente`);
106
- console.log(c.dim(` edita src/agent.ts ${c.dim('(es el único archivo que tienes que tocar)')}`));
107
- console.log(c.dim(` si usa un modelo, pon LLM_API_KEY en el .env\n`));
108
- console.log(` ${c.bold('3.')} Publicarlo en una URL https y registrarte`);
109
- console.log(c.dim(` npm start ${c.dim('(y expón el puerto con https)')}`));
110
- console.log(c.dim(` PUBLIC_URL=https://tu-dominio npm run register\n`));
111
- console.log(`${c.yellow('Ojo:')} el endpoint tiene que ser https y público. Sin él el cliente no puede`);
112
- console.log('mandarte el encargo ni descargar su resultado, y el agente queda de adorno.\n');
113
- console.log(c.dim('Guía completa: https://github.com/AgentHiv/Panal/tree/main/create-agent\n'));
205
+ // El README del proyecto, en su idioma. Es donde vive lo que no cabe en la
206
+ // pantalla de alta: cómo se cobra, qué hace cada archivo y qué rompe agentes.
207
+ writeFileSync(join(dest, 'README.md'), fill(t.readme, { name, address }), 'utf8');
208
+ console.log(`\n${c.green('✓')} ${c.bold(fill(t.created, { name }))}\n`);
209
+ console.log(` ${t.walletLabel} ${c.bold(address)}`);
210
+ console.log(c.dim(` ${fill(t.walletNote, { name })}\n`));
211
+ console.log(c.bold(`${t.stepsTitle}\n`));
212
+ console.log(` ${c.bold('1.')} ${t.s1Title}`);
213
+ console.log(c.dim(` ${fill(t.s1Install, { name })}`));
214
+ console.log(c.dim(` ${fill(t.s1Fund, { address })}\n`));
215
+ console.log(` ${c.bold('2.')} ${t.s2Title}`);
216
+ console.log(c.dim(` ${t.s2Edit}`));
217
+ console.log(c.dim(` ${t.s2Key}\n`));
218
+ console.log(` ${c.bold('3.')} ${t.s3Title}`);
219
+ console.log(c.dim(` ${t.s3Start}`));
220
+ console.log(c.dim(` ${t.s3Register}\n`));
221
+ console.log(`${c.yellow(t.warnLabel)} ${t.warnBody}\n`);
222
+ console.log(c.dim(`${t.docs}\n`));
114
223
  }
115
- main();
224
+ void main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,6 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "tsx test/scaffold.test.ts"
43
+ "test": "tsx test/i18n.test.ts && tsx test/scaffold.test.ts"
44
44
  }
45
45
  }
@@ -52,6 +52,10 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
52
52
  const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
53
53
  method: 'POST',
54
54
  headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
55
+ // Sin este tope, un modelo que se cuelga deja la tarea colgada para
56
+ // siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
57
+ // que vence el plazo. Pasó de verdad, en mainnet.
58
+ signal: AbortSignal.timeout(120_000),
55
59
  body: JSON.stringify({
56
60
  model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
57
61
  messages: [
@@ -26,7 +26,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { createPanalClient, TaskStatus } from '@panal/sdk';
28
28
  import { privateKeyToAccount } from 'viem/accounts';
29
- import { verifyMessage } from 'viem';
29
+ import { keccak256, toBytes, verifyMessage } from 'viem';
30
30
  import type { Address } from 'viem';
31
31
  import { handleTask } from './agent.js';
32
32
 
@@ -116,6 +116,82 @@ async function work(taskId: bigint, brief: string): Promise<void> {
116
116
  // HTTP
117
117
  // ---------------------------------------------------------------------------
118
118
 
119
+ /**
120
+ * Página de reenvío manual (GET /reenviar?task=<id>).
121
+ *
122
+ * Todo va incrustado: ni CDN ni fuentes ni librerías. Dentro del navegador de
123
+ * una wallet, cada recurso externo es una cosa más que puede no cargar, y esta
124
+ * página existe precisamente para cuando algo ya ha fallado.
125
+ */
126
+ const PAGINA_REENVIO = `<!doctype html>
127
+ <html lang="es"><head>
128
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
129
+ <title>Reenviar brief · Panal</title>
130
+ <style>
131
+ :root{color-scheme:dark light}
132
+ body{margin:0;padding:24px 18px;font:16px/1.5 system-ui,-apple-system,sans-serif;background:#0f0f11;color:#e8e8ea;max-width:34rem;margin-inline:auto}
133
+ h1{font-size:1.25rem;margin:0 0 .25rem}
134
+ p.sub{margin:0 0 1.5rem;color:#9a9aa2;font-size:.9rem}
135
+ label{display:block;margin:1rem 0 .35rem;font-size:.85rem;color:#b8b8c0}
136
+ input,textarea{width:100%;box-sizing:border-box;padding:.7rem .8rem;border-radius:10px;border:1px solid #33333a;background:#17171b;color:inherit;font:inherit}
137
+ textarea{min-height:9rem;resize:vertical}
138
+ button{width:100%;margin-top:1rem;padding:.85rem;border:0;border-radius:10px;background:#f5c518;color:#1a1a1a;font:600 1rem system-ui;cursor:pointer}
139
+ button.sec{background:#26262c;color:#e8e8ea}
140
+ #estado{margin-top:1.1rem;padding:.8rem;border-radius:10px;font-size:.9rem;white-space:pre-wrap;word-break:break-word}
141
+ #estado.bien{background:#12301c;color:#7ee2a8}
142
+ #estado.mal{background:#33161a;color:#ff9d9d}
143
+ #estado:empty{display:none}
144
+ </style></head><body>
145
+ <h1>Reenviar el brief</h1>
146
+ <p class="sub">Para cuando el envío automático no llegó. Copia el texto exacto del pedido desde panal.lat (botón "Copiar brief del pedido") y pégalo aquí.</p>
147
+ <label for="id">Número de tarea</label>
148
+ <input id="id" inputmode="numeric" placeholder="24">
149
+ <label for="brief">Texto del pedido</label>
150
+ <textarea id="brief" placeholder="Pega aquí el brief, tal cual"></textarea>
151
+ <button id="conectar" class="sec">Conectar wallet</button>
152
+ <button id="enviar">Firmar y enviar</button>
153
+ <div id="estado"></div>
154
+ <script>
155
+ var q = new URLSearchParams(location.search);
156
+ function $(s){ return document.querySelector(s); }
157
+ // Solo dígitos, siempre. Un teclado de móvil cuela un punto sin que lo veas y
158
+ // la petición se va a /brief/25. → 404, con el usuario mirando un número que
159
+ // parece correcto.
160
+ function soloDigitos(v){ return String(v || '').replace(/[^0-9]/g, ''); }
161
+ $('#id').value = soloDigitos(q.get('task'));
162
+ $('#id').addEventListener('input', function(){ this.value = soloDigitos(this.value); });
163
+ var cuenta = null;
164
+ function estado(msg, mal){ var e = $('#estado'); e.textContent = msg; e.className = mal ? 'mal' : 'bien'; }
165
+ $('#conectar').onclick = async function(){
166
+ if (!window.ethereum) { estado('Aquí no hay wallet. Abre esta página desde el navegador de MetaMask, no desde Chrome.', true); return; }
167
+ try {
168
+ var r = await ethereum.request({ method: 'eth_requestAccounts' });
169
+ cuenta = r[0];
170
+ $('#conectar').textContent = cuenta.slice(0,6) + '…' + cuenta.slice(-4);
171
+ estado('Wallet conectada.');
172
+ } catch (e) { estado('Conexión rechazada.', true); }
173
+ };
174
+ $('#enviar').onclick = async function(){
175
+ var id = soloDigitos($('#id').value);
176
+ var brief = $('#brief').value;
177
+ if (!id || !brief.trim()) { estado('Falta el número de tarea o el texto.', true); return; }
178
+ if (!cuenta) { estado('Conecta la wallet primero: hay que firmar con la misma que pagó.', true); return; }
179
+ try {
180
+ estado('Firma el mensaje en tu wallet. No cuesta gas.');
181
+ var firma = await ethereum.request({ method: 'personal_sign', params: ['Panal brief #' + id, cuenta] });
182
+ estado('Enviando…');
183
+ var res = await fetch('/brief/' + id, {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify({ brief: brief, address: cuenta, signature: firma })
187
+ });
188
+ var txt = await res.text();
189
+ if (res.ok) estado('Aceptado. El agente ya está trabajando en tu pedido.');
190
+ else estado('Rechazado (' + res.status + '):\\n' + txt, true);
191
+ } catch (e) { estado('Falló: ' + (e && e.message ? e.message : e), true); }
192
+ };
193
+ </script></body></html>`;
194
+
119
195
  function json(res: ServerResponse, status: number, body: unknown): void {
120
196
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
121
197
  res.end(JSON.stringify(body));
@@ -153,26 +229,50 @@ const server = createServer((req, res) => {
153
229
  return;
154
230
  }
155
231
 
232
+ // Reenvío manual del brief, para cuando el envío automático del dashboard
233
+ // no llega: móvil, wallet que se traga la firma, pestaña cerrada a medias.
234
+ // Se sirve desde el propio agente a propósito: mismo origen, sin CORS de
235
+ // por medio, y funciona dentro del navegador de una wallet.
236
+ if (url.pathname === '/reenviar' && req.method === 'GET') {
237
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
238
+ res.end(PAGINA_REENVIO);
239
+ return;
240
+ }
241
+
156
242
  // ---- El cliente te manda el encargo -------------------------------------
157
- if (url.pathname === '/brief' && req.method === 'POST') {
243
+ // La ruta canónica es POST /brief/<taskId>: es la que llama el dashboard de
244
+ // panal.lat y la que documenta el bot de referencia. Se admite también
245
+ // POST /brief con el taskId dentro del cuerpo, porque hay clientes que ya
246
+ // hablaban así y romperlos no arregla nada.
247
+ const rutaBrief = /^\/brief(?:\/(\d+))?$/.exec(url.pathname);
248
+ if (rutaBrief && req.method === 'POST') {
158
249
  const body = JSON.parse(await readBody(req)) as {
159
250
  taskId?: string | number;
160
251
  brief?: string;
252
+ address?: string;
161
253
  signature?: string;
162
254
  };
163
- if (body.taskId === undefined || !body.brief || !body.signature) {
255
+ const idCrudo = rutaBrief[1] ?? body.taskId;
256
+ if (idCrudo === undefined || !body.brief || !body.signature) {
164
257
  json(res, 400, { error: 'faltan taskId, brief o signature' });
165
258
  return;
166
259
  }
167
- const taskId = BigInt(body.taskId);
260
+ const taskId = BigInt(idCrudo);
168
261
  const task = await panal.getTask(taskId);
169
262
 
170
- // Tres comprobaciones, y las tres importan: que la tarea sea tuya, que
171
- // siga abierta, y que quien manda el brief sea el cliente que pagó.
263
+ // Cuatro comprobaciones, y las cuatro importan: que la tarea sea tuya,
264
+ // que siga abierta, que quien dice firmar sea el cliente que pagó, y que
265
+ // la firma lo demuestre.
172
266
  if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
173
267
  json(res, 403, { error: 'esa tarea no es de este agente' });
174
268
  return;
175
269
  }
270
+ // El dashboard manda además quién firma; si no cuadra con el cliente de
271
+ // la tarea, se corta antes de gastar una verificación de firma.
272
+ if (body.address && body.address.toLowerCase() !== task.client.toLowerCase()) {
273
+ json(res, 403, { error: 'esa dirección no es el cliente de la tarea' });
274
+ return;
275
+ }
176
276
  if (task.status !== TaskStatus.Open) {
177
277
  json(res, 409, { error: `la tarea está ${TaskStatus[task.status]}` });
178
278
  return;
@@ -181,6 +281,17 @@ const server = createServer((req, res) => {
181
281
  json(res, 401, { error: 'la firma no es del cliente de esta tarea' });
182
282
  return;
183
283
  }
284
+ // Y que el texto sea EL que se encargó. Para esto existe el taskHash: sin
285
+ // esta comprobación, un cliente podría pagar por una cosa on-chain y
286
+ // pedirte otra por HTTP, y en una disputa el árbitro no tendría con qué
287
+ // decidir. Un carácter de más y esto salta, que es justo lo que se busca.
288
+ if (keccak256(toBytes(body.brief)) !== task.taskHash) {
289
+ json(res, 409, {
290
+ error: 'ese texto no es el que se registró en la cadena para esta tarea',
291
+ taskHash: task.taskHash,
292
+ });
293
+ return;
294
+ }
184
295
 
185
296
  json(res, 202, { ok: true });
186
297
  // Sin await: el cliente no debería esperar a que termines de trabajar.