@nyx5/nyx5 0.6.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.
@@ -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
- constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, log = () => {} }) {
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
- vendiendo: { entregas_aceptadas: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
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 sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
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, `saldo insuficiente en ${l.account} (tiene ${state.balances[l.account] || 0}, necesita ${-l.delta})`);
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
- const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
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, `este buzón exige estampilla de ${price} tok en la casa ${this.domain} (campo stamp: {house, amount})`);
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
+ }
@@ -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
- async libroStatement(account, limit) {
160
- const r = await this.db.prepare(`
161
- SELECT doc FROM nyx5_libro_diario WHERE n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)
162
- ORDER BY n DESC LIMIT ?`).bind(account, limit).all();
163
- return r.results.map((row) => JSON.parse(row.doc)).reverse();
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) => this.db.prepare('INSERT INTO nyx5_indice_agentes (house, address, doc) VALUES (?, ?, ?)').bind(domain, c.address, j(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
- async indexSearch({ q, capability, accepts, house, limit = 50, offset = 0 } = {}) {
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 (q) { where.push('lower(doc) LIKE ?'); binds.push(`%${String(q).toLowerCase()}%`); }
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 rows = await this.db.prepare(`SELECT doc FROM nyx5_indice_agentes ${w} ORDER BY house, address LIMIT ? OFFSET ?`).bind(...binds, limit, offset).all();
230
- return { total, offset, agents: rows.results.map((r) => JSON.parse(r.doc)) };
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
  }