@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 +1 -1
- package/src/probes.js +2 -1
- package/src/search.js +1 -1
- package/src/server.js +49 -7
- package/src/stagec.js +6 -3
- package/src/store-pg.js +13 -0
- package/src/store.js +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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(
|
|
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
|
|
42
|
-
|
|
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
|
|
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());
|