|
| 1 | +// catalog.mjs — OSSERVATORIO DEL CATALOGO x402 (v1: osservazione + feed). |
| 2 | +// |
| 3 | +// Perché esiste: il catalogo discovery conta ~15.000 risorse e la domanda pagante |
| 4 | +// più concreta dell'ecosistema è "chi è VIVO?" — misurata sui payer che bruciano |
| 5 | +// $2-50/giorno sondando tutto a forza bruta. Qui la risposta viene prodotta una |
| 6 | +// volta e servita a tutti: sondiamo le TOP-N risorse a rotazione e pubblichiamo |
| 7 | +// stato/latenza/anzianità. Il feed completo è monetizzato dalla webapp (x402); |
| 8 | +// qui restano il probing e una vista gratuita limitata. |
| 9 | +// |
| 10 | +// REGOLE PRE-REGISTRATE (non cambiarle senza dichiararlo): |
| 11 | +// - Selezione: le TRACK_N risorse più recenti per lastUpdated nel discovery CDP |
| 12 | +// (+ sempre le nostre). Refresh della lista 1 volta al giorno. |
| 13 | +// - "alive" = risponde entro 8s con: 402 e challenge JSON che espone accepts[], |
| 14 | +// oppure 2xx (risorsa free). Qualsiasi altro esito = not-ok (codice registrato). |
| 15 | +// Le sonde NON pagano mai nessuno. |
| 16 | +// - Nessun giudizio, solo fatti misurati: code, ms, lastOkAt, fails consecutivi. |
| 17 | +// |
| 18 | +// VINCOLI PIANO FREE (verificati): ≤50 subrequest/invocazione → PER_TICK sonde |
| 19 | +// per giro, saltando il tick del sigillo giornaliero; ≤1000 scritture KV/giorno |
| 20 | +// → UNA scrittura aggregata per tick. CPU: le attese fetch non contano. |
| 21 | + |
| 22 | +const DISCOVERY_URL = |
| 23 | + "https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources"; |
| 24 | +const TRACK_N = 200; |
| 25 | +const PER_TICK = 17; |
| 26 | +const PROBE_TIMEOUT_MS = 8000; |
| 27 | +const LIST_KEY = "cat:list"; // { fetchedAt, urls: [ ... ] } |
| 28 | +const STATE_KEY = "cat:state"; // { updatedAt, cursor, entries: { url: {...} } } |
| 29 | +const OUR_PREFIX = "https://gblin.digital/"; |
| 30 | + |
| 31 | +async function fetchDiscoveryTop(env) { |
| 32 | + // 3 pagine da 100 → ordiniamo per lastUpdated e teniamo le TRACK_N più fresche. |
| 33 | + const all = []; |
| 34 | + for (let offset = 0; offset < 300; offset += 100) { |
| 35 | + try { |
| 36 | + const r = await fetch(`${DISCOVERY_URL}?limit=100&offset=${offset}`, { |
| 37 | + headers: { accept: "application/json" }, |
| 38 | + signal: AbortSignal.timeout(15_000), |
| 39 | + }); |
| 40 | + if (!r.ok) break; |
| 41 | + const j = await r.json(); |
| 42 | + const items = j.items || []; |
| 43 | + for (const it of items) { |
| 44 | + if (it?.resource && typeof it.resource === "string") { |
| 45 | + all.push({ url: it.resource, lastUpdated: it.lastUpdated || "" }); |
| 46 | + } |
| 47 | + } |
| 48 | + if (items.length < 100) break; |
| 49 | + } catch { break; } |
| 50 | + } |
| 51 | + all.sort((a, b) => (b.lastUpdated > a.lastUpdated ? 1 : -1)); |
| 52 | + const urls = []; |
| 53 | + const seen = new Set(); |
| 54 | + for (const { url } of all) { |
| 55 | + if (seen.has(url)) continue; |
| 56 | + seen.add(url); |
| 57 | + urls.push(url); |
| 58 | + if (urls.length >= TRACK_N) break; |
| 59 | + } |
| 60 | + // le nostre risorse sono SEMPRE osservate (siamo il primo soggetto del nostro strumento) |
| 61 | + for (const u of urls.filter((u) => u.startsWith(OUR_PREFIX))) seen.add(u); |
| 62 | + if (![...seen].some((u) => u.startsWith(OUR_PREFIX))) { |
| 63 | + urls.unshift("https://gblin.digital/api/x402/attestation"); |
| 64 | + } |
| 65 | + return urls; |
| 66 | +} |
| 67 | + |
| 68 | +async function probeOne(url) { |
| 69 | + const t0 = Date.now(); |
| 70 | + try { |
| 71 | + const r = await fetch(url, { |
| 72 | + headers: { accept: "application/json", "user-agent": "gblin-catalog-observer/1" }, |
| 73 | + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), |
| 74 | + redirect: "follow", |
| 75 | + }); |
| 76 | + const ms = Date.now() - t0; |
| 77 | + let ok = false; |
| 78 | + if (r.status === 402) { |
| 79 | + try { |
| 80 | + const j = await r.json(); |
| 81 | + ok = Array.isArray(j?.accepts) && j.accepts.length > 0; |
| 82 | + } catch { ok = false; } |
| 83 | + } else if (r.status >= 200 && r.status < 300) { |
| 84 | + ok = true; |
| 85 | + } |
| 86 | + return { code: r.status, ms, ok }; |
| 87 | + } catch { |
| 88 | + return { code: 0, ms: Date.now() - t0, ok: false }; |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +/** Un giro di sonde (chiamato dal cron, MAI nel tick del sigillo). */ |
| 93 | +export async function catalogTick(env, nowMs) { |
| 94 | + if (!env.COHERENCE) return; |
| 95 | + const now = nowMs ?? Date.now(); |
| 96 | + |
| 97 | + // lista: refresh 1/giorno (3 subrequest, solo in questo caso) |
| 98 | + let list = null; |
| 99 | + try { list = JSON.parse(await env.COHERENCE.get(LIST_KEY)); } catch { /* prima volta */ } |
| 100 | + if (!list || now - (list.fetchedAt || 0) > 24 * 3600e3) { |
| 101 | + const urls = await fetchDiscoveryTop(env); |
| 102 | + if (urls.length) { |
| 103 | + list = { fetchedAt: now, urls }; |
| 104 | + await env.COHERENCE.put(LIST_KEY, JSON.stringify(list)); |
| 105 | + } |
| 106 | + } |
| 107 | + if (!list?.urls?.length) return; |
| 108 | + |
| 109 | + let state = null; |
| 110 | + try { state = JSON.parse(await env.COHERENCE.get(STATE_KEY)); } catch { /* prima volta */ } |
| 111 | + if (!state) state = { updatedAt: 0, cursor: 0, entries: {} }; |
| 112 | + |
| 113 | + const batch = []; |
| 114 | + for (let i = 0; i < PER_TICK && i < list.urls.length; i++) { |
| 115 | + batch.push(list.urls[(state.cursor + i) % list.urls.length]); |
| 116 | + } |
| 117 | + state.cursor = (state.cursor + batch.length) % list.urls.length; |
| 118 | + |
| 119 | + const results = await Promise.all(batch.map((u) => probeOne(u))); |
| 120 | + for (let i = 0; i < batch.length; i++) { |
| 121 | + const u = batch[i], r = results[i]; |
| 122 | + const e = state.entries[u] || { firstSeenAt: now, fails: 0 }; |
| 123 | + e.code = r.code; e.ms = r.ms; e.ok = r.ok; e.lastProbeAt = now; |
| 124 | + if (r.ok) { e.lastOkAt = now; e.fails = 0; } else { e.fails = (e.fails || 0) + 1; } |
| 125 | + state.entries[u] = e; |
| 126 | + } |
| 127 | + // poti le voci uscite dalla lista (tienile 7 giorni per lo storico breve) |
| 128 | + for (const [u, e] of Object.entries(state.entries)) { |
| 129 | + if (!list.urls.includes(u) && now - (e.lastProbeAt || 0) > 7 * 864e5) delete state.entries[u]; |
| 130 | + } |
| 131 | + state.updatedAt = now; |
| 132 | + await env.COHERENCE.put(STATE_KEY, JSON.stringify(state)); // UNA scrittura per tick |
| 133 | +} |
| 134 | + |
| 135 | +function summarize(state) { |
| 136 | + const entries = Object.entries(state?.entries || {}); |
| 137 | + const probed = entries.filter(([, e]) => e.lastProbeAt); |
| 138 | + const alive = probed.filter(([, e]) => e.ok); |
| 139 | + return { |
| 140 | + tracked: entries.length, |
| 141 | + probed_at_least_once: probed.length, |
| 142 | + alive_now: alive.length, |
| 143 | + alive_pct: probed.length ? Math.round((alive.length / probed.length) * 1000) / 10 : null, |
| 144 | + updated_at: state?.updatedAt ? new Date(state.updatedAt).toISOString() : null, |
| 145 | + }; |
| 146 | +} |
| 147 | + |
| 148 | +/** Vista GRATUITA: aggregati + le nostre risorse in chiaro (dogfooding pubblico). */ |
| 149 | +export async function catalogReport(env) { |
| 150 | + let state = null; |
| 151 | + try { state = JSON.parse(await env.COHERENCE.get(STATE_KEY)); } catch { /* vuoto */ } |
| 152 | + const ours = {}; |
| 153 | + for (const [u, e] of Object.entries(state?.entries || {})) { |
| 154 | + if (u.startsWith(OUR_PREFIX)) ours[u] = { ok: e.ok, code: e.code, ms: e.ms, last_ok: e.lastOkAt ? new Date(e.lastOkAt).toISOString() : null }; |
| 155 | + } |
| 156 | + return { |
| 157 | + what: "x402 catalog observatory (v1 beta) — factual liveness of the most recently updated Bazaar listings, probed in rotation. No payments are made by probes; no judgements, only measurements.", |
| 158 | + alive_definition: "answers within 8s with HTTP 402 + parseable accepts[] challenge, or any 2xx", |
| 159 | + summary: summarize(state), |
| 160 | + our_own_listings: ours, |
| 161 | + full_feed: "per-endpoint detail (code, latency, last_ok, consecutive fails) is available as a paid x402 resource — see gblin.digital/api/x402/llms.txt", |
| 162 | + selection_rule: `top ${TRACK_N} listings by lastUpdated on the public CDP discovery catalog, refreshed daily`, |
| 163 | + }; |
| 164 | +} |
| 165 | + |
| 166 | +/** Feed COMPLETO per la webapp (che lo firma e lo vende via x402). Token condiviso. */ |
| 167 | +export async function catalogFull(env, token) { |
| 168 | + if (!env.CATALOG_TOKEN || token !== env.CATALOG_TOKEN) return null; |
| 169 | + let state = null; |
| 170 | + try { state = JSON.parse(await env.COHERENCE.get(STATE_KEY)); } catch { /* vuoto */ } |
| 171 | + return { summary: summarize(state), entries: state?.entries || {}, updated_at: state?.updatedAt || 0 }; |
| 172 | +} |
0 commit comments