@opennodes/registry 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/probes.js CHANGED
@@ -16,7 +16,8 @@ export async function runStageA(store, nodeId, card, { safeFetch } = {}) {
16
16
  try {
17
17
  const t0 = performance.now();
18
18
  const { status, body } = await fetchJson(card.endpoints.health);
19
- const ok = status === 200 && body?.status === 'ok';
19
+ // ONP health ({status:'ok'}), or an OpenAI /models listing for card-only operators.
20
+ const ok = status === 200 && (body?.status === 'ok' || Array.isArray(body?.data));
20
21
  await record(null, 'health', ok, ok ? null : `status ${status}`, { latency_ms: Math.round(performance.now() - t0) });
21
22
  } catch (err) {
22
23
  await record(null, 'health', false, String(err.message ?? err));
package/src/search.js CHANGED
@@ -7,7 +7,7 @@ const TIER_SCORE = { unverified: 0, community: 0.4, verified: 0.8, attested: 1.0
7
7
  export async function collectOfferings(store) {
8
8
  const out = [];
9
9
  for (const node of await store.allNodes()) {
10
- if (['delisted', 'suspended', 'submitted', 'challenged', 'disputed'].includes(node.state)) continue;
10
+ if (['delisted', 'suspended', 'submitted', 'challenged', 'disputed', 'claimed'].includes(node.state)) continue;
11
11
  const card = await store.latestCard(node.id);
12
12
  if (!card) continue;
13
13
  const probes = await store.probesFor(node.id);
package/src/server.js CHANGED
@@ -92,6 +92,40 @@ export async function startRegistry({
92
92
  const clientOf = (req) => (trustProxy && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress || 'unknown';
93
93
  const isAdmin = (req) => Boolean(adminToken) && req.headers.authorization === `Bearer ${adminToken}`;
94
94
 
95
+ /**
96
+ * Fetch for probing one node: carries the operator's probe key (card-only listings of
97
+ * keyed APIs), and only toward the card's own endpoint origins — never to anything else.
98
+ */
99
+ async function authFetch(nodeId, card) {
100
+ const key = await store.getProbeKey(nodeId);
101
+ if (!key || !card?.endpoints) return safeFetch;
102
+ const origins = new Set([card.endpoints.openai, card.endpoints.health].filter(Boolean).map((u) => new URL(u).origin));
103
+ return (url, opts = {}, timeoutMs) => safeFetch(url,
104
+ origins.has(new URL(url).origin) ? { ...opts, headers: { ...(opts.headers ?? {}), authorization: `Bearer ${key}` } } : opts,
105
+ timeoutMs);
106
+ }
107
+
108
+ /**
109
+ * A registered node that reaches community or higher retires imported catalog listings of
110
+ * the same operator — imports whose API host lies inside the node's own namespace. Bound to
111
+ * the namespace so nobody can 'claim' a third party's listings (e.g. Hugging Face's router).
112
+ */
113
+ async function supersedeImports(nodeId) {
114
+ const domain = domainForNodeId(nodeId).toLowerCase();
115
+ const claimed = [];
116
+ for (const imp of await store.allNodes()) {
117
+ if (imp.source !== 'import' || ['claimed', 'delisted'].includes(imp.state)) continue;
118
+ const card = await store.latestCard(imp.id);
119
+ let host;
120
+ try { host = new URL(card?.endpoints?.openai).hostname.toLowerCase(); } catch { continue; }
121
+ if (host === domain || host.endsWith('.' + domain)) {
122
+ await store.setState(imp.id, 'claimed', `claimed by ${nodeId}`);
123
+ claimed.push(imp.id);
124
+ }
125
+ }
126
+ return claimed;
127
+ }
128
+
95
129
  async function challengeSatisfied(node, challenge) {
96
130
  try { // HTTP method — only from a host that controls the id's namespace
97
131
  if (namespaceOk(node.origin, node.id)) {
@@ -137,7 +171,8 @@ export async function startRegistry({
137
171
  const app = createApp([
138
172
  ['POST', '/v0/nodes', async (req, res) => {
139
173
  if (!registerLimiter(clientOf(req))) return problem(res, 429, 'rate-limited', 'too many registration attempts from this address; try again later');
140
- const { card_url } = await readJson(req);
174
+ const { card_url, probe_key } = await readJson(req);
175
+ if (probe_key != null && (typeof probe_key !== 'string' || probe_key.length > 4096)) return problem(res, 400, 'bad-probe-key', 'probe_key must be a string');
141
176
  if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
142
177
  let card;
143
178
  try { card = await fetchCard(card_url); } catch (err) { return problem(res, 422, 'card-unfetchable', String(err.message)); }
@@ -159,6 +194,9 @@ export async function startRegistry({
159
194
  if (!listed) await store.setState(nodeId, 'challenged', 'registration received');
160
195
  const token = randomUUID();
161
196
  await store.setChallenge(nodeId, token);
197
+ // Held as pending: it only becomes active when this namespace's challenge is satisfied, so a
198
+ // stranger re-submitting someone else's card cannot replace (or break) their probe key.
199
+ if (probe_key) await store.setPendingProbeKey(nodeId, probe_key);
162
200
  sendJson(res, 202, {
163
201
  node_id: nodeId,
164
202
  state: listed ? existing.state : 'challenged',
@@ -181,19 +219,22 @@ export async function startRegistry({
181
219
  if (!challenge) return problem(res, 410, 'challenge-expired', 'register again');
182
220
  const check = await challengeSatisfied(node, challenge);
183
221
  if (!check.ok) return problem(res, 403, 'challenge-failed', 'neither HTTP nor DNS TXT challenge satisfied');
222
+ await store.promoteProbeKey(params.id);
184
223
  let indexed;
185
224
  try { indexed = await indexCard(params.id, node.card_url); }
186
225
  catch (err) { return problem(res, 422, 'card-invalid', String(err.message)); }
187
226
 
188
227
  await store.setState(params.id, 'indexed', `namespace verified via ${check.method}`);
189
- const stageA = await runStageA(store, params.id, indexed.card, { safeFetch });
228
+ const stageA = await runStageA(store, params.id, indexed.card, { safeFetch: await authFetch(params.id, indexed.card) });
190
229
  await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
191
230
  stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
231
+ const claimed = stageA.ok ? await supersedeImports(params.id) : [];
192
232
  sendJson(res, 200, {
193
233
  node_id: params.id,
194
234
  state: (await store.getNode(params.id)).state,
195
235
  signature_verified: indexed.sigOk,
196
236
  stage_a: stageA,
237
+ claimed_imports: claimed,
197
238
  });
198
239
  }],
199
240
 
@@ -202,7 +243,7 @@ export async function startRegistry({
202
243
  if (!node) return problem(res, 404, 'unknown-node', params.id);
203
244
  try {
204
245
  const { card } = await indexCard(params.id, node.card_url);
205
- const stageA = await runStageA(store, params.id, card, { safeFetch });
246
+ const stageA = await runStageA(store, params.id, card, { safeFetch: await authFetch(params.id, card) });
206
247
  sendJson(res, 200, { node_id: params.id, revision: card.revision, stage_a_ok: stageA.ok });
207
248
  } catch (err) {
208
249
  problem(res, 422, 'refresh-failed', String(err.message));
@@ -226,7 +267,7 @@ export async function startRegistry({
226
267
  }
227
268
  await refreshCard(params.id);
228
269
  const card = await store.latestCard(params.id);
229
- const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
270
+ const stageB = await runStageB(store, params.id, card, { safeFetch: await authFetch(params.id, card), capacity: stageBCapacity });
230
271
  if (stageB.stale) {
231
272
  return problem(res, 409, 'stale-card', 'the node changed its card revision during the battery; retry');
232
273
  }
@@ -234,6 +275,7 @@ export async function startRegistry({
234
275
  await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
235
276
  } else if (stageB.ok) {
236
277
  await store.setState(params.id, 'verified', 'Stage B passed');
278
+ await supersedeImports(params.id);
237
279
  }
238
280
  sendJson(res, 200, { node_id: params.id, state: (await store.getNode(params.id)).state, stage_b: stageB });
239
281
  }],
@@ -246,7 +288,7 @@ export async function startRegistry({
246
288
  const card = await store.latestCard(node_id);
247
289
  const offering = card?.offerings.find((o) => o.offering_id === offering_id);
248
290
  if (!offering) return problem(res, 404, 'unknown-offering', `${node_id}/${offering_id}`);
249
- const refs = await captureFingerprints(safeFetch, card, offering);
291
+ const refs = await captureFingerprints(await authFetch(node_id, card), card, offering);
250
292
  await store.addFingerprints(offering.model.artifact, refs);
251
293
  sendJson(res, 200, { artifact: offering.model.artifact, references: refs.length });
252
294
  }],
@@ -255,7 +297,7 @@ export async function startRegistry({
255
297
  ['GET', '/v0/nodes', async (req, res) => {
256
298
  const out = [];
257
299
  for (const node of await store.allNodes()) {
258
- if (['delisted'].includes(node.state)) continue;
300
+ if (['delisted', 'claimed'].includes(node.state)) continue;
259
301
  const card = await store.latestCard(node.id);
260
302
  const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
261
303
  out.push({
@@ -428,7 +470,7 @@ export async function startRegistry({
428
470
  ...exportRoutes(store, registryKeys),
429
471
  ], { cors: true }); // public read API: third-party web clients may call it directly
430
472
 
431
- const stageCHandle = stageC ? startStageC(store, { safeFetch, refreshCard, ...stageC }) : null;
473
+ const stageCHandle = stageC ? startStageC(store, { safeFetch, fetchFor: authFetch, refreshCard, ...stageC }) : null;
432
474
  const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
433
475
  const actualPort = await listen(app, port, host);
434
476
  return {
package/src/stagec.js CHANGED
@@ -9,6 +9,7 @@ const OBSERVED_STATES = ['community', 'verified', 'attested'];
9
9
 
10
10
  export function startStageC(store, {
11
11
  safeFetch,
12
+ fetchFor = null, // (nodeId, card) -> fetch that carries the node's probe key
12
13
  refreshCard = null, // re-index a node's card when its revision moved (restart / reprice)
13
14
  livenessIntervalMs = 300_000, // spec: <=5 min; tests shorten this
14
15
  blindIntervalMs = 3_600_000,
@@ -38,8 +39,9 @@ export function startStageC(store, {
38
39
  if (!card) continue;
39
40
  let ok = false;
40
41
  try {
41
- const { status, body } = await fetchJsonCapped(safeFetch, card.endpoints.health, {}, 5_000);
42
- ok = status === 200 && body?.status === 'ok';
42
+ const f = fetchFor ? await fetchFor(node.id, card) : safeFetch;
43
+ const { status, body } = await fetchJsonCapped(f, card.endpoints.health, {}, 5_000);
44
+ ok = status === 200 && (body?.status === 'ok' || Array.isArray(body?.data));
43
45
  } catch { ok = false; }
44
46
  await store.recordProbe(node.id, null, 'C', 'liveness', ok);
45
47
 
@@ -79,7 +81,8 @@ export function startStageC(store, {
79
81
  if (!chatOfferings.length) continue;
80
82
  const offering = chatOfferings[Math.floor(Math.random() * chatOfferings.length)];
81
83
  try {
82
- const { status, body } = await fetchJsonCapped(safeFetch, `${card.endpoints.openai}/chat/completions`, {
84
+ const f = fetchFor ? await fetchFor(node.id, card) : safeFetch;
85
+ const { status, body } = await fetchJsonCapped(f, `${card.endpoints.openai}/chat/completions`, {
83
86
  method: 'POST',
84
87
  headers: {
85
88
  'content-type': 'application/json',
package/src/store-pg.js CHANGED
@@ -17,6 +17,9 @@ const SCHEMA = `
17
17
  CREATE TABLE IF NOT EXISTS challenges (
18
18
  node_id TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at TEXT NOT NULL
19
19
  );
20
+ CREATE TABLE IF NOT EXISTS probe_keys (
21
+ node_id TEXT PRIMARY KEY, secret TEXT, pending TEXT, updated_at TEXT NOT NULL
22
+ );
20
23
  CREATE TABLE IF NOT EXISTS probes (
21
24
  id SERIAL PRIMARY KEY, node_id TEXT NOT NULL, offering_id TEXT,
22
25
  stage TEXT NOT NULL, kind TEXT NOT NULL, ok INTEGER NOT NULL,
@@ -74,6 +77,16 @@ export async function createPgStore({ connectionString, pool } = {}) {
74
77
  const rows = await q('SELECT raw FROM card_revisions WHERE node_id = $1 ORDER BY revision DESC LIMIT 1', [nodeId]);
75
78
  return rows[0] ? JSON.parse(rows[0].raw) : null;
76
79
  },
80
+ async setPendingProbeKey(nodeId, key) {
81
+ await q(`INSERT INTO probe_keys (node_id, secret, pending, updated_at) VALUES ($1, NULL, $2, $3)
82
+ ON CONFLICT (node_id) DO UPDATE SET pending=excluded.pending, updated_at=excluded.updated_at`, [nodeId, key, now()]);
83
+ },
84
+ async promoteProbeKey(nodeId) {
85
+ await q('UPDATE probe_keys SET secret = pending, pending = NULL, updated_at = $1 WHERE node_id = $2 AND pending IS NOT NULL', [now(), nodeId]);
86
+ },
87
+ async getProbeKey(nodeId) {
88
+ return (await q('SELECT secret FROM probe_keys WHERE node_id = $1', [nodeId]))[0]?.secret ?? null;
89
+ },
77
90
  async setChallenge(nodeId, token, ttlMs = 72 * 3600 * 1000) {
78
91
  await q(`INSERT INTO challenges (node_id, token, expires_at) VALUES ($1, $2, $3)
79
92
  ON CONFLICT (node_id) DO UPDATE SET token=excluded.token, expires_at=excluded.expires_at`,
package/src/store.js CHANGED
@@ -30,6 +30,11 @@ export async function createSqliteStore(path = ':memory:') {
30
30
  CREATE TABLE IF NOT EXISTS challenges (
31
31
  node_id TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at TEXT NOT NULL
32
32
  );
33
+ -- Operator-provided probe credentials. A separate table so they can never leak through
34
+ -- SELECT * FROM nodes; the pending key is activated only after the namespace challenge passes.
35
+ CREATE TABLE IF NOT EXISTS probe_keys (
36
+ node_id TEXT PRIMARY KEY, secret TEXT, pending TEXT, updated_at TEXT NOT NULL
37
+ );
33
38
  CREATE TABLE IF NOT EXISTS probes (
34
39
  id INTEGER PRIMARY KEY AUTOINCREMENT, node_id TEXT NOT NULL, offering_id TEXT,
35
40
  stage TEXT NOT NULL, kind TEXT NOT NULL, ok INTEGER NOT NULL,
@@ -76,6 +81,16 @@ export async function createSqliteStore(path = ':memory:') {
76
81
  const row = db.prepare('SELECT raw FROM card_revisions WHERE node_id = ? ORDER BY revision DESC LIMIT 1').get(nodeId);
77
82
  return row ? JSON.parse(row.raw) : null;
78
83
  },
84
+ async setPendingProbeKey(nodeId, key) {
85
+ db.prepare(`INSERT INTO probe_keys (node_id, secret, pending, updated_at) VALUES (?, NULL, ?, ?)
86
+ ON CONFLICT(node_id) DO UPDATE SET pending=excluded.pending, updated_at=excluded.updated_at`).run(nodeId, key, now());
87
+ },
88
+ async promoteProbeKey(nodeId) {
89
+ db.prepare('UPDATE probe_keys SET secret = pending, pending = NULL, updated_at = ? WHERE node_id = ? AND pending IS NOT NULL').run(now(), nodeId);
90
+ },
91
+ async getProbeKey(nodeId) {
92
+ return db.prepare('SELECT secret FROM probe_keys WHERE node_id = ?').get(nodeId)?.secret ?? null;
93
+ },
79
94
  async setChallenge(nodeId, token, ttlMs = 72 * 3600 * 1000) {
80
95
  db.prepare('INSERT OR REPLACE INTO challenges (node_id, token, expires_at) VALUES (?, ?, ?)')
81
96
  .run(nodeId, token, new Date(Date.now() + ttlMs).toISOString());