Skip to content

Commit 2b4a012

Browse files
committed
g
1 parent 239bce6 commit 2b4a012

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

worker/src/catalog.mjs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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+
}

worker/src/index.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
*
1111
* Design constraints (Workers free plan):
1212
* - 100k requests/day, 10 ms CPU per invocation. All tools are either
13+
*
14+
* 14/08/2026: aggiunto l'OSSERVATORIO DEL CATALOGO (src/catalog.mjs) — sonde a
15+
* rotazione sulle top-200 risorse del discovery x402, vista free su /catalog,
16+
* feed completo via token per la webapp (che lo vende via x402). Il giro di
17+
* sonde SALTA il tick del sigillo giornaliero per stare nei 50 subrequest.
1318
* cached upstream fetches (I/O, ~0 CPU) or tiny hex parsing.
1419
* - Stateless: no sessions, no SSE stream, one JSON response per POST.
1520
* Every JSON-RPC exchange is self-contained (spec-permitted mode).
@@ -19,6 +24,8 @@
1924
* - Best-effort per-IP rate limit (per isolate): 60 req/min.
2025
*/
2126

27+
import { catalogTick, catalogReport, catalogFull } from "./catalog.mjs";
28+
2229
const GBLIN = "0x36C81d7E1966310F305eA637e761Cf77F90852f0";
2330
const BASKET_SELECTOR = "0x8c7e0875"; // basket(uint256)
2431
// Multiple public RPCs: some (e.g. mainnet.base.org) reject requests coming
@@ -863,6 +870,28 @@ export default {
863870
return json(await coherenceReport(env));
864871
}
865872

873+
// Regime GRATUITO via REST (stessa matematica del tool MCP, cache 60s):
874+
// pensato per i provider dei plugin che girano a OGNI loop degli agenti.
875+
// Free tier: mai conteggiato nei contatori "paid" (promessa P2).
876+
if (url.pathname === "/regime" && request.method === "GET") {
877+
const regime = await cachedRegime(env);
878+
return json({
879+
...regime,
880+
note: "Free unsigned reading, 60s cache. For a signed, offline-verifiable proof: gblin.digital/api/x402/attestation ($0.003). Risk Gate pattern: gblin.digital/risk-gate",
881+
});
882+
}
883+
884+
// Osservatorio del catalogo x402 — vista FREE (aggregati + nostre risorse).
885+
if (url.pathname === "/catalog" && request.method === "GET") {
886+
return json(await catalogReport(env));
887+
}
888+
// Feed completo per la webapp (che lo firma e lo vende via x402).
889+
if (url.pathname === "/catalog/full" && request.method === "GET") {
890+
const full = await catalogFull(env, url.searchParams.get("token"));
891+
if (!full) return json({ error: "forbidden" }, 403);
892+
return json(full);
893+
}
894+
866895
// One-shot genesis seal, token-gated. Writes the first on-chain proof on
867896
// demand (also an end-to-end test of the signing path). Idempotent: refuses
868897
// once genesis is done. No token configured → 404 (feature stays invisible).
@@ -930,6 +959,10 @@ export default {
930959
// next 10-min tick retries the missing seal instead of waiting a day.
931960
const done = await coherenceAttestClosedDay(env);
932961
if (done) await env.COHERENCE.put("attest:lastRun", today);
962+
} else {
963+
// Giro di sonde del catalogo SOLO nei tick senza sigillo: il budget
964+
// free è 50 subrequest/invocazione e il sigillo ne consuma parecchi.
965+
await catalogTick(env).catch((e) => console.error("catalog:", e.message));
933966
}
934967
}
935968
})();

0 commit comments

Comments
 (0)