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)

OP OpenVibers committed 16 days ago · b1b6cc4aaf · 7 files, +287 −1

Changes

ddiff --git a/apps/gateway/public/net.html b/apps/gateway/public/net.html
iindex cdf0ade..87b9ca0 100644
---- a/apps/gateway/public/net.html
++++ b/apps/gateway/public/net.html
@@@ -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 toggle
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>`;
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) {
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex b726d05..e0d2215 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -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) ────────────────────────
ddiff --git a/apps/gateway/server/net/history-routes.js b/apps/gateway/server/net/history-routes.js
nnew file mode 100644
iindex 0000000..9310c0e
---- /dev/null
++++ b/apps/gateway/server/net/history-routes.js
@@@ -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 };
ddiff --git a/apps/gateway/server/net/history.js b/apps/gateway/server/net/history.js
nnew file mode 100644
iindex 0000000..7278b3a
---- /dev/null
++++ b/apps/gateway/server/net/history.js
@@@ -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 };
ddiff --git a/apps/gateway/server/net/routes.js b/apps/gateway/server/net/routes.js
iindex e1ba65c..78c6e7c 100644
---- a/apps/gateway/server/net/routes.js
++++ b/apps/gateway/server/net/routes.js
@@@ -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 response
const summary = {
ddiff --git a/apps/gateway/test/domain-history.test.js b/apps/gateway/test/domain-history.test.js
nnew file mode 100644
iindex 0000000..fca63bb
---- /dev/null
++++ b/apps/gateway/test/domain-history.test.js
@@@ -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); });
ddiff --git a/migrations/0008_domain_history.sql b/migrations/0008_domain_history.sql
nnew file mode 100644
iindex 0000000..03519b6
---- /dev/null
++++ b/migrations/0008_domain_history.sql
@@@ -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);