@opennodes/registry 0.1.1 → 0.1.2

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/src/server.js CHANGED
@@ -1,391 +1,392 @@
1
- // OpenNodes Discovery Service — MVP registry server (REQ-001 subset).
2
- import { randomUUID } from 'node:crypto';
3
- import {
4
- createApp, listen, closeApp, sendJson, problem, readJson,
5
- validateCard, verifyCardSignature, keyFromJwk, estimateBounds,
6
- extractFeatures, recommend,
7
- } from '@opennodes/core';
8
- import { resolveTxt } from 'node:dns/promises';
9
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
10
- import { fileURLToPath } from 'node:url';
11
- import { generateKeypair, keypairFromPem } from '@opennodes/core';
12
- import { createStore } from './store.js';
13
- import { runStageA } from './probes.js';
14
- import { runStageB, captureFingerprints } from './stageb.js';
15
- import { startStageC } from './stagec.js';
16
- import { startReimport } from './reimport.js';
17
- import { makeSafeFetch } from './safefetch.js';
18
- import { searchOfferings, collectOfferings, RANK_COMPONENTS } from './search.js';
19
- import { mcpRoutes } from './mcp.js';
20
- import { exportRoutes } from './export.js';
21
- import {
22
- importModelsDev, importHuggingFace, importOpenRouter,
23
- importOllamaLibrary, parseOllamaLibrary,
24
- HF_ROUTER_URL, HF_HUB_URL, OPENROUTER_URL, OLLAMA_LIBRARY_URL,
25
- } from './importers.js';
26
-
27
- export async function startRegistry({
28
- port = 0, host = '127.0.0.1', dbPath = ':memory:',
29
- allowPrivateTargets = false, // SSRF guard off only for dev/e2e
30
- dnsResolveTxt = resolveTxt, // injectable for tests
31
- stageBCapacity = {}, // e.g. { durationMs, steps } — shortened in tests
32
- stageC = null, // { livenessIntervalMs, blindIntervalMs, failThreshold } to enable
33
- reimport = null, // { intervalMs, sources } to keep imported catalogs fresh
34
- adminToken = null, // bearer for admin routes; without it, loopback-only
35
- keyPath = null, // PEM file for a persistent signing identity; null = ephemeral
36
- } = {}) {
37
- const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
38
- const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
39
- const webApp = readFileSync(fileURLToPath(new URL('./web/index.html', import.meta.url)), 'utf8');
40
- let registryKeys;
41
- if (keyPath && existsSync(keyPath)) {
42
- registryKeys = keypairFromPem(readFileSync(keyPath, 'utf8'));
43
- } else {
44
- registryKeys = generateKeypair();
45
- if (keyPath) writeFileSync(keyPath, registryKeys.privatePem, { mode: 0o600 });
46
- }
47
-
48
- const adminOk = (req, res) => {
49
- if (adminToken) {
50
- if (req.headers.authorization === `Bearer ${adminToken}`) return true;
51
- problem(res, 401, 'unauthorized', 'admin route requires bearer token');
52
- return false;
53
- }
54
- const remote = req.socket.remoteAddress;
55
- if (['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remote)) return true;
56
- problem(res, 403, 'forbidden', 'admin route is loopback-only without adminToken');
57
- return false;
58
- };
59
-
60
- async function fetchCard(cardUrl) {
61
- const res = await safeFetch(cardUrl, {}, 10_000);
62
- if (!res.ok) throw new Error(`card fetch failed: ${res.status}`);
63
- return res.json();
64
- }
65
-
66
- async function verifySignatureFromOrigin(card, origin) {
67
- const res = await safeFetch(`${origin}/.well-known/jwks.json`, {}, 10_000);
68
- if (!res.ok) throw new Error('jwks fetch failed');
69
- const jwks = await res.json();
70
- const kid = card.signatures?.[0]?.kid;
71
- const jwk = jwks.keys.find((k) => !kid || k.kid === kid) ?? jwks.keys[0];
72
- if (!jwk) throw new Error('no key in jwks');
73
- verifyCardSignature(card, keyFromJwk(jwk));
74
- }
75
-
76
- /** node.id is reverse-DNS: org.opennodes.fixture -> fixture.opennodes.org */
77
- const domainForNodeId = (nodeId) => nodeId.split('.').reverse().join('.');
78
-
79
- async function challengeSatisfied(node, challenge) {
80
- try { // HTTP method
81
- const resp = await safeFetch(`${node.origin}/.well-known/onp-challenge/${challenge.token}`, {}, 10_000);
82
- if (resp.ok) return { ok: true, method: 'http' };
83
- } catch { /* fall through to DNS */ }
84
- try { // DNS TXT method: _onp-challenge.{domain} TXT "{token}"
85
- const records = await dnsResolveTxt(`_onp-challenge.${domainForNodeId(node.id)}`);
86
- if (records.flat().includes(challenge.token)) return { ok: true, method: 'dns' };
87
- } catch { /* neither */ }
88
- return { ok: false };
89
- }
90
-
91
- async function indexCard(nodeId, cardUrl) {
92
- const card = await fetchCard(cardUrl);
93
- const errors = validateCard(card);
94
- if (errors.length) throw new Error('card invalid: ' + errors.slice(0, 5).join('; '));
95
- const origin = new URL(cardUrl).origin;
96
- if (card.node.id !== nodeId) throw new Error(`card node.id ${card.node.id} != registered ${nodeId}`);
97
- let sigOk = false;
98
- try { await verifySignatureFromOrigin(card, origin); sigOk = true; } catch { /* accepted but labeled */ }
99
- await store.saveRevision(nodeId, card.revision, card, sigOk);
100
- return { card, sigOk };
101
- }
102
-
103
- const app = createApp([
104
- ['POST', '/v0/nodes', async (req, res) => {
105
- const { card_url } = await readJson(req);
106
- if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
107
- let card;
108
- try { card = await fetchCard(card_url); } catch (err) { return problem(res, 422, 'card-unfetchable', String(err.message)); }
109
- const errors = validateCard(card);
110
- if (errors.length) return problem(res, 422, 'card-invalid', errors.slice(0, 10).join('; '));
111
-
112
- const nodeId = card.node.id;
113
- const origin = new URL(card_url).origin;
114
- await store.upsertNode(nodeId, origin, card_url, 'challenged');
115
- await store.setState(nodeId, 'challenged', 'registration received');
116
- const token = randomUUID();
117
- await store.setChallenge(nodeId, token);
118
- sendJson(res, 202, {
119
- node_id: nodeId,
120
- state: 'challenged',
121
- challenge: {
122
- methods: {
123
- http: `${origin}/.well-known/onp-challenge/${token}`,
124
- dns: `_onp-challenge.${nodeId.split('.').slice(0, 2).reverse().join('.')} TXT "${token}"`,
125
- },
126
- token,
127
- verify: `/v0/nodes/${nodeId}/verify`,
128
- },
129
- });
130
- }],
131
-
132
- ['POST', '/v0/nodes/:id/verify', async (req, res, { params }) => {
133
- const node = await store.getNode(params.id);
134
- if (!node) return problem(res, 404, 'unknown-node', params.id);
135
- const challenge = await store.getChallenge(params.id);
136
- if (!challenge) return problem(res, 410, 'challenge-expired', 'register again');
137
- const check = await challengeSatisfied(node, challenge);
138
- if (!check.ok) return problem(res, 403, 'challenge-failed', 'neither HTTP nor DNS TXT challenge satisfied');
139
- let indexed;
140
- try { indexed = await indexCard(params.id, node.card_url); }
141
- catch (err) { return problem(res, 422, 'card-invalid', String(err.message)); }
142
-
143
- await store.setState(params.id, 'indexed', `namespace verified via ${check.method}`);
144
- const stageA = await runStageA(store, params.id, indexed.card, { safeFetch });
145
- await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
146
- stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
147
- sendJson(res, 200, {
148
- node_id: params.id,
149
- state: (await store.getNode(params.id)).state,
150
- signature_verified: indexed.sigOk,
151
- stage_a: stageA,
152
- });
153
- }],
154
-
155
- ['PUT', '/v0/nodes/:id/ping', async (req, res, { params }) => {
156
- const node = await store.getNode(params.id);
157
- if (!node) return problem(res, 404, 'unknown-node', params.id);
158
- try {
159
- const { card } = await indexCard(params.id, node.card_url);
160
- const stageA = await runStageA(store, params.id, card, { safeFetch });
161
- sendJson(res, 200, { node_id: params.id, revision: card.revision, stage_a_ok: stageA.ok });
162
- } catch (err) {
163
- problem(res, 422, 'refresh-failed', String(err.message));
164
- }
165
- }],
166
-
167
- // Stage B admission: identity + context + capacity; promotes community -> verified,
168
- // or marks the node disputed on an identity failure (DS-ADM-02..04).
169
- ['POST', '/v0/nodes/:id/admit', async (req, res, { params }) => {
170
- const node = await store.getNode(params.id);
171
- if (!node) return problem(res, 404, 'unknown-node', params.id);
172
- if (node.state !== 'community' && node.state !== 'verified') {
173
- return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
174
- }
175
- const card = await store.latestCard(params.id);
176
- const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
177
- if (stageB.identityFailed) {
178
- await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
179
- } else if (stageB.ok) {
180
- await store.setState(params.id, 'verified', 'Stage B passed');
181
- }
182
- sendJson(res, 200, { node_id: params.id, state: (await store.getNode(params.id)).state, stage_b: stageB });
183
- }],
184
-
185
- // Operator-trusted reference capture (admin surface, MVP: unauthenticated local only):
186
- // runs the fingerprint battery against a KNOWN-GOOD deployment and stores references.
187
- ['POST', '/v0/fingerprints/capture', async (req, res) => {
188
- if (!adminOk(req, res)) return;
189
- const { node_id, offering_id } = await readJson(req);
190
- const card = await store.latestCard(node_id);
191
- const offering = card?.offerings.find((o) => o.offering_id === offering_id);
192
- if (!offering) return problem(res, 404, 'unknown-offering', `${node_id}/${offering_id}`);
193
- const refs = await captureFingerprints(safeFetch, card, offering);
194
- await store.addFingerprints(offering.model.artifact, refs);
195
- sendJson(res, 200, { artifact: offering.model.artifact, references: refs.length });
196
- }],
197
-
198
- // Node directory: every listed node with tier, source, offering count, availability.
199
- ['GET', '/v0/nodes', async (req, res) => {
200
- const out = [];
201
- for (const node of await store.allNodes()) {
202
- if (['delisted'].includes(node.state)) continue;
203
- const card = await store.latestCard(node.id);
204
- const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
205
- out.push({
206
- id: node.id,
207
- state: node.state,
208
- tier: node.state === 'indexed' ? 'unverified' : node.state,
209
- source: node.source ?? 'registration',
210
- institutional: Boolean(node.institutional),
211
- updated_at: node.updated_at,
212
- name: card?.node?.name ?? node.id,
213
- operator: card?.node?.operator?.name ?? null,
214
- country: card?.node?.operator?.country ?? null,
215
- regions: card?.node?.regions ?? [],
216
- offerings: card?.offerings?.length ?? 0,
217
- availability,
218
- });
219
- }
220
- out.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''));
221
- sendJson(res, 200, { nodes: out });
222
- }],
223
-
224
- // Freshness feed: recent listing events (registrations, tier changes, suspensions),
225
- // newest first, each linked to its provenance reason.
226
- ['GET', '/v0/feed', async (req, res, { query }) => {
227
- const limit = Math.min(Number(query.get('limit') ?? 50), 200);
228
- const events = (await store.recentTransitions(limit)).map((t) => ({
229
- at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
230
- reason: t.reason, source: t.source ?? 'registration',
231
- }));
232
- sendJson(res, 200, { events });
233
- }],
234
-
235
- ['GET', '/v0/nodes/:id', async (req, res, { params }) => {
236
- const node = await store.getNode(params.id);
237
- if (!node) return problem(res, 404, 'unknown-node', params.id);
238
- sendJson(res, 200, {
239
- ...node,
240
- card: await store.latestCard(params.id),
241
- probes: (await store.probesFor(params.id)).slice(0, 20),
242
- history: await store.transitionsFor(params.id),
243
- });
244
- }],
245
-
246
- ['GET', '/v0/offerings', async (req, res, { query }) => {
247
- sendJson(res, 200, { offerings: await searchOfferings(store, query) });
248
- }],
249
-
250
- ['GET', '/v0/offerings/:nodeId/:offeringId', async (req, res, { params }) => {
251
- const all = await collectOfferings(store);
252
- const found = all.find((o) => o.node_id === params.nodeId && o.offering_id === params.offeringId);
253
- if (!found) return problem(res, 404, 'unknown-offering', `${params.nodeId}/${params.offeringId}`);
254
- sendJson(res, 200, found);
255
- }],
256
-
257
- ['POST', '/v0/estimate', async (req, res) => {
258
- const { steps } = await readJson(req);
259
- if (!Array.isArray(steps) || steps.length === 0) return problem(res, 400, 'missing-steps', 'body must be {"steps": [...]}');
260
- const all = await collectOfferings(store);
261
- const out = [];
262
- let min = 0, max = 0;
263
- let currency = 'USD';
264
- for (const step of steps) {
265
- let offering;
266
- if (step.offering) {
267
- const [nodeId, offeringId] = step.offering.split('/');
268
- offering = all.find((o) => o.node_id === nodeId && o.offering_id === offeringId);
269
- if (!offering) return problem(res, 404, 'unknown-offering', step.offering);
270
- } else if (step.select) {
271
- const query = new URLSearchParams(step.select);
272
- const results = await searchOfferings(store, query);
273
- if (!results.length) return problem(res, 404, 'no-offering-matches', JSON.stringify(step.select));
274
- offering = results[0];
275
- } else {
276
- return problem(res, 400, 'bad-step', 'each step needs "offering" or "select"');
277
- }
278
- const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
279
- currency = offering.pricing.currency;
280
- min += bounds.min; max += bounds.max;
281
- out.push({
282
- offering: `${offering.node_id}/${offering.offering_id}`,
283
- card_revision: offering.card_revision,
284
- cost: { currency: offering.pricing.currency, ...bounds },
285
- });
286
- }
287
- sendJson(res, 200, { total: { currency, min: round6(min), max: round6(max) }, steps: out });
288
- }],
289
-
290
- // ONP-3 §5b: task → ranked offerings with estimate + explanation. Accepts either a
291
- // task description (features extracted here) or pre-extracted `features` so a
292
- // client never has to send its prompt to the registry.
293
- ['POST', '/v0/recommend', async (req, res) => {
294
- const body = await readJson(req, 1_000_000);
295
- const features = body.features ?? (body.task != null
296
- ? extractFeatures(String(body.task), { attachments: body.attachments ?? [], est_output_tokens: body.est_output_tokens ?? null, messages: body.messages ?? [] })
297
- : null);
298
- if (!features) return problem(res, 400, 'missing-task', 'body must carry "task" (text) or "features" (from extractFeatures)');
299
- const all = await collectOfferings(store);
300
- sendJson(res, 200, recommend(all, features, body.policy ?? {}));
301
- }],
302
-
303
- // Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
304
- ['POST', '/v0/import/models-dev', async (req, res) => {
305
- if (!adminOk(req, res)) return;
306
- const body = await readJson(req, 50_000_000);
307
- let catalog = body.catalog;
308
- if (!catalog && body.url) {
309
- const resp = await safeFetch(body.url, {}, 30_000);
310
- catalog = await resp.json();
311
- }
312
- if (!catalog) return problem(res, 400, 'missing-catalog', 'body must carry {catalog} or {url}');
313
- sendJson(res, 200, await importModelsDev(store, catalog));
314
- }],
315
-
316
- // HF Inference Providers importer (admin): inline {router, hub}, or fetch the live APIs.
317
- ['POST', '/v0/import/huggingface', async (req, res) => {
318
- if (!adminOk(req, res)) return;
319
- const body = await readJson(req, 100_000_000);
320
- let { router, hub } = body;
321
- if (!router) {
322
- router = await (await safeFetch(HF_ROUTER_URL, {}, 60_000)).json();
323
- try { hub = await (await safeFetch(HF_HUB_URL, {}, 60_000)).json(); }
324
- catch { hub = []; } // hub enrichment is optional
325
- }
326
- sendJson(res, 200, await importHuggingFace(store, router, hub ?? []));
327
- }],
328
-
329
- // Ollama library importer (admin): inline {entries} or {html}, or fetch the live page.
330
- ['POST', '/v0/import/ollama', async (req, res) => {
331
- if (!adminOk(req, res)) return;
332
- const body = await readJson(req, 50_000_000);
333
- let entries = body.entries;
334
- if (!entries) {
335
- const html = body.html ?? await (await safeFetch(OLLAMA_LIBRARY_URL, {}, 60_000)).text();
336
- entries = parseOllamaLibrary(html);
337
- }
338
- sendJson(res, 200, await importOllamaLibrary(store, entries));
339
- }],
340
-
341
- // OpenRouter catalog importer (admin): inline {catalog}, or fetch the live API.
342
- ['POST', '/v0/import/openrouter', async (req, res) => {
343
- if (!adminOk(req, res)) return;
344
- const body = await readJson(req, 100_000_000);
345
- const catalog = body.catalog ?? await (await safeFetch(OPENROUTER_URL, {}, 60_000)).json();
346
- sendJson(res, 200, await importOpenRouter(store, catalog));
347
- }],
348
-
349
- ['GET', '/v0/health', (req, res) => {
350
- sendJson(res, 200, {
351
- service: 'opennodes-registry',
352
- onp: ['0.1'],
353
- ranking: { components: RANK_COMPONENTS, note: 'weighted sum; sponsored placement not implemented' },
354
- peers: [],
355
- fees: { listing: 'free', settlement: 'not-operated' },
356
- mcp: '/mcp',
357
- webmcp: true, // the web app at / registers in-page tools via navigator.modelContext where supported
358
- export: '/v0/export',
359
- signing_key: { ...registryKeys.publicJwk, kid: 'registry-1' },
360
- stage_c: Boolean(stageC),
361
- });
362
- }],
363
-
364
- // The registry web app: OpenRouter-style discovery + ranking + node registration UI.
365
- ['GET', '/', (req, res) => {
366
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'max-age=300' });
367
- res.end(webApp);
368
- }],
369
-
370
- ...mcpRoutes(store),
371
- ...exportRoutes(store, registryKeys),
372
- ], { cors: true }); // public read API: third-party web clients may call it directly
373
-
374
- const stageCHandle = stageC ? startStageC(store, { safeFetch, ...stageC }) : null;
375
- const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
376
- const actualPort = await listen(app, port, host);
377
- return {
378
- port: actualPort,
379
- origin: `http://${host}:${actualPort}`,
380
- store,
381
- registryKeys,
382
- close: async () => {
383
- stageCHandle?.stop();
384
- reimportHandle?.stop();
385
- await closeApp(app);
386
- await store.close();
387
- },
388
- };
389
- }
390
-
391
- const round6 = (n) => Math.round(n * 1e6) / 1e6;
1
+ // OpenNodes Discovery Service — MVP registry server (REQ-001 subset).
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ createApp, listen, closeApp, sendJson, problem, readJson,
5
+ validateCard, verifyCardSignature, keyFromJwk, estimateBounds,
6
+ extractFeatures, recommend,
7
+ } from '@opennodes/core';
8
+ import { resolveTxt } from 'node:dns/promises';
9
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { generateKeypair, keypairFromPem } from '@opennodes/core';
12
+ import { createStore } from './store.js';
13
+ import { runStageA } from './probes.js';
14
+ import { runStageB, captureFingerprints } from './stageb.js';
15
+ import { startStageC } from './stagec.js';
16
+ import { startReimport } from './reimport.js';
17
+ import { makeSafeFetch } from './safefetch.js';
18
+ import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENTS } from './search.js';
19
+ import { mcpRoutes } from './mcp.js';
20
+ import { exportRoutes } from './export.js';
21
+ import {
22
+ importModelsDev, importHuggingFace, importOpenRouter,
23
+ importOllamaLibrary, parseOllamaLibrary,
24
+ HF_ROUTER_URL, HF_HUB_URL, OPENROUTER_URL, OLLAMA_LIBRARY_URL,
25
+ } from './importers.js';
26
+
27
+ export async function startRegistry({
28
+ port = 0, host = '127.0.0.1', dbPath = ':memory:',
29
+ allowPrivateTargets = false, // SSRF guard off only for dev/e2e
30
+ dnsResolveTxt = resolveTxt, // injectable for tests
31
+ stageBCapacity = {}, // e.g. { durationMs, steps } — shortened in tests
32
+ stageC = null, // { livenessIntervalMs, blindIntervalMs, failThreshold } to enable
33
+ reimport = null, // { intervalMs, sources } to keep imported catalogs fresh
34
+ adminToken = null, // bearer for admin routes; without it, loopback-only
35
+ keyPath = null, // PEM file for a persistent signing identity; null = ephemeral
36
+ } = {}) {
37
+ const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
38
+ const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
39
+ const webApp = readFileSync(fileURLToPath(new URL('./web/index.html', import.meta.url)), 'utf8');
40
+ let registryKeys;
41
+ if (keyPath && existsSync(keyPath)) {
42
+ registryKeys = keypairFromPem(readFileSync(keyPath, 'utf8'));
43
+ } else {
44
+ registryKeys = generateKeypair();
45
+ if (keyPath) writeFileSync(keyPath, registryKeys.privatePem, { mode: 0o600 });
46
+ }
47
+
48
+ const adminOk = (req, res) => {
49
+ if (adminToken) {
50
+ if (req.headers.authorization === `Bearer ${adminToken}`) return true;
51
+ problem(res, 401, 'unauthorized', 'admin route requires bearer token');
52
+ return false;
53
+ }
54
+ const remote = req.socket.remoteAddress;
55
+ if (['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remote)) return true;
56
+ problem(res, 403, 'forbidden', 'admin route is loopback-only without adminToken');
57
+ return false;
58
+ };
59
+
60
+ async function fetchCard(cardUrl) {
61
+ const res = await safeFetch(cardUrl, {}, 10_000);
62
+ if (!res.ok) throw new Error(`card fetch failed: ${res.status}`);
63
+ return res.json();
64
+ }
65
+
66
+ async function verifySignatureFromOrigin(card, origin) {
67
+ const res = await safeFetch(`${origin}/.well-known/jwks.json`, {}, 10_000);
68
+ if (!res.ok) throw new Error('jwks fetch failed');
69
+ const jwks = await res.json();
70
+ const kid = card.signatures?.[0]?.kid;
71
+ const jwk = jwks.keys.find((k) => !kid || k.kid === kid) ?? jwks.keys[0];
72
+ if (!jwk) throw new Error('no key in jwks');
73
+ verifyCardSignature(card, keyFromJwk(jwk));
74
+ }
75
+
76
+ /** node.id is reverse-DNS: org.opennodes.fixture -> fixture.opennodes.org */
77
+ const domainForNodeId = (nodeId) => nodeId.split('.').reverse().join('.');
78
+
79
+ async function challengeSatisfied(node, challenge) {
80
+ try { // HTTP method
81
+ const resp = await safeFetch(`${node.origin}/.well-known/onp-challenge/${challenge.token}`, {}, 10_000);
82
+ if (resp.ok) return { ok: true, method: 'http' };
83
+ } catch { /* fall through to DNS */ }
84
+ try { // DNS TXT method: _onp-challenge.{domain} TXT "{token}"
85
+ const records = await dnsResolveTxt(`_onp-challenge.${domainForNodeId(node.id)}`);
86
+ if (records.flat().includes(challenge.token)) return { ok: true, method: 'dns' };
87
+ } catch { /* neither */ }
88
+ return { ok: false };
89
+ }
90
+
91
+ async function indexCard(nodeId, cardUrl) {
92
+ const card = await fetchCard(cardUrl);
93
+ const errors = validateCard(card);
94
+ if (errors.length) throw new Error('card invalid: ' + errors.slice(0, 5).join('; '));
95
+ const origin = new URL(cardUrl).origin;
96
+ if (card.node.id !== nodeId) throw new Error(`card node.id ${card.node.id} != registered ${nodeId}`);
97
+ let sigOk = false;
98
+ try { await verifySignatureFromOrigin(card, origin); sigOk = true; } catch { /* accepted but labeled */ }
99
+ await store.saveRevision(nodeId, card.revision, card, sigOk);
100
+ return { card, sigOk };
101
+ }
102
+
103
+ const app = createApp([
104
+ ['POST', '/v0/nodes', async (req, res) => {
105
+ const { card_url } = await readJson(req);
106
+ if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
107
+ let card;
108
+ try { card = await fetchCard(card_url); } catch (err) { return problem(res, 422, 'card-unfetchable', String(err.message)); }
109
+ const errors = validateCard(card);
110
+ if (errors.length) return problem(res, 422, 'card-invalid', errors.slice(0, 10).join('; '));
111
+
112
+ const nodeId = card.node.id;
113
+ const origin = new URL(card_url).origin;
114
+ await store.upsertNode(nodeId, origin, card_url, 'challenged');
115
+ await store.setState(nodeId, 'challenged', 'registration received');
116
+ const token = randomUUID();
117
+ await store.setChallenge(nodeId, token);
118
+ sendJson(res, 202, {
119
+ node_id: nodeId,
120
+ state: 'challenged',
121
+ challenge: {
122
+ methods: {
123
+ http: `${origin}/.well-known/onp-challenge/${token}`,
124
+ dns: `_onp-challenge.${nodeId.split('.').slice(0, 2).reverse().join('.')} TXT "${token}"`,
125
+ },
126
+ token,
127
+ verify: `/v0/nodes/${nodeId}/verify`,
128
+ },
129
+ });
130
+ }],
131
+
132
+ ['POST', '/v0/nodes/:id/verify', async (req, res, { params }) => {
133
+ const node = await store.getNode(params.id);
134
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
135
+ const challenge = await store.getChallenge(params.id);
136
+ if (!challenge) return problem(res, 410, 'challenge-expired', 'register again');
137
+ const check = await challengeSatisfied(node, challenge);
138
+ if (!check.ok) return problem(res, 403, 'challenge-failed', 'neither HTTP nor DNS TXT challenge satisfied');
139
+ let indexed;
140
+ try { indexed = await indexCard(params.id, node.card_url); }
141
+ catch (err) { return problem(res, 422, 'card-invalid', String(err.message)); }
142
+
143
+ await store.setState(params.id, 'indexed', `namespace verified via ${check.method}`);
144
+ const stageA = await runStageA(store, params.id, indexed.card, { safeFetch });
145
+ await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
146
+ stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
147
+ sendJson(res, 200, {
148
+ node_id: params.id,
149
+ state: (await store.getNode(params.id)).state,
150
+ signature_verified: indexed.sigOk,
151
+ stage_a: stageA,
152
+ });
153
+ }],
154
+
155
+ ['PUT', '/v0/nodes/:id/ping', async (req, res, { params }) => {
156
+ const node = await store.getNode(params.id);
157
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
158
+ try {
159
+ const { card } = await indexCard(params.id, node.card_url);
160
+ const stageA = await runStageA(store, params.id, card, { safeFetch });
161
+ sendJson(res, 200, { node_id: params.id, revision: card.revision, stage_a_ok: stageA.ok });
162
+ } catch (err) {
163
+ problem(res, 422, 'refresh-failed', String(err.message));
164
+ }
165
+ }],
166
+
167
+ // Stage B admission: identity + context + capacity; promotes community -> verified,
168
+ // or marks the node disputed on an identity failure (DS-ADM-02..04).
169
+ ['POST', '/v0/nodes/:id/admit', async (req, res, { params }) => {
170
+ const node = await store.getNode(params.id);
171
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
172
+ if (node.state !== 'community' && node.state !== 'verified') {
173
+ return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
174
+ }
175
+ const card = await store.latestCard(params.id);
176
+ const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
177
+ if (stageB.identityFailed) {
178
+ await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
179
+ } else if (stageB.ok) {
180
+ await store.setState(params.id, 'verified', 'Stage B passed');
181
+ }
182
+ sendJson(res, 200, { node_id: params.id, state: (await store.getNode(params.id)).state, stage_b: stageB });
183
+ }],
184
+
185
+ // Operator-trusted reference capture (admin surface, MVP: unauthenticated local only):
186
+ // runs the fingerprint battery against a KNOWN-GOOD deployment and stores references.
187
+ ['POST', '/v0/fingerprints/capture', async (req, res) => {
188
+ if (!adminOk(req, res)) return;
189
+ const { node_id, offering_id } = await readJson(req);
190
+ const card = await store.latestCard(node_id);
191
+ const offering = card?.offerings.find((o) => o.offering_id === offering_id);
192
+ if (!offering) return problem(res, 404, 'unknown-offering', `${node_id}/${offering_id}`);
193
+ const refs = await captureFingerprints(safeFetch, card, offering);
194
+ await store.addFingerprints(offering.model.artifact, refs);
195
+ sendJson(res, 200, { artifact: offering.model.artifact, references: refs.length });
196
+ }],
197
+
198
+ // Node directory: every listed node with tier, source, offering count, availability.
199
+ ['GET', '/v0/nodes', async (req, res) => {
200
+ const out = [];
201
+ for (const node of await store.allNodes()) {
202
+ if (['delisted'].includes(node.state)) continue;
203
+ const card = await store.latestCard(node.id);
204
+ const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
205
+ out.push({
206
+ id: node.id,
207
+ state: node.state,
208
+ tier: node.state === 'indexed' ? 'unverified' : node.state,
209
+ source: node.source ?? 'registration',
210
+ institutional: Boolean(node.institutional),
211
+ updated_at: node.updated_at,
212
+ name: card?.node?.name ?? node.id,
213
+ operator: card?.node?.operator?.name ?? null,
214
+ country: card?.node?.operator?.country ?? null,
215
+ regions: card?.node?.regions ?? [],
216
+ offerings: card?.offerings?.length ?? 0,
217
+ availability,
218
+ });
219
+ }
220
+ out.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''));
221
+ sendJson(res, 200, { nodes: out });
222
+ }],
223
+
224
+ // Freshness feed: recent listing events (registrations, tier changes, suspensions),
225
+ // newest first, each linked to its provenance reason.
226
+ ['GET', '/v0/feed', async (req, res, { query }) => {
227
+ const limit = Math.min(Number(query.get('limit') ?? 50), 200);
228
+ const events = (await store.recentTransitions(limit)).map((t) => ({
229
+ at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
230
+ reason: t.reason, source: t.source ?? 'registration',
231
+ }));
232
+ sendJson(res, 200, { events });
233
+ }],
234
+
235
+ ['GET', '/v0/nodes/:id', async (req, res, { params }) => {
236
+ const node = await store.getNode(params.id);
237
+ if (!node) return problem(res, 404, 'unknown-node', params.id);
238
+ sendJson(res, 200, {
239
+ ...node,
240
+ card: await store.latestCard(params.id),
241
+ probes: (await store.probesFor(params.id)).slice(0, 20),
242
+ history: await store.transitionsFor(params.id),
243
+ });
244
+ }],
245
+
246
+ ['GET', '/v0/offerings', async (req, res, { query }) => {
247
+ const { offerings, total } = await searchOfferingsPaged(store, query);
248
+ sendJson(res, 200, { offerings, total }); // total = matches before `limit`
249
+ }],
250
+
251
+ ['GET', '/v0/offerings/:nodeId/:offeringId', async (req, res, { params }) => {
252
+ const all = await collectOfferings(store);
253
+ const found = all.find((o) => o.node_id === params.nodeId && o.offering_id === params.offeringId);
254
+ if (!found) return problem(res, 404, 'unknown-offering', `${params.nodeId}/${params.offeringId}`);
255
+ sendJson(res, 200, found);
256
+ }],
257
+
258
+ ['POST', '/v0/estimate', async (req, res) => {
259
+ const { steps } = await readJson(req);
260
+ if (!Array.isArray(steps) || steps.length === 0) return problem(res, 400, 'missing-steps', 'body must be {"steps": [...]}');
261
+ const all = await collectOfferings(store);
262
+ const out = [];
263
+ let min = 0, max = 0;
264
+ let currency = 'USD';
265
+ for (const step of steps) {
266
+ let offering;
267
+ if (step.offering) {
268
+ const [nodeId, offeringId] = step.offering.split('/');
269
+ offering = all.find((o) => o.node_id === nodeId && o.offering_id === offeringId);
270
+ if (!offering) return problem(res, 404, 'unknown-offering', step.offering);
271
+ } else if (step.select) {
272
+ const query = new URLSearchParams(step.select);
273
+ const results = await searchOfferings(store, query);
274
+ if (!results.length) return problem(res, 404, 'no-offering-matches', JSON.stringify(step.select));
275
+ offering = results[0];
276
+ } else {
277
+ return problem(res, 400, 'bad-step', 'each step needs "offering" or "select"');
278
+ }
279
+ const bounds = estimateBounds(offering.pricing, step.est_input_tokens ?? 0, step.est_output_tokens ?? 0);
280
+ currency = offering.pricing.currency;
281
+ min += bounds.min; max += bounds.max;
282
+ out.push({
283
+ offering: `${offering.node_id}/${offering.offering_id}`,
284
+ card_revision: offering.card_revision,
285
+ cost: { currency: offering.pricing.currency, ...bounds },
286
+ });
287
+ }
288
+ sendJson(res, 200, { total: { currency, min: round6(min), max: round6(max) }, steps: out });
289
+ }],
290
+
291
+ // ONP-3 §5b: task → ranked offerings with estimate + explanation. Accepts either a
292
+ // task description (features extracted here) or pre-extracted `features` so a
293
+ // client never has to send its prompt to the registry.
294
+ ['POST', '/v0/recommend', async (req, res) => {
295
+ const body = await readJson(req, 1_000_000);
296
+ const features = body.features ?? (body.task != null
297
+ ? extractFeatures(String(body.task), { attachments: body.attachments ?? [], est_output_tokens: body.est_output_tokens ?? null, messages: body.messages ?? [] })
298
+ : null);
299
+ if (!features) return problem(res, 400, 'missing-task', 'body must carry "task" (text) or "features" (from extractFeatures)');
300
+ const all = await collectOfferings(store);
301
+ sendJson(res, 200, recommend(all, features, body.policy ?? {}));
302
+ }],
303
+
304
+ // Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
305
+ ['POST', '/v0/import/models-dev', async (req, res) => {
306
+ if (!adminOk(req, res)) return;
307
+ const body = await readJson(req, 50_000_000);
308
+ let catalog = body.catalog;
309
+ if (!catalog && body.url) {
310
+ const resp = await safeFetch(body.url, {}, 30_000);
311
+ catalog = await resp.json();
312
+ }
313
+ if (!catalog) return problem(res, 400, 'missing-catalog', 'body must carry {catalog} or {url}');
314
+ sendJson(res, 200, await importModelsDev(store, catalog));
315
+ }],
316
+
317
+ // HF Inference Providers importer (admin): inline {router, hub}, or fetch the live APIs.
318
+ ['POST', '/v0/import/huggingface', async (req, res) => {
319
+ if (!adminOk(req, res)) return;
320
+ const body = await readJson(req, 100_000_000);
321
+ let { router, hub } = body;
322
+ if (!router) {
323
+ router = await (await safeFetch(HF_ROUTER_URL, {}, 60_000)).json();
324
+ try { hub = await (await safeFetch(HF_HUB_URL, {}, 60_000)).json(); }
325
+ catch { hub = []; } // hub enrichment is optional
326
+ }
327
+ sendJson(res, 200, await importHuggingFace(store, router, hub ?? []));
328
+ }],
329
+
330
+ // Ollama library importer (admin): inline {entries} or {html}, or fetch the live page.
331
+ ['POST', '/v0/import/ollama', async (req, res) => {
332
+ if (!adminOk(req, res)) return;
333
+ const body = await readJson(req, 50_000_000);
334
+ let entries = body.entries;
335
+ if (!entries) {
336
+ const html = body.html ?? await (await safeFetch(OLLAMA_LIBRARY_URL, {}, 60_000)).text();
337
+ entries = parseOllamaLibrary(html);
338
+ }
339
+ sendJson(res, 200, await importOllamaLibrary(store, entries));
340
+ }],
341
+
342
+ // OpenRouter catalog importer (admin): inline {catalog}, or fetch the live API.
343
+ ['POST', '/v0/import/openrouter', async (req, res) => {
344
+ if (!adminOk(req, res)) return;
345
+ const body = await readJson(req, 100_000_000);
346
+ const catalog = body.catalog ?? await (await safeFetch(OPENROUTER_URL, {}, 60_000)).json();
347
+ sendJson(res, 200, await importOpenRouter(store, catalog));
348
+ }],
349
+
350
+ ['GET', '/v0/health', (req, res) => {
351
+ sendJson(res, 200, {
352
+ service: 'opennodes-registry',
353
+ onp: ['0.1'],
354
+ ranking: { components: RANK_COMPONENTS, note: 'weighted sum; sponsored placement not implemented' },
355
+ peers: [],
356
+ fees: { listing: 'free', settlement: 'not-operated' },
357
+ mcp: '/mcp',
358
+ webmcp: true, // the web app at / registers in-page tools via navigator.modelContext where supported
359
+ export: '/v0/export',
360
+ signing_key: { ...registryKeys.publicJwk, kid: 'registry-1' },
361
+ stage_c: Boolean(stageC),
362
+ });
363
+ }],
364
+
365
+ // The registry web app: OpenRouter-style discovery + ranking + node registration UI.
366
+ ['GET', '/', (req, res) => {
367
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'max-age=300' });
368
+ res.end(webApp);
369
+ }],
370
+
371
+ ...mcpRoutes(store),
372
+ ...exportRoutes(store, registryKeys),
373
+ ], { cors: true }); // public read API: third-party web clients may call it directly
374
+
375
+ const stageCHandle = stageC ? startStageC(store, { safeFetch, ...stageC }) : null;
376
+ const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
377
+ const actualPort = await listen(app, port, host);
378
+ return {
379
+ port: actualPort,
380
+ origin: `http://${host}:${actualPort}`,
381
+ store,
382
+ registryKeys,
383
+ close: async () => {
384
+ stageCHandle?.stop();
385
+ reimportHandle?.stop();
386
+ await closeApp(app);
387
+ await store.close();
388
+ },
389
+ };
390
+ }
391
+
392
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;