openvibers/openvibe.host Commits
Domain ending pages move to OpenVibe.Domains: /catalog/domains/:tld 301s there (#321)
Changes
@@@ -34,7 +34,7 @@ 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 LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. 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.++- **Domains** (`/catalog/domains`, `/catalog/domains/check?name=`; a TLD's own page is OpenVibe.Domains' `https://openvibe.domains/tld/:tld`, and Host's `/catalog/domains/:tld` 301s there with its query, 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.- **Price history.** A plan's page and a TLD's page draw the price over time from the append-only change rows (`catalog_price_changes`, `catalog_tld_price_changes`) with the shared `kit.chart` step chart: from the first observation to today, the current price as the last point, with "Was $X on <date>, now $Y (−Z%)", the low and the high; the full change list folds under "Every change". A TLD draws the cheapest registration and renewal at any registrar. One observation only shows the price with "No changes recorded since <date>" and no chart. The catalog home has a "Price changes this month" rail (up to 8, biggest move first, rose/dropped in words). `GET /api/v1/catalog/plans/:id/price-trend` and `/api/v1/catalog/tlds/:tld/price-trend?kind=register|renew` return `{ points: [{ at, price }], currency, now, low, high, change: { amount, percent } | null }`.- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.- **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`).@@@ -175,7 +175,7 @@ async function createApp(opts = {}) {const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/benchmarks' }, { 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) })),-- ...(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' }])];++ { path: '/catalog/domains', lastmod: tlds.length ? latest(tlds) : undefined }];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); }});@@@ -192,7 +192,6 @@ async function createApp(opts = {}) {];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.' }))];// AGT-5: each of Host's own pages is linked at its Markdown twin (openvibe-shared 4.108.0 leaves files and other// links as they are). /terms, /privacy and /dmca are shared legal pages with no twin, so they are linked as HTML;@@@ -207,7 +206,7 @@ async function createApp(opts = {}) {const [providers, endings, plans] = await Promise.all([catalog.providers(), catalog.domains.sitemap(), catalog.sitemapPlans()]);const notes = {'/catalog': `each of the ${plans.length} plans has a page at /catalog/plans/<id> with its sourced price history`,-- '/catalog/domains': `each of the ${endings.length} endings has a page at /catalog/domains/<ending> (for example /catalog/domains/dev)`,++ '/catalog/domains': `each of the ${endings.length} endings has a page on OpenVibe.Domains at https://openvibe.domains/tld/<ending> (for example https://openvibe.domains/tld/dev)`,};res.type('text/plain').send(sharedSeo.llmsTxt({name: 'OpenVibe.Host', summary, markdown: true, base: config.baseUrl,@@@ -217,7 +216,7 @@ async function createApp(opts = {}) {],optional: [...publicPages.filter((p) => LLMS_SECONDARY.has(p.url)).map((p) => llmsLink(p)),-- { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'every plan and domain ending page' },++ { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'every plan and provider page' },{ title: 'Full text for language models', url: `${config.baseUrl}/llms-full.txt`, note: 'every page, with what it holds' },],}));@@@ -39,7 +39,6 @@ function createDomainStore({ store, announce = () => {} }) {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);@@@ -51,7 +50,6 @@ function createDomainStore({ store, announce = () => {} }) {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;@@@ -63,13 +61,12 @@ function createDomainStore({ store, announce = () => {} }) {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']);++ if (p.status === 'active') announce(['/catalog/domains']);return 'applied';}@@@ -112,10 +112,12 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi}page(req, res, { title: c.display + ': is it taken?', body: renderDomains.check(c), indexable: false, description: 'Whether ' + c.display + ' is registered, and what it costs at each registrar.' });}));++ // One canonical copy of a domain ending's page: OpenVibe.Domains' /tld/:tld. Host keeps the comparison++ // (/catalog/domains) and the name check, but a price page here would be a second self-canonical copy.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, await views.domainTld(req.params.tld));++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';++ return res.redirect(301, `https://openvibe.domains/tld/${encodeURIComponent(t.tld)}${q}`);}));router.get('/benchmarks', view(async (req, res) => page(req, res, await views.benchmarkList(req.query))));const benchForm = async (req, res, { error = null, status = 200 } = {}) => {@@@ -23,7 +23,8 @@ const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); returnconst 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 DOMAINS = 'https://openvibe.domains';++const tldHref = (tld) => `${DOMAINS}/tld/${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.@@@ -169,10 +170,10 @@ ${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', `All ${Number(statconst 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` })];++ return [seo.jsonLd.itemList('Domain endings by price', rows.slice(0, 50).map((r) => ({ name: `${r.display} domains`, url: 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)}` }])];++ return [seo.jsonLd.breadcrumbs([{ name: 'Domains', url: `${base}/catalog/domains` }, { name: t.display, url: tldHref(t.tld) }])];},};@@@ -127,24 +127,22 @@ function fakeRegistry() {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 () => {++ await check('pages: the comparison links OpenVibe.Domains; a TLD page here 301s there (query kept)', 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, /href="https:\/\/openvibe\.domains\/tld\/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/);++ assert.deepStrictEqual([dev.status, dev.headers.location], [301, 'https://openvibe.domains/tld/dev']);++ const filtered = await t.api('GET', '/catalog/domains/dev?registrar=porkbun&sort=tld');++ assert.deepStrictEqual([filtered.status, filtered.headers.location], [301, 'https://openvibe.domains/tld/dev?registrar=porkbun&sort=tld']);const upper = await t.api('GET', '/catalog/domains/DEV');-- assert.deepStrictEqual([upper.status, upper.headers.location], [301, '/catalog/domains/dev']);++ assert.deepStrictEqual([upper.status, upper.headers.location], [301, 'https://openvibe.domains/tld/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/);@@@ -155,7 +153,7 @@ function fakeRegistry() {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.ok(!map.includes('/catalog/domains/co.uk</loc>'), 'a TLD page is OpenVibe.Domains\'s, not Host\'s');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']);@@@ -171,8 +169,8 @@ function fakeRegistry() {assert.match(r.text, /\]\(https:\/\/openvibe\.host\/terms\) \(HTML\)/, 'the shared legal pages have no twin');assert.match(r.text, /\n## Optional\n/);// A map, not the catalog: the endings and plans are counted in notes, never listed one by one.-- assert.ok(!/\/catalog\/domains\/[a-z]/.test(r.text.replace(/\/catalog\/domains\/<ending>|\/catalog\/domains\/dev\)/g, '')), 'no ending is listed by itself');-- assert.match(r.text, /each of the \d+ endings has a page at \/catalog\/domains\/<ending>/);++ assert.ok(!/\/catalog\/domains\/[a-z]/.test(r.text), 'no ending is listed by itself');++ assert.match(r.text, /each of the \d+ endings has a page on OpenVibe\.Domains at https:\/\/openvibe\.domains\/tld\/<ending>/);assert.ok(Buffer.byteLength(r.text) < 16 * 1024, `${Buffer.byteLength(r.text)} bytes`);assert.ok(r.text.indexOf('(https://openvibe.host/terms) (HTML)') > r.text.indexOf('\n## Optional\n'), 'the legal pages are optional');});@@@ -109,13 +109,9 @@ const item = (tld, fields) => ({const renew = (await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=renew')).json();assert.deepStrictEqual([renew.kind, renew.now, renew.high, renew.change], ['renew', 13, 13, { amount: 1, percent: 8 }]);assert.strictEqual((await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=transfer')).status, 422);++ // The chart page itself is OpenVibe.Domains' (Host's old path 301s there, query kept); the API stays here.const page = await t.api('GET', '/catalog/domains/dev');-- assert.strictEqual(page.status, 200);-- assert.match(page.text, /Registration and renewal over time/);-- assert.match(page.text, /class="ovk-chart ovk-chart--line"/);-- assert.match(page.text, /Registration<\/strong> Was \$9\.00 on 2026-09-\d\d, now \$8\.00 \(−11%\)/);-- assert.match(page.text, /Renewal<\/strong> Was \$12\.00 on 2026-09-\d\d, now \$13\.00 \(\+8%\)/);-- assert.match(page.text, /Every change \(1\)/);++ assert.deepStrictEqual([page.status, page.headers.location], [301, 'https://openvibe.domains/tld/dev']);});} finally { await t.close(); }done();
@@ -34,7 +34,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -34,7 +34,7 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- Staff entries, which must name their source URL.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- 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 LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. 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`.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. 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.
+- **Domains** (`/catalog/domains`, `/catalog/domains/check?name=`; a TLD's own page is OpenVibe.Domains' `https://openvibe.domains/tld/:tld`, and Host's `/catalog/domains/:tld` 301s there with its query, 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.
- **Price history.** A plan's page and a TLD's page draw the price over time from the append-only change rows (`catalog_price_changes`, `catalog_tld_price_changes`) with the shared `kit.chart` step chart: from the first observation to today, the current price as the last point, with "Was $X on <date>, now $Y (−Z%)", the low and the high; the full change list folds under "Every change". A TLD draws the cheapest registration and renewal at any registrar. One observation only shows the price with "No changes recorded since <date>" and no chart. The catalog home has a "Price changes this month" rail (up to 8, biggest move first, rose/dropped in words). `GET /api/v1/catalog/plans/:id/price-trend` and `/api/v1/catalog/tlds/:tld/price-trend?kind=register|renew` return `{ points: [{ at, price }], currency, now, low, high, change: { amount, percent } | null }`.
- **Price history.** A plan's page and a TLD's page draw the price over time from the append-only change rows (`catalog_price_changes`, `catalog_tld_price_changes`) with the shared `kit.chart` step chart: from the first observation to today, the current price as the last point, with "Was $X on <date>, now $Y (−Z%)", the low and the high; the full change list folds under "Every change". A TLD draws the cheapest registration and renewal at any registrar. One observation only shows the price with "No changes recorded since <date>" and no chart. The catalog home has a "Price changes this month" rail (up to 8, biggest move first, rose/dropped in words). `GET /api/v1/catalog/plans/:id/price-trend` and `/api/v1/catalog/tlds/:tld/price-trend?kind=register|renew` return `{ points: [{ at, price }], currency, now, low, high, change: { amount, percent } | null }`.
- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **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`).
- **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`).
@@ -175,7 +175,7 @@ async function createApp(opts = {}) {
@@ -175,7 +175,7 @@ async function createApp(opts = {}) {
const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/benchmarks' }, { path: '/catalog/providers', lastmod: latest(plans) },
const urls = [{ path: '/', lastmod: latest(plans) }, ...legalPaths.map((p) => ({ path: p })), { path: '/catalog', lastmod: latest(plans) }, { path: '/catalog/promos' }, { path: '/catalog/benchmarks' }, { path: '/catalog/providers', lastmod: latest(plans) },
...providers.map((p) => ({ path: `/catalog/providers/${p.slug}`, lastmod: latest(plans.filter((v) => v.provider_slug === p.slug)) })),
...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' }])];
+ { path: '/catalog/domains', lastmod: tlds.length ? latest(tlds) : undefined }];
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 }))));
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); }
} catch (err) { next(err); }
});
});
@@ -192,7 +192,6 @@ async function createApp(opts = {}) {
@@ -192,7 +192,6 @@ async function createApp(opts = {}) {
];
];
const allPages = async () => [...publicPages,
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.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.' }))];
...(await catalog.sitemapPlans()).map((p) => ({ title: `Hosting plan ${p.id}`, url: `/catalog/plans/${p.id}`, text: 'Sourced plan and price history.' }))];
// AGT-5: each of Host's own pages is linked at its Markdown twin (openvibe-shared 4.108.0 leaves files and other
// AGT-5: each of Host's own pages is linked at its Markdown twin (openvibe-shared 4.108.0 leaves files and other
// links as they are). /terms, /privacy and /dmca are shared legal pages with no twin, so they are linked as HTML;
// links as they are). /terms, /privacy and /dmca are shared legal pages with no twin, so they are linked as HTML;
@@ -207,7 +206,7 @@ async function createApp(opts = {}) {
@@ -207,7 +206,7 @@ async function createApp(opts = {}) {
const [providers, endings, plans] = await Promise.all([catalog.providers(), catalog.domains.sitemap(), catalog.sitemapPlans()]);
const [providers, endings, plans] = await Promise.all([catalog.providers(), catalog.domains.sitemap(), catalog.sitemapPlans()]);
const notes = {
const notes = {
'/catalog': `each of the ${plans.length} plans has a page at /catalog/plans/<id> with its sourced price history`,
'/catalog': `each of the ${plans.length} plans has a page at /catalog/plans/<id> with its sourced price history`,
- '/catalog/domains': `each of the ${endings.length} endings has a page at /catalog/domains/<ending> (for example /catalog/domains/dev)`,
+ '/catalog/domains': `each of the ${endings.length} endings has a page on OpenVibe.Domains at https://openvibe.domains/tld/<ending> (for example https://openvibe.domains/tld/dev)`,
};
};
res.type('text/plain').send(sharedSeo.llmsTxt({
res.type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Host', summary, markdown: true, base: config.baseUrl,
name: 'OpenVibe.Host', summary, markdown: true, base: config.baseUrl,
@@ -217,7 +216,7 @@ async function createApp(opts = {}) {
@@ -217,7 +216,7 @@ async function createApp(opts = {}) {
],
],
optional: [
optional: [
...publicPages.filter((p) => LLMS_SECONDARY.has(p.url)).map((p) => llmsLink(p)),
...publicPages.filter((p) => LLMS_SECONDARY.has(p.url)).map((p) => llmsLink(p)),
- { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'every plan and domain ending page' },
+ { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'every plan and provider page' },
{ title: 'Full text for language models', url: `${config.baseUrl}/llms-full.txt`, note: 'every page, with what it holds' },
{ title: 'Full text for language models', url: `${config.baseUrl}/llms-full.txt`, note: 'every page, with what it holds' },
],
],
}));
}));
@@ -39,7 +39,6 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -39,7 +39,6 @@ function createDomainStore({ store, announce = () => {} }) {
const tld = domains.normalizeTld(item.identity);
const tld = domains.normalizeTld(item.identity);
if (!tld) return 'hold';
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);
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';
return r.changes ? 'removed' : 'hold';
}
}
const v = domains.mapTldPrice(item);
const v = domains.mapTldPrice(item);
@@ -51,7 +50,6 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -51,7 +50,6 @@ function createDomainStore({ store, announce = () => {} }) {
p = { id, status: 'active' };
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 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) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
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;
const moved = ['register', 'renew', 'transfer'].some((k) => n(old[k]) !== v[k]) || old.currency !== v.currency;
@@ -63,13 +61,12 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -63,13 +61,12 @@ function createDomainStore({ store, announce = () => {} }) {
const status = old.status === 'gone' ? 'active' : old.status;
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 = ?')
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);
.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';
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
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);
.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;
known.at = 0;
- if (p.status === 'active') announce([page, '/catalog/domains']);
+ if (p.status === 'active') announce(['/catalog/domains']);
return 'applied';
return 'applied';
}
}
@@ -112,10 +112,12 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -112,10 +112,12 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
}
}
page(req, res, { title: c.display + ': is it taken?', body: renderDomains.check(c), indexable: false, description: 'Whether ' + c.display + ' is registered, and what it costs at each registrar.' });
page(req, res, { title: c.display + ': is it taken?', body: renderDomains.check(c), indexable: false, description: 'Whether ' + c.display + ' is registered, and what it costs at each registrar.' });
}));
}));
+ // One canonical copy of a domain ending's page: OpenVibe.Domains' /tld/:tld. Host keeps the comparison
+ // (/catalog/domains) and the name check, but a price page here would be a second self-canonical copy.
router.get('/domains/:tld', view(async (req, res) => {
router.get('/domains/:tld', view(async (req, res) => {
const t = await catalog.domains.tld(req.params.tld);
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, await views.domainTld(req.params.tld));
+ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
+ return res.redirect(301, `https://openvibe.domains/tld/${encodeURIComponent(t.tld)}${q}`);
}));
}));
router.get('/benchmarks', view(async (req, res) => page(req, res, await views.benchmarkList(req.query))));
router.get('/benchmarks', view(async (req, res) => page(req, res, await views.benchmarkList(req.query))));
const benchForm = async (req, res, { error = null, status = 200 } = {}) => {
const benchForm = async (req, res, { error = null, status = 200 } = {}) => {
@@ -23,7 +23,8 @@ const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return
@@ -23,7 +23,8 @@ const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return
const usd = (v) => (v == null ? UNKNOWN : esc(money(v, 'USD')));
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 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 out = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="nofollow noopener">${label}</a>`;
-const tldHref = (tld) => `/catalog/domains/${encodeURIComponent(tld)}`;
+const DOMAINS = 'https://openvibe.domains';
+const tldHref = (tld) => `${DOMAINS}/tld/${encodeURIComponent(tld)}`;
const checkHref = (name) => `/catalog/domains/check?${new URLSearchParams({ name })}`;
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>`;
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.
// A renewal more than a fifth above the first year is the usual trap: a cheap first year, then the real price.
@@ -169,10 +170,10 @@ ${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', `All ${Number(stat
@@ -169,10 +170,10 @@ ${stats.tlds ? `<p class="hc-more">${link('/catalog/domains', `All ${Number(stat
const jsonLd = {
const jsonLd = {
index(base, rows) {
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` })];
+ return [seo.jsonLd.itemList('Domain endings by price', rows.slice(0, 50).map((r) => ({ name: `${r.display} domains`, url: tldHref(r.tld) })), { url: `${base}/catalog/domains` })];
},
},
tld(base, t) {
tld(base, t) {
- return [seo.jsonLd.breadcrumbs([{ name: 'Domains', url: `${base}/catalog/domains` }, { name: t.display, url: `${base}${tldHref(t.tld)}` }])];
+ return [seo.jsonLd.breadcrumbs([{ name: 'Domains', url: `${base}/catalog/domains` }, { name: t.display, url: tldHref(t.tld) }])];
},
},
};
};
@@ -127,24 +127,22 @@ function fakeRegistry() {
@@ -127,24 +127,22 @@ function fakeRegistry() {
assert.strictEqual((await t.api('GET', '/api/v1/catalog/domains?registrar=porkbun&sort=tld')).json().tlds.length, 6);
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 () => {
+ await check('pages: the comparison links OpenVibe.Domains; a TLD page here 301s there (query kept)', async () => {
const index = await t.api('GET', '/catalog/domains');
const index = await t.api('GET', '/catalog/domains');
assert.strictEqual(index.status, 200);
assert.strictEqual(index.status, 200);
assert.match(index.text, /<h1>Compare domain prices<\/h1>/);
assert.match(index.text, /<h1>Compare domain prices<\/h1>/);
assert.match(index.text, /6 endings with what registrars charge/);
assert.match(index.text, /6 endings with what registrars charge/);
- assert.match(index.text, /href="\/catalog\/domains\/dev">\.dev<\/a>/);
+ assert.match(index.text, /href="https:\/\/openvibe\.domains\/tld\/dev">\.dev<\/a>/);
assert.match(index.text, /Renews higher/);
assert.match(index.text, /Renews higher/);
assert.match(index.text, /Porkbun today/);
assert.match(index.text, /Porkbun today/);
assert.match(index.text, /rel="nofollow noopener"/);
assert.match(index.text, /rel="nofollow noopener"/);
assert.doesNotMatch(index.text, /noindex/);
assert.doesNotMatch(index.text, /noindex/);
const dev = await t.api('GET', '/catalog/domains/dev');
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/);
+ assert.deepStrictEqual([dev.status, dev.headers.location], [301, 'https://openvibe.domains/tld/dev']);
+ const filtered = await t.api('GET', '/catalog/domains/dev?registrar=porkbun&sort=tld');
+ assert.deepStrictEqual([filtered.status, filtered.headers.location], [301, 'https://openvibe.domains/tld/dev?registrar=porkbun&sort=tld']);
const upper = await t.api('GET', '/catalog/domains/DEV');
const upper = await t.api('GET', '/catalog/domains/DEV');
- assert.deepStrictEqual([upper.status, upper.headers.location], [301, '/catalog/domains/dev']);
+ assert.deepStrictEqual([upper.status, upper.headers.location], [301, 'https://openvibe.domains/tld/dev']);
assert.strictEqual((await t.api('GET', '/catalog/domains/nope')).status, 404);
assert.strictEqual((await t.api('GET', '/catalog/domains/nope')).status, 404);
const pb = await t.api('GET', '/catalog/providers/porkbun');
const pb = await t.api('GET', '/catalog/providers/porkbun');
assert.match(pb.text, /6 domain endings priced/);
assert.match(pb.text, /6 domain endings priced/);
@@ -155,7 +153,7 @@ function fakeRegistry() {
@@ -155,7 +153,7 @@ function fakeRegistry() {
assert.match(front.text, /href="\/catalog\/domains"/);
assert.match(front.text, /href="\/catalog\/domains"/);
const map = (await t.api('GET', '/sitemap.xml')).text;
const map = (await t.api('GET', '/sitemap.xml')).text;
assert.match(map, /\/catalog\/domains<\/loc>/);
assert.match(map, /\/catalog\/domains<\/loc>/);
- assert.match(map, /\/catalog\/domains\/co\.uk<\/loc>/);
+ assert.ok(!map.includes('/catalog/domains/co.uk</loc>'), 'a TLD page is OpenVibe.Domains\'s, not Host\'s');
assert.match((await t.api('GET', '/robots.txt')).text, /Disallow: \/catalog\/domains\/check/);
assert.match((await t.api('GET', '/robots.txt')).text, /Disallow: \/catalog\/domains\/check/);
const old = await t.api('GET', '/domains');
const old = await t.api('GET', '/domains');
assert.deepStrictEqual([old.status, old.headers.location], [301, '/catalog/domains']);
assert.deepStrictEqual([old.status, old.headers.location], [301, '/catalog/domains']);
@@ -171,8 +169,8 @@ function fakeRegistry() {
@@ -171,8 +169,8 @@ function fakeRegistry() {
assert.match(r.text, /\]\(https:\/\/openvibe\.host\/terms\) \(HTML\)/, 'the shared legal pages have no twin');
assert.match(r.text, /\]\(https:\/\/openvibe\.host\/terms\) \(HTML\)/, 'the shared legal pages have no twin');
assert.match(r.text, /\n## Optional\n/);
assert.match(r.text, /\n## Optional\n/);
// A map, not the catalog: the endings and plans are counted in notes, never listed one by one.
// A map, not the catalog: the endings and plans are counted in notes, never listed one by one.
- assert.ok(!/\/catalog\/domains\/[a-z]/.test(r.text.replace(/\/catalog\/domains\/<ending>|\/catalog\/domains\/dev\)/g, '')), 'no ending is listed by itself');
- assert.match(r.text, /each of the \d+ endings has a page at \/catalog\/domains\/<ending>/);
+ assert.ok(!/\/catalog\/domains\/[a-z]/.test(r.text), 'no ending is listed by itself');
+ assert.match(r.text, /each of the \d+ endings has a page on OpenVibe\.Domains at https:\/\/openvibe\.domains\/tld\/<ending>/);
assert.ok(Buffer.byteLength(r.text) < 16 * 1024, `${Buffer.byteLength(r.text)} bytes`);
assert.ok(Buffer.byteLength(r.text) < 16 * 1024, `${Buffer.byteLength(r.text)} bytes`);
assert.ok(r.text.indexOf('(https://openvibe.host/terms) (HTML)') > r.text.indexOf('\n## Optional\n'), 'the legal pages are optional');
assert.ok(r.text.indexOf('(https://openvibe.host/terms) (HTML)') > r.text.indexOf('\n## Optional\n'), 'the legal pages are optional');
});
});
@@ -109,13 +109,9 @@ const item = (tld, fields) => ({
@@ -109,13 +109,9 @@ const item = (tld, fields) => ({
const renew = (await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=renew')).json();
const renew = (await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=renew')).json();
assert.deepStrictEqual([renew.kind, renew.now, renew.high, renew.change], ['renew', 13, 13, { amount: 1, percent: 8 }]);
assert.deepStrictEqual([renew.kind, renew.now, renew.high, renew.change], ['renew', 13, 13, { amount: 1, percent: 8 }]);
assert.strictEqual((await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=transfer')).status, 422);
assert.strictEqual((await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=transfer')).status, 422);
+ // The chart page itself is OpenVibe.Domains' (Host's old path 301s there, query kept); the API stays here.
const page = await t.api('GET', '/catalog/domains/dev');
const page = await t.api('GET', '/catalog/domains/dev');
- assert.strictEqual(page.status, 200);
- assert.match(page.text, /Registration and renewal over time/);
- assert.match(page.text, /class="ovk-chart ovk-chart--line"/);
- assert.match(page.text, /Registration<\/strong> Was \$9\.00 on 2026-09-\d\d, now \$8\.00 \(−11%\)/);
- assert.match(page.text, /Renewal<\/strong> Was \$12\.00 on 2026-09-\d\d, now \$13\.00 \(\+8%\)/);
- assert.match(page.text, /Every change \(1\)/);
+ assert.deepStrictEqual([page.status, page.headers.location], [301, 'https://openvibe.domains/tld/dev']);
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();