@opennodes/registry 0.1.9 → 0.1.11
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/feed.js +42 -0
- package/src/probes.js +2 -1
- package/src/search.js +1 -1
- package/src/server.js +76 -9
- package/src/stagec.js +6 -3
- package/src/store-pg.js +13 -0
- package/src/store.js +15 -0
- package/src/web/index.html +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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/feed.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Feed shaping: a node that keeps dropping offline and coming back would otherwise fill
|
|
2
|
+
// the public feed with suspended/reinstated pairs. Consecutive liveness transitions of
|
|
3
|
+
// one node collapse into a single availability event; everything else passes through.
|
|
4
|
+
|
|
5
|
+
const isLiveness = (e) => /^(liveness:|reinstated:)/.test(e.reason ?? '');
|
|
6
|
+
|
|
7
|
+
/** events: newest first. Returns newest first, with liveness runs (2+ events) collapsed. */
|
|
8
|
+
export function collapseFlaps(events) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const open = new Map(); // node_id -> collapsed entry still accepting older liveness events
|
|
11
|
+
for (const e of events) {
|
|
12
|
+
if (!isLiveness(e)) {
|
|
13
|
+
open.delete(e.node_id);
|
|
14
|
+
out.push(e);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const run = open.get(e.node_id);
|
|
18
|
+
if (run) {
|
|
19
|
+
run.events.push(e);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const entry = { ...e, events: [e] };
|
|
23
|
+
open.set(e.node_id, entry);
|
|
24
|
+
out.push(entry);
|
|
25
|
+
}
|
|
26
|
+
return out.map((entry) => {
|
|
27
|
+
if (!entry.events) return entry;
|
|
28
|
+
const { events: run, ...newest } = entry;
|
|
29
|
+
if (run.length === 1) return newest;
|
|
30
|
+
const oldest = run[run.length - 1];
|
|
31
|
+
const outages = run.filter((e) => e.to === 'suspended').length;
|
|
32
|
+
const now = newest.to === 'suspended' ? 'suspended now' : `${newest.to} again`;
|
|
33
|
+
return {
|
|
34
|
+
...newest,
|
|
35
|
+
from: oldest.from,
|
|
36
|
+
kind: 'availability',
|
|
37
|
+
collapsed: run.length,
|
|
38
|
+
since: oldest.at,
|
|
39
|
+
reason: `availability: ${outages} outage${outages === 1 ? '' : 's'} since ${oldest.at.slice(0, 10)}, ${now}`,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
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
|
@@ -19,6 +19,7 @@ import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENT
|
|
|
19
19
|
import { mcpRoutes } from './mcp.js';
|
|
20
20
|
import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
|
|
21
21
|
import { exportRoutes } from './export.js';
|
|
22
|
+
import { collapseFlaps } from './feed.js';
|
|
22
23
|
import {
|
|
23
24
|
importModelsDev, importHuggingFace, importOpenRouter,
|
|
24
25
|
importOllamaLibrary, parseOllamaLibrary,
|
|
@@ -92,6 +93,40 @@ export async function startRegistry({
|
|
|
92
93
|
const clientOf = (req) => (trustProxy && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress || 'unknown';
|
|
93
94
|
const isAdmin = (req) => Boolean(adminToken) && req.headers.authorization === `Bearer ${adminToken}`;
|
|
94
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Fetch for probing one node: carries the operator's probe key (card-only listings of
|
|
98
|
+
* keyed APIs), and only toward the card's own endpoint origins — never to anything else.
|
|
99
|
+
*/
|
|
100
|
+
async function authFetch(nodeId, card) {
|
|
101
|
+
const key = await store.getProbeKey(nodeId);
|
|
102
|
+
if (!key || !card?.endpoints) return safeFetch;
|
|
103
|
+
const origins = new Set([card.endpoints.openai, card.endpoints.health].filter(Boolean).map((u) => new URL(u).origin));
|
|
104
|
+
return (url, opts = {}, timeoutMs) => safeFetch(url,
|
|
105
|
+
origins.has(new URL(url).origin) ? { ...opts, headers: { ...(opts.headers ?? {}), authorization: `Bearer ${key}` } } : opts,
|
|
106
|
+
timeoutMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A registered node that reaches community or higher retires imported catalog listings of
|
|
111
|
+
* the same operator — imports whose API host lies inside the node's own namespace. Bound to
|
|
112
|
+
* the namespace so nobody can 'claim' a third party's listings (e.g. Hugging Face's router).
|
|
113
|
+
*/
|
|
114
|
+
async function supersedeImports(nodeId) {
|
|
115
|
+
const domain = domainForNodeId(nodeId).toLowerCase();
|
|
116
|
+
const claimed = [];
|
|
117
|
+
for (const imp of await store.allNodes()) {
|
|
118
|
+
if (imp.source !== 'import' || ['claimed', 'delisted'].includes(imp.state)) continue;
|
|
119
|
+
const card = await store.latestCard(imp.id);
|
|
120
|
+
let host;
|
|
121
|
+
try { host = new URL(card?.endpoints?.openai).hostname.toLowerCase(); } catch { continue; }
|
|
122
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
123
|
+
await store.setState(imp.id, 'claimed', `claimed by ${nodeId}`);
|
|
124
|
+
claimed.push(imp.id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return claimed;
|
|
128
|
+
}
|
|
129
|
+
|
|
95
130
|
async function challengeSatisfied(node, challenge) {
|
|
96
131
|
try { // HTTP method — only from a host that controls the id's namespace
|
|
97
132
|
if (namespaceOk(node.origin, node.id)) {
|
|
@@ -137,7 +172,8 @@ export async function startRegistry({
|
|
|
137
172
|
const app = createApp([
|
|
138
173
|
['POST', '/v0/nodes', async (req, res) => {
|
|
139
174
|
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);
|
|
175
|
+
const { card_url, probe_key } = await readJson(req);
|
|
176
|
+
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
177
|
if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
|
|
142
178
|
let card;
|
|
143
179
|
try { card = await fetchCard(card_url); } catch (err) { return problem(res, 422, 'card-unfetchable', String(err.message)); }
|
|
@@ -159,6 +195,9 @@ export async function startRegistry({
|
|
|
159
195
|
if (!listed) await store.setState(nodeId, 'challenged', 'registration received');
|
|
160
196
|
const token = randomUUID();
|
|
161
197
|
await store.setChallenge(nodeId, token);
|
|
198
|
+
// Held as pending: it only becomes active when this namespace's challenge is satisfied, so a
|
|
199
|
+
// stranger re-submitting someone else's card cannot replace (or break) their probe key.
|
|
200
|
+
if (probe_key) await store.setPendingProbeKey(nodeId, probe_key);
|
|
162
201
|
sendJson(res, 202, {
|
|
163
202
|
node_id: nodeId,
|
|
164
203
|
state: listed ? existing.state : 'challenged',
|
|
@@ -181,19 +220,22 @@ export async function startRegistry({
|
|
|
181
220
|
if (!challenge) return problem(res, 410, 'challenge-expired', 'register again');
|
|
182
221
|
const check = await challengeSatisfied(node, challenge);
|
|
183
222
|
if (!check.ok) return problem(res, 403, 'challenge-failed', 'neither HTTP nor DNS TXT challenge satisfied');
|
|
223
|
+
await store.promoteProbeKey(params.id);
|
|
184
224
|
let indexed;
|
|
185
225
|
try { indexed = await indexCard(params.id, node.card_url); }
|
|
186
226
|
catch (err) { return problem(res, 422, 'card-invalid', String(err.message)); }
|
|
187
227
|
|
|
188
228
|
await store.setState(params.id, 'indexed', `namespace verified via ${check.method}`);
|
|
189
|
-
const stageA = await runStageA(store, params.id, indexed.card, { safeFetch });
|
|
229
|
+
const stageA = await runStageA(store, params.id, indexed.card, { safeFetch: await authFetch(params.id, indexed.card) });
|
|
190
230
|
await store.setState(params.id, stageA.ok ? 'community' : 'indexed',
|
|
191
231
|
stageA.ok ? 'Stage A passed' : 'Stage A failures: ' + stageA.results.filter((r) => !r.ok).map((r) => r.kind).join(','));
|
|
232
|
+
const claimed = stageA.ok ? await supersedeImports(params.id) : [];
|
|
192
233
|
sendJson(res, 200, {
|
|
193
234
|
node_id: params.id,
|
|
194
235
|
state: (await store.getNode(params.id)).state,
|
|
195
236
|
signature_verified: indexed.sigOk,
|
|
196
237
|
stage_a: stageA,
|
|
238
|
+
claimed_imports: claimed,
|
|
197
239
|
});
|
|
198
240
|
}],
|
|
199
241
|
|
|
@@ -202,7 +244,7 @@ export async function startRegistry({
|
|
|
202
244
|
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
203
245
|
try {
|
|
204
246
|
const { card } = await indexCard(params.id, node.card_url);
|
|
205
|
-
const stageA = await runStageA(store, params.id, card, { safeFetch });
|
|
247
|
+
const stageA = await runStageA(store, params.id, card, { safeFetch: await authFetch(params.id, card) });
|
|
206
248
|
sendJson(res, 200, { node_id: params.id, revision: card.revision, stage_a_ok: stageA.ok });
|
|
207
249
|
} catch (err) {
|
|
208
250
|
problem(res, 422, 'refresh-failed', String(err.message));
|
|
@@ -226,7 +268,7 @@ export async function startRegistry({
|
|
|
226
268
|
}
|
|
227
269
|
await refreshCard(params.id);
|
|
228
270
|
const card = await store.latestCard(params.id);
|
|
229
|
-
const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
|
|
271
|
+
const stageB = await runStageB(store, params.id, card, { safeFetch: await authFetch(params.id, card), capacity: stageBCapacity });
|
|
230
272
|
if (stageB.stale) {
|
|
231
273
|
return problem(res, 409, 'stale-card', 'the node changed its card revision during the battery; retry');
|
|
232
274
|
}
|
|
@@ -234,6 +276,7 @@ export async function startRegistry({
|
|
|
234
276
|
await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
|
|
235
277
|
} else if (stageB.ok) {
|
|
236
278
|
await store.setState(params.id, 'verified', 'Stage B passed');
|
|
279
|
+
await supersedeImports(params.id);
|
|
237
280
|
}
|
|
238
281
|
sendJson(res, 200, { node_id: params.id, state: (await store.getNode(params.id)).state, stage_b: stageB });
|
|
239
282
|
}],
|
|
@@ -246,7 +289,7 @@ export async function startRegistry({
|
|
|
246
289
|
const card = await store.latestCard(node_id);
|
|
247
290
|
const offering = card?.offerings.find((o) => o.offering_id === offering_id);
|
|
248
291
|
if (!offering) return problem(res, 404, 'unknown-offering', `${node_id}/${offering_id}`);
|
|
249
|
-
const refs = await captureFingerprints(
|
|
292
|
+
const refs = await captureFingerprints(await authFetch(node_id, card), card, offering);
|
|
250
293
|
await store.addFingerprints(offering.model.artifact, refs);
|
|
251
294
|
sendJson(res, 200, { artifact: offering.model.artifact, references: refs.length });
|
|
252
295
|
}],
|
|
@@ -255,7 +298,7 @@ export async function startRegistry({
|
|
|
255
298
|
['GET', '/v0/nodes', async (req, res) => {
|
|
256
299
|
const out = [];
|
|
257
300
|
for (const node of await store.allNodes()) {
|
|
258
|
-
if (['delisted'].includes(node.state)) continue;
|
|
301
|
+
if (['delisted', 'claimed'].includes(node.state)) continue;
|
|
259
302
|
const card = await store.latestCard(node.id);
|
|
260
303
|
const availability = (await store.getObservations(node.id, '_node')).availability ?? null;
|
|
261
304
|
out.push({
|
|
@@ -279,13 +322,16 @@ export async function startRegistry({
|
|
|
279
322
|
|
|
280
323
|
// Freshness feed: recent listing events (registrations, tier changes, suspensions),
|
|
281
324
|
// newest first, each linked to its provenance reason.
|
|
325
|
+
// Liveness flaps (suspended/reinstated runs) collapse into one availability event;
|
|
326
|
+
// ?raw=1 returns every transition.
|
|
282
327
|
['GET', '/v0/feed', async (req, res, { query }) => {
|
|
283
328
|
const limit = Math.min(Number(query.get('limit') ?? 50), 200);
|
|
284
|
-
const
|
|
329
|
+
const raw = query.get('raw') === '1';
|
|
330
|
+
const events = (await store.recentTransitions(raw ? limit : Math.min(limit * 10, 2000))).map((t) => ({
|
|
285
331
|
at: t.at, node_id: t.node_id, from: t.from_state, to: t.to_state,
|
|
286
332
|
reason: t.reason, source: t.source ?? 'registration',
|
|
287
333
|
}));
|
|
288
|
-
sendJson(res, 200, { events });
|
|
334
|
+
sendJson(res, 200, { events: raw ? events : collapseFlaps(events).slice(0, limit) });
|
|
289
335
|
}],
|
|
290
336
|
|
|
291
337
|
['GET', '/v0/nodes/:id', async (req, res, { params }) => {
|
|
@@ -357,6 +403,27 @@ export async function startRegistry({
|
|
|
357
403
|
sendJson(res, 200, recommend(all, features, body.policy ?? {}));
|
|
358
404
|
}],
|
|
359
405
|
|
|
406
|
+
// Delist (admin): operator opt-out of an imported listing, or retiring a node. Search,
|
|
407
|
+
// the directory and Stage C skip delisted nodes, and re-imports never revive them.
|
|
408
|
+
// Body {reason}. A registered node returns by registering again; relist re-opens imports.
|
|
409
|
+
['POST', '/v0/admin/nodes/:id/delist', async (req, res, { params }) => {
|
|
410
|
+
if (!adminOk(req, res)) return;
|
|
411
|
+
const node = await store.getNode(params.id);
|
|
412
|
+
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
413
|
+
const { reason } = await readJson(req);
|
|
414
|
+
if (node.state !== 'delisted') await store.setState(node.id, 'delisted', `delisted: ${reason || 'by registry admin'}`);
|
|
415
|
+
sendJson(res, 200, { id: node.id, state: 'delisted' });
|
|
416
|
+
}],
|
|
417
|
+
['POST', '/v0/admin/nodes/:id/relist', async (req, res, { params }) => {
|
|
418
|
+
if (!adminOk(req, res)) return;
|
|
419
|
+
const node = await store.getNode(params.id);
|
|
420
|
+
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
421
|
+
if (node.state !== 'delisted') return problem(res, 409, 'not-delisted', `${node.id} is ${node.state}`);
|
|
422
|
+
if (node.source !== 'import') return problem(res, 409, 'register-again', 'a registered node returns by registering again (POST /v0/nodes)');
|
|
423
|
+
await store.setState(node.id, 'indexed', 'relisted by registry admin');
|
|
424
|
+
sendJson(res, 200, { id: node.id, state: 'indexed' });
|
|
425
|
+
}],
|
|
426
|
+
|
|
360
427
|
// Seed importer (admin): body {catalog} in models.dev shape, or {url} to fetch it.
|
|
361
428
|
['POST', '/v0/import/models-dev', async (req, res) => {
|
|
362
429
|
if (!adminOk(req, res)) return;
|
|
@@ -428,7 +495,7 @@ export async function startRegistry({
|
|
|
428
495
|
...exportRoutes(store, registryKeys),
|
|
429
496
|
], { cors: true }); // public read API: third-party web clients may call it directly
|
|
430
497
|
|
|
431
|
-
const stageCHandle = stageC ? startStageC(store, { safeFetch, refreshCard, ...stageC }) : null;
|
|
498
|
+
const stageCHandle = stageC ? startStageC(store, { safeFetch, fetchFor: authFetch, refreshCard, ...stageC }) : null;
|
|
432
499
|
const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
|
|
433
500
|
const actualPort = await listen(app, port, host);
|
|
434
501
|
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());
|
package/src/web/index.html
CHANGED
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
<!-- ================= NEW (feed) ================= -->
|
|
318
318
|
<div id="view-feed" hidden>
|
|
319
319
|
<h1>New</h1>
|
|
320
|
-
<p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents.</p>
|
|
320
|
+
<p class="sub">Dated listing events, newest first — registrations, tier changes, suspensions — each with its provenance. A node that keeps dropping offline and coming back shows as one availability line. Also available as <a id="feed-json" href="/v0/feed">JSON</a> for agents (<a href="/v0/feed?raw=1">every transition</a>).</p>
|
|
321
321
|
<div class="panel pad" id="feed-list"></div>
|
|
322
322
|
</div>
|
|
323
323
|
|
|
@@ -1729,7 +1729,7 @@ async function loadFeed() {
|
|
|
1729
1729
|
$('#feed-list').innerHTML = events.length ? events.map((e) => `<div class="feeditem">
|
|
1730
1730
|
<span class="prov">${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
|
|
1731
1731
|
<span><a href="#/node/${encodeURIComponent(e.node_id)}" class="mono" style="font-size:0.78rem">${esc(e.node_id)}</a>
|
|
1732
|
-
→ ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + '</span>' : ''}</span>
|
|
1732
|
+
→ ${tierChip(e.to)} ${e.reason ? '<span style="color:var(--muted)">— ' + esc(e.reason) + (e.collapsed ? ' (' + e.collapsed + ' events)' : '') + '</span>' : ''}</span>
|
|
1733
1733
|
</div>`).join('') : '<p class="sub">No events yet.</p>';
|
|
1734
1734
|
} catch (e) { $('#feed-list').innerHTML = '<p class="err">' + esc(e.message) + '</p>'; }
|
|
1735
1735
|
}
|