@nyx5/nyx5 0.5.0 → 0.7.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/README.md +36 -5
- package/bin/nyx5.js +29 -3
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +2 -0
- package/docs/SPEC.md +77 -9
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/correo/agente.js +132 -18
- package/src/correo/asistente.js +174 -0
- package/src/correo/estafeta.js +1040 -43
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +189 -0
- package/src/correo/resolver.js +35 -11
- package/src/correo/unirse.js +6 -2
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +77 -10
- package/src/libro/notaria.js +74 -0
- package/src/nucleo/almacen-d1.js +123 -13
- package/src/nucleo/almacen.js +109 -12
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +19 -12
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +10 -3
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/worker.js +33 -8
- package/src/puentes/herramientas.js +190 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
package/src/libro/libro.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { signObject, verifyObject, canonical, sha256hex, uuid } from '../nucleo/crypto.js';
|
|
29
29
|
import { Resolver, parseAddress } from '../correo/resolver.js';
|
|
30
30
|
import { CONTRATOS } from './contratos.js';
|
|
31
|
+
import { boletaDe } from './estado.js';
|
|
31
32
|
import { LibroError } from './errores.js';
|
|
32
33
|
export { LibroError };
|
|
33
34
|
|
|
@@ -42,10 +43,13 @@ const iso = () => new Date().toISOString();
|
|
|
42
43
|
export class Libro {
|
|
43
44
|
// feeBps: fee de la casa en basis points enteros (1000 = 10%). `feePct` sigue aceptándose
|
|
44
45
|
// como azúcar (0.10 -> 1000) pero el cálculo es siempre entero: sin punto flotante en el dinero.
|
|
45
|
-
|
|
46
|
+
// reclaimGraceMs / reviewWindowMs (NX-503): cuánto después del plazo el comprador recupera un
|
|
47
|
+
// escrow sin entrega, y cuánto tiene para objetar una entrega antes de que se libere sola.
|
|
48
|
+
constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, reclaimGraceMs = 24 * 3600_000, reviewWindowMs = 72 * 3600_000, log = () => {} }) {
|
|
46
49
|
this.domain = domain; this.store = store; this.keys = keys; this.resolver = resolver;
|
|
47
50
|
this.feeBps = feeBps ?? (feePct != null ? Math.round(feePct * 10_000) : 1000);
|
|
48
51
|
this.welcome = welcome; this.log = log;
|
|
52
|
+
this.reclaimGraceMs = reclaimGraceMs; this.reviewWindowMs = reviewWindowMs;
|
|
49
53
|
this.casa = `casa@${domain}`;
|
|
50
54
|
this.address = `libro@${domain}`;
|
|
51
55
|
this.tx = null; // transacción en curso: { state, asientos, contracts: Map, mandates: Map, op }
|
|
@@ -62,11 +66,11 @@ export class Libro {
|
|
|
62
66
|
// y ambos entrarían, superando el tope.
|
|
63
67
|
async _begin(concepto = 'control', refs = {}) {
|
|
64
68
|
const base = await this.store.libroState();
|
|
65
|
-
this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), op: null, concepto, refs };
|
|
69
|
+
this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), sellos: [], op: null, concepto, refs };
|
|
66
70
|
}
|
|
67
71
|
_bundle() {
|
|
68
72
|
const t = this.tx;
|
|
69
|
-
return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], op: t.op };
|
|
73
|
+
return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], sellos: t.sellos, op: t.op };
|
|
70
74
|
}
|
|
71
75
|
async _commit() {
|
|
72
76
|
const t = this.tx;
|
|
@@ -90,6 +94,9 @@ export class Libro {
|
|
|
90
94
|
putContract(c) { if (!this.tx) throw new LibroError(500, 'putContract fuera de transacción'); this.tx.contracts.set(c.id, c); }
|
|
91
95
|
async getMandate(id) { return this.tx?.mandates.get(id) ?? await this.store.libroGetMandate(id); }
|
|
92
96
|
putMandate(m) { if (!this.tx) throw new LibroError(500, 'putMandate fuera de transacción'); this.tx.mandates.set(m.id, m); }
|
|
97
|
+
// Sello de notaría (NX-601): no mueve dinero ni consume número de asiento. Su candado contra el
|
|
98
|
+
// doble sello es el índice único (sha256, by) del almacén, que hace fallar cerrado el commit.
|
|
99
|
+
putSello(s) { if (!this.tx) throw new LibroError(500, 'putSello fuera de transacción'); this.tx.sellos.push(s); }
|
|
93
100
|
|
|
94
101
|
// ---------- consultas ----------
|
|
95
102
|
get ops() { return Object.keys(CONTRATOS.ops); }
|
|
@@ -120,7 +127,9 @@ export class Libro {
|
|
|
120
127
|
const cuenta = () => ({ n: 0, tokens: 0 });
|
|
121
128
|
const h = {
|
|
122
129
|
address, house: this.domain,
|
|
123
|
-
|
|
130
|
+
// entregas_por_silencio: liberadas por el reloj de la casa al vencer la ventana de revisión
|
|
131
|
+
// (NX-503). Silencio no es aceptación: se cuentan aparte, y también dentro de aceptadas.
|
|
132
|
+
vendiendo: { entregas_aceptadas: cuenta(), entregas_por_silencio: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
|
|
124
133
|
comprando: { encargos_liberados: cuenta(), encargos_devueltos: cuenta(), compras_directas: cuenta() },
|
|
125
134
|
afirmando: { fianzas_sostenidas: cuenta(), fianzas_ejecutadas: cuenta(), fianzas_vigentes: cuenta() },
|
|
126
135
|
avalando: { avales_sostenidos: cuenta(), avales_ejecutados: cuenta() },
|
|
@@ -142,7 +151,10 @@ export class Libro {
|
|
|
142
151
|
else sumar(sostenida ? grupo.fianzas_sostenidas : grupo.fianzas_ejecutadas, monto);
|
|
143
152
|
} else if (c.seller === address) {
|
|
144
153
|
if (c.kind === 'spot') sumar(h.vendiendo.ventas_directas, monto);
|
|
145
|
-
else
|
|
154
|
+
else {
|
|
155
|
+
sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
|
|
156
|
+
if (c.state === 'released' && c.history?.some((x) => x.op === 'expire')) sumar(h.vendiendo.entregas_por_silencio, monto);
|
|
157
|
+
}
|
|
146
158
|
} else {
|
|
147
159
|
if (c.kind === 'spot') sumar(h.comprando.compras_directas, monto);
|
|
148
160
|
else sumar(c.state === 'released' ? h.comprando.encargos_liberados : h.comprando.encargos_devueltos, monto);
|
|
@@ -150,6 +162,24 @@ export class Libro {
|
|
|
150
162
|
}
|
|
151
163
|
// Fianzas todavía en pie: no son historial cumplido, pero sí tokens en juego ahora mismo.
|
|
152
164
|
for (const c of mios) if (c.kind === 'bond' && c.state === 'posted' && c.seller === address) sumar(h.afirmando.fianzas_vigentes, Number(c.amount) || 0);
|
|
165
|
+
// Arbitrados por la casa (NX-302): sólo los contratos en que este agente VENDÍA, el árbitro
|
|
166
|
+
// era `verifica@<casa>` y el veredicto terminal (release / refund / forfeit) lo dio ese
|
|
167
|
+
// árbitro, no una parte. Es la porción del historial que dos cómplices no pueden fabricar
|
|
168
|
+
// entre sí: para sumar aquí hay que pasar una prueba determinista que corre la casa. Un
|
|
169
|
+
// escrow liberado por el comprador, o por silencio (`expire`), queda fuera aunque el
|
|
170
|
+
// contrato nombrara a verifica@ de árbitro. Campo ADITIVO: la forma anterior no cambia.
|
|
171
|
+
const arbitro = `verifica@${this.domain}`;
|
|
172
|
+
h.arbitrados = { arbitro, liberados: cuenta(), devueltos: cuenta(), ejecutadas: cuenta() };
|
|
173
|
+
for (const c of mios) {
|
|
174
|
+
if (c.seller !== address || c.arbiter !== arbitro) continue;
|
|
175
|
+
if (!(Libro.TERMINALES[c.kind] || []).includes(c.state)) continue;
|
|
176
|
+
const veredicto = (c.history || []).filter((x) => ['release', 'refund', 'forfeit'].includes(x.op)).at(-1);
|
|
177
|
+
if (!veredicto || veredicto.by !== arbitro) continue;
|
|
178
|
+
const monto = Number(c.amount) || 0;
|
|
179
|
+
if (veredicto.op === 'release') sumar(h.arbitrados.liberados, monto);
|
|
180
|
+
else if (veredicto.op === 'refund') sumar(h.arbitrados.devueltos, monto);
|
|
181
|
+
else sumar(h.arbitrados.ejecutadas, monto);
|
|
182
|
+
}
|
|
153
183
|
// El resumen es lo que un agente lee para decidir en una línea; el detalle queda arriba.
|
|
154
184
|
const entregadas = h.vendiendo.entregas_aceptadas.n + h.vendiendo.ventas_directas.n;
|
|
155
185
|
const falladas = h.vendiendo.entregas_devueltas.n;
|
|
@@ -162,6 +192,8 @@ export class Libro {
|
|
|
162
192
|
// Sin historial no hay tasa: cero de cero no es 100%, es "todavía nada". Lo decimos así.
|
|
163
193
|
cumplimiento: entregadas + falladas > 0 ? Number((entregadas / (entregadas + falladas)).toFixed(4)) : null,
|
|
164
194
|
veracidad: afirmaciones > 0 ? Number((h.afirmando.fianzas_sostenidas.n / afirmaciones).toFixed(4)) : null,
|
|
195
|
+
// Lo que el índice federado usa para ordenar (§13): ponderado por tokens, null sin historial arbitrado.
|
|
196
|
+
puntaje_arbitrado: puntajeArbitrado(h.arbitrados),
|
|
165
197
|
};
|
|
166
198
|
return h;
|
|
167
199
|
}
|
|
@@ -177,7 +209,7 @@ export class Libro {
|
|
|
177
209
|
const next = { ...state.balances };
|
|
178
210
|
for (const l of lines) {
|
|
179
211
|
next[l.account] = (next[l.account] || 0) + l.delta;
|
|
180
|
-
if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `
|
|
212
|
+
if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `insufficient balance in ${l.account} (has ${state.balances[l.account] || 0}, needs ${-l.delta})`);
|
|
181
213
|
}
|
|
182
214
|
const asiento = signObject({ id: uuid(), n: state.seq + 1, at: iso(), house: this.domain, concept, lines, meta, refs }, this.keys);
|
|
183
215
|
if (this.tx) {
|
|
@@ -228,8 +260,11 @@ export class Libro {
|
|
|
228
260
|
|
|
229
261
|
// ---------- cotizaciones: documentos firmados por el vendedor ----------
|
|
230
262
|
// Una cotización viaja adentro de un sobre (cifrado si se quiere) y se presenta al Libro al aceptar.
|
|
231
|
-
static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null }, sellerKeys) {
|
|
263
|
+
static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null, service = null }, sellerKeys) {
|
|
232
264
|
if (!CONTRATOS[contract]?.quoteable) throw new LibroError(400, `contrato no cotizable: ${contract}`);
|
|
265
|
+
// `service` (NX-301): el id de un servicio publicado en la ficha del vendedor. Va firmado en la
|
|
266
|
+
// cotización y verifyQuote lo cruza con la ficha al aceptar: precio y contrato tienen que ser los publicados.
|
|
267
|
+
if (service != null && (typeof service !== 'string' || !/^[a-z0-9-]{1,40}$/.test(service))) throw new LibroError(400, 'service debe ser el id de un servicio de la ficha (letras minúsculas, dígitos y guiones)');
|
|
233
268
|
if (arbiter) parseAddress(arbiter);
|
|
234
269
|
// Comisión de referido: el vendedor firma en su cotización que le paga `share` (en basis points)
|
|
235
270
|
// a quien trajo el trato. La comisión sale de LO QUE RECIBE el vendedor, no se suma al precio:
|
|
@@ -238,7 +273,7 @@ export class Libro {
|
|
|
238
273
|
parseAddress(referrer.address);
|
|
239
274
|
if (!Number.isInteger(referrer.share) || referrer.share <= 0) throw new LibroError(400, 'referrer.share debe ser un entero de basis points > 0');
|
|
240
275
|
}
|
|
241
|
-
return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, issued: iso(), expires: expires || null }, sellerKeys);
|
|
276
|
+
return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, service: service || undefined, issued: iso(), expires: expires || null }, sellerKeys);
|
|
242
277
|
}
|
|
243
278
|
async verifyQuote(q, buyer) {
|
|
244
279
|
if (q?.tipo !== 'cotizacion' || !q.id || !q.seller || !q.signature) throw new LibroError(400, 'cotización malformada');
|
|
@@ -260,6 +295,19 @@ export class Libro {
|
|
|
260
295
|
let card;
|
|
261
296
|
try { card = await this.resolver.agentCardForKid(q.seller, q.signature.kid); } catch (e) { throw new LibroError(e.permanent ? 403 : 421, `no se pudo verificar al vendedor: ${e.message}`); }
|
|
262
297
|
if (!Resolver.acceptedKids(card).includes(q.signature.kid) || !verifyObject(q, q.signature.kid)) throw new LibroError(403, 'firma de la cotización inválida');
|
|
298
|
+
// Una dirección de sólo mensajes (la del Claude de un teléfono, cuya llave guarda la casa) no
|
|
299
|
+
// vende: aceptarle una cotización le movería saldo, que es justo lo que su dueño no autorizó.
|
|
300
|
+
if (card.delegation?.scope?.messages_only) throw new LibroError(403, 'the seller is a messages-only address: it cannot sell');
|
|
301
|
+
// Cotización sobre un servicio publicado (NX-301): se compara contra la ficha CERTIFICADA del
|
|
302
|
+
// vendedor tal como está hoy, nunca contra lo que la cotización dice de sí misma. Mismo espíritu
|
|
303
|
+
// que tareas.coincide: un precio o un contrato distintos del publicado son rechazo, no negociación.
|
|
304
|
+
// Si el vendedor cambió su catálogo después de cotizar, la cotización vieja ya no coincide y se rechaza.
|
|
305
|
+
if (q.service != null) {
|
|
306
|
+
if (typeof q.service !== 'string') throw new LibroError(400, 'service must be the id of a published service');
|
|
307
|
+
const s = (card.profile?.services || []).find((x) => x.id === q.service);
|
|
308
|
+
if (!s) throw new LibroError(400, `service "${q.service}" is not published in the profile of ${q.seller}`);
|
|
309
|
+
if (s.price.tokens !== q.price || s.contract !== q.contract) throw new LibroError(400, `service ${s.id} is published at ${s.price.tokens} tok as ${s.contract}; the quote says ${q.price} as ${q.contract}`);
|
|
310
|
+
}
|
|
263
311
|
if (await this.store.libroFindContractByQuote(q.id)) throw new LibroError(409, 'cotización ya aceptada');
|
|
264
312
|
return card;
|
|
265
313
|
}
|
|
@@ -273,6 +321,9 @@ export class Libro {
|
|
|
273
321
|
const body = env.content.body || {};
|
|
274
322
|
const op = CONTRATOS.ops[body.op];
|
|
275
323
|
if (!op) return { ok: false, code: 400, reason: `unknown operation: ${body.op}. Valid ones: ${Object.keys(CONTRATOS.ops).join(', ')}` };
|
|
324
|
+
// Sólo mensajes no opera el Libro, entre el sobre por donde entre: la regla vive donde se mueve
|
|
325
|
+
// el dinero, no sólo en la puerta (revisión del 11-sep-2026).
|
|
326
|
+
if (senderCard?.delegation?.scope?.messages_only) return { ok: false, code: 403, reason: 'this is a messages-only address: it cannot operate the ledger' };
|
|
276
327
|
const ctx = { libro: this, env, from: env.from, body, senderCard, opHash: sha256hex(canonical(env)), scope: senderCard?.delegation?.scope || null };
|
|
277
328
|
return this._serial(async () => {
|
|
278
329
|
await this._begin(`op ${body.op}`, { op: env.id, op_sha256: ctx.opHash });
|
|
@@ -283,7 +334,11 @@ export class Libro {
|
|
|
283
334
|
if (e instanceof LibroError) return { ok: false, code: e.code, reason: e.message };
|
|
284
335
|
throw e;
|
|
285
336
|
}
|
|
286
|
-
|
|
337
|
+
// Boleta (NX-501): todo recibo que lleva un asiento con reparto dice cuánto fue fee de la casa
|
|
338
|
+
// y cuánto comisión de referido, leído de las líneas del asiento (estado.js), en el único
|
|
339
|
+
// punto donde se arman los recibos.
|
|
340
|
+
const boleta = (b) => boletaDe(b.asiento, { casa: this.casa, feeBps: this.feeBps, share: b.contract?.referrer?.share ?? null });
|
|
341
|
+
const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, ...boleta(r.body), of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
|
|
287
342
|
this.tx.op = { id: env.id, result: out };
|
|
288
343
|
try { await this._commit(); }
|
|
289
344
|
catch (e) {
|
|
@@ -301,7 +356,7 @@ export class Libro {
|
|
|
301
356
|
// dedupe del sobre (inboundCommit) — así una reentrega jamás cobra la estampilla dos veces.
|
|
302
357
|
async stamp(env, recipient, price) {
|
|
303
358
|
const s = env.stamp;
|
|
304
|
-
if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `
|
|
359
|
+
if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `this mailbox requires a stamp of ${price} tok in house ${this.domain} (stamp field: {house, amount})`);
|
|
305
360
|
return this._serial(async () => {
|
|
306
361
|
await this._begin('estampilla', { envelope: env.id });
|
|
307
362
|
try {
|
|
@@ -313,3 +368,15 @@ export class Libro {
|
|
|
313
368
|
});
|
|
314
369
|
}
|
|
315
370
|
}
|
|
371
|
+
|
|
372
|
+
// Puntaje de reputación arbitrada: tokens liberados sobre tokens decididos por verifica@
|
|
373
|
+
// (liberados + devueltos + fianzas ejecutadas). Ponderado por monto, no por cuenta: una entrega
|
|
374
|
+
// de 5.000 pesa más que diez de 10. `null` cuando no hay nada decidido: cero de cero no es 100 %.
|
|
375
|
+
// UNA definición: la usan `historial` (resumen.puntaje_arbitrado) y el índice federado.
|
|
376
|
+
export function puntajeArbitrado(arbitrados) {
|
|
377
|
+
if (!arbitrados || typeof arbitrados !== 'object') return null;
|
|
378
|
+
const t = (k) => Number(arbitrados[k]?.tokens) || 0;
|
|
379
|
+
const total = t('liberados') + t('devueltos') + t('ejecutadas');
|
|
380
|
+
if (total <= 0) return null;
|
|
381
|
+
return Number((t('liberados') / total).toFixed(4));
|
|
382
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Nyx5/1 — Notaría (NX-601): la casa sella el hash de un documento con fecha y firma.
|
|
2
|
+
//
|
|
3
|
+
// Qué certifica un sello: que en el instante `at` la casa recibió un sobre firmado (hash
|
|
4
|
+
// `op_sha256`) en el que `by` declaró el hash `sha256`. Nada más. La casa no ve el documento, no
|
|
5
|
+
// afirma que exista ni qué contiene: quien lo tenga puede demostrar que ya existía a esa fecha
|
|
6
|
+
// presentando el documento (su hash coincide) y el sello (la firma de la casa verifica con la
|
|
7
|
+
// tarjeta del dominio, sin cuenta).
|
|
8
|
+
//
|
|
9
|
+
// No mueve dinero: no hay asiento. El candado contra el doble sello es el índice único
|
|
10
|
+
// (sha256, by) del almacén, no el número de asiento: dos sobres distintos del mismo agente con el
|
|
11
|
+
// mismo hash producen UN sello (el primero) y la segunda petición recibe ese mismo sello. Dos
|
|
12
|
+
// agentes distintos sí producen dos sellos: cada uno atestigua que ÉL lo declaró.
|
|
13
|
+
//
|
|
14
|
+
// Dos versiones firmadas del mismo sello, con el mismo id: `sello` (con `by`) y `anonimo` (con
|
|
15
|
+
// `by: null`). La pública sirve la primera si el agente es visible y la segunda si es secreto,
|
|
16
|
+
// para que un secreto no quede confirmado por su propio sello (NX-202) y aun así la firma
|
|
17
|
+
// verifique. Las dos comparten id, sha256, at y op_sha256: son el mismo hecho, con o sin nombre.
|
|
18
|
+
//
|
|
19
|
+
// Qué NO cubre: no prueba autoría del documento (prueba quién lo DECLARÓ), no prueba que el
|
|
20
|
+
// documento sea anterior a `at` (sólo que no es posterior), y no verifica que `media` o `name`
|
|
21
|
+
// describan al documento: son lo que el declarante dijo.
|
|
22
|
+
|
|
23
|
+
import { signObject, uuid } from '../nucleo/crypto.js';
|
|
24
|
+
import { limpio } from '../correo/politica.js';
|
|
25
|
+
import { LibroError } from './errores.js';
|
|
26
|
+
|
|
27
|
+
export const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
|
|
28
|
+
export const LIMITES = Object.freeze({ name: 120, media: 100, note: 500 });
|
|
29
|
+
|
|
30
|
+
const fail = (code, msg) => { throw new LibroError(code, msg); };
|
|
31
|
+
|
|
32
|
+
// Valida el cuerpo de la op. Campos desconocidos se ignoran (invariante 7). El hash se normaliza
|
|
33
|
+
// a minúsculas: el mismo documento no puede tener dos sellos por cómo se escribió su hash.
|
|
34
|
+
export function validarNotarize(body = {}) {
|
|
35
|
+
if (typeof body.sha256 !== 'string' || !SHA256_HEX.test(body.sha256)) fail(400, 'sha256 must be the hex digest of the document: 64 hexadecimal characters');
|
|
36
|
+
const texto = (k) => {
|
|
37
|
+
if (body[k] == null) return null;
|
|
38
|
+
if (typeof body[k] !== 'string') fail(400, `${k} must be a string`);
|
|
39
|
+
return limpio(body[k], LIMITES[k]) || null;
|
|
40
|
+
};
|
|
41
|
+
return { sha256: body.sha256.toLowerCase(), name: texto('name'), media: texto('media'), note: texto('note') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Construye el registro firmado. `keys` son las llaves del dominio (las mismas que firman recibos).
|
|
45
|
+
export function sellar({ sha256, name, media, note, by, house, at, op_sha256, secret = false }, keys) {
|
|
46
|
+
const cuerpo = { nyx5: '1', tipo: 'sello', id: uuid(), sha256, name, media, note, by, house, at, op_sha256 };
|
|
47
|
+
return {
|
|
48
|
+
id: cuerpo.id, sha256, by, at,
|
|
49
|
+
// `secret` es la visibilidad del declarante cuando selló: un agente de OTRA casa no se puede
|
|
50
|
+
// re-consultar en vivo desde aquí, así que lo que se supo al sellar es lo que manda para él.
|
|
51
|
+
secret: !!secret,
|
|
52
|
+
sello: signObject(cuerpo, keys),
|
|
53
|
+
anonimo: signObject({ ...cuerpo, by: null }, keys),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Lo que se sirve al público por un registro: la versión con nombre si el declarante es
|
|
58
|
+
// visible para «nadie» (quien pregunta sin cuenta), la anónima si no.
|
|
59
|
+
export function selloPublico(doc, visible) { return visible ? doc.sello : doc.anonimo; }
|
|
60
|
+
|
|
61
|
+
// La op del Libro. Corre dentro de la transacción de Libro.handle(): escribe con libro.putSello.
|
|
62
|
+
export async function notarize(ctx) {
|
|
63
|
+
const { libro, from, body, senderCard, opHash } = ctx;
|
|
64
|
+
const v = validarNotarize(body);
|
|
65
|
+
const previo = await libro.store.notariaFind(v.sha256, from);
|
|
66
|
+
if (previo) {
|
|
67
|
+
// El mismo declarante ya selló este hash: se le devuelve ESE sello, no se crea otro. La
|
|
68
|
+
// receta lleva el hash del sobre que la pidió (invariante 5) aunque el sello sea anterior.
|
|
69
|
+
return { result: { seal: previo.sello, existing: true }, recibos: [{ to: [from], body: { seal: previo.sello, existing: true } }] };
|
|
70
|
+
}
|
|
71
|
+
const doc = sellar({ ...v, by: from, house: libro.domain, at: new Date().toISOString(), op_sha256: opHash, secret: senderCard?.visibility === 'secret' }, libro.keys);
|
|
72
|
+
libro.putSello(doc);
|
|
73
|
+
return { result: { seal: doc.sello, existing: false }, recibos: [{ to: [from], body: { seal: doc.sello, existing: false } }] };
|
|
74
|
+
}
|
package/src/nucleo/almacen-d1.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// el roundtrip y las firmas siguen verificando (invariante 7).
|
|
9
9
|
|
|
10
10
|
import { LibroError } from '../libro/errores.js';
|
|
11
|
+
import { filaDeIndice, codificarCursor, CADUCO } from '../correo/indice.js';
|
|
11
12
|
|
|
12
13
|
const j = (v) => JSON.stringify(v);
|
|
13
14
|
const p = (row, col = 'doc') => (row ? JSON.parse(row[col]) : null);
|
|
@@ -71,6 +72,16 @@ export class D1Store {
|
|
|
71
72
|
const r = await this.db.prepare('UPDATE nyx5_mailbox SET acked = ? WHERE local = ? AND id = ? AND acked IS NULL').bind(iso(), local, id).run();
|
|
72
73
|
return r.meta.changes === 1;
|
|
73
74
|
}
|
|
75
|
+
// Historial: lo pendiente y lo confirmado, los últimos N en orden de llegada (índice 0006).
|
|
76
|
+
async listMailHistory(local, { limit = 200 } = {}) {
|
|
77
|
+
const r = await this.db.prepare('SELECT doc, acked FROM nyx5_mailbox WHERE local = ? ORDER BY received DESC, id DESC LIMIT ?').bind(local, limit).all();
|
|
78
|
+
return r.results.map((row) => ({ ...JSON.parse(row.doc), acked: row.acked || null })).reverse();
|
|
79
|
+
}
|
|
80
|
+
// Lo pendiente llegado después de un momento: la espera en tiempo real pregunta esto cada segundo,
|
|
81
|
+
// así que filtra en SQL en vez de traer el buzón entero.
|
|
82
|
+
async listMailSince(local, sinceIso) {
|
|
83
|
+
return (await this.db.prepare('SELECT doc FROM nyx5_mailbox WHERE local = ? AND acked IS NULL AND received > ? ORDER BY received, id').bind(local, sinceIso || '').all()).results.map((r) => JSON.parse(r.doc));
|
|
84
|
+
}
|
|
74
85
|
|
|
75
86
|
// --- cola de salida ---
|
|
76
87
|
_jobRow(job) { return [job.id, j(job), job.next_attempt, job.status, job.claimed_until || 0]; }
|
|
@@ -108,6 +119,39 @@ export class D1Store {
|
|
|
108
119
|
}
|
|
109
120
|
async pruneNonces(beforeMs) { await this.db.prepare('DELETE FROM nyx5_nonces WHERE ts < ?').bind(beforeMs).run(); }
|
|
110
121
|
|
|
122
|
+
// --- kv con vencimiento (0006): clientes OAuth, códigos de un uso, tokens (hash) y la bóveda ---
|
|
123
|
+
async kvGet(ns, key, nowMs = Date.now()) {
|
|
124
|
+
return p(await this.db.prepare('SELECT doc FROM nyx5_kv WHERE ns = ? AND key = ? AND (expires IS NULL OR expires > ?)').bind(ns, key, nowMs).first());
|
|
125
|
+
}
|
|
126
|
+
async kvPut(ns, key, doc, expires = null) {
|
|
127
|
+
await this.db.prepare('INSERT INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, ?, ?, ?) ON CONFLICT(ns, key) DO UPDATE SET doc = excluded.doc, expires = excluded.expires')
|
|
128
|
+
.bind(ns, key, j(doc), expires, iso()).run();
|
|
129
|
+
}
|
|
130
|
+
async kvPutIfAbsent(ns, key, doc, expires = null, nowMs = Date.now()) {
|
|
131
|
+
await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ? AND expires IS NOT NULL AND expires <= ?').bind(ns, key, nowMs).run();
|
|
132
|
+
const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, ?, ?, ?)').bind(ns, key, j(doc), expires, iso()).run();
|
|
133
|
+
return r.meta.changes === 1;
|
|
134
|
+
}
|
|
135
|
+
// Tomar y borrar en UNA sentencia: dos canjes simultáneos del mismo código no pueden ganar los dos.
|
|
136
|
+
async kvTake(ns, key, nowMs = Date.now()) {
|
|
137
|
+
const r = await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ? RETURNING doc, expires').bind(ns, key).all();
|
|
138
|
+
const row = r.results[0];
|
|
139
|
+
if (!row || (row.expires != null && row.expires <= nowMs)) return null;
|
|
140
|
+
return JSON.parse(row.doc);
|
|
141
|
+
}
|
|
142
|
+
// Contador atómico con vencimiento en UNA sentencia: dos isolates que suman a la vez no pierden
|
|
143
|
+
// ninguna. Un contador ya vencido arranca de nuevo en 1 (la purga puede no haber pasado aún).
|
|
144
|
+
async kvIncrement(ns, key, expires = null, nowMs = Date.now()) {
|
|
145
|
+
const r = await this.db.prepare(`INSERT INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, '1', ?, ?)
|
|
146
|
+
ON CONFLICT(ns, key) DO UPDATE SET
|
|
147
|
+
doc = CASE WHEN nyx5_kv.expires IS NOT NULL AND nyx5_kv.expires <= ? THEN '1' ELSE CAST(CAST(nyx5_kv.doc AS INTEGER) + 1 AS TEXT) END,
|
|
148
|
+
expires = excluded.expires
|
|
149
|
+
RETURNING doc`).bind(ns, key, expires, iso(), nowMs).all();
|
|
150
|
+
return Number(r.results[0].doc);
|
|
151
|
+
}
|
|
152
|
+
async kvDelete(ns, key) { await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ?').bind(ns, key).run(); }
|
|
153
|
+
async kvPurge(nowMs = Date.now()) { await this.db.prepare('DELETE FROM nyx5_kv WHERE expires IS NOT NULL AND expires <= ?').bind(nowMs).run(); }
|
|
154
|
+
|
|
111
155
|
// --- pins (una fila por dominio: el primero gana y ningún isolate pisa lo que otro aprendió) ---
|
|
112
156
|
async getPins() {
|
|
113
157
|
const r = await this.db.prepare('SELECT domain, kid FROM nyx5_pin').all();
|
|
@@ -147,6 +191,15 @@ export class D1Store {
|
|
|
147
191
|
async libroListMandates() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos').all()).results.map((r) => JSON.parse(r.doc)); }
|
|
148
192
|
async libroGetOp(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_ops WHERE id = ?').bind(id).first()); }
|
|
149
193
|
async libroPutOp(id, v) { await this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(id, j(v)).run(); }
|
|
194
|
+
|
|
195
|
+
// --- notaría (NX-601): sellos de hash (migrations/0008_notaria.sql) ---
|
|
196
|
+
async notariaGet(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE id = ?').bind(id).first()); }
|
|
197
|
+
async notariaList(sha256) { return (await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE sha256 = ? ORDER BY at, id').bind(sha256).all()).results.map((r) => JSON.parse(r.doc)); }
|
|
198
|
+
async notariaFind(sha256, by) { return p(await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE sha256 = ? AND "by" = ?').bind(sha256, by).first()); }
|
|
199
|
+
// Sin ON CONFLICT a propósito: el índice único (sha256, by) es el candado, y un choque tiene
|
|
200
|
+
// que tirar el batch entero (421 reintentable), no pisar el sello anterior en silencio.
|
|
201
|
+
_selloStmt(s) { return this.db.prepare('INSERT INTO nyx5_notaria (id, sha256, "by", at, doc) VALUES (?, ?, ?, ?, ?)').bind(s.id, s.sha256, s.by, s.at, j(s)); }
|
|
202
|
+
async notariaPut(s) { try { await this._selloStmt(s).run(); } catch (e) { throw this._traducirConflicto(e); } }
|
|
150
203
|
// ---------- instrumentación ----------
|
|
151
204
|
async putEvent(e) { await this.db.prepare('INSERT OR IGNORE INTO nyx5_eventos (id, name, ts, actor, data) VALUES (?, ?, ?, ?, ?)').bind(e.id, e.name, e.ts, e.actor || null, j(e.data || {})).run(); }
|
|
152
205
|
async listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
@@ -156,11 +209,28 @@ export class D1Store {
|
|
|
156
209
|
const r = await this.db.prepare(`SELECT id, name, ts, actor, data FROM nyx5_eventos WHERE ${cond.join(' AND ')} ORDER BY ts DESC LIMIT ?`).bind(...bind, Number(limit)).all();
|
|
157
210
|
return r.results.map((x) => ({ ...x, data: JSON.parse(x.data) })).reverse();
|
|
158
211
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
212
|
+
// Extracto por cuenta en [since, until) sobre `at` del asiento (json_extract sobre el doc: la
|
|
213
|
+
// fecha ya vive ahí, sin migración). Los `limit` más recientes y el total del rango.
|
|
214
|
+
_statementWhere(account, since, until) {
|
|
215
|
+
const cond = ['n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)']; const binds = [account];
|
|
216
|
+
if (since) { cond.push("json_extract(doc, '$.at') >= ?"); binds.push(since); }
|
|
217
|
+
if (until) { cond.push("json_extract(doc, '$.at') < ?"); binds.push(until); }
|
|
218
|
+
return { where: cond.join(' AND '), binds };
|
|
219
|
+
}
|
|
220
|
+
async libroStatementRange(account, { since = null, until = null, limit = 20 } = {}) {
|
|
221
|
+
const { where, binds } = this._statementWhere(account, since, until);
|
|
222
|
+
const total = Number((await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_libro_diario WHERE ${where}`).bind(...binds).first())?.c || 0);
|
|
223
|
+
const r = await this.db.prepare(`SELECT doc FROM nyx5_libro_diario WHERE ${where} ORDER BY n DESC LIMIT ?`).bind(...binds, limit).all();
|
|
224
|
+
return { entries: r.results.map((row) => JSON.parse(row.doc)).reverse(), total };
|
|
225
|
+
}
|
|
226
|
+
async libroStatement(account, limit) { return (await this.libroStatementRange(account, { limit })).entries; }
|
|
227
|
+
// Saldo de una cuenta antes del asiento `n` y/o de la fecha `at`, sumado de las líneas.
|
|
228
|
+
async libroBalanceBefore(account, { n = null, at = null } = {}) {
|
|
229
|
+
const cond = ['l.account = ?']; const binds = [account];
|
|
230
|
+
if (n != null) { cond.push('l.n < ?'); binds.push(n); }
|
|
231
|
+
if (at != null) { cond.push("json_extract(d.doc, '$.at') < ?"); binds.push(at); }
|
|
232
|
+
const row = await this.db.prepare(`SELECT COALESCE(SUM(l.delta), 0) AS s FROM nyx5_libro_lineas l JOIN nyx5_libro_diario d ON d.n = l.n WHERE ${cond.join(' AND ')}`).bind(...binds).first();
|
|
233
|
+
return Number(row?.s || 0);
|
|
164
234
|
}
|
|
165
235
|
|
|
166
236
|
// Un movimiento del Libro, atómico. El orden importa: el diario va primero (PK n = candado
|
|
@@ -171,6 +241,7 @@ export class D1Store {
|
|
|
171
241
|
if (bundle.state) stmts.push(this.db.prepare('UPDATE nyx5_libro_state SET seq = ?, balances = ? WHERE id = 1').bind(bundle.state.seq, j(bundle.state.balances)));
|
|
172
242
|
for (const c of bundle.contracts || []) stmts.push(this._contractStmt(c));
|
|
173
243
|
for (const m of bundle.mandates || []) stmts.push(this._mandateStmt(m));
|
|
244
|
+
for (const s of bundle.sellos || []) stmts.push(this._selloStmt(s));
|
|
174
245
|
if (bundle.op) stmts.push(this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?)').bind(bundle.op.id, j(bundle.op.result)));
|
|
175
246
|
return stmts;
|
|
176
247
|
}
|
|
@@ -212,21 +283,60 @@ export class D1Store {
|
|
|
212
283
|
async indexGetHouse(domain) { return p(await this.db.prepare('SELECT doc FROM nyx5_indice_casas WHERE domain = ?').bind(domain).first()); }
|
|
213
284
|
async indexPutHouse(h) { await this.db.prepare('INSERT INTO nyx5_indice_casas (domain, doc) VALUES (?, ?) ON CONFLICT(domain) DO UPDATE SET doc = excluded.doc').bind(h.domain, j(h)).run(); }
|
|
214
285
|
async indexListHouses() { return (await this.db.prepare('SELECT doc FROM nyx5_indice_casas').all()).results.map((r) => JSON.parse(r.doc)); }
|
|
286
|
+
// Reemplazo por casa con generación: cada fila conserva un historial corto de sus puntajes
|
|
287
|
+
// (hist, first_gen) para que un recorrido por cursor no repita ni salte a quien cambió de
|
|
288
|
+
// puntaje entre página y página (cabecera de src/correo/indice.js).
|
|
215
289
|
async indexReplaceAgents(domain, cards) {
|
|
290
|
+
const previas = (await this.db.prepare('SELECT address, first_gen, hist FROM nyx5_indice_agentes WHERE house = ?').bind(domain).all()).results;
|
|
291
|
+
const previo = new Map(previas.map((r) => [r.address, { first_gen: r.first_gen, hist: JSON.parse(r.hist || '[]') }]));
|
|
292
|
+
const gen = ((await this.db.prepare('SELECT COALESCE(MAX(gen), 0) AS g FROM nyx5_indice_agentes').first()).g || 0) + 1;
|
|
216
293
|
await this.db.batch([
|
|
217
294
|
this.db.prepare('DELETE FROM nyx5_indice_agentes WHERE house = ?').bind(domain),
|
|
218
|
-
...cards.map((c) =>
|
|
295
|
+
...cards.map((c) => {
|
|
296
|
+
const r = filaDeIndice(domain, c, gen, previo.get(c.address) || null);
|
|
297
|
+
return this.db.prepare('INSERT INTO nyx5_indice_agentes (house, address, doc, tags, langs, price_min, score, jobs_done, gen, first_gen, hist) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
298
|
+
.bind(domain, c.address, j(c), j(r.tags), j(r.langs), r.price_min, r.score, r.jobs_done, r.gen, r.first_gen, j(r.hist));
|
|
299
|
+
}),
|
|
219
300
|
]);
|
|
220
301
|
}
|
|
221
|
-
|
|
302
|
+
// La misma búsqueda que buscarEnMemoria (indice.js), en SQL. `g` va como literal entero (ya
|
|
303
|
+
// validado como entero seguro) porque la clave de orden aparece varias veces en la consulta.
|
|
304
|
+
async indexSearch(f = {}) {
|
|
305
|
+
const gActual = (await this.db.prepare('SELECT COALESCE(MAX(gen), 0) AS g FROM nyx5_indice_agentes').first()).g || 0;
|
|
306
|
+
if (f.cursor && f.cursor.g > gActual) throw Object.assign(new Error('invalid cursor: it names a generation of the index that does not exist'), { status: 400 });
|
|
307
|
+
const g = f.cursor ? f.cursor.g : gActual;
|
|
308
|
+
if (!Number.isSafeInteger(g) || g < 0) throw Object.assign(new Error('invalid cursor'), { status: 400 });
|
|
309
|
+
// El puntaje congelado en g: la última entrada del historial con generación <= g; NULL si la
|
|
310
|
+
// dirección entró al índice después de g (claveCongelada, en indice.js, es la referencia).
|
|
311
|
+
const ULT = `(SELECT json_extract(e.value, '$.s') FROM json_each(hist) e WHERE json_extract(e.value, '$.g') <= ${g} ORDER BY json_extract(e.value, '$.g') DESC LIMIT 1)`;
|
|
312
|
+
const K = `(CASE WHEN first_gen > ${g} THEN NULL ELSE ${ULT} END)`;
|
|
222
313
|
const where = []; const binds = [];
|
|
223
|
-
if (house) { where.push('house = ?'); binds.push(house); }
|
|
224
|
-
if (capability) { where.push("json_extract(doc, '$.capabilities.' || ?) IS NOT NULL"); binds.push(capability); }
|
|
225
|
-
if (accepts) { where.push("EXISTS (SELECT 1 FROM json_each(doc, '$.capabilities.accepts') WHERE value = ?)"); binds.push(accepts); }
|
|
226
|
-
if (
|
|
314
|
+
if (f.house) { where.push('house = ?'); binds.push(f.house); }
|
|
315
|
+
if (f.capability) { where.push("json_extract(doc, '$.capabilities.' || ?) IS NOT NULL"); binds.push(f.capability); }
|
|
316
|
+
if (f.accepts) { where.push("EXISTS (SELECT 1 FROM json_each(doc, '$.capabilities.accepts') WHERE value = ?)"); binds.push(f.accepts); }
|
|
317
|
+
if (f.tag) { where.push('EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)'); binds.push(f.tag); }
|
|
318
|
+
if (f.lang) { where.push("EXISTS (SELECT 1 FROM json_each(langs) WHERE value = ? OR value LIKE ? || '-%')"); binds.push(f.lang, f.lang); }
|
|
319
|
+
if (f.price_max != null) { where.push('price_min IS NOT NULL AND price_min <= ?'); binds.push(f.price_max); }
|
|
320
|
+
if (f.q) { where.push("lower(doc) LIKE ? ESCAPE '\\'"); binds.push(`%${String(f.q).toLowerCase().replace(/[\\%_]/g, (c) => '\\' + c)}%`); }
|
|
321
|
+
const wBase = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
322
|
+
if (f.cursor) {
|
|
323
|
+
// Caduco: una fila que ya existía en g y cuyo historial no conserva ninguna entrada <= g.
|
|
324
|
+
const caduco = (await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_indice_agentes ${wBase} ${where.length ? 'AND' : 'WHERE'} first_gen <= ${g} AND NOT EXISTS (SELECT 1 FROM json_each(hist) e WHERE json_extract(e.value, '$.g') <= ${g})`).bind(...binds).first()).c;
|
|
325
|
+
if (caduco > 0) throw Object.assign(new Error(CADUCO), { status: 410 });
|
|
326
|
+
}
|
|
327
|
+
if (f.min_score != null) { where.push(`${K} >= ?`); binds.push(f.min_score); }
|
|
227
328
|
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
228
329
|
const total = (await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_indice_agentes ${w}`).bind(...binds).first()).c;
|
|
229
|
-
const
|
|
230
|
-
|
|
330
|
+
const keyset = []; const kb = [];
|
|
331
|
+
if (f.cursor) {
|
|
332
|
+
if (f.cursor.s == null) { keyset.push(`${K} IS NULL AND address > ?`); kb.push(f.cursor.a); }
|
|
333
|
+
else { keyset.push(`(${K} < ? OR (${K} = ? AND address > ?) OR ${K} IS NULL)`); kb.push(f.cursor.s, f.cursor.s, f.cursor.a); }
|
|
334
|
+
}
|
|
335
|
+
const wPag = [...where, ...keyset].length ? `WHERE ${[...where, ...keyset].join(' AND ')}` : '';
|
|
336
|
+
const rows = (await this.db.prepare(`SELECT address, ${K} AS k, doc FROM nyx5_indice_agentes ${wPag} ORDER BY k DESC NULLS LAST, address ASC LIMIT ?`).bind(...binds, ...kb, f.limit + 1).all()).results;
|
|
337
|
+
const pagina = rows.slice(0, f.limit);
|
|
338
|
+
const ultima = pagina[pagina.length - 1];
|
|
339
|
+
const next_cursor = rows.length > f.limit ? codificarCursor({ s: ultima.k ?? null, a: ultima.address, g }) : null;
|
|
340
|
+
return { total, agents: pagina.map((r) => JSON.parse(r.doc)), next_cursor };
|
|
231
341
|
}
|
|
232
342
|
}
|