@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 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.21",
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.6",
46
+ "@opennodes/core": "0.1.7",
47
47
  "pg": "^8.13.1"
48
48
  }
49
49
  }
@@ -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 availability = (await store.getObservations(node.id, '_node')).availability ?? null;
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
  },
@@ -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>