@opennodes/registry 0.1.3 → 0.1.5
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/bin.js +3 -0
- package/package.json +1 -1
- package/src/namespace.js +39 -0
- package/src/server.js +41 -5
- package/src/web/index.html +29 -7
package/bin.js
CHANGED
|
@@ -10,6 +10,9 @@ const registry = await startRegistry({
|
|
|
10
10
|
// Persistent signing identity: explicit path, or derived from a file db; pg deployments set ONP_REGISTRY_KEY.
|
|
11
11
|
keyPath: process.env.ONP_REGISTRY_KEY ?? (persistent ? `${dbPath}.key.pem` : (dbPath.startsWith('postgres') ? 'onp-registry-key.pem' : null)),
|
|
12
12
|
allowPrivateTargets: process.env.ONP_ALLOW_PRIVATE === '1', // dev only: permit localhost nodes
|
|
13
|
+
registerPerHour: Number(process.env.ONP_REGISTER_PER_HOUR ?? 30), // per client address
|
|
14
|
+
admitCooldownMs: Number(process.env.ONP_ADMIT_COOLDOWN_MS ?? 30 * 60_000), // Stage B per node
|
|
15
|
+
trustProxy: process.env.ONP_TRUST_PROXY === '1', // behind Caddy / a CDN
|
|
13
16
|
adminToken: process.env.ONP_ADMIN_TOKEN ?? null,
|
|
14
17
|
stageC: process.env.ONP_STAGE_C === '0' ? null : {
|
|
15
18
|
livenessIntervalMs: Number(process.env.ONP_LIVENESS_MS ?? 300_000),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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/namespace.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Namespace binding (ONP-3 §4): a node id is a reverse-DNS name, and only a host that
|
|
2
|
+
// controls that DNS name may register it. Without this an HTTP challenge proves control of
|
|
3
|
+
// *some* server, not of the namespace being claimed.
|
|
4
|
+
|
|
5
|
+
/** org.example.ai.box -> box.ai.example.org */
|
|
6
|
+
export const domainForNodeId = (nodeId) => String(nodeId).split('.').reverse().join('.').toLowerCase();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when `host` may serve the card and HTTP challenge for `nodeId`: the id's domain is the
|
|
10
|
+
* host itself or a name beneath it (the owner of example.org controls org.example.*).
|
|
11
|
+
* A *child* host never controls its parent's namespace (alice.github.io cannot claim io.github).
|
|
12
|
+
*/
|
|
13
|
+
export function hostControlsNamespace(host, nodeId) {
|
|
14
|
+
const h = String(host).toLowerCase().replace(/\.$/, '');
|
|
15
|
+
const domain = domainForNodeId(nodeId);
|
|
16
|
+
if (!h.includes('.')) return false; // a bare TLD / single label controls nothing
|
|
17
|
+
return domain === h || domain.endsWith('.' + h);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Loopback / private literal hosts: only acceptable in dev mode (allowPrivateTargets). */
|
|
21
|
+
export function isPrivateHost(host) {
|
|
22
|
+
const h = String(host).toLowerCase().replace(/^\[|\]$/g, '');
|
|
23
|
+
return h === 'localhost' || h === '::1' || /^127\./.test(h) || /^10\./.test(h)
|
|
24
|
+
|| /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fixed-window in-memory rate limiter keyed by client address. limit <= 0 disables it. */
|
|
28
|
+
export function makeRateLimiter(limit, windowMs = 3_600_000) {
|
|
29
|
+
const hits = new Map();
|
|
30
|
+
return (key) => {
|
|
31
|
+
if (!(limit > 0)) return true;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const rec = hits.get(key);
|
|
34
|
+
if (!rec || now - rec.start > windowMs) { hits.set(key, { start: now, n: 1 }); return true; }
|
|
35
|
+
rec.n += 1;
|
|
36
|
+
if (hits.size > 10_000) for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
|
37
|
+
return rec.n <= limit;
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { startReimport } from './reimport.js';
|
|
|
17
17
|
import { makeSafeFetch } from './safefetch.js';
|
|
18
18
|
import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENTS } from './search.js';
|
|
19
19
|
import { mcpRoutes } from './mcp.js';
|
|
20
|
+
import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
|
|
20
21
|
import { exportRoutes } from './export.js';
|
|
21
22
|
import {
|
|
22
23
|
importModelsDev, importHuggingFace, importOpenRouter,
|
|
@@ -33,6 +34,10 @@ export async function startRegistry({
|
|
|
33
34
|
reimport = null, // { intervalMs, sources } to keep imported catalogs fresh
|
|
34
35
|
adminToken = null, // bearer for admin routes; without it, loopback-only
|
|
35
36
|
keyPath = null, // PEM file for a persistent signing identity; null = ephemeral
|
|
37
|
+
enforceNamespace = null, // null = enforce except for private hosts in dev mode; true/false to force
|
|
38
|
+
registerPerHour = 0, // per-client cap on register/verify calls (0 = unlimited; set in production)
|
|
39
|
+
admitCooldownMs = 0, // minimum gap between Stage B runs per node (0 = none; set in production)
|
|
40
|
+
trustProxy = false, // read the client address from X-Forwarded-For (behind Caddy / a CDN)
|
|
36
41
|
} = {}) {
|
|
37
42
|
const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
|
|
38
43
|
const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
|
|
@@ -76,10 +81,23 @@ export async function startRegistry({
|
|
|
76
81
|
/** node.id is reverse-DNS: org.opennodes.fixture -> fixture.opennodes.org */
|
|
77
82
|
const domainForNodeId = (nodeId) => nodeId.split('.').reverse().join('.');
|
|
78
83
|
|
|
84
|
+
/** May `originUrl`'s host register `nodeId`? (ONP-3 §4: the host must control the id's DNS namespace.) */
|
|
85
|
+
const namespaceOk = (originUrl, nodeId) => {
|
|
86
|
+
const host = new URL(originUrl).hostname;
|
|
87
|
+
if (enforceNamespace === false) return true;
|
|
88
|
+
if (enforceNamespace !== true && allowPrivateTargets && isPrivateHost(host)) return true; // dev / e2e on loopback
|
|
89
|
+
return hostControlsNamespace(host, nodeId);
|
|
90
|
+
};
|
|
91
|
+
const registerLimiter = makeRateLimiter(registerPerHour);
|
|
92
|
+
const clientOf = (req) => (trustProxy && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress || 'unknown';
|
|
93
|
+
const isAdmin = (req) => Boolean(adminToken) && req.headers.authorization === `Bearer ${adminToken}`;
|
|
94
|
+
|
|
79
95
|
async function challengeSatisfied(node, challenge) {
|
|
80
|
-
try { // HTTP method
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
try { // HTTP method — only from a host that controls the id's namespace
|
|
97
|
+
if (namespaceOk(node.origin, node.id)) {
|
|
98
|
+
const resp = await safeFetch(`${node.origin}/.well-known/onp-challenge/${challenge.token}`, {}, 10_000);
|
|
99
|
+
if (resp.ok) return { ok: true, method: 'http' };
|
|
100
|
+
}
|
|
83
101
|
} catch { /* fall through to DNS */ }
|
|
84
102
|
try { // DNS TXT method: _onp-challenge.{domain} TXT "{token}"
|
|
85
103
|
const records = await dnsResolveTxt(`_onp-challenge.${domainForNodeId(node.id)}`);
|
|
@@ -118,6 +136,7 @@ export async function startRegistry({
|
|
|
118
136
|
|
|
119
137
|
const app = createApp([
|
|
120
138
|
['POST', '/v0/nodes', async (req, res) => {
|
|
139
|
+
if (!registerLimiter(clientOf(req))) return problem(res, 429, 'rate-limited', 'too many registration attempts from this address; try again later');
|
|
121
140
|
const { card_url } = await readJson(req);
|
|
122
141
|
if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
|
|
123
142
|
let card;
|
|
@@ -127,13 +146,22 @@ export async function startRegistry({
|
|
|
127
146
|
|
|
128
147
|
const nodeId = card.node.id;
|
|
129
148
|
const origin = new URL(card_url).origin;
|
|
149
|
+
// The card must be served by a host that controls the claimed id (reverse-DNS). Otherwise anyone
|
|
150
|
+
// could host a card naming someone else's node and pass the HTTP challenge on their own server.
|
|
151
|
+
if (!namespaceOk(origin, nodeId)) {
|
|
152
|
+
return problem(res, 403, 'namespace-mismatch',
|
|
153
|
+
`node id ${nodeId} maps to ${domainForNodeId(nodeId)}; its card must be served from that host or a parent domain, not ${new URL(origin).hostname}`);
|
|
154
|
+
}
|
|
155
|
+
const existing = await store.getNode(nodeId);
|
|
156
|
+
const listed = existing && !['challenged', 'submitted'].includes(existing.state) && existing.source !== 'import';
|
|
130
157
|
await store.upsertNode(nodeId, origin, card_url, 'challenged');
|
|
131
|
-
|
|
158
|
+
// Re-registration of a listed node issues a fresh challenge but never delists it: state moves at /verify.
|
|
159
|
+
if (!listed) await store.setState(nodeId, 'challenged', 'registration received');
|
|
132
160
|
const token = randomUUID();
|
|
133
161
|
await store.setChallenge(nodeId, token);
|
|
134
162
|
sendJson(res, 202, {
|
|
135
163
|
node_id: nodeId,
|
|
136
|
-
state: 'challenged',
|
|
164
|
+
state: listed ? existing.state : 'challenged',
|
|
137
165
|
challenge: {
|
|
138
166
|
methods: {
|
|
139
167
|
http: `${origin}/.well-known/onp-challenge/${token}`,
|
|
@@ -146,6 +174,7 @@ export async function startRegistry({
|
|
|
146
174
|
}],
|
|
147
175
|
|
|
148
176
|
['POST', '/v0/nodes/:id/verify', async (req, res, { params }) => {
|
|
177
|
+
if (!registerLimiter(clientOf(req))) return problem(res, 429, 'rate-limited', 'too many verification attempts from this address; try again later');
|
|
149
178
|
const node = await store.getNode(params.id);
|
|
150
179
|
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
151
180
|
const challenge = await store.getChallenge(params.id);
|
|
@@ -188,6 +217,13 @@ export async function startRegistry({
|
|
|
188
217
|
if (node.state !== 'community' && node.state !== 'verified') {
|
|
189
218
|
return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
|
|
190
219
|
}
|
|
220
|
+
if (admitCooldownMs > 0 && !isAdmin(req)) {
|
|
221
|
+
const lastB = (await store.probesFor(params.id)).find((p) => p.stage === 'B');
|
|
222
|
+
const age = lastB ? Date.now() - Date.parse(lastB.at) : Infinity;
|
|
223
|
+
if (age < admitCooldownMs) {
|
|
224
|
+
return problem(res, 429, 'admission-cooldown', `Stage B ran ${Math.round(age / 60000)} min ago; next run allowed in ${Math.ceil((admitCooldownMs - age) / 60000)} min`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
191
227
|
await refreshCard(params.id);
|
|
192
228
|
const card = await store.latestCard(params.id);
|
|
193
229
|
const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
|
package/src/web/index.html
CHANGED
|
@@ -983,11 +983,31 @@ async function loadChatTargets() {
|
|
|
983
983
|
$('#ch-modenote').textContent = MODE_NOTES[$('#ch-mode').value] ?? '';
|
|
984
984
|
if (!chat.offerings.length) {
|
|
985
985
|
try {
|
|
986
|
-
|
|
987
|
-
|
|
986
|
+
// Registered nodes answer straight from the browser with no key; imported catalog listings
|
|
987
|
+
// (OpenRouter, HF providers, models.dev) need the visitor's own key for that provider.
|
|
988
|
+
const [reg, all] = await Promise.all([
|
|
989
|
+
api('/v0/offerings?limit=60&modality=text&tier=community'),
|
|
990
|
+
api('/v0/offerings?limit=60&modality=text'),
|
|
991
|
+
]);
|
|
992
|
+
const key = (o) => o.node_id + '/' + o.offering_id;
|
|
993
|
+
const ready = reg.offerings.filter((o) => o.source !== 'import');
|
|
994
|
+
const seen = new Set(ready.map(key));
|
|
995
|
+
const rest = all.offerings.filter((o) => !seen.has(key(o)));
|
|
996
|
+
const local = rest.filter((o) => o.local); // Ollama library: runs on the visitor's own machine
|
|
997
|
+
const imports = rest.filter((o) => !o.local);
|
|
998
|
+
chat.offerings = [...ready, ...local, ...imports];
|
|
999
|
+
const opt = (o, i) => `<option value="${i}">${esc(o.model.name)} — ${esc(o.node_id)} — ${priceStr(o.pricing)}</option>`;
|
|
988
1000
|
$('#ch-add').innerHTML = '<option value="">+ Add a model…</option>'
|
|
989
|
-
+
|
|
1001
|
+
+ (ready.length ? `<optgroup label="Ready to use — no key needed (${ready.length})">${ready.map((o, i) => opt(o, i)).join('')}</optgroup>` : '')
|
|
1002
|
+
+ (local.length ? `<optgroup label="On your machine — needs a local Ollama with this model pulled">${local.map((o, i) => opt(o, ready.length + i)).join('')}</optgroup>` : '')
|
|
1003
|
+
+ (imports.length ? `<optgroup label="Imported catalogs — your own API key required">${imports.map((o, i) => opt(o, ready.length + local.length + i)).join('')}</optgroup>` : '')
|
|
990
1004
|
+ '<option value="custom">custom endpoint (local Ollama / any OpenAI server)</option>';
|
|
1005
|
+
if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff')) {
|
|
1006
|
+
chat.roster.push({ offering: ready[0] }); // a newcomer can just type
|
|
1007
|
+
renderRoster();
|
|
1008
|
+
const log = $('#ch-log');
|
|
1009
|
+
if (log && !chat.history.length) log.innerHTML = `<p class="sub" style="margin:0">Ready: <b>${esc(ready[0].model.name)}</b> on ${esc(ready[0].node_id)} (${esc(ready[0].tier)}, no key needed) is preselected — say something, or add more models on the right to compare.</p>`;
|
|
1010
|
+
}
|
|
991
1011
|
} catch { $('#ch-add').innerHTML = '<option value="custom">custom endpoint</option>'; }
|
|
992
1012
|
}
|
|
993
1013
|
await applyAdvisorHandoff();
|
|
@@ -1099,6 +1119,7 @@ $('#ch-judge').addEventListener('change', refreshEstimate);
|
|
|
1099
1119
|
function importKeyHint(offering, base, key) {
|
|
1100
1120
|
if (!offering || offering.source !== 'import' || key) return null;
|
|
1101
1121
|
const host = new URL(base).host;
|
|
1122
|
+
if (offering.local || /^(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(host)) return null; // your own machine: no key
|
|
1102
1123
|
const where = host.includes('huggingface') ? 'a Hugging Face token (huggingface.co/settings/tokens)'
|
|
1103
1124
|
: host.includes('openrouter') ? 'an OpenRouter API key (openrouter.ai/keys)' : `an API key for ${host}`;
|
|
1104
1125
|
return `This is an imported catalog listing served by ${host}: your browser calls it directly, which needs ${where} — paste it in the API key field (stored only in this browser). Or run it locally with BYOK: npx @opennodes/cli gateway --key ${host}=<key>. Registered OpenNodes nodes need no key.`;
|
|
@@ -1189,7 +1210,8 @@ async function callTargetStream(t, messages, onDelta, maxTokens = 1024) {
|
|
|
1189
1210
|
}
|
|
1190
1211
|
|
|
1191
1212
|
const metaLine = (r) => [r.ttft != null ? `ttft ${r.ttft}ms · ` + r.ms + 'ms total' : r.ms + 'ms', r.usage ? `${r.usage.prompt_tokens}+${r.usage.completion_tokens} tok` : null, r.receipt ? 'receipt ✓' : null].filter(Boolean).join(' · ');
|
|
1192
|
-
const corsHint =
|
|
1213
|
+
const corsHint = (err) => /failed to fetch|networkerror|load failed/i.test(String(err?.message))
|
|
1214
|
+
? ' — network/CORS error: the node must allow browser origins (local Ollama: set OLLAMA_ORIGINS)' : '';
|
|
1193
1215
|
|
|
1194
1216
|
function logEl(html) {
|
|
1195
1217
|
if ($('#ch-log').querySelector('p.sub')) $('#ch-log').innerHTML = '';
|
|
@@ -1239,7 +1261,7 @@ async function runSingle(text) {
|
|
|
1239
1261
|
} catch (err) {
|
|
1240
1262
|
chat.history.pop();
|
|
1241
1263
|
el.firstChild.textContent = '';
|
|
1242
|
-
el.querySelector('.mm').innerHTML = '<span class="err">' + esc(err.message) + '</span>' + corsHint;
|
|
1264
|
+
el.querySelector('.mm').innerHTML = '<span class="err">' + esc(err.message) + '</span>' + corsHint(err);
|
|
1243
1265
|
}
|
|
1244
1266
|
}
|
|
1245
1267
|
|
|
@@ -1259,7 +1281,7 @@ async function fanoutRound(labelPrefix, buildMessages, { remember = false } = {}
|
|
|
1259
1281
|
}
|
|
1260
1282
|
return { label: String.fromCharCode(65 + i), text: r.text, ok: true };
|
|
1261
1283
|
} catch (err) {
|
|
1262
|
-
cards[i].querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + '</span>';
|
|
1284
|
+
cards[i].querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint(err) + '</span>';
|
|
1263
1285
|
return { label: String.fromCharCode(65 + i), ok: false };
|
|
1264
1286
|
}
|
|
1265
1287
|
}));
|
|
@@ -1346,7 +1368,7 @@ async function runPipeline(text) {
|
|
|
1346
1368
|
el.querySelector('.ctext').textContent = r.text || '(empty)';
|
|
1347
1369
|
el.querySelector('.mm').textContent = metaLine(r);
|
|
1348
1370
|
} catch (err) {
|
|
1349
|
-
el.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint + ' — pipeline stopped</span>';
|
|
1371
|
+
el.querySelector('.ctext').innerHTML = '<span class="err">' + esc(err.message) + '</span><span class="prov">' + corsHint(err) + ' — pipeline stopped</span>';
|
|
1350
1372
|
return;
|
|
1351
1373
|
}
|
|
1352
1374
|
}
|