create-panal-agent 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -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.