@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.9",
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
- const ok = status === 200 && body?.status === 'ok';
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(safeFetch, card, offering);
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 events = (await store.recentTransitions(limit)).map((t) => ({
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 { status, body } = await fetchJsonCapped(safeFetch, card.endpoints.health, {}, 5_000);
42
- ok = status === 200 && body?.status === 'ok';
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 { status, body } = await fetchJsonCapped(safeFetch, `${card.endpoints.openai}/chat/completions`, {
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());
@@ -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
  }