@opennodes/registry 0.1.21 → 0.1.23
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 +9 -0
- package/package.json +2 -2
- package/src/location.js +172 -0
- package/src/search.js +9 -1
- package/src/server.js +42 -0
- package/src/web/index.html +20 -0
package/bin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startRegistry } from './src/server.js';
|
|
3
|
+
import { parseSite } from './src/location.js';
|
|
3
4
|
|
|
4
5
|
const dbPath = process.env.ONP_DB ?? ':memory:'; // sqlite path/:memory: or postgres:// URL
|
|
5
6
|
const persistent = dbPath !== ':memory:' && !dbPath.startsWith('postgres');
|
|
@@ -18,6 +19,14 @@ const registry = await startRegistry({
|
|
|
18
19
|
livenessIntervalMs: Number(process.env.ONP_LIVENESS_MS ?? 300_000),
|
|
19
20
|
blindIntervalMs: Number(process.env.ONP_BLIND_MS ?? 3_600_000),
|
|
20
21
|
},
|
|
22
|
+
// Server-location checks: ONP_LOCATION=1, this registry's site, and optional remote probe agents
|
|
23
|
+
// ("FI Helsinki 60.17 24.93 https://host/onp-probe", comma-separated) sharing ONP_PROBE_AGENT_TOKEN.
|
|
24
|
+
location: process.env.ONP_LOCATION === '1' ? {
|
|
25
|
+
selfSite: parseSite(process.env.ONP_PROBE_SITE),
|
|
26
|
+
agents: (process.env.ONP_PROBE_AGENTS ?? '').split(',').map(parseSite).filter((a) => a?.url)
|
|
27
|
+
.map((a) => ({ ...a, token: process.env.ONP_PROBE_AGENT_TOKEN ?? null })),
|
|
28
|
+
intervalMs: Number(process.env.ONP_LOCATION_MS ?? 24 * 3600 * 1000),
|
|
29
|
+
} : null,
|
|
21
30
|
reimport: process.env.ONP_REIMPORT === '1' ? {
|
|
22
31
|
intervalMs: Number(process.env.ONP_REIMPORT_MS ?? 6 * 3600 * 1000),
|
|
23
32
|
sources: (process.env.ONP_REIMPORT_SOURCES ?? 'openrouter,huggingface').split(','),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@opennodes/core": "0.1.
|
|
46
|
+
"@opennodes/core": "0.1.7",
|
|
47
47
|
"pg": "^8.13.1"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/location.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Server-location checks (ONP-1 §6 threat 5, ONP-3 §6.5): where a node's API host actually is,
|
|
2
|
+
// compared with the operator's declared country. Two signals, both evidence rather than proof:
|
|
3
|
+
// (a) IP geolocation + network owner of the endpoint host (RIPE NCC RIPEstat, no key needed);
|
|
4
|
+
// CDN / anycast networks are reported as such, never as a location.
|
|
5
|
+
// (b) TCP connect round-trip times from the registry's own probe sites. Light in fibre covers
|
|
6
|
+
// about 100 km per millisecond of round trip, so a reply faster than physics allows for the
|
|
7
|
+
// geolocated point means that point is wrong; one far slower than expected from every site
|
|
8
|
+
// is a soft warning (congestion or a forwarding hop can also cause it).
|
|
9
|
+
// A front server that forwards requests elsewhere still defeats both; attested residency
|
|
10
|
+
// (an auditor's credential) is the strong guarantee, planned for Draft 0.2.
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import dns from 'node:dns/promises';
|
|
13
|
+
import { EEA_COUNTRIES } from '@opennodes/core';
|
|
14
|
+
import { isPrivateHost } from './namespace.js';
|
|
15
|
+
|
|
16
|
+
const RIPESTAT = 'https://stat.ripe.net/data';
|
|
17
|
+
const LOOKUP_TIMEOUT_MS = 30_000;
|
|
18
|
+
const KM_PER_MS_RTT = 100; // ~2/3 c in fibre, out and back
|
|
19
|
+
|
|
20
|
+
/** Networks whose addresses are anycast / CDN edges: their IP says nothing about where inference runs. */
|
|
21
|
+
export const CDN_ASNS = new Map([
|
|
22
|
+
[13335, 'Cloudflare'], [209242, 'Cloudflare'], [54113, 'Fastly'], [20940, 'Akamai'], [16625, 'Akamai'],
|
|
23
|
+
[16702, 'Akamai'], [32787, 'Akamai'], [60068, 'CDN77'], [22822, 'Edgio'], [15133, 'Edgecast'],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const EEA = new Set(EEA_COUNTRIES);
|
|
27
|
+
|
|
28
|
+
export function haversineKm(a, b) {
|
|
29
|
+
const rad = (d) => (d * Math.PI) / 180;
|
|
30
|
+
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
|
|
31
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
|
32
|
+
return 2 * 6371 * Math.asin(Math.sqrt(h));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Judge measured RTTs against a claimed point. vantages: [{site, lat, lon, rtt_ms}].
|
|
37
|
+
* 'too_fast' — some site measured less than physics allows for that point: the point is wrong.
|
|
38
|
+
* 'too_slow' — every site measured far more than the point would explain: likely farther away.
|
|
39
|
+
* 'consistent' otherwise; 'unknown' without a point or measurements.
|
|
40
|
+
*/
|
|
41
|
+
export function assessLatency(vantages, point) {
|
|
42
|
+
const measured = vantages.filter((v) => Number.isFinite(v.rtt_ms));
|
|
43
|
+
if (!point || !measured.length) return { verdict: 'unknown', sites: measured };
|
|
44
|
+
const sites = measured.map((v) => {
|
|
45
|
+
const km = Math.round(haversineKm(v, point));
|
|
46
|
+
const min_rtt_ms = Math.round((km / KM_PER_MS_RTT) * 10) / 10;
|
|
47
|
+
return { site: v.site, rtt_ms: v.rtt_ms, distance_km: km, min_rtt_ms, max_distance_km: Math.round(v.rtt_ms * KM_PER_MS_RTT) };
|
|
48
|
+
});
|
|
49
|
+
if (sites.some((s) => s.rtt_ms < s.min_rtt_ms * 0.9 - 1)) return { verdict: 'too_fast', sites };
|
|
50
|
+
if (sites.every((s) => s.rtt_ms > s.min_rtt_ms * 2.5 + 25)) return { verdict: 'too_slow', sites };
|
|
51
|
+
return { verdict: 'consistent', sites };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** match | same_region (both in the EEA) | mismatch | cdn | unknown */
|
|
55
|
+
export function countryVerdict(declared, server) {
|
|
56
|
+
if (server?.cdn) return 'cdn';
|
|
57
|
+
if (!declared || !server?.country) return 'unknown';
|
|
58
|
+
if (server.country === declared) return 'match';
|
|
59
|
+
if (EEA.has(server.country) && EEA.has(declared)) return 'same_region';
|
|
60
|
+
return 'mismatch';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function ripestat(fetchImpl, path, resource) {
|
|
64
|
+
const url = `${RIPESTAT}/${path}/data.json?resource=${encodeURIComponent(resource)}&sourceapp=opennodes-registry`;
|
|
65
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) });
|
|
66
|
+
if (!res.ok) throw new Error(`ripestat ${path} ${res.status}`);
|
|
67
|
+
return (await res.json()).data ?? {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Country, city, coordinates and network of one IP address. */
|
|
71
|
+
export async function lookupIp(ip, { fetchImpl = fetch, asHolders = new Map() } = {}) {
|
|
72
|
+
const [geo, netinfo] = await Promise.all([
|
|
73
|
+
ripestat(fetchImpl, 'maxmind-geo-lite', ip).catch(() => ({})),
|
|
74
|
+
ripestat(fetchImpl, 'network-info', ip).catch(() => ({})),
|
|
75
|
+
]);
|
|
76
|
+
const loc = (geo.located_resources ?? []).flatMap((r) => r.locations ?? [])
|
|
77
|
+
.sort((a, b) => (b.covered_percentage ?? 0) - (a.covered_percentage ?? 0))[0];
|
|
78
|
+
const asn = Number((netinfo.asns ?? [])[0]) || null;
|
|
79
|
+
let holder = asn ? (CDN_ASNS.get(asn) ?? asHolders.get(asn)) : null;
|
|
80
|
+
if (asn && !holder) {
|
|
81
|
+
holder = (await ripestat(fetchImpl, 'as-overview', `AS${asn}`).catch(() => ({}))).holder ?? null;
|
|
82
|
+
if (holder) asHolders.set(asn, holder);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ip,
|
|
86
|
+
country: loc?.country ? String(loc.country).toUpperCase() : null,
|
|
87
|
+
city: loc?.city || null,
|
|
88
|
+
lat: Number.isFinite(loc?.latitude) ? loc.latitude : null,
|
|
89
|
+
lon: Number.isFinite(loc?.longitude) ? loc.longitude : null,
|
|
90
|
+
asn, network: holder, prefix: netinfo.prefix ?? null,
|
|
91
|
+
cdn: asn ? CDN_ASNS.has(asn) : false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Median TCP connect time (ms) to host:443 from here. */
|
|
96
|
+
export async function measureRtt(host, { port = 443, attempts = 3, timeoutMs = 5000 } = {}) {
|
|
97
|
+
const times = [];
|
|
98
|
+
for (let i = 0; i < attempts; i++) {
|
|
99
|
+
const t = await new Promise((resolve) => {
|
|
100
|
+
const t0 = performance.now();
|
|
101
|
+
const socket = net.connect({ host, port, timeout: timeoutMs });
|
|
102
|
+
const done = (v) => { socket.destroy(); resolve(v); };
|
|
103
|
+
socket.once('connect', () => done(performance.now() - t0));
|
|
104
|
+
socket.once('timeout', () => done(null));
|
|
105
|
+
socket.once('error', () => done(null));
|
|
106
|
+
});
|
|
107
|
+
if (t !== null) times.push(t);
|
|
108
|
+
}
|
|
109
|
+
if (!times.length) return null;
|
|
110
|
+
times.sort((a, b) => a - b);
|
|
111
|
+
return Math.round(times[Math.floor(times.length / 2)] * 10) / 10;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Ask a remote probe agent (deploy/reference-node/probe-agent) for its RTT to host. */
|
|
115
|
+
export async function remoteRtt(agent, host, fetchImpl = fetch) {
|
|
116
|
+
const res = await fetchImpl(`${agent.url.replace(/\/$/, '')}/rtt`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json', ...(agent.token ? { authorization: `Bearer ${agent.token}` } : {}) },
|
|
119
|
+
body: JSON.stringify({ host }),
|
|
120
|
+
signal: AbortSignal.timeout(30_000),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) throw new Error(`probe agent ${res.status}`);
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
return { site: body.site ?? agent.site, lat: body.lat ?? agent.lat, lon: body.lon ?? agent.lon, rtt_ms: body.rtt_ms ?? null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check one node: resolve its API host, geolocate the first public IPv4, measure RTT from every
|
|
129
|
+
* probe site, and compare with the declared country.
|
|
130
|
+
* opts: { selfSite: {site, lat, lon}, agents: [{site, lat, lon, url, token}], fetchImpl, resolve, rtt }
|
|
131
|
+
*/
|
|
132
|
+
export async function checkNodeLocation(card, opts = {}) {
|
|
133
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
134
|
+
let host = null;
|
|
135
|
+
try { host = new URL(card?.endpoints?.openai).hostname; } catch { /* listing without an API */ }
|
|
136
|
+
const declared = card?.node?.operator?.country ? String(card.node.operator.country).toUpperCase() : null;
|
|
137
|
+
const out = { host, declared, checked_at: new Date().toISOString() };
|
|
138
|
+
if (!host) return { ...out, verdict: 'unknown', reason: 'no API endpoint' };
|
|
139
|
+
|
|
140
|
+
const ips = net.isIP(host) ? [host] : await (opts.resolve ?? ((h) => dns.resolve4(h)))(host).catch(() => []);
|
|
141
|
+
const ip = ips.find((a) => !isPrivateHost(a));
|
|
142
|
+
if (!ip) return { ...out, ips, verdict: 'unknown', reason: 'no public IPv4 address' };
|
|
143
|
+
|
|
144
|
+
const server = await lookupIp(ip, { fetchImpl, asHolders: opts.asHolders });
|
|
145
|
+
const vantages = [];
|
|
146
|
+
const rtt = opts.rtt ?? measureRtt;
|
|
147
|
+
if (opts.selfSite) vantages.push({ ...opts.selfSite, rtt_ms: await rtt(ip).catch(() => null) });
|
|
148
|
+
for (const agent of opts.agents ?? []) {
|
|
149
|
+
vantages.push(await remoteRtt(agent, ip, fetchImpl).catch(() => ({ site: agent.site, lat: agent.lat, lon: agent.lon, rtt_ms: null })));
|
|
150
|
+
}
|
|
151
|
+
// The point to test: the geolocated position unless it's a CDN edge.
|
|
152
|
+
const point = !server.cdn && server.lat !== null ? { lat: server.lat, lon: server.lon } : null;
|
|
153
|
+
const latency = assessLatency(vantages, point);
|
|
154
|
+
let verdict = countryVerdict(declared, server);
|
|
155
|
+
if (latency.verdict === 'too_fast' && verdict !== 'cdn') verdict = 'inconsistent'; // the geolocation itself is wrong
|
|
156
|
+
return {
|
|
157
|
+
...out, ips,
|
|
158
|
+
server: { country: server.country, city: server.city, asn: server.asn, network: server.network, cdn: server.cdn },
|
|
159
|
+
verdict,
|
|
160
|
+
latency,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse "FI Helsinki 60.17 24.93 https://host/onp-probe" (agents) or "DE Nuremberg 49.45 11.08" (self). */
|
|
165
|
+
export function parseSite(spec) {
|
|
166
|
+
if (!spec) return null;
|
|
167
|
+
const parts = String(spec).trim().split(/\s+/);
|
|
168
|
+
const [country, city, lat, lon, url] = parts;
|
|
169
|
+
const site = { site: `${country}-${city}`, lat: Number(lat), lon: Number(lon) };
|
|
170
|
+
if (!Number.isFinite(site.lat) || !Number.isFinite(site.lon)) return null;
|
|
171
|
+
return url ? { ...site, url } : site;
|
|
172
|
+
}
|
package/src/search.js
CHANGED
|
@@ -54,7 +54,14 @@ async function buildOfferings(store) {
|
|
|
54
54
|
const stageA = probes.filter((p) => p.stage === 'A');
|
|
55
55
|
const probeSuccess = stageA.length ? stageA.filter((p) => p.ok).length / stageA.length : 0;
|
|
56
56
|
const tier = node.state === 'indexed' ? 'unverified' : node.state;
|
|
57
|
-
const
|
|
57
|
+
const nodeObs = await store.getObservations(node.id, '_node');
|
|
58
|
+
const availability = nodeObs.availability ?? null;
|
|
59
|
+
const loc = nodeObs.location ?? null;
|
|
60
|
+
// Server-location check (location.js): what the registry found, next to what the operator declared.
|
|
61
|
+
const location = loc ? {
|
|
62
|
+
verdict: loc.verdict, server_country: loc.server?.country ?? null, network: loc.server?.network ?? null,
|
|
63
|
+
cdn: Boolean(loc.server?.cdn), latency: loc.latency?.verdict ?? 'unknown', checked_at: loc.checked_at,
|
|
64
|
+
} : null;
|
|
58
65
|
for (const offering of card.offerings) {
|
|
59
66
|
const obs = await store.getObservations(node.id, offering.offering_id);
|
|
60
67
|
// Measured values override claims (DS-ADM-03/04): cap the advertised context to
|
|
@@ -86,6 +93,7 @@ async function buildOfferings(store) {
|
|
|
86
93
|
country: card.node?.operator?.country ?? null,
|
|
87
94
|
regions: card.node?.regions ?? [],
|
|
88
95
|
basis: card.node?.operator?.country ? (node.source === 'import' ? 'inferred' : 'declared') : null,
|
|
96
|
+
location,
|
|
89
97
|
},
|
|
90
98
|
// Allocated hardware for THIS offering (offering-level override wins over the
|
|
91
99
|
// node-level description) — same artifact on different vendors runs on different
|
package/src/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mcpRoutes } from './mcp.js';
|
|
|
20
20
|
import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
|
|
21
21
|
import { exportRoutes } from './export.js';
|
|
22
22
|
import { collapseFlaps } from './feed.js';
|
|
23
|
+
import { checkNodeLocation } from './location.js';
|
|
23
24
|
import {
|
|
24
25
|
importModelsDev, importHuggingFace, importOpenRouter,
|
|
25
26
|
importOllamaLibrary, parseOllamaLibrary,
|
|
@@ -39,6 +40,7 @@ export async function startRegistry({
|
|
|
39
40
|
registerPerHour = 0, // per-client cap on register/verify calls (0 = unlimited; set in production)
|
|
40
41
|
admitCooldownMs = 0, // minimum gap between Stage B runs per node (0 = none; set in production)
|
|
41
42
|
trustProxy = false, // read the client address from X-Forwarded-For (behind Caddy / a CDN)
|
|
43
|
+
location = null, // { selfSite, agents, intervalMs, fetchImpl, resolve, rtt } to check server locations
|
|
42
44
|
} = {}) {
|
|
43
45
|
const store = watchStoreWrites(await createStore(dbPath)); // sqlite path/:memory: or postgres:// URL; writes refresh the cached catalog
|
|
44
46
|
const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
|
|
@@ -230,6 +232,7 @@ export async function startRegistry({
|
|
|
230
232
|
await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
|
|
231
233
|
stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
|
|
232
234
|
const claimed = stageA.ok ? await supersedeImports(params.id) : [];
|
|
235
|
+
if (location) locateNode(params.id).catch(() => {}); // background: never delays registration
|
|
233
236
|
sendJson(res, 200, {
|
|
234
237
|
node_id: params.id,
|
|
235
238
|
state: (await store.getNode(params.id)).state,
|
|
@@ -342,6 +345,7 @@ export async function startRegistry({
|
|
|
342
345
|
card: await store.latestCard(params.id),
|
|
343
346
|
probes: (await store.probesFor(params.id)).slice(0, 20),
|
|
344
347
|
history: await store.transitionsFor(params.id),
|
|
348
|
+
location: (await store.getObservations(params.id, '_node')).location ?? null,
|
|
345
349
|
});
|
|
346
350
|
}],
|
|
347
351
|
|
|
@@ -407,6 +411,14 @@ export async function startRegistry({
|
|
|
407
411
|
// Delist (admin): operator opt-out of an imported listing, or retiring a node. Search,
|
|
408
412
|
// the directory and Stage C skip delisted nodes, and re-imports never revive them.
|
|
409
413
|
// Body {reason}. A registered node returns by registering again; relist re-opens imports.
|
|
414
|
+
// Server-location check on demand (admin): geolocation + network + RTT from the probe sites.
|
|
415
|
+
['POST', '/v0/admin/nodes/:id/locate', async (req, res, { params }) => {
|
|
416
|
+
if (!adminOk(req, res)) return;
|
|
417
|
+
if (!location) return problem(res, 409, 'location-checks-disabled', 'start the registry with location checks enabled');
|
|
418
|
+
const node = await store.getNode(params.id);
|
|
419
|
+
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
420
|
+
sendJson(res, 200, await locateNode(node.id));
|
|
421
|
+
}],
|
|
410
422
|
['POST', '/v0/admin/nodes/:id/delist', async (req, res, { params }) => {
|
|
411
423
|
if (!adminOk(req, res)) return;
|
|
412
424
|
const node = await store.getNode(params.id);
|
|
@@ -496,8 +508,37 @@ export async function startRegistry({
|
|
|
496
508
|
...exportRoutes(store, registryKeys),
|
|
497
509
|
], { cors: true }); // public read API: third-party web clients may call it directly
|
|
498
510
|
|
|
511
|
+
// ---- server-location checks (location.js) ----
|
|
512
|
+
const asHolders = new Map();
|
|
513
|
+
async function locateNode(nodeId) {
|
|
514
|
+
const card = await store.latestCard(nodeId);
|
|
515
|
+
const result = await checkNodeLocation(card, { ...location, asHolders });
|
|
516
|
+
await store.setObservation(nodeId, '_node', 'location', result);
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
// Daily sweep over registered nodes and imports that carry a country (the ones jurisdiction
|
|
520
|
+
// filters can select), one at a time so the lookup service is never hammered.
|
|
521
|
+
function startLocationSweep() {
|
|
522
|
+
let stopped = false, timer = null;
|
|
523
|
+
const sweep = async () => {
|
|
524
|
+
for (const node of await store.allNodes()) {
|
|
525
|
+
if (stopped) return;
|
|
526
|
+
if (['delisted', 'claimed', 'submitted', 'challenged'].includes(node.state)) continue;
|
|
527
|
+
const card = await store.latestCard(node.id);
|
|
528
|
+
if (!card?.endpoints?.openai) continue;
|
|
529
|
+
if (node.source === 'import' && !card.node?.operator?.country) continue;
|
|
530
|
+
await locateNode(node.id).catch(() => {});
|
|
531
|
+
await new Promise((r) => setTimeout(r, location.pauseMs ?? 2000));
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const loop = async () => { if (stopped) return; await sweep().catch(() => {}); if (!stopped) timer = setTimeout(loop, location.intervalMs ?? 24 * 3600 * 1000); };
|
|
535
|
+
timer = setTimeout(loop, location.firstDelayMs ?? 60_000);
|
|
536
|
+
return { stop: () => { stopped = true; clearTimeout(timer); } };
|
|
537
|
+
}
|
|
538
|
+
|
|
499
539
|
const stageCHandle = stageC ? startStageC(store, { safeFetch, fetchFor: authFetch, refreshCard, ...stageC }) : null;
|
|
500
540
|
const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
|
|
541
|
+
const locationHandle = location ? startLocationSweep() : null;
|
|
501
542
|
const actualPort = await listen(app, port, host);
|
|
502
543
|
return {
|
|
503
544
|
port: actualPort,
|
|
@@ -507,6 +548,7 @@ export async function startRegistry({
|
|
|
507
548
|
close: async () => {
|
|
508
549
|
stageCHandle?.stop();
|
|
509
550
|
reimportHandle?.stop();
|
|
551
|
+
locationHandle?.stop();
|
|
510
552
|
await closeApp(app);
|
|
511
553
|
await store.close();
|
|
512
554
|
},
|
package/src/web/index.html
CHANGED
|
@@ -1779,6 +1779,25 @@ function claimBanner(n, card) {
|
|
|
1779
1779
|
</div>`;
|
|
1780
1780
|
}
|
|
1781
1781
|
|
|
1782
|
+
/** Server-location check (registry location.js) in plain words. */
|
|
1783
|
+
function locationRow(loc, imported) {
|
|
1784
|
+
if (!loc) return '';
|
|
1785
|
+
const srv = loc.server || {};
|
|
1786
|
+
const claim = imported ? 'inferred for this unclaimed listing' : 'declared by the operator';
|
|
1787
|
+
const words = {
|
|
1788
|
+
match: `✓ server in ${esc(srv.country)}, matching the country ${imported ? 'inferred for this unclaimed listing' : 'the operator declared'}`,
|
|
1789
|
+
same_region: `✓ server in ${esc(srv.country)} (EEA); country ${claim}: ${esc(loc.declared)}`,
|
|
1790
|
+
mismatch: `<b style="color:var(--bad, #b42318)">⚠ server in ${esc(srv.country)}; country ${claim}: ${esc(loc.declared)}</b>`,
|
|
1791
|
+
cdn: `behind a CDN (${esc(srv.network || 'anycast')}) — location not determinable from the network`,
|
|
1792
|
+
inconsistent: `<b style="color:var(--bad, #b42318)">⚠ geolocation (${esc(srv.country || '?')}) contradicted by measured latency</b>`,
|
|
1793
|
+
unknown: `not determined${loc.reason ? ' — ' + esc(loc.reason) : ''}`,
|
|
1794
|
+
};
|
|
1795
|
+
const lat = loc.latency || {};
|
|
1796
|
+
const sites = (lat.sites || []).map((v) => `${esc(v.site)} ${v.rtt_ms} ms`).join(', ');
|
|
1797
|
+
const latWord = { consistent: 'consistent', too_slow: 'slower than expected from every site (soft warning)', too_fast: 'faster than physics allows for the geolocated point', unknown: 'not measured' }[lat.verdict || 'unknown'];
|
|
1798
|
+
return `<tr><td style="color:var(--muted)" class="mono">server</td><td style="font-size:0.8rem">${words[loc.verdict] || esc(loc.verdict)}${srv.asn ? ` · AS${srv.asn}${srv.network ? ' ' + esc(srv.network) : ''}` : ''}<br><span style="color:var(--muted)">latency ${latWord}${sites ? ' (' + sites + ')' : ''} · checked ${esc((loc.checked_at || '').slice(0, 10))} · evidence, not proof</span></td></tr>`;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1782
1801
|
async function loadNode(id) {
|
|
1783
1802
|
$('#node-title').textContent = id;
|
|
1784
1803
|
try {
|
|
@@ -1812,6 +1831,7 @@ async function loadNode(id) {
|
|
|
1812
1831
|
<div class="panel pad" style="max-width:56rem; margin-top:0.6rem"><table class="grid">
|
|
1813
1832
|
<tr><td style="color:var(--muted); width:8rem" class="mono">endpoint</td><td class="mono" style="font-size:0.74rem">${card.endpoints?.openai ? esc(card.endpoints.openai) : '— <span style="font-family:inherit; color:var(--muted)">listing only: no known API endpoint, not callable from OpenNodes</span>'}</td></tr>
|
|
1814
1833
|
${docsUrl(card.endpoints) ? `<tr><td style="color:var(--muted)" class="mono">docs</td><td class="mono" style="font-size:0.74rem"><a href="${esc(docsUrl(card.endpoints))}" target="_blank" rel="noopener">${esc(docsUrl(card.endpoints))}</a></td></tr>` : ''}
|
|
1834
|
+
${locationRow(n.location, n.source === 'import')}
|
|
1815
1835
|
${card.hardware ? `<tr><td style="color:var(--muted)" class="mono">hardware</td><td style="font-size:0.78rem">${hwStr({ hardware: card.hardware })}</td></tr>` : ''}
|
|
1816
1836
|
<tr><td style="color:var(--muted)" class="mono">revision</td><td class="mono" style="font-size:0.74rem">${esc(card.revision ?? '—')}</td></tr>
|
|
1817
1837
|
</table></div>
|