openvibers/openvibe.tools Commits
Domain history: the WHOIS and RDAP tools keep each domain's public registration facts over time (a snapshot on change, never contacts), a History card on the pages, and GET /api/v1/domains(/:domain) for anyone (#126)
Changes
@@@ -181,6 +181,16 @@/* Result table */.res-table { width: 100%; border-collapse: collapse; font-size: 13px; }++ /* The domain's history (WHOIS / RDAP): when it was first seen here and what changed when. */++ .hist-note { margin: 0 0 8px; font-size: 13px; color: var(--text-secondary, #a8b3c4); }++ .hist-note b { color: var(--text-primary, #e6edf7); }++ .hist-list { list-style: none; margin: 4px 0 8px; padding: 0 0 0 14px; border-left: 2px solid var(--border, rgba(255,255,255,.12)); display: grid; gap: 10px; }++ .hist-list > li { position: relative; font-size: 13px; }++ .hist-list > li::before { content: ''; position: absolute; left: -20px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); }++ .hist-day { display: block; font-weight: 700; font-size: 12px; color: var(--text-muted, #8693a8); margin-bottom: 2px; }++ .hist-list ul { margin: 0; padding-left: 16px; }++ .hist-was { color: var(--text-muted, #8693a8); text-decoration: line-through; }++ .hist-small { margin: 4px 0 0; font-size: 12px; color: var(--text-muted, #8693a8); }.res-table th {text-align: left; padding: 6px 10px; color: var(--text-muted);font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px;@@@ -1211,10 +1221,38 @@${s.entities.map(e => `<tr><td class="val">${esc(e.handle || '—')}</td><td>${(e.roles||[]).join(', ')}</td><td>${esc(e.name || '—')}</td></tr>`).join('')}</table></div>`;}++ // History: what OpenVibe Tools saw of this domain over time (GET /api/v1/domains/<domain>, filled in below)++ if (data.type === 'domain') html += `<div class="card" id="rdap-history"><div class="card-head"><i class="fa-solid fa-clock-rotate-left"></i> History</div><p class="hist-note">Loading what OpenVibe Tools saw of this domain over time…</p></div>`;// Raw togglehtml += `<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--text-muted);font-size:13px">Raw RDAP response</summary><div class="raw-block" style="margin-top:8px">${esc(JSON.stringify(data.raw, null, 2))}</div></details>`;el.innerHTML = html;++ if (data.type === 'domain') loadDomainHistory(el.querySelector('#rdap-history'), data.target);++ }++++ // The domain's history (apps/gateway/server/net/history.js): every lookup here keeps its public registration facts++ // (registrar, dates, status, name servers, DNSSEC; never contact details), and a change becomes a new snapshot.++ const HIST_LABEL = { registrar: 'Registrar', created: 'Registered', expires: 'Expires', status: 'Status', nameservers: 'Name servers', dnssec: 'DNSSEC' };++ function histValue(field, v) {++ if (v == null || (Array.isArray(v) && !v.length)) return '—';++ if (field === 'dnssec') return v ? 'on' : 'off';++ if (field === 'created' || field === 'expires') return new Date(v).toISOString().slice(0, 10);++ return Array.isArray(v) ? v.join(', ') : String(v);++ }++ async function loadDomainHistory(box, domain) {++ if (!box) return;++ let d = null;++ try { const r = await fetch('/api/v1/domains/' + encodeURIComponent(domain), { credentials: 'omit' }); d = r.ok ? await r.json() : null; } catch (e) { d = null; }++ const day = (iso) => (iso ? new Date(iso).toISOString().slice(0, 10) : '');++ if (!d || !d.ok || !d.snapshots || !d.snapshots.length) {++ box.querySelector('.hist-note').textContent = 'This is the first time OpenVibe Tools has seen this domain. Its facts are kept from now on, so the next lookup can show what changed.';++ return;++ }++ let html = `<p class="hist-note">First seen here on <b>${esc(day(d.first_seen))}</b> · looked up <b>${esc(String(d.lookups))}</b> time${d.lookups === 1 ? '' : 's'} · ${esc(String(d.snapshots.length))} version${d.snapshots.length === 1 ? '' : 's'} kept</p>`;++ if (!d.changes.length) html += `<p class="hist-note">No changes seen since ${esc(day(d.first_seen))}.</p>`;++ else html += `<ol class="hist-list">${d.changes.map((c) => `<li><span class="hist-day">${esc(day(c.at))}</span><ul>${c.changes.map((x) => `<li><b>${esc(HIST_LABEL[x.field] || x.field)}</b> <span class="hist-was">${esc(histValue(x.field, x.before))}</span> → ${esc(histValue(x.field, x.after))}</li>`).join('')}</ul></li>`).join('')}</ol>`;++ html += `<p class="hist-small">Registration facts only, never contact details. <a href="/api/v1/domains/${encodeURIComponent(domain)}" rel="nofollow">As JSON</a></p>`;++ box.querySelector('.hist-note').outerHTML = html;}function renderSSL(el, data) {@@@ -265,7 +265,11 @@ for (const method of ['put', 'delete']) {}// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──--const netRouter = createNetRoutes(null, requireAuth, { guard });++// Domain history (net/history.js, migration 0008): the WHOIS and RDAP tools keep each domain's public facts over time,++// and anyone reads them at /api/v1/domains (net/history-routes.js: the WHOIS page's History card, the API, the SDK).++const domainHistory = require('./net/history').createDomainHistory({ db: toolsDb.db });++const netRouter = createNetRoutes(null, requireAuth, { guard, history: domainHistory });++app.use('/api/v1/domains', actorLimits.backstop('tools.tool.run'), require('./net/history-routes').createDomainHistoryRoutes({ history: domainHistory }));const devRouter = createDevRoutes(null, requireAuth, { guard });// ── Run API and jobs facade (ADR-027) ────────────────────────nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// Domain history, readable by anyone (net/history.js): the WHOIS and RDAP pages' History card, the API and the SDK.++//++// GET /api/v1/domains the lists: the most looked up domains and those of them expiring within 60 days++// (each looked up five times or more, so nobody's own new domain shows)++// GET /api/v1/domains/:domain one domain: its snapshots (registrar, dates, status, name servers, DNSSEC), newest++// first, what changed when, and how often it was looked up++//++// Public registration facts only (RDAP's own), answered to any origin without credentials, kept a minute by caches.++const express = require('express');++++const DOMAIN = /^(?=.{3,253}$)[a-z0-9-]+(\.[a-z0-9-]+)+$/;++++function createDomainHistoryRoutes({ history }) {++ const r = express.Router();++ r.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=60' }); next(); });++ r.get('/', async (req, res) => {++ try { res.json({ ok: true, ...(await history.lists({ limit: req.query.limit })) }); } catch { res.status(500).json({ ok: false, error: 'Domain history could not be read' }); }++ });++ r.get('/:domain', async (req, res) => {++ const domain = String(req.params.domain || '').trim().toLowerCase().replace(/\.$/, '');++ if (!DOMAIN.test(domain) || !/[a-z]/.test(domain.split('.').pop())) return res.status(400).json({ ok: false, error: 'Enter a full domain name (like example.com)' });++ try { res.json({ ok: true, ...(await history.history(domain)) }); } catch { res.status(500).json({ ok: false, error: 'Domain history could not be read' }); }++ });++ return r;++}++++module.exports = { createDomainHistoryRoutes };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// Domain history: the public registration facts of every domain looked up on the WHOIS and RDAP tools, kept over time++// (migration 0008; the owner, 2026-10-28: "all our platforms should also be keeping… historical data or trends… caching++// of Whois data or domain lookups… historical or archive data or like lists or other useful tools… comparisons").++//++// What is kept is what RDAP publishes for anyone: the registrar, the registration, change and expiry dates, the status++// codes, the name servers and whether DNSSEC is on. Never a registrant's, admin's or tech contact's name, address, email++// or phone, and never who looked. A new snapshot is written only when those facts change; a lookup that finds them the++// same moves the last snapshot's last_seen. The lists ("most looked up", "expiring soon") show a domain only after++// MIN_LISTED lookups, so a person checking their own new domain never puts it on a public list.++const crypto = require('crypto');++++const MIN_LISTED = 5;++const MAX_SNAPSHOTS = 200;++++const lower = (s) => String(s || '').trim().toLowerCase();++++/** The public facts of an RDAP domain answer (the route's summary or the raw RDAP document). */++function factsFromRdap(data) {++ const d = data || {};++ const events = (d.events || []).map((e) => ({ action: e.eventAction || e.action, date: e.eventDate || e.date }));++ const at = (...names) => { const e = events.find((x) => names.includes(String(x.action || '').toLowerCase())); return e && e.date ? new Date(e.date).toISOString() : null; };++ const registrarEntity = (d.entities || []).find((e) => (e.roles || []).includes('registrar'));++ const vcardName = (e) => (e && e.vcardArray && Array.isArray(e.vcardArray[1]) ? (e.vcardArray[1].find((v) => v[0] === 'fn') || [])[3] : null) || (e && e.name) || null;++ const nameservers = (d.nameservers || []).map((ns) => lower(typeof ns === 'string' ? ns : ns.ldhName || ns.unicodeName)).filter(Boolean).sort();++ return {++ registrar: registrarEntity ? String(vcardName(registrarEntity) || registrarEntity.handle || '').slice(0, 200) || null : null,++ created: at('registration'),++ updated: at('last changed'), // never "last update of RDAP database": that moves every day by itself++ expires: at('expiration'),++ status: [...new Set((d.status || []).map((s) => lower(s)))].sort(),++ nameservers: [...new Set(nameservers)],++ dnssec: d.secureDNS && typeof d.secureDNS.delegationSigned === 'boolean' ? d.secureDNS.delegationSigned : null,++ };++}++++const digestOf = (facts) => crypto.createHash('sha256').update(JSON.stringify(facts)).digest('hex').slice(0, 32);++++/** What changed between two snapshots' facts: [{ field, before, after }]. */++function changesBetween(a, b) {++ const out = [];++ for (const field of ['registrar', 'created', 'expires', 'status', 'nameservers', 'dnssec']) {++ const x = a ? a[field] : null, y = b ? b[field] : null;++ if (JSON.stringify(x) !== JSON.stringify(y)) out.push({ field, before: x === undefined ? null : x, after: y === undefined ? null : y });++ }++ return out;++}++++function createDomainHistory({ db, log = console, now = () => new Date() } = {}) {++ /** Keep one lookup's facts (RDAP domain answers only); never throws into the tool's answer. */++ async function record(domain, rdap) {++ if (!db) return null;++ const name = lower(domain);++ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(name) || name.length > 253 || !/[a-z]/.test(name.split('.').pop())) return null;++ const facts = factsFromRdap(rdap);++ const digest = digestOf(facts);++ const at = now().toISOString();++ try {++ const last = await db.maybe('SELECT id, digest FROM domain_snapshots WHERE domain = $1 ORDER BY first_seen DESC, id DESC LIMIT 1', [name]);++ if (last && last.digest === digest) await db.exec('UPDATE domain_snapshots SET last_seen = $2, seen = seen + 1 WHERE id = $1', [last.id, at]);++ else await db.exec('INSERT INTO domain_snapshots (domain, facts, digest, first_seen, last_seen) VALUES ($1, $2::jsonb, $3, $4, $4)', [name, JSON.stringify(facts), digest, at]);++ await db.exec(`INSERT INTO domain_lookups (domain, lookups, last_at, registrar, expires_at) VALUES ($1, 1, $2, $3, $4)++ ON CONFLICT (domain) DO UPDATE SET lookups = domain_lookups.lookups + 1, last_at = EXCLUDED.last_at, registrar = EXCLUDED.registrar, expires_at = EXCLUDED.expires_at`,++ [name, at, facts.registrar, facts.expires]);++ return { changed: !last || last.digest !== digest, facts };++ } catch (err) {++ if (log.warn) log.warn('[tools] domain history not kept:', err.message);++ return null;++ }++ }++++ /** One domain's history: its snapshots (newest first) and what changed at each. */++ async function history(domain) {++ const name = lower(domain);++ const rows = await db.many('SELECT facts, first_seen, last_seen, seen FROM domain_snapshots WHERE domain = $1 ORDER BY first_seen DESC, id DESC LIMIT $2', [name, MAX_SNAPSHOTS]);++ const snapshots = rows.map((r) => ({ facts: typeof r.facts === 'string' ? JSON.parse(r.facts) : r.facts, first_seen: new Date(r.first_seen).toISOString(), last_seen: new Date(r.last_seen).toISOString(), seen: Number(r.seen) }));++ const changes = [];++ for (let i = 0; i < snapshots.length - 1; i++) {++ const diff = changesBetween(snapshots[i + 1].facts, snapshots[i].facts);++ if (diff.length) changes.push({ at: snapshots[i].first_seen, changes: diff });++ }++ const lookups = await db.maybe('SELECT lookups FROM domain_lookups WHERE domain = $1', [name]);++ return { domain: name, snapshots, changes, lookups: lookups ? Number(lookups.lookups) : 0, first_seen: snapshots.length ? snapshots[snapshots.length - 1].first_seen : null };++ }++++ /** The public lists: the most looked up domains and those of them expiring within 60 days (MIN_LISTED lookups each). */++ async function lists({ limit = 20 } = {}) {++ const n = Math.min(Math.max(Number(limit) || 20, 1), 100);++ const most = await db.many('SELECT domain, lookups, registrar, expires_at FROM domain_lookups WHERE lookups >= $1 ORDER BY lookups DESC, domain LIMIT $2', [MIN_LISTED, n]);++ const soon = await db.many(`SELECT domain, lookups, registrar, expires_at FROM domain_lookups++ WHERE lookups >= $1 AND expires_at IS NOT NULL AND expires_at > $2 AND expires_at < $3 ORDER BY expires_at LIMIT $4`,++ [MIN_LISTED, now().toISOString(), new Date(now().getTime() + 60 * 86400000).toISOString(), n]);++ const row = (r) => ({ domain: r.domain, lookups: Number(r.lookups), registrar: r.registrar || null, expires: r.expires_at ? new Date(r.expires_at).toISOString() : null });++ return { most_looked_up: most.map(row), expiring_soon: soon.map(row), min_lookups: MIN_LISTED };++ }++++ return { record, history, lists };++}++++module.exports = { createDomainHistory, factsFromRdap, changesBetween, MIN_LISTED };@@@ -154,6 +154,9 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {const guard = opts.guard || null;// Traceroute and MTR (net/trace.js): mtr on the checked address; tests pass a tracer with a fake mtr.const tracer = opts.tracer || createTracer({ reverse: reverseDns });++ // Domain history (net/history.js, migration 0008): every domain the WHOIS and RDAP tools look up keeps its public++ // registration facts over time. index.js passes it, built on the tools database; without it nothing is kept.++ const history = opts.history || null;// ── Helpers ──────────────────────────────────────────────@@@ -550,6 +553,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {if (!r.ok) return fail(res, r.status === 404 ? `No registration record for ${target}` : `RDAP lookup failed (${r.status})`, r.status >= 500 ? 502 : 404);const data = r.data;++ // Kept in the domain's history without holding the answer up (a failure only logs).++ if (rdapType === 'domain' && history) history.record(target, data).catch(() => {});// Extract key fields from RDAP responseconst summary = {nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++// Domain history (server/net/history.js, migration 0008) on a real in-memory PGlite database:++// - a domain's RDAP facts are kept (registrar, dates, status, name servers, DNSSEC) and never its contacts' details;++// - the same facts again move the last snapshot's last_seen; changed facts write a new snapshot, and the history says++// what changed and when;++// - the lists show only domains looked up five times or more ("most looked up", "expiring soon");++// - the routes: a WHOIS lookup records without holding up its answer; anyone reads GET /api/v1/domains/:domain and++// /api/v1/domains (any origin, cached a minute).++const assert = require('assert');++const http = require('http');++const express = require('express');++const { createDb } = require('openvibe-sdk/db');++const { openToolsDb } = require('../../_shared/db');++const { createDomainHistory, factsFromRdap, changesBetween } = require('../server/net/history');++const createNetRoutes = require('../server/net/routes');++const { createDomainHistoryRoutes } = require('../server/net/history-routes');++++const rdap = ({ registrar = 'Cloudflare, Inc.', expires = '2027-03-01T00:00:00Z', ns = ['NS1.EXAMPLE.NET', 'ns2.example.net'], status = ['client transfer prohibited'] } = {}) => ({++ ldhName: 'EXAMPLE.ORG', status, secureDNS: { delegationSigned: false },++ events: [{ eventAction: 'registration', eventDate: '2019-03-01T10:00:00Z' }, { eventAction: 'expiration', eventDate: expires }, { eventAction: 'last update of RDAP database', eventDate: new Date().toISOString() }],++ entities: [++ { roles: ['registrar'], handle: '1910', vcardArray: ['vcard', [['version', {}, 'text', '4.0'], ['fn', {}, 'text', registrar]]] },++ { roles: ['registrant'], vcardArray: ['vcard', [['fn', {}, 'text', 'Alice Private'], ['email', {}, 'text', '[email protected]'], ['tel', {}, 'text', '+1 555 0100']]] },++ ],++ nameservers: ns.map((n) => ({ ldhName: n })),++});++++(async () => {++ // ── the facts ──++ const f = factsFromRdap(rdap());++ assert.deepStrictEqual(f, { registrar: 'Cloudflare, Inc.', created: '2019-03-01T10:00:00.000Z', updated: null, expires: '2027-03-01T00:00:00.000Z', status: ['client transfer prohibited'], nameservers: ['ns1.example.net', 'ns2.example.net'], dnssec: false });++ assert.ok(!/Alice|alice@|555/.test(JSON.stringify(f)), 'never a contact\'s name, email or phone');++ assert.deepStrictEqual(changesBetween(f, { ...f, registrar: 'Gandi SAS' }), [{ field: 'registrar', before: 'Cloudflare, Inc.', after: 'Gandi SAS' }]);++++ // ── on a real database ──++ const { db, ready } = openToolsDb({ createDb, service: 'tools-test', log: { warn() {}, info() {}, log() {}, error() {} } });++ await ready;++ let clock = Date.parse('2026-10-01T00:00:00Z');++ const h = createDomainHistory({ db, log: { warn() {} }, now: () => new Date(clock) });++ assert.strictEqual((await h.record('Example.org', rdap())).changed, true, 'the first lookup writes a snapshot');++ clock += 86400000;++ assert.strictEqual((await h.record('example.org', rdap())).changed, false, 'the same facts again: no new snapshot');++ clock += 86400000;++ assert.strictEqual((await h.record('example.org', rdap({ registrar: 'Gandi SAS', ns: ['a.ns.gandi.net', 'b.ns.gandi.net'], expires: '2028-03-01T00:00:00Z' }))).changed, true, 'a transfer is a new snapshot');++ const hist = await h.history('EXAMPLE.ORG');++ assert.strictEqual(hist.snapshots.length, 2);++ assert.strictEqual(hist.snapshots[1].seen, 2, 'the first snapshot was seen twice');++ assert.strictEqual(hist.first_seen, '2026-10-01T00:00:00.000Z');++ assert.strictEqual(hist.lookups, 3);++ assert.deepStrictEqual(hist.changes[0].changes.map((c) => c.field), ['registrar', 'expires', 'nameservers'], 'what changed: the registrar, the expiry and the name servers');++ assert.strictEqual(hist.changes[0].at, '2026-10-03T00:00:00.000Z');++ assert.strictEqual(await h.record('not a domain', rdap()), null, 'only domain names are kept');++++ // ── the lists: five lookups or more ──++ let lists = await h.lists();++ assert.deepStrictEqual(lists.most_looked_up, [], 'three lookups: not listed yet');++ for (let i = 0; i < 2; i++) await h.record('example.org', rdap({ registrar: 'Gandi SAS', ns: ['a.ns.gandi.net', 'b.ns.gandi.net'], expires: '2026-11-15T00:00:00Z' }));++ lists = await h.lists();++ assert.deepStrictEqual(lists.most_looked_up.map((d) => [d.domain, d.lookups]), [['example.org', 5]]);++ assert.deepStrictEqual(lists.expiring_soon.map((d) => d.domain), ['example.org'], 'expires within 60 days');++++ // ── the routes ──++ const fetchRdap = async (u) => ({ ok: true, status: 200, headers: new Map([['content-type', 'application/rdap+json']]), json: async () => (/rdap\.org|bootstrap|dns\.json/.test(String(u)) ? { services: [[['org'], ['https://rdap.example/']]] } : rdap({ registrar: 'Porkbun LLC' })), text: async () => '' });++ const app = express();++ const routesHistory = createDomainHistory({ db, log: { warn() {} } });++ app.use('/api/net', createNetRoutes(null, (_q, _s, n) => n(), { fetch: fetchRdap, history: routesHistory }));++ app.use('/api/v1/domains', createDomainHistoryRoutes({ history: routesHistory }));++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const root = `http://127.0.0.1:${server.address().port}`;++ const get = async (p) => { const r = await fetch(root + p); return { status: r.status, json: await r.json(), cors: r.headers.get('access-control-allow-origin') }; };++ const who = await get('/api/net/whois/fresh-domain.org');++ assert.strictEqual(who.status, 200, JSON.stringify(who.json).slice(0, 200));++ for (let i = 0; i < 30 && !(await get('/api/v1/domains/fresh-domain.org')).json.snapshots.length; i++) await new Promise((r) => setTimeout(r, 50));++ const one = await get('/api/v1/domains/Fresh-Domain.org');++ assert.deepStrictEqual([one.json.ok, one.json.snapshots.length, one.json.snapshots[0].facts.registrar, one.cors], [true, 1, 'Porkbun LLC', '*'], 'a WHOIS lookup is kept, and anyone may read it');++ assert.strictEqual((await get('/api/v1/domains/1.2.3.4')).status, 400, 'a domain name, not an address');++ const all = await get('/api/v1/domains');++ assert.ok(all.json.ok && Array.isArray(all.json.most_looked_up) && all.json.min_lookups === 5);++ server.close();++ await db.close();++ console.log('domain history: facts without contacts, snapshots on change, lists after five lookups, routes ok');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++-- domain_snapshots: the public registration facts (RDAP: registrar, dates, status, name servers, DNSSEC) of each domain++-- someone looked up on the WHOIS and RDAP tools, kept over time (apps/gateway/server/net/history.js; the owner,++-- 2026-10-28: "caching of Whois data or domain lookups… historical or archive data… lists… comparisons"). A new row only++-- when the facts change; otherwise the last row's last_seen moves. Never a registrant's contact details, never who looked.++-- domain_lookups: how often each domain was looked up, for the "most looked up" and "expiring soon" lists (a domain is++-- listed only after five lookups, so nobody's own new domain shows there). Additive: two new empty tables.++CREATE TABLE IF NOT EXISTS domain_snapshots (++ id bigserial PRIMARY KEY,++ domain text NOT NULL,++ facts jsonb NOT NULL,++ digest text NOT NULL,++ first_seen timestamptz NOT NULL DEFAULT now(),++ last_seen timestamptz NOT NULL DEFAULT now(),++ seen integer NOT NULL DEFAULT 1++);++CREATE INDEX IF NOT EXISTS domain_snapshots_domain ON domain_snapshots (domain, first_seen DESC);++++CREATE TABLE IF NOT EXISTS domain_lookups (++ domain text PRIMARY KEY,++ lookups integer NOT NULL DEFAULT 0,++ last_at timestamptz NOT NULL DEFAULT now(),++ registrar text,++ expires_at timestamptz++);++CREATE INDEX IF NOT EXISTS domain_lookups_lookups ON domain_lookups (lookups DESC);++CREATE INDEX IF NOT EXISTS domain_lookups_expires ON domain_lookups (expires_at);
@@ -181,6 +181,16 @@
@@ -181,6 +181,16 @@
/* Result table */
/* Result table */
.res-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.res-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+ /* The domain's history (WHOIS / RDAP): when it was first seen here and what changed when. */
+ .hist-note { margin: 0 0 8px; font-size: 13px; color: var(--text-secondary, #a8b3c4); }
+ .hist-note b { color: var(--text-primary, #e6edf7); }
+ .hist-list { list-style: none; margin: 4px 0 8px; padding: 0 0 0 14px; border-left: 2px solid var(--border, rgba(255,255,255,.12)); display: grid; gap: 10px; }
+ .hist-list > li { position: relative; font-size: 13px; }
+ .hist-list > li::before { content: ''; position: absolute; left: -20px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); }
+ .hist-day { display: block; font-weight: 700; font-size: 12px; color: var(--text-muted, #8693a8); margin-bottom: 2px; }
+ .hist-list ul { margin: 0; padding-left: 16px; }
+ .hist-was { color: var(--text-muted, #8693a8); text-decoration: line-through; }
+ .hist-small { margin: 4px 0 0; font-size: 12px; color: var(--text-muted, #8693a8); }
.res-table th {
.res-table th {
text-align: left; padding: 6px 10px; color: var(--text-muted);
text-align: left; padding: 6px 10px; color: var(--text-muted);
font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px;
font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px;
@@ -1211,10 +1221,38 @@
@@ -1211,10 +1221,38 @@
${s.entities.map(e => `<tr><td class="val">${esc(e.handle || '—')}</td><td>${(e.roles||[]).join(', ')}</td><td>${esc(e.name || '—')}</td></tr>`).join('')}
${s.entities.map(e => `<tr><td class="val">${esc(e.handle || '—')}</td><td>${(e.roles||[]).join(', ')}</td><td>${esc(e.name || '—')}</td></tr>`).join('')}
</table></div>`;
</table></div>`;
}
}
+ // History: what OpenVibe Tools saw of this domain over time (GET /api/v1/domains/<domain>, filled in below)
+ if (data.type === 'domain') html += `<div class="card" id="rdap-history"><div class="card-head"><i class="fa-solid fa-clock-rotate-left"></i> History</div><p class="hist-note">Loading what OpenVibe Tools saw of this domain over time…</p></div>`;
// Raw toggle
// Raw toggle
html += `<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--text-muted);font-size:13px">Raw RDAP response</summary>
html += `<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--text-muted);font-size:13px">Raw RDAP response</summary>
<div class="raw-block" style="margin-top:8px">${esc(JSON.stringify(data.raw, null, 2))}</div></details>`;
<div class="raw-block" style="margin-top:8px">${esc(JSON.stringify(data.raw, null, 2))}</div></details>`;
el.innerHTML = html;
el.innerHTML = html;
+ if (data.type === 'domain') loadDomainHistory(el.querySelector('#rdap-history'), data.target);
+ }
+
+ // The domain's history (apps/gateway/server/net/history.js): every lookup here keeps its public registration facts
+ // (registrar, dates, status, name servers, DNSSEC; never contact details), and a change becomes a new snapshot.
+ const HIST_LABEL = { registrar: 'Registrar', created: 'Registered', expires: 'Expires', status: 'Status', nameservers: 'Name servers', dnssec: 'DNSSEC' };
+ function histValue(field, v) {
+ if (v == null || (Array.isArray(v) && !v.length)) return '—';
+ if (field === 'dnssec') return v ? 'on' : 'off';
+ if (field === 'created' || field === 'expires') return new Date(v).toISOString().slice(0, 10);
+ return Array.isArray(v) ? v.join(', ') : String(v);
+ }
+ async function loadDomainHistory(box, domain) {
+ if (!box) return;
+ let d = null;
+ try { const r = await fetch('/api/v1/domains/' + encodeURIComponent(domain), { credentials: 'omit' }); d = r.ok ? await r.json() : null; } catch (e) { d = null; }
+ const day = (iso) => (iso ? new Date(iso).toISOString().slice(0, 10) : '');
+ if (!d || !d.ok || !d.snapshots || !d.snapshots.length) {
+ box.querySelector('.hist-note').textContent = 'This is the first time OpenVibe Tools has seen this domain. Its facts are kept from now on, so the next lookup can show what changed.';
+ return;
+ }
+ let html = `<p class="hist-note">First seen here on <b>${esc(day(d.first_seen))}</b> · looked up <b>${esc(String(d.lookups))}</b> time${d.lookups === 1 ? '' : 's'} · ${esc(String(d.snapshots.length))} version${d.snapshots.length === 1 ? '' : 's'} kept</p>`;
+ if (!d.changes.length) html += `<p class="hist-note">No changes seen since ${esc(day(d.first_seen))}.</p>`;
+ else html += `<ol class="hist-list">${d.changes.map((c) => `<li><span class="hist-day">${esc(day(c.at))}</span><ul>${c.changes.map((x) => `<li><b>${esc(HIST_LABEL[x.field] || x.field)}</b> <span class="hist-was">${esc(histValue(x.field, x.before))}</span> → ${esc(histValue(x.field, x.after))}</li>`).join('')}</ul></li>`).join('')}</ol>`;
+ html += `<p class="hist-small">Registration facts only, never contact details. <a href="/api/v1/domains/${encodeURIComponent(domain)}" rel="nofollow">As JSON</a></p>`;
+ box.querySelector('.hist-note').outerHTML = html;
}
}
function renderSSL(el, data) {
function renderSSL(el, data) {
@@ -265,7 +265,11 @@ for (const method of ['put', 'delete']) {
@@ -265,7 +265,11 @@ for (const method of ['put', 'delete']) {
}
}
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
-const netRouter = createNetRoutes(null, requireAuth, { guard });
+// Domain history (net/history.js, migration 0008): the WHOIS and RDAP tools keep each domain's public facts over time,
+// and anyone reads them at /api/v1/domains (net/history-routes.js: the WHOIS page's History card, the API, the SDK).
+const domainHistory = require('./net/history').createDomainHistory({ db: toolsDb.db });
+const netRouter = createNetRoutes(null, requireAuth, { guard, history: domainHistory });
+app.use('/api/v1/domains', actorLimits.backstop('tools.tool.run'), require('./net/history-routes').createDomainHistoryRoutes({ history: domainHistory }));
const devRouter = createDevRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
// ── Run API and jobs facade (ADR-027) ────────────────────────
// ── Run API and jobs facade (ADR-027) ────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// Domain history, readable by anyone (net/history.js): the WHOIS and RDAP pages' History card, the API and the SDK.
+//
+// GET /api/v1/domains the lists: the most looked up domains and those of them expiring within 60 days
+// (each looked up five times or more, so nobody's own new domain shows)
+// GET /api/v1/domains/:domain one domain: its snapshots (registrar, dates, status, name servers, DNSSEC), newest
+// first, what changed when, and how often it was looked up
+//
+// Public registration facts only (RDAP's own), answered to any origin without credentials, kept a minute by caches.
+const express = require('express');
+
+const DOMAIN = /^(?=.{3,253}$)[a-z0-9-]+(\.[a-z0-9-]+)+$/;
+
+function createDomainHistoryRoutes({ history }) {
+ const r = express.Router();
+ r.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=60' }); next(); });
+ r.get('/', async (req, res) => {
+ try { res.json({ ok: true, ...(await history.lists({ limit: req.query.limit })) }); } catch { res.status(500).json({ ok: false, error: 'Domain history could not be read' }); }
+ });
+ r.get('/:domain', async (req, res) => {
+ const domain = String(req.params.domain || '').trim().toLowerCase().replace(/\.$/, '');
+ if (!DOMAIN.test(domain) || !/[a-z]/.test(domain.split('.').pop())) return res.status(400).json({ ok: false, error: 'Enter a full domain name (like example.com)' });
+ try { res.json({ ok: true, ...(await history.history(domain)) }); } catch { res.status(500).json({ ok: false, error: 'Domain history could not be read' }); }
+ });
+ return r;
+}
+
+module.exports = { createDomainHistoryRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// Domain history: the public registration facts of every domain looked up on the WHOIS and RDAP tools, kept over time
+// (migration 0008; the owner, 2026-10-28: "all our platforms should also be keeping… historical data or trends… caching
+// of Whois data or domain lookups… historical or archive data or like lists or other useful tools… comparisons").
+//
+// What is kept is what RDAP publishes for anyone: the registrar, the registration, change and expiry dates, the status
+// codes, the name servers and whether DNSSEC is on. Never a registrant's, admin's or tech contact's name, address, email
+// or phone, and never who looked. A new snapshot is written only when those facts change; a lookup that finds them the
+// same moves the last snapshot's last_seen. The lists ("most looked up", "expiring soon") show a domain only after
+// MIN_LISTED lookups, so a person checking their own new domain never puts it on a public list.
+const crypto = require('crypto');
+
+const MIN_LISTED = 5;
+const MAX_SNAPSHOTS = 200;
+
+const lower = (s) => String(s || '').trim().toLowerCase();
+
+/** The public facts of an RDAP domain answer (the route's summary or the raw RDAP document). */
+function factsFromRdap(data) {
+ const d = data || {};
+ const events = (d.events || []).map((e) => ({ action: e.eventAction || e.action, date: e.eventDate || e.date }));
+ const at = (...names) => { const e = events.find((x) => names.includes(String(x.action || '').toLowerCase())); return e && e.date ? new Date(e.date).toISOString() : null; };
+ const registrarEntity = (d.entities || []).find((e) => (e.roles || []).includes('registrar'));
+ const vcardName = (e) => (e && e.vcardArray && Array.isArray(e.vcardArray[1]) ? (e.vcardArray[1].find((v) => v[0] === 'fn') || [])[3] : null) || (e && e.name) || null;
+ const nameservers = (d.nameservers || []).map((ns) => lower(typeof ns === 'string' ? ns : ns.ldhName || ns.unicodeName)).filter(Boolean).sort();
+ return {
+ registrar: registrarEntity ? String(vcardName(registrarEntity) || registrarEntity.handle || '').slice(0, 200) || null : null,
+ created: at('registration'),
+ updated: at('last changed'), // never "last update of RDAP database": that moves every day by itself
+ expires: at('expiration'),
+ status: [...new Set((d.status || []).map((s) => lower(s)))].sort(),
+ nameservers: [...new Set(nameservers)],
+ dnssec: d.secureDNS && typeof d.secureDNS.delegationSigned === 'boolean' ? d.secureDNS.delegationSigned : null,
+ };
+}
+
+const digestOf = (facts) => crypto.createHash('sha256').update(JSON.stringify(facts)).digest('hex').slice(0, 32);
+
+/** What changed between two snapshots' facts: [{ field, before, after }]. */
+function changesBetween(a, b) {
+ const out = [];
+ for (const field of ['registrar', 'created', 'expires', 'status', 'nameservers', 'dnssec']) {
+ const x = a ? a[field] : null, y = b ? b[field] : null;
+ if (JSON.stringify(x) !== JSON.stringify(y)) out.push({ field, before: x === undefined ? null : x, after: y === undefined ? null : y });
+ }
+ return out;
+}
+
+function createDomainHistory({ db, log = console, now = () => new Date() } = {}) {
+ /** Keep one lookup's facts (RDAP domain answers only); never throws into the tool's answer. */
+ async function record(domain, rdap) {
+ if (!db) return null;
+ const name = lower(domain);
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(name) || name.length > 253 || !/[a-z]/.test(name.split('.').pop())) return null;
+ const facts = factsFromRdap(rdap);
+ const digest = digestOf(facts);
+ const at = now().toISOString();
+ try {
+ const last = await db.maybe('SELECT id, digest FROM domain_snapshots WHERE domain = $1 ORDER BY first_seen DESC, id DESC LIMIT 1', [name]);
+ if (last && last.digest === digest) await db.exec('UPDATE domain_snapshots SET last_seen = $2, seen = seen + 1 WHERE id = $1', [last.id, at]);
+ else await db.exec('INSERT INTO domain_snapshots (domain, facts, digest, first_seen, last_seen) VALUES ($1, $2::jsonb, $3, $4, $4)', [name, JSON.stringify(facts), digest, at]);
+ await db.exec(`INSERT INTO domain_lookups (domain, lookups, last_at, registrar, expires_at) VALUES ($1, 1, $2, $3, $4)
+ ON CONFLICT (domain) DO UPDATE SET lookups = domain_lookups.lookups + 1, last_at = EXCLUDED.last_at, registrar = EXCLUDED.registrar, expires_at = EXCLUDED.expires_at`,
+ [name, at, facts.registrar, facts.expires]);
+ return { changed: !last || last.digest !== digest, facts };
+ } catch (err) {
+ if (log.warn) log.warn('[tools] domain history not kept:', err.message);
+ return null;
+ }
+ }
+
+ /** One domain's history: its snapshots (newest first) and what changed at each. */
+ async function history(domain) {
+ const name = lower(domain);
+ const rows = await db.many('SELECT facts, first_seen, last_seen, seen FROM domain_snapshots WHERE domain = $1 ORDER BY first_seen DESC, id DESC LIMIT $2', [name, MAX_SNAPSHOTS]);
+ const snapshots = rows.map((r) => ({ facts: typeof r.facts === 'string' ? JSON.parse(r.facts) : r.facts, first_seen: new Date(r.first_seen).toISOString(), last_seen: new Date(r.last_seen).toISOString(), seen: Number(r.seen) }));
+ const changes = [];
+ for (let i = 0; i < snapshots.length - 1; i++) {
+ const diff = changesBetween(snapshots[i + 1].facts, snapshots[i].facts);
+ if (diff.length) changes.push({ at: snapshots[i].first_seen, changes: diff });
+ }
+ const lookups = await db.maybe('SELECT lookups FROM domain_lookups WHERE domain = $1', [name]);
+ return { domain: name, snapshots, changes, lookups: lookups ? Number(lookups.lookups) : 0, first_seen: snapshots.length ? snapshots[snapshots.length - 1].first_seen : null };
+ }
+
+ /** The public lists: the most looked up domains and those of them expiring within 60 days (MIN_LISTED lookups each). */
+ async function lists({ limit = 20 } = {}) {
+ const n = Math.min(Math.max(Number(limit) || 20, 1), 100);
+ const most = await db.many('SELECT domain, lookups, registrar, expires_at FROM domain_lookups WHERE lookups >= $1 ORDER BY lookups DESC, domain LIMIT $2', [MIN_LISTED, n]);
+ const soon = await db.many(`SELECT domain, lookups, registrar, expires_at FROM domain_lookups
+ WHERE lookups >= $1 AND expires_at IS NOT NULL AND expires_at > $2 AND expires_at < $3 ORDER BY expires_at LIMIT $4`,
+ [MIN_LISTED, now().toISOString(), new Date(now().getTime() + 60 * 86400000).toISOString(), n]);
+ const row = (r) => ({ domain: r.domain, lookups: Number(r.lookups), registrar: r.registrar || null, expires: r.expires_at ? new Date(r.expires_at).toISOString() : null });
+ return { most_looked_up: most.map(row), expiring_soon: soon.map(row), min_lookups: MIN_LISTED };
+ }
+
+ return { record, history, lists };
+}
+
+module.exports = { createDomainHistory, factsFromRdap, changesBetween, MIN_LISTED };
@@ -154,6 +154,9 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
@@ -154,6 +154,9 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
const guard = opts.guard || null;
const guard = opts.guard || null;
// Traceroute and MTR (net/trace.js): mtr on the checked address; tests pass a tracer with a fake mtr.
// Traceroute and MTR (net/trace.js): mtr on the checked address; tests pass a tracer with a fake mtr.
const tracer = opts.tracer || createTracer({ reverse: reverseDns });
const tracer = opts.tracer || createTracer({ reverse: reverseDns });
+ // Domain history (net/history.js, migration 0008): every domain the WHOIS and RDAP tools look up keeps its public
+ // registration facts over time. index.js passes it, built on the tools database; without it nothing is kept.
+ const history = opts.history || null;
// ── Helpers ──────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────
@@ -550,6 +553,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
@@ -550,6 +553,8 @@ module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
if (!r.ok) return fail(res, r.status === 404 ? `No registration record for ${target}` : `RDAP lookup failed (${r.status})`, r.status >= 500 ? 502 : 404);
if (!r.ok) return fail(res, r.status === 404 ? `No registration record for ${target}` : `RDAP lookup failed (${r.status})`, r.status >= 500 ? 502 : 404);
const data = r.data;
const data = r.data;
+ // Kept in the domain's history without holding the answer up (a failure only logs).
+ if (rdapType === 'domain' && history) history.record(target, data).catch(() => {});
// Extract key fields from RDAP response
// Extract key fields from RDAP response
const summary = {
const summary = {
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+// Domain history (server/net/history.js, migration 0008) on a real in-memory PGlite database:
+// - a domain's RDAP facts are kept (registrar, dates, status, name servers, DNSSEC) and never its contacts' details;
+// - the same facts again move the last snapshot's last_seen; changed facts write a new snapshot, and the history says
+// what changed and when;
+// - the lists show only domains looked up five times or more ("most looked up", "expiring soon");
+// - the routes: a WHOIS lookup records without holding up its answer; anyone reads GET /api/v1/domains/:domain and
+// /api/v1/domains (any origin, cached a minute).
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { createDb } = require('openvibe-sdk/db');
+const { openToolsDb } = require('../../_shared/db');
+const { createDomainHistory, factsFromRdap, changesBetween } = require('../server/net/history');
+const createNetRoutes = require('../server/net/routes');
+const { createDomainHistoryRoutes } = require('../server/net/history-routes');
+
+const rdap = ({ registrar = 'Cloudflare, Inc.', expires = '2027-03-01T00:00:00Z', ns = ['NS1.EXAMPLE.NET', 'ns2.example.net'], status = ['client transfer prohibited'] } = {}) => ({
+ ldhName: 'EXAMPLE.ORG', status, secureDNS: { delegationSigned: false },
+ events: [{ eventAction: 'registration', eventDate: '2019-03-01T10:00:00Z' }, { eventAction: 'expiration', eventDate: expires }, { eventAction: 'last update of RDAP database', eventDate: new Date().toISOString() }],
+ entities: [
+ { roles: ['registrar'], handle: '1910', vcardArray: ['vcard', [['version', {}, 'text', '4.0'], ['fn', {}, 'text', registrar]]] },
+ { roles: ['registrant'], vcardArray: ['vcard', [['fn', {}, 'text', 'Alice Private'], ['email', {}, 'text', '[email protected]'], ['tel', {}, 'text', '+1 555 0100']]] },
+ ],
+ nameservers: ns.map((n) => ({ ldhName: n })),
+});
+
+(async () => {
+ // ── the facts ──
+ const f = factsFromRdap(rdap());
+ assert.deepStrictEqual(f, { registrar: 'Cloudflare, Inc.', created: '2019-03-01T10:00:00.000Z', updated: null, expires: '2027-03-01T00:00:00.000Z', status: ['client transfer prohibited'], nameservers: ['ns1.example.net', 'ns2.example.net'], dnssec: false });
+ assert.ok(!/Alice|alice@|555/.test(JSON.stringify(f)), 'never a contact\'s name, email or phone');
+ assert.deepStrictEqual(changesBetween(f, { ...f, registrar: 'Gandi SAS' }), [{ field: 'registrar', before: 'Cloudflare, Inc.', after: 'Gandi SAS' }]);
+
+ // ── on a real database ──
+ const { db, ready } = openToolsDb({ createDb, service: 'tools-test', log: { warn() {}, info() {}, log() {}, error() {} } });
+ await ready;
+ let clock = Date.parse('2026-10-01T00:00:00Z');
+ const h = createDomainHistory({ db, log: { warn() {} }, now: () => new Date(clock) });
+ assert.strictEqual((await h.record('Example.org', rdap())).changed, true, 'the first lookup writes a snapshot');
+ clock += 86400000;
+ assert.strictEqual((await h.record('example.org', rdap())).changed, false, 'the same facts again: no new snapshot');
+ clock += 86400000;
+ assert.strictEqual((await h.record('example.org', rdap({ registrar: 'Gandi SAS', ns: ['a.ns.gandi.net', 'b.ns.gandi.net'], expires: '2028-03-01T00:00:00Z' }))).changed, true, 'a transfer is a new snapshot');
+ const hist = await h.history('EXAMPLE.ORG');
+ assert.strictEqual(hist.snapshots.length, 2);
+ assert.strictEqual(hist.snapshots[1].seen, 2, 'the first snapshot was seen twice');
+ assert.strictEqual(hist.first_seen, '2026-10-01T00:00:00.000Z');
+ assert.strictEqual(hist.lookups, 3);
+ assert.deepStrictEqual(hist.changes[0].changes.map((c) => c.field), ['registrar', 'expires', 'nameservers'], 'what changed: the registrar, the expiry and the name servers');
+ assert.strictEqual(hist.changes[0].at, '2026-10-03T00:00:00.000Z');
+ assert.strictEqual(await h.record('not a domain', rdap()), null, 'only domain names are kept');
+
+ // ── the lists: five lookups or more ──
+ let lists = await h.lists();
+ assert.deepStrictEqual(lists.most_looked_up, [], 'three lookups: not listed yet');
+ for (let i = 0; i < 2; i++) await h.record('example.org', rdap({ registrar: 'Gandi SAS', ns: ['a.ns.gandi.net', 'b.ns.gandi.net'], expires: '2026-11-15T00:00:00Z' }));
+ lists = await h.lists();
+ assert.deepStrictEqual(lists.most_looked_up.map((d) => [d.domain, d.lookups]), [['example.org', 5]]);
+ assert.deepStrictEqual(lists.expiring_soon.map((d) => d.domain), ['example.org'], 'expires within 60 days');
+
+ // ── the routes ──
+ const fetchRdap = async (u) => ({ ok: true, status: 200, headers: new Map([['content-type', 'application/rdap+json']]), json: async () => (/rdap\.org|bootstrap|dns\.json/.test(String(u)) ? { services: [[['org'], ['https://rdap.example/']]] } : rdap({ registrar: 'Porkbun LLC' })), text: async () => '' });
+ const app = express();
+ const routesHistory = createDomainHistory({ db, log: { warn() {} } });
+ app.use('/api/net', createNetRoutes(null, (_q, _s, n) => n(), { fetch: fetchRdap, history: routesHistory }));
+ app.use('/api/v1/domains', createDomainHistoryRoutes({ history: routesHistory }));
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const root = `http://127.0.0.1:${server.address().port}`;
+ const get = async (p) => { const r = await fetch(root + p); return { status: r.status, json: await r.json(), cors: r.headers.get('access-control-allow-origin') }; };
+ const who = await get('/api/net/whois/fresh-domain.org');
+ assert.strictEqual(who.status, 200, JSON.stringify(who.json).slice(0, 200));
+ for (let i = 0; i < 30 && !(await get('/api/v1/domains/fresh-domain.org')).json.snapshots.length; i++) await new Promise((r) => setTimeout(r, 50));
+ const one = await get('/api/v1/domains/Fresh-Domain.org');
+ assert.deepStrictEqual([one.json.ok, one.json.snapshots.length, one.json.snapshots[0].facts.registrar, one.cors], [true, 1, 'Porkbun LLC', '*'], 'a WHOIS lookup is kept, and anyone may read it');
+ assert.strictEqual((await get('/api/v1/domains/1.2.3.4')).status, 400, 'a domain name, not an address');
+ const all = await get('/api/v1/domains');
+ assert.ok(all.json.ok && Array.isArray(all.json.most_looked_up) && all.json.min_lookups === 5);
+ server.close();
+ await db.close();
+ console.log('domain history: facts without contacts, snapshots on change, lists after five lookups, routes ok');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+-- domain_snapshots: the public registration facts (RDAP: registrar, dates, status, name servers, DNSSEC) of each domain
+-- someone looked up on the WHOIS and RDAP tools, kept over time (apps/gateway/server/net/history.js; the owner,
+-- 2026-10-28: "caching of Whois data or domain lookups… historical or archive data… lists… comparisons"). A new row only
+-- when the facts change; otherwise the last row's last_seen moves. Never a registrant's contact details, never who looked.
+-- domain_lookups: how often each domain was looked up, for the "most looked up" and "expiring soon" lists (a domain is
+-- listed only after five lookups, so nobody's own new domain shows there). Additive: two new empty tables.
+CREATE TABLE IF NOT EXISTS domain_snapshots (
+ id bigserial PRIMARY KEY,
+ domain text NOT NULL,
+ facts jsonb NOT NULL,
+ digest text NOT NULL,
+ first_seen timestamptz NOT NULL DEFAULT now(),
+ last_seen timestamptz NOT NULL DEFAULT now(),
+ seen integer NOT NULL DEFAULT 1
+);
+CREATE INDEX IF NOT EXISTS domain_snapshots_domain ON domain_snapshots (domain, first_seen DESC);
+
+CREATE TABLE IF NOT EXISTS domain_lookups (
+ domain text PRIMARY KEY,
+ lookups integer NOT NULL DEFAULT 0,
+ last_at timestamptz NOT NULL DEFAULT now(),
+ registrar text,
+ expires_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS domain_lookups_lookups ON domain_lookups (lookups DESC);
+CREATE INDEX IF NOT EXISTS domain_lookups_expires ON domain_lookups (expires_at);