openvibers/openvibe.host Commits

Host: domain prices compared, and a name check over RDAP (/catalog/domains) (#115)

OP OpenVibers committed 28 days ago · 96703d71d3 · 19 files, +972 −25

* Host: domain prices compared (first year, renewal, transfer, five years per ending) and a name check at the registry over RDAP, at /catalog/domains

* Host domains test: simulate the missing tables instead of dropping them (the app role cannot drop on PostgreSQL)

Changes

ddiff --git a/README.md b/README.md
iindex 9e67359..5acccd4 100644
---- a/README.md
++++ b/README.md
@@@ -25,12 +25,13 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndTalk's Offers board and LowEndBox's offer posts, from their public feeds through Sources (`lowendtalk-offers`, `lowendbox-posts`; `server/catalog/offers.js`). Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
++- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
--- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id` and `/catalog/promos`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
++- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers` and `/catalog/domains` (with each ending's page and the name check). They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
## Static hosting moved
@@@ -74,7 +75,7 @@ Stage A adds a few safety rules of its own:
## Owns
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
--- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), and the catalog's Search documents (`host.index_document.*`).
++- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), and the catalog's Search documents (`host.index_document.*`).
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
ddiff --git a/STATUS.json b/STATUS.json
iindex be7c9d6..d5e83ed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -47,7 +47,8 @@
"Prometheus alerts relay to Network",
"browser check and release-lifecycle acceptance suite",
"catalog provider and plan discussion threads on OpenVibe.Community (optional HOST_COMMUNITY_URL; server-rendered comments, after-commit visibility sync)",
-- "hosting comparison catalog with providers, plans, promotions, price history and discussion"
++ "hosting comparison catalog with providers, plans, promotions, price history and discussion",
++ "domain prices compared (first year, renewal, transfer, five years per TLD from registrars' public lists through Sources) and a name check over RDAP at /catalog/domains"
],
"retired": {
"movedTo": "https://openvibe.space",
ddiff --git a/migrations/0011_catalog_domains.sql b/migrations/0011_catalog_domains.sql
nnew file mode 100644
iindex 0000000..a1bbea4
---- /dev/null
++++ b/migrations/0011_catalog_domains.sql
@@@ -0,0 +1,44 @@
++-- phase: expand
++-- Domain names compared (plan T20, the owner's "like tld-list.com"): what each registrar charges for a TLD, for the
++-- first year, a renewal and a transfer, read from registrars' public price lists through OpenVibe.Sources (category
++-- hosting, server/catalog/domains.js) or entered by staff with the page it came from. The registrar is a catalog
++-- provider. Every price is an observation with the time it was checked; changes are kept, append-only.
++CREATE TABLE catalog_tld_prices (
++ id text COLLATE "C" PRIMARY KEY,
++ provider_id text COLLATE "C" NOT NULL REFERENCES catalog_providers(id),
++ tld text COLLATE "C" NOT NULL CHECK (tld ~ '^(xn--)?[a-z0-9]+(-[a-z0-9]+)*(\.(xn--)?[a-z0-9]+(-[a-z0-9]+)*)*$' AND length(tld) <= 120),
++ currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
++ register numeric(12,4) CHECK (register >= 0),
++ renew numeric(12,4) CHECK (renew >= 0),
++ transfer numeric(12,4) CHECK (transfer >= 0),
++ origin text NOT NULL CHECK (origin IN ('sources','staff')),
++ source_key text,
++ source_item_id text,
++ source_url text NOT NULL,
++ checked_at bigint NOT NULL,
++ first_seen_at bigint NOT NULL,
++ last_seen_at bigint NOT NULL,
++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','gone','hidden')),
++ created_by text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ UNIQUE (provider_id, tld),
++ CHECK (register IS NOT NULL OR renew IS NOT NULL OR transfer IS NOT NULL),
++ CHECK (origin = 'staff' OR (source_key IS NOT NULL AND source_item_id IS NOT NULL))
++);
++CREATE INDEX catalog_tld_prices_tld ON catalog_tld_prices (tld, status, register);
++CREATE INDEX catalog_tld_prices_register ON catalog_tld_prices (status, currency, register, tld);
++CREATE TABLE catalog_tld_price_changes (
++ price_id text COLLATE "C" NOT NULL REFERENCES catalog_tld_prices(id),
++ observed_at bigint NOT NULL,
++ currency text NOT NULL,
++ register_before numeric(12,4),
++ register_after numeric(12,4),
++ renew_before numeric(12,4),
++ renew_after numeric(12,4),
++ transfer_before numeric(12,4),
++ transfer_after numeric(12,4),
++ PRIMARY KEY (price_id, observed_at)
++);
++CREATE FUNCTION catalog_tld_price_changes_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'host: domain price history is immutable'; END $$;
++CREATE TRIGGER catalog_tld_price_changes_immutable BEFORE UPDATE OR DELETE ON catalog_tld_price_changes FOR EACH ROW EXECUTE FUNCTION catalog_tld_price_changes_immutable();
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex e88ccb4..fd79552 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -154,3 +154,39 @@
/* Filled buttons on the readable accent (white on --accent-strong passes AA; dark text on the light accent does not everywhere). */
.hc-visit .button:not(.hc-button-quiet), .hc-more .button:not(.hc-button-quiet) { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
.hc-watch { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .8rem; margin: .9rem 0 0; }
++
++/* Domains (server/render/domains.js): the name check, registrar links and a check's verdict. */
++.hd-check { display: block; margin: 0 0 1.25rem; padding: 1rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); max-width: 46rem; }
++.hd-check label { display: block; margin-bottom: .45rem; font-weight: 600; }
++.hd-check-row { display: flex; gap: .5rem; width: 100%; }
++.hd-check-row input { flex: 1 1 auto; width: 100%; min-width: 0; box-sizing: border-box; min-height: 2.75rem; font-size: 1.05rem; color: var(--hs-fg); background: var(--hs-bg); }
++.hd-check-row button { flex: none; min-height: 2.75rem; padding: 0 1.25rem; font-weight: 700; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.hd-check-note { margin: .45rem 0 0; font-size: 13px; color: var(--hs-muted); }
++.hd-registrars { list-style: none; margin: .5rem 0 1.5rem; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: .5rem; }
++.hd-registrars a { display: flex; flex-direction: column; gap: .1rem; padding: .6rem .8rem; border: 1px solid var(--hs-line); border-radius: 10px; background: var(--hs-card); color: var(--hs-fg); text-decoration: none; overflow-wrap: anywhere; }
++.hd-registrars a:hover { border-color: var(--hs-accent); }
++.hd-registrars span { font-weight: 600; }
++.hd-registrars small { font-size: 12px; color: var(--hs-muted); }
++.hd-name { overflow-wrap: anywhere; }
++.hd-status { margin: 0 0 1.5rem; padding: 1rem 1.2rem; border-radius: 12px; border: 1px solid var(--hs-line); border-left-width: 4px; background: var(--hs-card); max-width: 46rem; }
++.hd-status p { margin: .3rem 0; }
++.hd-verdict { font-size: 1.35rem; font-weight: 800; letter-spacing: -.01em; }
++.hd-registered { border-left-color: var(--hs-warn); }
++.hd-unregistered { border-left-color: var(--hs-accent); }
++.hd-unknown { border-left-color: var(--hs-line); }
++.hd-cost strong { font-variant-numeric: tabular-nums; }
++.hd-src { font-size: 13px; }
++.hd-go { text-align: right; white-space: nowrap; }
++.hd-go .button { font-size: 13px; padding: .35rem .7rem; }
++.hd-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.hd-front .hd-check { margin-top: .75rem; }
++@media (max-width: 44rem) {
++ .hd-table td.hd-first { grid-column: 2; order: -1; text-align: right; align-self: start; }
++ .hd-table td.hd-first::before { display: none; }
++ .hd-table td.hd-first .hc-price { font-size: 18px; }
++ .hd-table td.hd-go { grid-column: 1 / -1; text-align: left; }
++ .hd-table td.hd-go::before { display: none; }
++ .hd-table td[data-label="Checked"] { grid-column: 1 / -1; }
++ /* The list of endings stays short on a phone: the first year, the renewal and five years; the rest is on each ending's page. */
++ .hd-list td[data-label="Transfer"], .hd-list td[data-label="Registrars"], .hd-list td[data-label="Checked"] { display: none; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 7789799..f0d7e83 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -25,6 +25,9 @@ const { createCatalog } = require('./catalog/store');
const { createCommunity } = require('./catalog/community');
const { createCatalogIngest } = require('./catalog/ingest');
const { createCatalogSearch } = require('./catalog/search-index');
++const { createRdap } = require('./catalog/rdap');
++const { createDomainCheck } = require('./catalog/domain-check');
++const domainsRender = require('./render/domains');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
const catalogRender = require('./render/catalog');
@@@ -73,10 +76,11 @@ async function createApp(opts = {}) {
})();
const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
++ const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
-- const ctx = { config, store, outbox, catalog, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
++ const ctx = { config, store, outbox, catalog, domainCheck, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
const app = express();
app.disable('x-powered-by');
app.disable('etag');
@@@ -118,29 +122,32 @@ async function createApp(opts = {}) {
app.get('/api/ready', createHostReadiness({ store, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, valkey }).handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
const legalPaths = require('openvibe-shared/legal').PATHS;
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /catalog/domains/check', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/sitemap.xml', async (_req, res, next) => {
try {
-- const [providers, plans] = await Promise.all([catalog.providers(), catalog.sitemapPlans()]);
++ const [providers, plans, tlds] = await Promise.all([catalog.providers(), catalog.sitemapPlans(), catalog.domains.sitemap()]);
const day = (ms) => Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString().slice(0, 10) : undefined;
const latest = (rows) => day(rows.reduce((m, p) => Math.max(m, Number(p.checked_at) || 0), 0) || undefined);
const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/providers', lastmod: latest(plans) },
...providers.map((p) => ({ path: `/catalog/providers/${p.slug}`, lastmod: latest(plans.filter((v) => v.provider_slug === p.slug)) })),
-- ...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) }))];
++ ...plans.map((p) => ({ path: `/catalog/plans/${p.id}`, lastmod: day(p.checked_at) })),
++ ...(tlds.length ? [{ path: '/catalog/domains', lastmod: latest(tlds) }, ...tlds.map((t) => ({ path: `/catalog/domains/${encodeURIComponent(t.tld)}`, lastmod: day(t.checked_at) }))] : [{ path: '/catalog/domains' }])];
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.sitemapXml(urls.map((u) => ({ loc: `${config.baseUrl}${u.path}`, lastmod: u.lastmod }))));
} catch (err) { next(err); }
});
-- const summary = 'OpenVibe.Host compares cloud, VPS and dedicated hosting plans with dated and sourced prices.';
++ const summary = 'OpenVibe.Host compares cloud, VPS and dedicated hosting plans and domain name prices, every price dated and sourced.';
const publicPages = [
{ title: 'OpenVibe.Host', url: '/', text: summary },
{ title: 'Compare hosting plans', url: '/catalog', text: 'Filter and compare hosting plans by price and resources.' },
{ title: 'Hosting providers', url: '/catalog/providers', text: 'Every provider in the catalog.' },
{ title: 'Hosting promotions', url: '/catalog/promos', text: 'Current provider promotions.' },
++ { title: 'Compare domain prices', url: '/catalog/domains', text: 'What registrars charge for each domain ending: first year, renewal, transfer and five years, and a name check at the registry.' },
{ title: 'What shipped on OpenVibe.Host', url: '/updates', text: 'The public update log.' },
...legalPaths.map((p) => ({ title: p.slice(1), url: p, text: `The ${p.slice(1)} of OpenVibe.Host.` })),
];
const allPages = async () => [...publicPages,
...(await catalog.providers()).map((p) => ({ title: p.name, url: `/catalog/providers/${p.slug}`, text: `Hosting plans from ${p.name}.` })),
++ ...(await catalog.domains.sitemap()).map((t) => ({ title: `${require('./catalog/domains').displayTld(t.tld)} domain prices`, url: `/catalog/domains/${encodeURIComponent(t.tld)}`, text: 'Every registrar\'s price for this ending.' })),
...(await catalog.sitemapPlans()).map((p) => ({ title: `Hosting plan ${p.id}`, url: `/catalog/plans/${p.id}`, text: 'Sourced plan and price history.' }))];
app.get('/llms.txt', async (_req, res, next) => { try { res.type('text/plain').send(sharedSeo.llmsTxt({ name: 'OpenVibe.Host', summary, sections: [{ title: 'Public pages', links: (await allPages()).map((p) => ({ title: p.title, url: `${config.baseUrl}${p.url}` })) }] })); } catch (err) { next(err); } });
app.get('/llms-full.txt', async (_req, res, next) => { try { res.type('text/plain').send(sharedSeo.llmsFull({ site: { name: 'OpenVibe.Host', url: config.baseUrl }, summary, base: config.baseUrl, maxBytes: 512 * 1024, sections: [{ title: 'Public pages', pages: await allPages() }] })); } catch (err) { next(err); } });
@@@ -163,6 +170,8 @@ async function createApp(opts = {}) {
const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
// Lookaheads: app.use strips what the path matched, and a consumed '/' would make Express skip the route.
app.use(/^\/api\/v1\/(?:projects|sites|deploys|domains|resources)(?=\/|$)/, movedApi);
++ // /domains was static hosting's custom domains (moved to Space); the page itself is now the domain comparison.
++ app.get('/domains', (_req, res) => res.redirect(301, '/catalog/domains'));
app.use(/^\/(?:hosting|projects|sites|deploys|domains|preview)(?=\/|$)/, movedPage);
app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
app.get('/providers', (_req, res) => res.redirect(301, '/catalog/providers'));
@@@ -170,8 +179,8 @@ async function createApp(opts = {}) {
app.get('/updates', (_req, res) => res.type('html').send(renderPage({ title: 'What shipped on OpenVibe.Host', body: frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, config, path: '/updates', indexable: true })));
app.get('/', viewers.middleware('dashboard'), async (req, res, next) => {
try {
-- const [result, providers, promos, stats, recent] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({})]);
-- res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
++ const [result, providers, promos, stats, recent, popular, domainStats] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}), catalog.domains.list({}), catalog.domains.stats()]);
++ res.type('html').send(renderPage({ body: catalogRender.front({ plans: result.plans, providers, promos, stats, offers: recent.offers, domains: domainsRender.frontSection({ popular: popular.tlds, stats: domainStats }) }), viewer: req.viewer, config, path: '/', indexable: true, styles: [showcase.STYLESHEET], css: ['css/catalog.css'], jsonLd: catalogRender.jsonLd.front(config.baseUrl, result.plans) }));
} catch (err) { next(err); }
});
app.use((req, res) => req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }) : res.status(404).type('html').send(renderPage({ title: 'Not found', body: '<h1>Not found</h1>', config, path: req.originalUrl })));
ddiff --git a/server/catalog/domain-check.js b/server/catalog/domain-check.js
nnew file mode 100644
iindex 0000000..e642ea9
---- /dev/null
++++ b/server/catalog/domain-check.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * A name check (/catalog/domains/check, /api/v1/catalog/domains/check): read what the person typed, ask the registry
++ * over RDAP whether it is registered, and price the same name at the popular endings.
++ *
++ * createDomainCheck({ catalog, rdap }) → { check(input) }
++ * check('OpenVibe.dev') → { label, tld, name, display, labelDisplay, status, registrar, created, expires, server, reason, cached, prices }
++ *
++ * A bare label ("openvibe") is checked as .com. A name that is not a domain name is a 422.
++ */
++const url = require('url');
++const { ApiError } = require('../http/errors');
++const domains = require('./domains');
++
++function createDomainCheck({ catalog, rdap }) {
++ async function check(input) {
++ const known = await catalog.domains.knownTlds();
++ const parsed = domains.parseName(typeof input === 'string' ? input : '', known);
++ if (!parsed) throw new ApiError(422, 'host.domain.invalid', 'That is not a domain name: letters, digits and hyphens, such as my-site.dev.');
++ const tld = parsed.tld || 'com';
++ const name = parsed.name || `${parsed.label}.com`;
++ const answer = await rdap.lookup(name);
++ // The name's own ending first, then up to twelve popular endings that have a listed price.
++ const prices = (await catalog.domains.cheapest([tld, ...domains.POPULAR.filter((t) => t !== tld)])).slice(0, 13);
++ return {
++ label: parsed.label, labelDisplay: url.domainToUnicode(parsed.label) || parsed.label, tld, name, display: url.domainToUnicode(name) || name,
++ status: answer.status, registrar: answer.registrar || null, created: answer.created || null, expires: answer.expires || null,
++ server: answer.server || null, reason: answer.reason || null, cached: Boolean(answer.cached), prices,
++ };
++ }
++ return { check };
++}
++
++module.exports = { createDomainCheck };
ddiff --git a/server/catalog/domain-store.js b/server/catalog/domain-store.js
nnew file mode 100644
iindex 0000000..d9e5e53
---- /dev/null
++++ b/server/catalog/domain-store.js
@@@ -0,0 +1,191 @@
++'use strict';
++/**
++ * Domain prices in the catalog (migrations/0011_catalog_domains.sql, server/catalog/domains.js): one row per registrar
++ * and TLD, applied from Sources items; the TLD table, a TLD's page and the cheapest price for a list of endings.
++ *
++ * Until 0011 has applied (a held contract migration holds every later one) the tables do not exist: reads answer
++ * empty and `ready()` is false, so pages leave the price sections out rather than fail.
++ */
++const { newId } = require('../ids');
++const { ApiError } = require('../http/errors');
++const domains = require('./domains');
++
++const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
++const PAGE = 100;
++const POPULAR_RANK = `CASE p.tld ${domains.POPULAR.map((t, i) => `WHEN '${t}' THEN ${i}`).join(' ')} ELSE ${domains.POPULAR.length} END`;
++// Five years at a registrar: the first year, then four renewals (a registrar that states only one of them uses it).
++const FIVE_YEAR = '(COALESCE(p.register, p.renew) + 4 * COALESCE(p.renew, p.register))';
++const SORTS = {
++ popular: `MIN(${POPULAR_RANK}) ASC, p.tld ASC`,
++ tld: 'p.tld ASC',
++ register: 'MIN(p.register) ASC NULLS LAST, p.tld ASC',
++ renew: 'MIN(p.renew) ASC NULLS LAST, p.tld ASC',
++ five_year: `MIN(${FIVE_YEAR}) ASC NULLS LAST, p.tld ASC`,
++};
++const KINDS = {
++ country: "p.tld ~ '^[a-z]{2}$'",
++ generic: "position('.' in p.tld) = 0 AND p.tld !~ '^[a-z]{2}$' AND p.tld NOT LIKE 'xn--%'",
++ 'second-level': "position('.' in p.tld) > 0",
++ internationalized: "position('.' in p.tld) = 0 AND p.tld LIKE 'xn--%'",
++};
++const missingTable = (err) => err && err.code === '42P01';
++const n = (v) => (v == null ? null : Number(v));
++const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
++
++function createDomainStore({ store, announce = () => {} }) {
++ const db = store.db;
++ let known = { at: 0, set: new Set() };
++ let isReady = null;
++
++ async function ready() {
++ if (isReady) return true;
++ const r = await db.prepare("SELECT to_regclass('public.catalog_tld_prices') AS t").get();
++ isReady = Boolean(r && r.t);
++ return isReady;
++ }
++ async function safe(fn, fallback) {
++ try { return await fn(); } catch (err) { if (missingTable(err)) { isReady = false; return fallback; } throw err; }
++ }
++
++ async function apply(item, t = db) {
++ const now = store.now();
++ if (item.removed) {
++ const tld = domains.normalizeTld(item.identity);
++ if (!tld) return 'hold';
++ const r = await t.prepare("UPDATE catalog_tld_prices SET status = 'gone', updated_at = ? WHERE source_key = ? AND tld = ? AND status = 'active'").run(now, item.source_key, tld);
++ if (r.changes) announce([`/catalog/domains/${encodeURIComponent(tld)}`]);
++ return r.changes ? 'removed' : 'hold';
++ }
++ const v = domains.mapTldPrice(item);
++ if (!v) return 'hold';
++ let p = await t.prepare('SELECT id, status FROM catalog_providers WHERE slug = ?').get(v.provider.slug);
++ if (!p) {
++ const id = newId('catalogProvider', now);
++ await t.prepare('INSERT INTO catalog_providers (id, slug, name, homepage_url, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, v.provider.slug, v.provider.name, v.provider.homepage_url, 'active', now, now);
++ p = { id, status: 'active' };
++ }
++ const old = await t.prepare('SELECT * FROM catalog_tld_prices WHERE provider_id = ? AND tld = ?').get(p.id, v.tld);
++ const page = `/catalog/domains/${encodeURIComponent(v.tld)}`;
++ if (old) {
++ if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
++ const moved = ['register', 'renew', 'transfer'].some((k) => n(old[k]) !== v[k]) || old.currency !== v.currency;
++ if (moved) {
++ await t.prepare('INSERT INTO catalog_tld_price_changes (price_id, observed_at, currency, register_before, register_after, renew_before, renew_after, transfer_before, transfer_after) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING')
++ .run(old.id, v.checked_at, v.currency, old.register, v.register, old.renew, v.renew, old.transfer, v.transfer);
++ }
++ // A TLD the registrar stopped selling and sells again comes back; one staff hid stays hidden.
++ const status = old.status === 'gone' ? 'active' : old.status;
++ await t.prepare('UPDATE catalog_tld_prices SET currency = ?, register = ?, renew = ?, transfer = ?, source_item_id = ?, source_url = ?, checked_at = ?, last_seen_at = ?, status = ?, updated_at = ? WHERE id = ?')
++ .run(v.currency, v.register, v.renew, v.transfer, v.source_item_id, v.source_url, v.checked_at, v.checked_at, status, now, old.id);
++ if ((moved || status !== old.status) && status === 'active' && p.status === 'active') announce([page]);
++ return 'applied';
++ }
++ await t.prepare('INSERT INTO catalog_tld_prices (id, provider_id, tld, currency, register, renew, transfer, origin, source_key, source_item_id, source_url, checked_at, first_seen_at, last_seen_at, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(newId('catalogTldPrice', now), p.id, v.tld, v.currency, v.register, v.renew, v.transfer, 'sources', v.source_key, v.source_item_id, v.source_url, v.checked_at, v.first_seen_at, v.checked_at, 'active', now, now);
++ known.at = 0;
++ if (p.status === 'active') announce([page, '/catalog/domains']);
++ return 'applied';
++ }
++
++ const tldRow = (r) => ({
++ tld: r.tld, display: domains.displayTld(r.tld), kind: domains.tldKind(r.tld), currency: r.currency,
++ register: n(r.register), renew: n(r.renew), transfer: n(r.transfer), five_year: n(r.five_year),
++ registrars: Number(r.registrars), checked_at: iso(r.checked_at),
++ });
++ const ACTIVE = "p.status = 'active' AND v.status = 'active'";
++
++ /** The TLD table: the cheapest price at any registrar for each TLD, filtered and sorted. */
++ async function list(query = {}) {
++ const allowed = new Set(['q', 'kind', 'sort', 'registrar', 'cursor']);
++ for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
++ const sort = query.sort || 'popular';
++ if (!SORTS[sort]) invalid('Invalid sort');
++ if (query.kind && !KINDS[query.kind]) invalid('Invalid kind');
++ const offset = query.cursor == null || query.cursor === '' ? 0 : Number(query.cursor);
++ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 5000) invalid('Invalid cursor');
++ const args = ['USD'];
++ let where = `${ACTIVE} AND p.currency = ?`;
++ if (query.q) {
++ const q = domains.normalizeTld(String(query.q));
++ if (!q) invalid('Type an ending such as .dev or co.uk');
++ where += ' AND (p.tld = ? OR p.tld LIKE ?)'; args.push(q, `%${q}%`);
++ }
++ if (query.kind) where += ` AND ${KINDS[query.kind]}`;
++ if (query.registrar) {
++ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(query.registrar))) invalid('Invalid registrar');
++ where += ' AND v.slug = ?'; args.push(query.registrar);
++ }
++ return await safe(async () => {
++ const rows = await db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} GROUP BY p.tld ORDER BY ${SORTS[sort]} LIMIT ${PAGE + 1} OFFSET ${offset}`).all(...args);
++ return { tlds: rows.slice(0, PAGE).map(tldRow), next_cursor: rows.length > PAGE ? String(offset + PAGE) : null };
++ }, { tlds: [], next_cursor: null });
++ }
++
++ /** One TLD: every registrar's price (cheapest first year first) and the latest price changes. */
++ async function tld(input) {
++ const t = domains.normalizeTld(String(input || ''));
++ if (!t) throw new ApiError(404, 'host.catalog.not_found', 'Not a domain ending');
++ const data = await safe(async () => {
++ const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
++ const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
++ WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];
++ return { rows, history };
++ }, { rows: [], history: [] });
++ if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');
++ const prices = data.rows.map((r) => ({
++ registrar: r.provider_name, registrar_slug: r.provider_slug, homepage_url: r.homepage_url, affiliate_url: r.affiliate_url, affiliate_note: r.affiliate_note,
++ currency: r.currency, register: n(r.register), renew: n(r.renew), transfer: n(r.transfer), five_year: n(r.five_year),
++ source_url: r.source_url, checked_at: iso(r.checked_at), first_seen_at: iso(r.first_seen_at),
++ }));
++ const min = (k) => prices.reduce((m, p) => (p[k] != null && (m == null || p[k] < m) ? p[k] : m), null);
++ return {
++ tld: t, display: domains.displayTld(t), kind: domains.tldKind(t), currency: 'USD', prices,
++ cheapest: { register: min('register'), renew: min('renew'), transfer: min('transfer'), five_year: min('five_year') },
++ history: data.history.map((h) => ({
++ registrar: h.provider_name, observed_at: iso(h.observed_at), currency: h.currency,
++ register: [n(h.register_before), n(h.register_after)], renew: [n(h.renew_before), n(h.renew_after)], transfer: [n(h.transfer_before), n(h.transfer_after)],
++ })),
++ };
++ }
++
++ /** The cheapest prices for a few endings (a name check's alternatives), in the order asked. */
++ async function cheapest(tlds) {
++ const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);
++ if (!list.length) return [];
++ const rows = await safe(() => db.prepare(`SELECT p.tld, 'USD' AS currency, MIN(p.register) AS register, MIN(p.renew) AS renew, MIN(p.transfer) AS transfer, MIN(${FIVE_YEAR}) AS five_year, COUNT(*) AS registrars, MAX(p.checked_at) AS checked_at
++ FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.currency = 'USD' AND p.tld IN (${list.map(() => '?').join(', ')}) GROUP BY p.tld`).all(...list), []);
++ const by = new Map(rows.map((r) => [r.tld, tldRow(r)]));
++ return list.filter((t) => by.has(t)).map((t) => by.get(t));
++ }
++
++ /** Every TLD with a listed price (for reading what a person typed), refreshed every five minutes. */
++ async function knownTlds() {
++ if (store.now() - known.at < 5 * 60_000) return known.set;
++ const rows = await safe(() => db.prepare(`SELECT DISTINCT p.tld FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).all(), []);
++ known = { at: store.now(), set: new Set(rows.map((r) => r.tld)) };
++ return known.set;
++ }
++
++ async function stats() {
++ return await safe(async () => {
++ const r = await db.prepare(`SELECT COUNT(DISTINCT p.tld) AS tlds, MAX(p.checked_at) AS last FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE}`).get();
++ const names = await db.prepare(`SELECT DISTINCT v.name FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} ORDER BY v.name`).all();
++ return { tlds: Number(r.tlds) || 0, registrars: names.map((x) => x.name), lastChecked: r.last == null ? null : Number(r.last) };
++ }, { tlds: 0, registrars: [], lastChecked: null });
++ }
++
++ async function sitemap() {
++ return await safe(() => db.prepare(`SELECT p.tld, MAX(p.checked_at) AS checked_at FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} GROUP BY p.tld ORDER BY p.tld`).all(), []);
++ }
++
++ /** How many TLDs each registrar lists (a provider page's domain line). */
++ async function countFor(providerId) {
++ return await safe(async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0, 0);
++ }
++
++ return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, ready, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };
++}
++
++module.exports = { createDomainStore };
ddiff --git a/server/catalog/domains.js b/server/catalog/domains.js
nnew file mode 100644
iindex 0000000..e3cd8b9
---- /dev/null
++++ b/server/catalog/domains.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Domain names compared (migrations/0011_catalog_domains.sql): what registrars charge for each TLD, and the names a
++ * person can check.
++ *
++ * SOURCES the Sources keys that carry a registrar's price list (category hosting)
++ * mapTldPrice(item) a Sources item → one registrar's price for one TLD, or null (held: not an ICANN TLD, no price)
++ * REGISTRARS where to search a name at each well-known registrar (deep links, never affiliate)
++ * POPULAR the endings people ask for most, shown first
++ * parseName(input, tlds) "My-Site.dev" → { label, tld, name }, the TLD the longest known suffix
++ * tldKind(tld) 'country' | 'generic' | 'second-level' | 'internationalized'
++ * displayTld(tld) ".рф" for "xn--p1ai"
++ */
++const url = require('url');
++
++// Only these keys may create a registrar. A Sources api item carries the TLD as `identity` and the registrar's price
++// fields flat in `fields`, as the API gave them.
++const SOURCES = Object.freeze({
++ 'porkbun-tld-pricing': {
++ slug: 'porkbun', name: 'Porkbun', homepage_url: 'https://porkbun.com/', source_url: 'https://porkbun.com/products/domains',
++ // "2,060.25": Porkbun writes thousands with a comma.
++ prices: (x) => ({ register: amount(x.registration), renew: amount(x.renewal), transfer: amount(x.transfer) }),
++ // Handshake names are sold beside ICANN TLDs but resolve only in Handshake's own root.
++ skip: (x) => x.special_type != null && x.special_type !== '',
++ },
++});
++
++function amount(v) {
++ if (v == null || v === '') return null;
++ if (typeof v === 'number') return Number.isFinite(v) && v >= 0 ? v : null;
++ const s = String(v).trim().replace(/,(?=\d{3}(\D|$))/g, '');
++ return /^\d+(\.\d+)?$/.test(s) ? Number(s) : null;
++}
++
++const TLD_RE = /^(xn--)?[a-z0-9]+(-[a-z0-9]+)*(\.(xn--)?[a-z0-9]+(-[a-z0-9]+)*)*$/;
++/** A TLD as stored: lower case, ASCII (punycode), no leading dot. */
++function normalizeTld(v) {
++ if (typeof v !== 'string') return null;
++ const raw = v.trim().toLowerCase().replace(/^\.+/, '');
++ if (!raw || raw.length > 120) return null;
++ const ascii = /^[\x00-\x7f]*$/.test(raw) ? raw : url.domainToASCII(raw);
++ return ascii && TLD_RE.test(ascii) ? ascii : null;
++}
++
++function mapTldPrice(item) {
++ const source = SOURCES[item && item.source_key];
++ if (!source) return null;
++ if (item.category !== 'hosting' || !item.fields || typeof item.fields !== 'object' || !item.provenance || !item.provenance.retrieved_at || !item.provenance.first_seen_at) throw new TypeError('incomplete domain price item');
++ const checked = Date.parse(item.provenance.retrieved_at), first = Date.parse(item.provenance.first_seen_at);
++ if (!Number.isFinite(checked) || !Number.isFinite(first)) throw new TypeError('invalid fetch time');
++ if (source.skip(item.fields)) return null;
++ const tld = normalizeTld(item.identity);
++ if (!tld) return null;
++ const p = source.prices(item.fields);
++ if (p.register == null && p.renew == null && p.transfer == null) return null;
++ return {
++ provider: source, tld, currency: 'USD', ...p, source_key: item.source_key, source_item_id: item.id,
++ source_url: source.source_url, checked_at: checked, first_seen_at: first,
++ };
++}
++
++const REGISTRARS = Object.freeze([
++ { name: 'Porkbun', search: (n) => `https://porkbun.com/checkout/search?q=${encodeURIComponent(n)}` },
++ { name: 'Cloudflare', search: (n) => `https://domains.cloudflare.com/?domain=${encodeURIComponent(n)}` },
++ { name: 'Namecheap', search: (n) => `https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(n)}` },
++ { name: 'Spaceship', search: (n) => `https://www.spaceship.com/domain-search/?query=${encodeURIComponent(n)}` },
++ { name: 'Dynadot', search: (n) => `https://www.dynadot.com/domain/search?domain=${encodeURIComponent(n)}` },
++ { name: 'NameSilo', search: (n) => `https://www.namesilo.com/domain/search-domains?query=${encodeURIComponent(n)}` },
++ { name: 'Name.com', search: (n) => `https://www.name.com/domain/search/${encodeURIComponent(n)}` },
++ { name: 'Squarespace', search: (n) => `https://domains.squarespace.com/domain-search?query=${encodeURIComponent(n)}` },
++ { name: 'Hover', search: (n) => `https://www.hover.com/domains/results?q=${encodeURIComponent(n)}` },
++ { name: 'GoDaddy', search: (n) => `https://www.godaddy.com/domainsearch/find?domainToCheck=${encodeURIComponent(n)}` },
++]);
++
++const POPULAR = Object.freeze(['com', 'net', 'org', 'io', 'dev', 'app', 'ai', 'co', 'xyz', 'me', 'info', 'tech', 'site', 'online', 'store', 'us', 'uk', 'ca', 'de', 'gg']);
++
++const displayTld = (tld) => `.${tld.split('.').map((l) => (l.startsWith('xn--') ? url.domainToUnicode(l) || l : l)).join('.')}`;
++
++function tldKind(tld) {
++ if (tld.includes('.')) return 'second-level';
++ if (tld.startsWith('xn--')) return 'internationalized';
++ return /^[a-z]{2}$/.test(tld) ? 'country' : 'generic';
++}
++const KIND_LABEL = { country: 'Country code', generic: 'Generic', 'second-level': 'Second level', internationalized: 'Internationalized' };
++
++const LABEL_RE = /^(xn--)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
++/**
++ * A name a person typed: "openvibe", "OpenVibe.dev", "https://www.example.co.uk/x". Returns { label, tld, name } with
++ * the TLD the longest suffix in `known` (a Set of stored TLDs), else the last label; null when it is not a name.
++ */
++function parseName(input, known = new Set()) {
++ if (typeof input !== 'string') return null;
++ let s = input.trim().toLowerCase();
++ if (!s || s.length > 253) return null;
++ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[/?#].*$/, '').replace(/:\d+$/, '').replace(/\.$/, '');
++ const ascii = /^[\x00-\x7f]*$/.test(s) ? s : url.domainToASCII(s);
++ if (!ascii) return null;
++ const labels = ascii.split('.');
++ if (labels.some((l) => !LABEL_RE.test(l) || /^..--/.test(l) && !l.startsWith('xn--'))) return null;
++ if (labels.length === 1) return { label: labels[0], tld: null, name: null };
++ // The registrable label sits right before the longest known suffix ("example" in example.co.uk when co.uk is sold).
++ let tld = null;
++ for (let i = 1; i < labels.length; i++) {
++ const suffix = labels.slice(i).join('.');
++ if (known.has(suffix)) { tld = suffix; break; }
++ }
++ if (!tld) tld = labels[labels.length - 1];
++ const before = labels.slice(0, labels.length - tld.split('.').length);
++ const label = before[before.length - 1];
++ if (!label) return null;
++ return { label, tld, name: `${label}.${tld}` };
++}
++
++module.exports = { SOURCES, REGISTRARS, POPULAR, KIND_LABEL, mapTldPrice, normalizeTld, parseName, tldKind, displayTld, amount };
ddiff --git a/server/catalog/ingest.js b/server/catalog/ingest.js
iindex 3f37f7e..da5002b 100644
---- a/server/catalog/ingest.js
++++ b/server/catalog/ingest.js
@@@ -3,6 +3,7 @@
const ingest = require('openvibe-publishing/ingest');
const { SOURCES } = require('./mapping');
const offers = require('./offers');
++const domains = require('./domains');
function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
if (!config.catalog.ingest || !config.catalog.sourcesUrl) return { enabled: false, pull: async () => ({ disabled: true }), consume: async () => ({ status: 503, code: 'ingest.disabled' }) };
const sources = source || ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: config.catalog.sourcesUrl, category: 'hosting' } }, fetchImpl });
@@@ -14,7 +15,7 @@ function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
let unknown = 0;
const result = await ingest.pullChanges({ db: store.db, cursor, source: sources, name: 'sources.hosting.after', apply: async (item, t) => {
const result = await catalog.applyItem(item, t);
-- if (result === 'hold' && !SOURCES[item.source_key] && !offers.SOURCES[item.source_key]) unknown++;
++ if (result === 'hold' && !SOURCES[item.source_key] && !offers.SOURCES[item.source_key] && !domains.SOURCES[item.source_key]) unknown++;
return result;
} });
return { ...result, unknown };
ddiff --git a/server/catalog/rdap.js b/server/catalog/rdap.js
nnew file mode 100644
iindex 0000000..6edb138
---- /dev/null
++++ b/server/catalog/rdap.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * Is a domain name taken? RDAP (RFC 9082/9083), the registries' public lookup that replaced WHOIS: IANA's bootstrap
++ * file names each TLD's RDAP server, and the registry answers 200 with the registration or 404 when it holds none.
++ *
++ * createRdap({ fetchImpl, now, lookup }) → { lookup(name) } (`lookup`: a dns.lookup stand-in, for tests)
++ * lookup('example.com') → { name, status: 'registered'|'unregistered'|'unknown', registrar, created, expires, server, reason }
++ *
++ * Only the bootstrap's own https servers are asked (never a URL from the person), redirects are refused, every call
++ * has a timeout, and answers are cached (10 minutes, 2,000 names) so a burst of checks does not reach the registries.
++ * "unregistered" means the registry holds no registration: the name is probably available, or reserved.
++ */
++const egress = require('openvibe-shared/egress');
++
++const BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json';
++const BOOTSTRAP_TTL = 24 * 3600_000;
++const ANSWER_TTL = 10 * 60_000;
++const MAX_CACHED = 2000;
++const TIMEOUT_MS = 6000;
++const UA = 'OpenVibe.Host domain check (+https://openvibe.host/catalog/domains)';
++
++function createRdap({ fetchImpl = globalThis.fetch, now = Date.now, log = console, lookup: dnsLookup } = {}) {
++ let bootstrap = null; // { at, servers: Map<tld, base> }
++ let loading = null;
++ const answers = new Map();
++
++ async function get(u, accept) {
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
++ try {
++ return await fetchImpl(u, { headers: { accept, 'user-agent': UA }, redirect: 'error', signal: ctrl.signal });
++ } finally { clearTimeout(timer); }
++ }
++ async function servers() {
++ if (bootstrap && now() - bootstrap.at < BOOTSTRAP_TTL) return bootstrap.servers;
++ if (!loading) {
++ loading = (async () => {
++ const res = await get(BOOTSTRAP_URL, 'application/json');
++ if (!res.ok) throw new Error(`IANA bootstrap answered ${res.status}`);
++ const body = await res.json();
++ const map = new Map();
++ for (const entry of Array.isArray(body.services) ? body.services : []) {
++ const [tlds, urls] = Array.isArray(entry) ? entry : [];
++ const base = (Array.isArray(urls) ? urls : []).find((x) => typeof x === 'string' && x.startsWith('https://'));
++ if (!base) continue;
++ for (const t of Array.isArray(tlds) ? tlds : []) if (typeof t === 'string') map.set(t.toLowerCase(), base.endsWith('/') ? base : `${base}/`);
++ }
++ if (!map.size) throw new Error('IANA bootstrap listed no RDAP servers');
++ bootstrap = { at: now(), servers: map };
++ return map;
++ })().finally(() => { loading = null; });
++ }
++ try { return await loading; } catch (err) {
++ log.warn(`[Host] RDAP bootstrap: ${err.message}`);
++ return bootstrap ? bootstrap.servers : null;
++ }
++ }
++ const eventDate = (body, action) => {
++ const e = (Array.isArray(body.events) ? body.events : []).find((x) => x && x.eventAction === action && typeof x.eventDate === 'string');
++ return e && Number.isFinite(Date.parse(e.eventDate)) ? new Date(Date.parse(e.eventDate)).toISOString() : null;
++ };
++ const registrarOf = (body) => {
++ const ent = (Array.isArray(body.entities) ? body.entities : []).find((x) => x && Array.isArray(x.roles) && x.roles.includes('registrar'));
++ if (!ent) return null;
++ const card = Array.isArray(ent.vcardArray) && Array.isArray(ent.vcardArray[1]) ? ent.vcardArray[1] : [];
++ const fn = card.find((f) => Array.isArray(f) && f[0] === 'fn');
++ const name = fn && typeof fn[3] === 'string' ? fn[3].trim() : '';
++ return name ? name.slice(0, 200) : null;
++ };
++
++ async function ask(name) {
++ const tld = name.slice(name.lastIndexOf('.') + 1);
++ const map = await servers();
++ if (!map) return { status: 'unknown', reason: 'The registries\' lookup directory did not answer just now.' };
++ const base = map.get(tld);
++ if (!base) return { status: 'unknown', reason: `The .${tld} registry offers no public RDAP lookup; a registrar's search will say.` };
++ let target;
++ try { target = await egress.assertPublicUrl(`${base}domain/${encodeURIComponent(name)}`, { protocols: ['https:'], lookup: dnsLookup }); } catch {
++ return { status: 'unknown', reason: 'That registry\'s lookup is not reachable from here.' };
++ }
++ const server = new URL(base).hostname;
++ let res;
++ try { res = await get(target.href, 'application/rdap+json, application/json'); } catch (err) {
++ return { status: 'unknown', server, reason: err && err.name === 'AbortError' ? 'The registry did not answer in time.' : 'The registry could not be reached.' };
++ }
++ if (res.status === 404) return { status: 'unregistered', server };
++ if (res.status === 429) return { status: 'unknown', server, reason: 'The registry asked us to slow down; try again in a minute.' };
++ if (!res.ok) return { status: 'unknown', server, reason: `The registry answered ${res.status}.` };
++ let body;
++ try { body = await res.json(); } catch { return { status: 'unknown', server, reason: 'The registry\'s answer could not be read.' }; }
++ if (!body || typeof body !== 'object' || body.objectClassName !== 'domain') return { status: 'unknown', server, reason: 'The registry\'s answer was not a domain record.' };
++ return { status: 'registered', server, registrar: registrarOf(body), created: eventDate(body, 'registration'), expires: eventDate(body, 'expiration') };
++ }
++
++ async function lookup(name) {
++ const hit = answers.get(name);
++ if (hit && now() - hit.at < ANSWER_TTL) return { name, ...hit.answer, cached: true };
++ const answer = await ask(name);
++ if (answer.status !== 'unknown') {
++ if (answers.size >= MAX_CACHED) answers.delete(answers.keys().next().value);
++ answers.set(name, { at: now(), answer });
++ }
++ return { name, ...answer, cached: false };
++ }
++ return { lookup };
++}
++
++module.exports = { createRdap, BOOTSTRAP_URL };
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 1f6b4eb..3339617 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -4,6 +4,8 @@ const { newId } = require('../ids');
const { ApiError } = require('../http/errors');
const { mapItem } = require('./mapping');
const offerFeed = require('./offers');
++const domainFeed = require('./domains');
++const { createDomainStore } = require('./domain-store');
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const fields = {
@@@ -55,6 +57,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
indexnow.pingSoon([...paths, '/sitemap.xml'].map((path) => `${baseUrl}${path}`));
};
const db = store.db;
++ const domains = createDomainStore({ store, announce });
const listeners = [];
const changed = (type) => { for (const fn of listeners) { try { fn(type); } catch { /* a listener never breaks a save */ } } };
let visibilityQueue = Promise.resolve();
@@@ -110,6 +113,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
});
async function applyItem(item, t = db) {
if (item && offerFeed.SOURCES[item.source_key]) return await applyOffer(item, t);
++ if (item && domainFeed.SOURCES[item.source_key]) return await domains.apply(item, t);
if (item && item.removed) return 'hold';
const v = mapItem(item);
if (!v) return 'hold';
@@@ -230,7 +234,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
if (slug) {
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
if (!p) missing();
-- return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id) };
++ return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };
}
return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);
}
@@@ -251,6 +255,6 @@ function createCatalog({ store, community = null, log = console, indexnow = null
}
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
-- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
++ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
module.exports = { createCatalog };
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex db97838..7815f72 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -5,12 +5,16 @@ const contracts = require('openvibe-contracts');
const { ApiError, run, privateNoStore, sendError } = require('./errors');
const { renderPage, esc } = require('../render/layout');
const render = require('../render/catalog');
++const renderDomains = require('../render/domains');
++const rateLimit = require('express-rate-limit');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
const cache = require('openvibe-shared/cache-policy');
const disclosure = render.DISCLOSURE;
--function createCatalogApi({ catalog, viewers, actorLimits }) {
++// A name check asks a registry: 30 a minute per address, below every registry's own RDAP limit.
++const checkLimit = () => rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
++function createCatalogApi({ catalog, domainCheck, viewers, actorLimits }) {
const router = express.Router();
router.use(viewers.middleware('api'));
if (actorLimits) router.use(actorLimits);
@@@ -23,6 +27,9 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {
router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
router.get('/offers', run(async (req) => await catalog.offers(req.query)));
++ router.get('/domains', run(async (req) => await catalog.domains.list(req.query)));
++ router.get('/domains/check', checkLimit(), run(async (req) => ({ check: await domainCheck.check(req.query.name) })));
++ router.get('/domains/:tld', run(async (req) => ({ tld: await catalog.domains.tld(req.params.tld) })));
for (const type of ['providers', 'plans', 'promos']) {
router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
@@@ -30,7 +37,7 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
}
--function createCatalogPages({ catalog, community, config, viewers, actorLimits, log = console }) {
++function createCatalogPages({ catalog, domainCheck, community, config, viewers, actorLimits, log = console }) {
const router = express.Router();
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
@@@ -94,6 +101,29 @@ function createCatalogPages({ catalog, community, config, viewers, actorLimits,
page(req, res, 'Hosting offers', render.offers({ result, query }), !Object.keys(req.query).length,
'Hosting offers from LowEndTalk and LowEndBox, newest first or cheapest a month, each with the price its headline states and the day it was posted.');
}));
++ router.get('/domains', view(async (req, res) => {
++ const [result, stats] = await Promise.all([catalog.domains.list(req.query), catalog.domains.stats()]);
++ page(req, res, 'Compare domain prices', renderDomains.index({ result, query: req.query, stats, registrars: stats.registrars }), !Object.keys(req.query).length,
++ 'Domain prices compared: what registrars charge for each ending for the first year, a renewal and a transfer, five years added up, and a name check at the registry.',
++ renderDomains.jsonLd.index(config.baseUrl, result.tlds));
++ }));
++ router.get('/domains/check', checkLimit(), view(async (req, res) => {
++ if (typeof req.query.name !== 'string' || !req.query.name.trim()) return res.redirect(303, '/catalog/domains');
++ let c;
++ try { c = await domainCheck.check(req.query.name); } catch (err) {
++ if (err.status !== 422) throw err;
++ cachePage(req, res, false);
++ return res.status(422).type('html').send(renderPage({ title: 'Check a domain name', body: `<h1>That is not a domain name</h1><p>${esc(err.message)}</p><p><a href="/catalog/domains">Check another name</a></p>`, viewer: req.viewer, config, path: req.originalUrl, css: ['css/catalog.css'] }));
++ }
++ page(req, res, `${c.display}: is it taken?`, renderDomains.check(c), false, `Whether ${c.display} is registered, and what it costs at each registrar.`);
++ }));
++ router.get('/domains/:tld', view(async (req, res) => {
++ const t = await catalog.domains.tld(req.params.tld);
++ if (req.params.tld !== t.tld) return res.redirect(301, `/catalog/domains/${encodeURIComponent(t.tld)}`);
++ page(req, res, `${t.display} domain prices`, renderDomains.tld(t, { baseUrl: config.baseUrl, watchUrl: config.watch && config.watch.publicUrl }), true,
++ `${t.display} domains from ${render.money(t.cheapest.register, 'USD') || 'an unknown price'} the first year, renewing from ${render.money(t.cheapest.renew, 'USD') || 'an unknown price'}: every registrar's price, dated and sourced.`,
++ renderDomains.jsonLd.tld(config.baseUrl, t));
++ }));
router.get('/manage', view(async (req, res) => {
if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
const csrf = csrfToken(config, req.viewer);
ddiff --git a/server/ids.js b/server/ids.js
iindex 2af24ab..33999c0 100644
---- a/server/ids.js
++++ b/server/ids.js
@@@ -2,7 +2,7 @@
/** Catalog identifiers: <prefix>_<ULID>. */
const { ids } = require('openvibe-contracts');
--const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof' };
++const PREFIX = { catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr', catalogOffer: 'hof', catalogTldPrice: 'hdp' };
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex d9d1e86..c96669e 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -3,7 +3,7 @@
/**
* The hosting catalog's pages (plan T20 step 3: hosting compared). Server-rendered, complete without JavaScript:
*
-- * front({ plans, providers, promos, stats }) the signed-out front page of openvibe.host
++ * front({ plans, providers, promos, stats, domains }) the front page of openvibe.host (`domains`: render/domains.js frontSection)
* catalog({ result, query, providers }) /catalog: the filterable comparison table
* provider(p) /catalog/providers/:slug
* plan(p) /catalog/plans/:id
@@@ -187,10 +187,10 @@ function provider(p, discussion = null) {
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
<header class="hc-head hc-head-row">
<div><h1>${esc(p.name)}</h1>${p.description ? `<p class="hc-lede">${esc(p.description)}</p>` : ''}
--<p class="hc-facts">${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>
++<p class="hc-facts">${p.plans.length || !p.domains ? `${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed` : ''}${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.domains ? `${p.plans.length ? ' · ' : ''}${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `${p.domains.toLocaleString('en')} domain endings priced`)}` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>
${visit(p)}
</header>
--${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
++${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
${discussionSection(discussion)}
<h2>Promotions</h2>
${promoCards(p.promos, { showProvider: false })}
@@@ -301,7 +301,7 @@ const jsonLd = {
};
/** The front page of the hosting comparison. */
--function front({ plans, providers, promos: live, stats, offers: recent = [], now = Date.now() }) {
++function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', now = Date.now() }) {
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Lowest monthly prices right now</p>
<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
@@@ -312,11 +312,12 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], now
title: 'Hosting, compared honestly.',
accent: 'Every price dated and sourced.',
lede: 'Cloud, VPS and dedicated plans from providers\' official APIs, side by side: price per month, per vCPU and per GB, the day each price was checked and a link to where it came from. Sorted by what you ask for, never by who pays.',
-- actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Latest offers', href: '/catalog/offers' }],
++ actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Domain prices', href: '/catalog/domains' }, { label: 'Latest offers', href: '/catalog/offers' }],
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
aside: aside ? { html: aside } : undefined,
}) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
++ + domainSection
+ showcase.features({
id: 'why',
title: 'Why this comparison is different',
ddiff --git a/server/render/domains.js b/server/render/domains.js
nnew file mode 100644
iindex 0000000..1032164
---- /dev/null
++++ b/server/render/domains.js
@@@ -0,0 +1,175 @@
++'use strict';
++/**
++ * Domain names compared (server/catalog/domains.js, domain-store.js, rdap.js). Server-rendered, complete without
++ * JavaScript:
++ *
++ * index({ result, query, stats, registrars }) /catalog/domains: check a name, and every ending's cheapest prices
++ * tld(t, { baseUrl, watchUrl }) /catalog/domains/:tld: every registrar's price for one ending
++ * check(c) /catalog/domains/check?name=: is it taken, where to register it
++ * frontSection({ popular, stats }) the front page's domain block
++ *
++ * Prices are what registrars publish, each with the day it was checked and its source; the five-year cost is the
++ * first year plus four renewals at the same registrar. Registrar search links are plain links (never affiliate).
++ */
++const seo = require('openvibe-shared/seo');
++const { esc } = require('./layout');
++const { money } = require('./catalog');
++const domains = require('../catalog/domains');
++
++const UNKNOWN = '<span class="hc-unknown" title="The registrar does not state it">Unknown</span>';
++const SORTS = [['popular', 'Popular first'], ['register', 'Cheapest first year'], ['renew', 'Cheapest renewal'], ['five_year', 'Cheapest over five years'], ['tld', 'A to Z']];
++const KINDS = [['', 'Every ending'], ['generic', 'Generic (.dev, .app)'], ['country', 'Country codes (.uk, .io)'], ['second-level', 'Second level (.co.uk)'], ['internationalized', 'Internationalized (.рф)']];
++const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
++const usd = (v) => (v == null ? UNKNOWN : esc(money(v, 'USD')));
++const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
++const out = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="nofollow noopener">${label}</a>`;
++const tldHref = (tld) => `/catalog/domains/${encodeURIComponent(tld)}`;
++const checkHref = (name) => `/catalog/domains/check?${new URLSearchParams({ name })}`;
++const select = (name, label, options, current) => `<label><span>${esc(label)}</span><select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(current || '') === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
++// A renewal more than a fifth above the first year is the usual trap: a cheap first year, then the real price.
++const searchAt = (registrar, name, fallback) => { const r = domains.REGISTRARS.find((x) => x.name === registrar); return r ? r.search(name) : fallback; };
++const jumps = (r) => r.register != null && r.renew != null && r.renew > r.register * 1.2;
++const jumpChip = (r) => (jumps(r) ? ' <span class="hc-chip" title="The renewal costs more than the first year">Renews higher</span>' : '');
++
++function checkForm({ value = '', tld = '', label = 'Is your name free?', autofocus = false } = {}) {
++ const ph = tld ? `yourname${domains.displayTld(tld)}` : 'yourname.dev';
++ return `<form class="hd-check" method="get" action="/catalog/domains/check" role="search" aria-label="Check a domain name">
++<label for="hd-name">${esc(label)}</label>
++<div class="hd-check-row"><input id="hd-name" name="name" value="${esc(value)}" placeholder="${esc(ph)}" autocomplete="off" autocapitalize="off" spellcheck="false" inputmode="url" maxlength="253" required${autofocus ? ' autofocus' : ''}>
++<button type="submit">Check</button></div>
++<p class="hd-check-note">Asks the registry directly (RDAP), then shows what it costs at each registrar.</p>
++</form>`;
++}
++
++function tldTable(rows, { caption }) {
++ if (!rows.length) return '';
++ // With one registrar listed the count says nothing: the column appears once a second registrar's list is read.
++ const many = rows.some((r) => r.registrars > 1);
++ return `<div class="hc-table-wrap"><table class="hc-table hd-table hd-list"><caption>${esc(caption)}</caption>
++<thead><tr><th scope="col">Ending</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">5 years</th>${many ? '<th scope="col" class="hc-n">Registrars</th>' : ''}<th scope="col">Checked</th></tr></thead>
++<tbody>${rows.map((r) => `<tr>
++<th scope="row" data-label="Ending">${link(tldHref(r.tld), esc(r.display))}<span class="hc-kind">${esc(domains.KIND_LABEL[r.kind] || '')}</span></th>
++<td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(r.register)}</span></td>
++<td data-label="Renewal" class="hc-n">${usd(r.renew)}${jumpChip(r)}</td>
++<td data-label="Transfer" class="hc-n hc-dim">${usd(r.transfer)}</td>
++<td data-label="5 years" class="hc-n">${usd(r.five_year)}</td>
++${many ? `<td data-label="Registrars" class="hc-n hc-dim">${esc(String(r.registrars))}</td>` : ''}
++<td data-label="Checked" class="hc-checked">${esc(day(r.checked_at) || '')}</td>
++</tr>`).join('')}</tbody></table></div>`;
++}
++
++function method(registrars) {
++ return `<section class="hc-method" aria-labelledby="hd-method-h">
++<h2 id="hd-method-h">How domain prices are compared</h2>
++<ul>
++<li><strong>Published prices.</strong> From registrars' own public price lists through OpenVibe.Sources${registrars.length ? ` (${esc(registrars.join(', '))} today)` : ''}, each with the day it was checked and a link to the list. More registrars join as their lists are checked.</li>
++<li><strong>The renewal counts.</strong> A first year is often cheap and the renewal is the real price, so every ending shows both, marks a renewal more than a fifth higher, and adds up five years (the first year and four renewals at one registrar).</li>
++<li><strong>Checked at the registry.</strong> A name check asks the ending's registry over RDAP, the public lookup that replaced WHOIS. A name with no registration is probably free; a registry can still hold it as reserved or premium.</li>
++<li><strong>Not for sale.</strong> The order comes only from the sort you pick. The registrar links are plain links; nobody pays to be listed.</li>
++</ul></section>`;
++}
++
++function registrarLinks(name) {
++ return `<ul class="hd-registrars">${domains.REGISTRARS.map((r) => `<li>${out(r.search(name), `<span>${esc(r.name)}</span><small>Search ${esc(name)}</small>`)}</li>`).join('')}</ul>`;
++}
++
++function index({ result, query = {}, stats, registrars = [] }) {
++ const q = query || {};
++ const rows = result.tlds;
++ const filtered = ['q', 'kind', 'registrar'].some((k) => q[k]);
++ const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog/domains?${new URLSearchParams({ ...q, cursor: result.next_cursor })}`, 'Next 100 endings →', 'button hc-button-quiet')}</p>` : '';
++ const sortLabel = (SORTS.find(([v]) => v === (q.sort || 'popular')) || SORTS[0])[1].toLowerCase();
++ const prices = stats.tlds ? `<form class="hc-filters" method="get" action="/catalog/domains" role="search" aria-label="Filter domain endings">
++<label><span>Ending</span><input name="q" value="${esc(q.q || '')}" placeholder=".dev, co.uk" autocapitalize="off" spellcheck="false"></label>
++${select('kind', 'Kind', KINDS, q.kind)}
++${select('sort', 'Sort by', SORTS, q.sort || 'popular')}
++<div class="hc-filter-actions"><button type="submit">Show</button> <a href="/catalog/domains" class="hc-reset">Reset</a></div>
++</form>
++${tldTable(rows, { caption: `${rows.length}${result.next_cursor ? '+' : ''} ending${rows.length === 1 ? '' : 's'}, ${sortLabel}; the cheapest price at any registrar` })}
++${!rows.length && filtered ? `<div class="hc-empty"><p><strong>No ending matches.</strong></p><p>${link('/catalog/domains', 'Clear the filters')} or try another ending.</p></div>` : ''}${next}` : '';
++ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · domains compared</p><h1>Compare domain prices</h1>
++<p class="hc-lede">${stats.tlds ? `${stats.tlds.toLocaleString('en')} endings with what registrars charge for the first year, a renewal and a transfer, and the total over five years.` : 'What registrars charge for each ending, for the first year, a renewal and a transfer.'} Check whether a name is taken, then register it wherever it costs least.</p></header>
++${checkForm({ autofocus: !filtered && !q.sort && !q.cursor })}
++${prices}
++<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">Search a name at every registrar</h2>${registrarLinks('yourname.com')}</section>
++${method(registrars)}
++<p class="hc-links">${link('/catalog', 'Compare hosting plans')} · ${link('/catalog/offers', 'Hosting offers')}</p>`;
++}
++
++function tld(t, { baseUrl = '', watchUrl = '' } = {}) {
++ const c = t.cheapest;
++ const jump = c.register != null && c.renew != null && c.renew > c.register * 1.2;
++ const kind = t.kind === 'second-level' ? `Second level under ${domains.displayTld(t.tld.slice(t.tld.indexOf('.') + 1))}` : domains.KIND_LABEL[t.kind];
++ const history = t.history.length ? `<ol class="hc-history">${t.history.map((h) => {
++ const moved = [['first year', h.register], ['renewal', h.renew], ['transfer', h.transfer]].filter(([, [a, b]]) => a !== b)
++ .map(([k, [a, b]]) => `${k} ${a == null ? 'none' : esc(money(a, h.currency))} → <strong>${b == null ? 'none' : esc(money(b, h.currency))}</strong>`).join(', ');
++ return `<li><time datetime="${esc(day(h.observed_at))}">${esc(day(h.observed_at))}</time> ${esc(h.registrar)}: ${moved}</li>`;
++ }).join('')}</ol>` : '<p class="hc-dim">No price change recorded since the catalog first saw these prices.</p>';
++ const rows = t.prices.map((p) => `<tr>
++<th scope="row" data-label="Registrar">${link(`/catalog/providers/${encodeURIComponent(p.registrar_slug)}`, esc(p.registrar))}</th>
++<td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(p.register)}</span></td>
++<td data-label="Renewal" class="hc-n">${usd(p.renew)}${jumpChip(p)}</td>
++<td data-label="Transfer" class="hc-n hc-dim">${usd(p.transfer)}</td>
++<td data-label="5 years" class="hc-n">${usd(p.five_year)}</td>
++<td data-label="Checked" class="hc-checked">${esc(day(p.checked_at) || '')} ${out(p.source_url, 'source', 'hc-src')}</td>
++<td data-label="Register" class="hd-go">${out(searchAt(p.registrar, `yourname.${t.tld}`, p.homepage_url), `Register at ${esc(p.registrar)}`, 'button hc-button-quiet')}</td>
++</tr>`).join('');
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog/domains', 'Domains')} / ${esc(t.display)}</nav>
++<header class="hc-head"><p class="hc-eyebrow">${esc(kind)} ending</p><h1>${esc(t.display)} domain prices</h1>
++<p class="hc-lede">A ${esc(t.display)} name costs from <strong>${usd(c.register)}</strong> for the first year and renews from <strong>${usd(c.renew)}</strong> a year; five years cost from <strong>${usd(c.five_year)}</strong>.${jump ? ' The renewal is the real price here: it costs well above the first year.' : ''}</p></header>
++${checkForm({ tld: t.tld, label: `Check a ${t.display} name` })}
++<div class="hc-table-wrap"><table class="hc-table hd-table"><caption>${esc(t.display)} at ${t.prices.length} registrar${t.prices.length === 1 ? '' : 's'}, cheapest first year first</caption>
++<thead><tr><th scope="col">Registrar</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">5 years</th><th scope="col">Checked</th><th scope="col"><span class="hd-sr">Register</span></th></tr></thead>
++<tbody>${rows}</tbody></table></div>
++<h2>Price history</h2>
++${history}
++${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/domains/${t.tld}`, value_path: 'tld.cheapest.renew', op: 'changed', name: `${t.display} renewal price` })}`, `Tell me when ${esc(t.display)} prices change`, 'button hc-button-quiet')} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
++<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">Search a ${esc(t.display)} name at every registrar</h2>${registrarLinks(`yourname.${t.tld}`)}</section>
++<p class="hc-links">${link('/catalog/domains', '← Every ending')}</p>`;
++}
++
++const STATUS_TEXT = {
++ registered: (c) => `<p class="hd-verdict">Taken</p><p>${esc(c.display)} is registered${c.registrar ? ` through <strong>${esc(c.registrar)}</strong>` : ''}${c.created ? ` since ${esc(day(c.created))}` : ''}${c.expires ? `, paid up to ${esc(day(c.expires))}` : ''}.</p>`,
++ unregistered: (c) => `<p class="hd-verdict">Probably free</p><p>The ${esc(domains.displayTld(c.tld))} registry holds no registration for ${esc(c.display)}. It can still keep a name back as reserved or premium; a registrar's search says for sure.</p>`,
++ unknown: (c) => `<p class="hd-verdict">Not known</p><p>${esc(c.reason || 'The registry did not answer.')}</p>`,
++};
++
++function check(c) {
++ const own = c.prices.find((p) => p.tld === c.tld);
++ const others = c.prices.filter((p) => p.tld !== c.tld);
++ const card = c.name ? `<section class="hd-status hd-${esc(c.status)}" aria-live="polite">${STATUS_TEXT[c.status](c)}
++${own ? `<p class="hd-cost">${c.status === 'registered' ? `A ${esc(domains.displayTld(c.tld))} name` : 'It'} costs from <strong>${usd(own.register)}</strong> the first year and <strong>${usd(own.renew)}</strong> a year after. ${link(tldHref(c.tld), `Every registrar's ${esc(domains.displayTld(c.tld))} price`)}</p>` : ''}
++<p class="hc-dim hd-src">${c.server ? `Asked ${esc(c.server)} over RDAP${c.cached ? ' a few minutes ago' : ' just now'}.` : ''}</p></section>` : '';
++ const alts = others.length ? `<section aria-labelledby="hd-alt-h"><h2 id="hd-alt-h">${esc(c.label)} with other endings</h2>
++<div class="hc-table-wrap"><table class="hc-table hd-table"><caption>Cheapest price at any registrar</caption>
++<thead><tr><th scope="col">Name</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">5 years</th><th scope="col"><span class="hd-sr">Check</span></th></tr></thead>
++<tbody>${others.map((p) => { const n = `${c.label}.${p.tld}`; return `<tr><th scope="row" data-label="Name">${link(tldHref(p.tld), esc(`${c.labelDisplay}${p.display}`))}</th>
++<td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(p.register)}</span></td><td data-label="Renewal" class="hc-n">${usd(p.renew)}${jumpChip(p)}</td><td data-label="5 years" class="hc-n">${usd(p.five_year)}</td>
++<td data-label="Check" class="hd-go">${link(checkHref(n), `Check ${esc(`${c.labelDisplay}${p.display}`)}`, 'button hc-button-quiet')}</td></tr>`; }).join('')}</tbody></table></div></section>` : '';
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog/domains', 'Domains')} / Check a name</nav>
++<header class="hc-head"><p class="hc-eyebrow">Domain check</p><h1 class="hd-name">${esc(c.display || c.labelDisplay)}</h1></header>
++${checkForm({ value: c.display || c.labelDisplay, label: 'Check another name' })}
++${card}
++<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">${c.status === 'registered' ? 'Look for it at a registrar' : 'Register it at a registrar'}</h2>${registrarLinks(c.name || `${c.label}.com`)}</section>
++${alts}
++<p class="hc-links">${link('/catalog/domains', '← Compare every ending')}</p>`;
++}
++
++function frontSection({ popular = [], stats }) {
++ return `<section class="sc-sec hd-front" aria-labelledby="hd-front-h"><h2 id="hd-front-h">Domain names, compared too</h2>
++<p class="hc-dim">${stats.tlds ? `${stats.tlds.toLocaleString('en')} endings, with the first year, the renewal and five years side by side. ` : ''}Check a name at its registry, then register it where it costs least.</p>
++${checkForm({})}
++${tldTable(popular.slice(0, 6), { caption: 'Popular endings: the cheapest price at any registrar' })}
++${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', 'Every ending, by price →', 'button')}</p>` : ''}</section>`;
++}
++
++const jsonLd = {
++ index(base, rows) {
++ return [seo.jsonLd.itemList('Domain endings by price', rows.slice(0, 50).map((r) => ({ name: `${r.display} domains`, url: `${base}${tldHref(r.tld)}` })), { url: `${base}/catalog/domains` })];
++ },
++ tld(base, t) {
++ return [seo.jsonLd.breadcrumbs([{ name: 'Domains', url: `${base}/catalog/domains` }, { name: t.display, url: `${base}${tldHref(t.tld)}` }])];
++ },
++};
++
++module.exports = { index, tld, check, frontSection, jsonLd, SORTS };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 62a1590..c0c7a41 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
--const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
++const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
ddiff --git a/test/catalog-domains.test.js b/test/catalog-domains.test.js
nnew file mode 100644
iindex 0000000..be133fc
---- /dev/null
++++ b/test/catalog-domains.test.js
@@@ -0,0 +1,198 @@
++'use strict';
++/**
++ * Domain names compared (migrations/0011, server/catalog/domains.js, domain-store.js, rdap.js, domain-check.js): Porkbun's
++ * price list from Sources becomes one price per TLD (Handshake names left out, "2,060.25" read, a change kept as history,
++ * a TLD no longer sold gone); the TLD table sorts popular first, by price or over five years; a TLD's page lists every
++ * registrar; a name check reads what was typed, asks the registry over RDAP (cached, refused redirects, unknown when the
++ * ending has no RDAP) and prices the same name at the popular endings; without the tables (a held migration) the pages
++ * still answer.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const domains = require('../server/catalog/domains');
++const { createRdap, BOOTSTRAP_URL } = require('../server/catalog/rdap');
++
++const at = '2026-10-16T12:00:00Z';
++let seq = 0;
++const item = (tld, fields, { time = at, removed = null } = {}) => ({
++ id: `itm_${String(++seq).padStart(26, '0')}`, source_key: 'porkbun-tld-pricing', category: 'hosting', kind: 'tld_price', identity: tld, canonical_url: null, title: tld,
++ summary: null, authors: [], published_at: null, source_updated_at: null, fields: { special_type: null, ...fields }, revision: 1, removed,
++ provenance: { retrieved_at: time, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },
++});
++const price = (register, renewal = register, transfer = renewal) => ({ registration: register, renewal, transfer });
++// A dns.lookup stand-in that answers one public address, so the egress check passes without the network.
++const publicLookup = (_host, _opts, cb) => cb(null, [{ address: '192.30.252.1', family: 4 }]);
++
++function fakeRegistry() {
++ const calls = [];
++ const fetchImpl = async (u, init) => {
++ calls.push({ url: String(u), redirect: init.redirect });
++ if (u === BOOTSTRAP_URL) return Response.json({ services: [[['com', 'net'], ['https://rdap.registry.test/com/v1/']], [['dev', 'app'], ['http://insecure.test/', 'https://rdap.dev.test/']]] });
++ const m = /\/domain\/([^/?]+)$/.exec(String(u));
++ const name = m && decodeURIComponent(m[1]);
++ if (name === 'taken.com') {
++ return new Response(JSON.stringify({ objectClassName: 'domain', ldhName: 'TAKEN.COM', events: [{ eventAction: 'registration', eventDate: '2001-05-04T00:00:00Z' }, { eventAction: 'expiration', eventDate: '2027-05-04T00:00:00Z' }],
++ entities: [{ objectClassName: 'entity', roles: ['registrar'], vcardArray: ['vcard', [['version', {}, 'text', '4.0'], ['fn', {}, 'text', 'Example Registrar, Inc.']]] }] }), { status: 200, headers: { 'content-type': 'application/rdap+json' } });
++ }
++ if (name === 'busy.com') return new Response('', { status: 429 });
++ return new Response('', { status: 404 });
++ };
++ return { calls, fetchImpl };
++}
++
++(async () => {
++ await check('reading registrar prices and typed names', async () => {
++ assert.deepStrictEqual([domains.amount('2,060.25'), domains.amount('8.75'), domains.amount(''), domains.amount('n/a'), domains.amount(12)], [2060.25, 8.75, null, null, 12]);
++ assert.deepStrictEqual(['.DEV', 'co.uk', 'рф', 'xn--p1ai', 'bad_tld', '..', ''].map(domains.normalizeTld), ['dev', 'co.uk', 'xn--p1ai', 'xn--p1ai', null, null, null]);
++ assert.deepStrictEqual(['com', 'uk', 'co.uk', 'xn--p1ai'].map(domains.tldKind), ['generic', 'country', 'second-level', 'internationalized']);
++ assert.strictEqual(domains.displayTld('xn--p1ai'), '.рф');
++ const known = new Set(['com', 'uk', 'co.uk', 'dev']);
++ assert.deepStrictEqual(domains.parseName('https://www.Example.co.uk/path?x', known), { label: 'example', tld: 'co.uk', name: 'example.co.uk' });
++ assert.deepStrictEqual(domains.parseName('My-Site.dev', known), { label: 'my-site', tld: 'dev', name: 'my-site.dev' });
++ assert.deepStrictEqual(domains.parseName('openvibe', known), { label: 'openvibe', tld: null, name: null });
++ assert.deepStrictEqual(domains.parseName('shop.example.zz', known), { label: 'example', tld: 'zz', name: 'example.zz' }, 'an unknown ending is the last label');
++ for (const bad of ['', 'a..b', '-x.com', 'x-.com', 'ab--cd.com', 'sp ace.com', 'a'.repeat(64) + '.com', '.com']) assert.strictEqual(domains.parseName(bad, known), null, bad);
++ assert.strictEqual(domains.parseName('xn--p1ai.xn--p1ai', known).name, 'xn--p1ai.xn--p1ai', 'punycode labels are names');
++ assert.strictEqual(domains.mapTldPrice(item('0zebra', { ...price('3.63', '8.20'), special_type: 'handshake' })), null, 'Handshake names are not ICANN TLDs');
++ assert.strictEqual(domains.mapTldPrice(item('dev', price('', ''))), null, 'no price, no row');
++ const v = domains.mapTldPrice(item('security', price('2,060.25')));
++ assert.deepStrictEqual([v.tld, v.register, v.renew, v.currency, v.provider.slug], ['security', 2060.25, 2060.25, 'USD', 'porkbun']);
++ assert.strictEqual(domains.mapTldPrice({ source_key: 'vultr-plans' }), null, 'a plan source is not a price list');
++ });
++
++ await check('RDAP: registered, unregistered, rate-limited, no RDAP; https only, no redirects, answers cached', async () => {
++ const reg = fakeRegistry();
++ let clock = Date.parse(at);
++ const rdap = createRdap({ fetchImpl: reg.fetchImpl, now: () => clock, lookup: publicLookup, log: { warn() {} } });
++ const taken = await rdap.lookup('taken.com');
++ assert.deepStrictEqual([taken.status, taken.registrar, taken.created, taken.expires, taken.server], ['registered', 'Example Registrar, Inc.', '2001-05-04T00:00:00.000Z', '2027-05-04T00:00:00.000Z', 'rdap.registry.test']);
++ assert.strictEqual((await rdap.lookup('free.dev')).status, 'unregistered');
++ assert.ok(reg.calls.some((c) => c.url === 'https://rdap.dev.test/domain/free.dev'), 'the https server of the two is asked');
++ assert.ok(reg.calls.every((c) => c.redirect === 'error'), 'redirects are refused');
++ const busy = await rdap.lookup('busy.com');
++ assert.deepStrictEqual([busy.status, /slow down/.test(busy.reason)], ['unknown', true]);
++ const none = await rdap.lookup('name.zz');
++ assert.deepStrictEqual([none.status, /no public RDAP/.test(none.reason)], ['unknown', true]);
++ const before = reg.calls.length;
++ assert.strictEqual((await rdap.lookup('taken.com')).cached, true);
++ assert.strictEqual(reg.calls.length, before, 'a cached answer asks nobody');
++ clock += 11 * 60_000;
++ assert.strictEqual((await rdap.lookup('taken.com')).cached, false, 'after ten minutes the registry is asked again');
++ assert.strictEqual(reg.calls.filter((c) => c.url === BOOTSTRAP_URL).length, 1, 'the bootstrap file is read once a day');
++ const denied = createRdap({ fetchImpl: reg.fetchImpl, lookup: (_h, _o, cb) => cb(null, [{ address: '10.0.0.5', family: 4 }]), log: { warn() {} } });
++ assert.strictEqual((await denied.lookup('x.com')).status, 'unknown', 'a registry server resolving to a private address is not asked');
++ });
++
++ const reg = fakeRegistry();
++ const t = await boot({ rdap: createRdap({ fetchImpl: reg.fetchImpl, lookup: publicLookup, log: { warn() {} } }) });
++ const catalog = t.ctx.catalog;
++ try {
++ await check('applying Porkbun\'s list: one row per TLD, Handshake left out, a change kept, a TLD no longer sold gone', async () => {
++ for (const [tld, p] of [['com', price('11.08')], ['dev', price('8.75', '12.87')], ['co.uk', price('5.79', '7.10')], ['uk', price('4.49', '6.20')], ['xn--p1ai', price('7.43')], ['com.mx', price('11.33', '23.32')], ['security', price('2,060.25')]]) {
++ assert.strictEqual(await catalog.applyItem(item(tld, p)), 'applied', tld);
++ }
++ assert.strictEqual(await catalog.applyItem(item('0zebra', { ...price('3.63'), special_type: 'handshake' })), 'hold');
++ assert.strictEqual(await catalog.applyItem(item('dev', price('8.75', '13.50'), { time: '2026-10-17T12:00:00Z' })), 'applied');
++ assert.strictEqual(await catalog.applyItem(item('dev', price('1.00'), { time: '2026-10-15T12:00:00Z' })), 'hold', 'an older observation never overwrites a newer one');
++ assert.strictEqual(await catalog.applyItem(item('com.mx', {}, { removed: { at: at, reason: 'gone' } })), 'removed');
++ const dev = (await t.api('GET', '/api/v1/catalog/domains/dev')).json().tld;
++ assert.deepStrictEqual([dev.display, dev.kind, dev.cheapest.register, dev.cheapest.renew, dev.cheapest.five_year], ['.dev', 'generic', 8.75, 13.5, 62.75]);
++ assert.deepStrictEqual(dev.prices.map((p) => [p.registrar, p.source_url]), [['Porkbun', 'https://porkbun.com/products/domains']]);
++ assert.deepStrictEqual(dev.history.map((h) => [h.registrar, h.renew]), [['Porkbun', [12.87, 13.5]]]);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/com.mx')).status, 404, 'a TLD no longer sold is gone');
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/0zebra')).status, 404);
++ const providers = (await t.api('GET', '/api/v1/catalog/providers')).json().providers;
++ assert.deepStrictEqual(providers.map((p) => p.slug), ['porkbun'], 'the registrar is a catalog provider');
++ });
++
++ await check('the TLD table: popular first, sorts by price and five years, filters by kind and ending', async () => {
++ const list = async (q = '') => (await t.api('GET', `/api/v1/catalog/domains${q}`)).json().tlds.map((r) => r.tld);
++ assert.deepStrictEqual(await list(), ['com', 'dev', 'uk', 'co.uk', 'security', 'xn--p1ai'], 'POPULAR order, then A to Z');
++ assert.deepStrictEqual(await list('?sort=register'), ['uk', 'co.uk', 'xn--p1ai', 'dev', 'com', 'security']);
++ assert.deepStrictEqual(await list('?sort=five_year'), ['uk', 'co.uk', 'xn--p1ai', 'com', 'dev', 'security'], '.dev is cheap the first year and dear over five');
++ assert.deepStrictEqual(await list('?kind=country'), ['uk']);
++ assert.deepStrictEqual(await list('?kind=second-level'), ['co.uk']);
++ assert.deepStrictEqual(await list('?q=.UK'), ['uk', 'co.uk']);
++ assert.deepStrictEqual(await list(`?q=${encodeURIComponent('рф')}`), ['xn--p1ai']);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains?sort=cheap')).status, 422);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains?q=%25')).status, 422);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains?registrar=porkbun&sort=tld')).json().tlds.length, 6);
++ });
++
++ await check('pages: the comparison, a TLD (canonical lower case), the provider, the front page, sitemap and robots', async () => {
++ const index = await t.api('GET', '/catalog/domains');
++ assert.strictEqual(index.status, 200);
++ assert.match(index.text, /<h1>Compare domain prices<\/h1>/);
++ assert.match(index.text, /6 endings with what registrars charge/);
++ assert.match(index.text, /href="\/catalog\/domains\/dev">\.dev<\/a>/);
++ assert.match(index.text, /Renews higher/);
++ assert.match(index.text, /Porkbun today/);
++ assert.match(index.text, /rel="nofollow noopener"/);
++ assert.doesNotMatch(index.text, /noindex/);
++ const dev = await t.api('GET', '/catalog/domains/dev');
++ assert.strictEqual(dev.status, 200);
++ assert.match(dev.text, /<h1>\.dev domain prices<\/h1>/);
++ assert.match(dev.text, /from <strong>\$8\.75<\/strong> for the first year and renews from <strong>\$13\.50<\/strong>/);
++ assert.match(dev.text, /renewal \$12\.87 → <strong>\$13\.50<\/strong>/);
++ assert.match(dev.text, /Register at Porkbun/);
++ const upper = await t.api('GET', '/catalog/domains/DEV');
++ assert.deepStrictEqual([upper.status, upper.headers.location], [301, '/catalog/domains/dev']);
++ assert.strictEqual((await t.api('GET', '/catalog/domains/nope')).status, 404);
++ const pb = await t.api('GET', '/catalog/providers/porkbun');
++ assert.match(pb.text, /6 domain endings priced/);
++ assert.doesNotMatch(pb.text, /No plan of this provider/);
++ const front = await t.api('GET', '/');
++ assert.match(front.text, /Domain names, compared too/);
++ assert.match(front.text, /href="\/catalog\/domains"/);
++ const map = (await t.api('GET', '/sitemap.xml')).text;
++ assert.match(map, /\/catalog\/domains<\/loc>/);
++ assert.match(map, /\/catalog\/domains\/co\.uk<\/loc>/);
++ assert.match((await t.api('GET', '/robots.txt')).text, /Disallow: \/catalog\/domains\/check/);
++ const old = await t.api('GET', '/domains');
++ assert.deepStrictEqual([old.status, old.headers.location], [301, '/catalog/domains']);
++ assert.strictEqual((await t.api('GET', '/domains/abc')).status, 410, 'old custom-domain pages still say hosting moved');
++ });
++
++ await check('a name check: typed name read, registry asked, the same name priced at other endings; bad names are 422', async () => {
++ const free = await t.api('GET', '/catalog/domains/check?name=OpenVibe.dev');
++ assert.strictEqual(free.status, 200);
++ assert.match(free.text, /<h1 class="hd-name">openvibe\.dev<\/h1>/);
++ assert.match(free.text, /Probably free/);
++ assert.match(free.text, /openvibe with other endings/);
++ assert.match(free.text, /Check openvibe\.com/);
++ assert.match(free.text, /noindex/);
++ assert.match(free.headers['cache-control'], /private/);
++ const taken = (await t.api('GET', '/api/v1/catalog/domains/check?name=https://taken.com/')).json().check;
++ assert.deepStrictEqual([taken.name, taken.status, taken.registrar], ['taken.com', 'registered', 'Example Registrar, Inc.']);
++ assert.deepStrictEqual(taken.prices.map((p) => p.tld).slice(0, 3), ['com', 'dev', 'uk']);
++ const bare = (await t.api('GET', '/api/v1/catalog/domains/check?name=taken')).json().check;
++ assert.strictEqual(bare.name, 'taken.com', 'a bare label is checked as .com');
++ const uk = (await t.api('GET', '/api/v1/catalog/domains/check?name=shop.example.co.uk')).json().check;
++ assert.deepStrictEqual([uk.label, uk.tld, uk.status], ['example', 'co.uk', 'unknown'], 'the longest priced suffix; .uk has no RDAP here');
++ assert.strictEqual((await t.api('GET', '/catalog/domains/check?name=a..b')).status, 422);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/check?name=-x.com')).status, 422);
++ const empty = await t.api('GET', '/catalog/domains/check');
++ assert.deepStrictEqual([empty.status, empty.headers.location], [303, '/catalog/domains']);
++ });
++
++ await check('without the domain tables (a held migration) the pages still answer and a name can be checked', async () => {
++ // The app role cannot drop tables on PostgreSQL: every statement naming them answers as PostgreSQL does when they are missing.
++ const db = t.ctx.store.db;
++ const prepare = db.prepare;
++ const missing = () => Promise.reject(Object.assign(new Error('relation "catalog_tld_prices" does not exist'), { code: '42P01' }));
++ db.prepare = (sql) => (/catalog_tld_/.test(sql) ? { all: missing, get: missing, run: missing } : prepare.call(db, sql));
++ const index = await t.api('GET', '/catalog/domains');
++ assert.strictEqual(index.status, 200);
++ assert.match(index.text, /Is your name free\?/);
++ assert.doesNotMatch(index.text, /<table/, 'no empty price table');
++ assert.deepStrictEqual((await t.api('GET', '/api/v1/catalog/domains')).json(), { tlds: [], next_cursor: null });
++ assert.strictEqual((await t.api('GET', '/catalog/domains/dev')).status, 404);
++ assert.strictEqual((await t.api('GET', '/')).status, 200);
++ assert.strictEqual((await t.api('GET', '/sitemap.xml')).status, 200);
++ assert.strictEqual((await t.api('GET', '/catalog/providers/porkbun')).status, 200);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains/check?name=taken.com')).json().check.status, 'registered');
++ db.prepare = prepare;
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a909cf6..d1bc82a 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -39,7 +39,7 @@ async function boot(opts = {}) {
async function start() {
const config = configLib.load(env);
-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl });
++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.port = server.address().port;
t.app = built.app;
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 9907700..22f970e 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 250, // 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsBrotliKB: 60, // 56.3 (was 49.9)
cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
-- cssRawKB: 28.5, // 27.2 (2026-10-15 showcase.css 4.1.5; 25 before, 14.8 before catalog.css)
-- cssBrotliKB: 6.5, // 5.7 (3.6 before catalog.css)
++ cssRawKB: 31, // 29.9 (2026-10-16 the domain comparison's styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
++ cssBrotliKB: 7, // 6.4 (5.7 before the domain styles, 3.6 before catalog.css)
externalFiles: 1, // 0
};