openvibers/openvibe.rent Commits

Rent: OpenVibe.Homes, the buying side, on openvibe.homes (#58)

OP OpenVibers committed 29 days ago · 5b32b5dda3 · 10 files, +565 −13

Changes

ddiff --git a/README.md b/README.md
iindex c8a7f17..9c9b934 100644
---- a/README.md
++++ b/README.md
@@@ -9,6 +9,15 @@ public housing authorities and housing counselling agencies) near a place a visi
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**License:** AGPL-3.0 (same as every OpenVibe service).
++
++**OpenVibe.Homes** (since 2026-10-15, https://openvibe.homes): the buying side, served by this process for its own host
++(`server/homes`, `deploy/nginx/openvibe.homes.conf`).
++- **Search:** one place opens every for-sale site's own search (Zillow, Realtor.com, Redfin by ZIP, Homes.com, Trulia,
++ Craigslist), the government and bank homes (HUD Homestore, Fannie Mae HomePath, Auction.com), land (LandWatch) and
++ commercial property (LoopNet).
++- **Around the place:** the state's county records (NETR Online's directory) and the FHFA price index for the area.
++- **Mortgage calculator:** server-side arithmetic, nothing saved.
++- **Links back to Rent:** renting and the board stay on OpenVibe.Rent.
## What it is
The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,
ddiff --git a/deploy/nginx/openvibe.homes.conf b/deploy/nginx/openvibe.homes.conf
nnew file mode 100644
iindex 0000000..77ddecc
---- /dev/null
++++ b/deploy/nginx/openvibe.homes.conf
@@@ -0,0 +1,77 @@
++# ═══════════════════════════════════════════════════════════════
++# OpenVibe.Homes — https://openvibe.homes, served by OpenVibe.Rent's process (127.0.0.1:5010; server/homes, chosen by
++# the Host header). Uses the rate zones openvibe.rent.conf declares (rent_api). Its certificate is its own name's.
++# Took the name from Network's parked-domains.conf on 2026-10-15 (remove the parked blocks with it).
++#
++# sudo ov-vhost-install deploy/nginx/openvibe.homes.conf openvibe.homes.conf
++# ═══════════════════════════════════════════════════════════════
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.homes www.openvibe.homes;
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.homes$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.homes;
++ ssl_certificate /etc/letsencrypt/live/openvibe.homes/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.homes/privkey.pem;
++ return 301 https://openvibe.homes$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.homes;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.homes/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.homes/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.homes.access.log;
++ error_log /var/log/nginx/openvibe.homes.error.log;
++
++ # Homes has no form that posts and no upload: only GETs.
++ client_max_body_size 16k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # Rent's own routes are not served on this name.
++ location ~* ^/(metrics|internal|api)(/|$) { return 404; }
++
++ # The search geocodes a place (the app's per-caller budget); this is a coarse per-address ceiling in front of it.
++ location = /search {
++ limit_req zone=rent_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5010;
++ }
++
++ location / {
++ proxy_pass http://127.0.0.1:5010;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/public/css/homes.css b/public/css/homes.css
nnew file mode 100644
iindex 0000000..f9d3d28
---- /dev/null
++++ b/public/css/homes.css
@@@ -0,0 +1,13 @@
++/* OpenVibe.Homes (server/homes/pages.js): the search's context cards, the records list and the mortgage calculator. */
++.homes-cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); align-items: start; }
++.homes-card { padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.homes-card h3 { margin: 0 0 6px; font-size: 1rem; }
++.homes-card p { margin: 0; }
++.homes-states { list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 6px 14px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
++.homes-states li.on a { font-weight: 700; }
++.homes-calc { max-width: 560px; }
++.homes-result { margin-top: 18px; max-width: 560px; }
++.homes-table { width: 100%; border-collapse: collapse; }
++.homes-table th, .homes-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #1f2d47); }
++.homes-table td { text-align: right; font-variant-numeric: tabular-nums; }
++.homes-total th, .homes-total td { font-weight: 700; font-size: 1.05rem; }
ddiff --git a/server/app.js b/server/app.js
iindex c0d2060..0a631d4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -188,6 +188,10 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.RENT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ // OpenVibe.Homes (server/homes/pages.js): the same process, its own host and pages.
++ const homesHost = (() => { try { return new URL(config.homes.url).hostname; } catch { return 'openvibe.homes'; } })();
++ const homesRoutes = require('./homes/pages').createHomesRoutes(ctx);
++ app.use((req, res, next) => (req.hostname === homesHost ? homesRoutes(req, res, next) : next()));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
ddiff --git a/server/config.js b/server/config.js
iindex 0c0d364..f605f97 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -24,6 +24,10 @@ function load(env = process.env) {
nodeEnv,
isProduction,
baseUrl,
++ // OpenVibe.Homes (server/homes): buying, served by this process for its own host.
++ homes: {
++ url: trim(env.RENT_HOMES_URL || (isProduction ? 'https://openvibe.homes' : `http://homes.localhost:${port}`)),
++ },
// IndexNow (openvibe-shared/indexnow): with a key, each page Search gains, changes or loses is sent to search
// engines in one batch (openvibe-publishing/search-feed), and the key is served at /<key>.txt. Unset: off.
indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
ddiff --git a/server/homes/mortgage.js b/server/homes/mortgage.js
nnew file mode 100644
iindex 0000000..07aed9c
---- /dev/null
++++ b/server/homes/mortgage.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++/**
++ * The mortgage calculator on OpenVibe.Homes: the monthly cost of a fixed-rate loan, from what the person types in.
++ * Plain arithmetic (the standard amortisation formula); no rates are fetched or suggested.
++ *
++ * monthly({ price, downPercent, ratePercent, years, taxPerYear, insurancePerYear, hoaPerMonth })
++ * → { loan, principalAndInterest, tax, insurance, hoa, total, totalInterest, payments } in dollars, or { error }
++ */
++const num = (v) => {
++ if (v == null || v === '') return null;
++ const n = Number(String(v).replace(/[$,\s]/g, ''));
++ return Number.isFinite(n) ? n : NaN;
++};
++const cents = (n) => Math.round(n * 100) / 100;
++
++function monthly(input) {
++ const price = num(input.price);
++ const down = num(input.downPercent);
++ const rate = num(input.ratePercent);
++ const years = num(input.years);
++ const tax = num(input.taxPerYear) || 0;
++ const ins = num(input.insurancePerYear) || 0;
++ const hoa = num(input.hoaPerMonth) || 0;
++ if (!(price > 0 && price <= 100_000_000)) return { error: 'Enter the home\'s price.' };
++ if (!(down >= 0 && down < 100)) return { error: 'The down payment is a percentage from 0 to 99.' };
++ if (!(rate >= 0 && rate <= 30)) return { error: 'The interest rate is a yearly percentage from 0 to 30.' };
++ if (![10, 15, 20, 25, 30, 40].includes(years)) return { error: 'Choose a loan term.' };
++ if ([tax, ins, hoa].some((x) => Number.isNaN(x) || x < 0 || x > 1_000_000)) return { error: 'Taxes, insurance and HOA are amounts in dollars.' };
++ const loan = price * (1 - down / 100);
++ const n = years * 12;
++ const r = rate / 100 / 12;
++ const pi = r === 0 ? loan / n : loan * (r * (1 + r) ** n) / ((1 + r) ** n - 1);
++ const total = pi + tax / 12 + ins / 12 + hoa;
++ return {
++ loan: cents(loan), principalAndInterest: cents(pi), tax: cents(tax / 12), insurance: cents(ins / 12), hoa: cents(hoa),
++ total: cents(total), totalInterest: cents(pi * n - loan), payments: n,
++ };
++}
++
++module.exports = { monthly };
ddiff --git a/server/homes/pages.js b/server/homes/pages.js
nnew file mode 100644
iindex 0000000..3fdece5
---- /dev/null
++++ b/server/homes/pages.js
@@@ -0,0 +1,194 @@
++'use strict';
++
++/**
++ * OpenVibe.Homes (https://openvibe.homes): buying a home, the owning side of OpenVibe.Rent (the owner's 09-28 ask:
++ * "the homes and property and records version of openvibe.rent… interconnect them"). Served by Rent's process for its
++ * own host (server/app.js); every page is server-rendered and works without JavaScript.
++ *
++ * / what Homes is, and the search
++ * /search one place → every for-sale site's own search, foreclosures and government homes, the county's
++ * records, the FHFA price index for the area, and Rent's search for the same place
++ * /records each state's county assessor and recorder sites (NETR Online's directory), and what they hold
++ * /mortgage the monthly cost of a loan, from what you type (server/homes/mortgage.js)
++ * /robots.txt /sitemap.xml /llms.txt
++ *
++ * Nothing is fetched from the listing sites; the place search spends the same per-caller budget as Rent's
++ * (rent.housing.search, the OpenStreetMap geocode).
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const showcase = require('openvibe-shared/showcase');
++const { send } = require('../render/layout');
++const { html, raw, notice } = require('../render/html');
++const sites = require('./sites');
++const { monthly } = require('./mortgage');
++const housingPlaces = require('../housing/places');
++const housingHpi = require('../housing/hpi');
++const { US_STATE_NAMES } = require('../aggregate/everywhere');
++
++const STATE_TITLE = Object.fromEntries(Object.entries(US_STATE_NAMES).map(([code, s]) => [code, s.split('-').map((w) => (w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ')]));
++const money = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
++const str = (v, max = 120) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
++
++function createHomesRoutes(ctx) {
++ const { config, s } = ctx;
++ const r = express.Router();
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ const base = String(config.homes.url).replace(/\/+$/, '');
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: { kind: 'anonymous' }, config, path: req.originalUrl, site: 'homes', ...o });
++ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
++
++ const searchForm = (v) => html`<form class="search-form everywhere-form" method="get" action="/search" role="search">
++<div class="field"><label for="hq">Where</label><input id="hq" name="q" type="search" required maxlength="120" placeholder="A city and state, or a ZIP code" value="${v.q || ''}" autocomplete="address-level2"></div>
++<div class="field-row">
++<div class="field"><label for="hk">Looking for</label><select id="hk" name="kind">${sites.KINDS.map((k) => html`<option value="${k}"${k === v.kind ? raw(' selected') : ''}>${sites.KIND_TEXT[k]}</option>`)}</select></div>
++<div class="field"><label for="hm">Up to</label><input id="hm" name="max" type="number" min="1" max="100000000" step="1" inputmode="numeric" placeholder="Any price" value="${v.max || ''}"></div>
++<div class="field"><label for="hb">Bedrooms</label><select id="hb" name="beds"><option value="">Any</option>${[1, 2, 3, 4, 5].map((n) => html`<option value="${n}"${String(n) === String(v.beds || '') ? raw(' selected') : ''}>${n}+</option>`)}</select></div>
++</div>
++<p><button class="sc-btn sc-primary" type="submit">Search every site</button></p></form>`;
++
++ // ── Home ─────────────────────────────────────────────────
++ r.get('/', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ description: 'Buying a home: one search opens Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, with HUD and Fannie Mae homes, foreclosure auctions, county property records and a mortgage calculator.',
++ styles: [showcase.STYLESHEET], localStyles: ['css/everywhere.css', 'css/homes.css'],
++ body: html`${raw(showcase.hero({
++ eyebrow: 'OpenVibe.Homes', title: 'Buying a home?', accent: 'Search every site at once.',
++ lede: 'Type a place once: Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist open their own searches for it, next to HUD and Fannie Mae homes, foreclosure auctions and the county\'s property records. The listings stay on those sites; OpenVibe.Homes copies none of them.',
++ actions: [{ label: 'Search every site', href: '#search', primary: true }, { label: 'Mortgage calculator', href: '/mortgage' }, { label: 'Property records', href: '/records' }],
++ }))}
++<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search</h2>${searchForm({ kind: 'home' })}</section>
++${raw(showcase.features({
++ title: 'What is here',
++ items: [
++ { icon: 'ov:search', title: 'Every for-sale site', text: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist, each opened at your place and price.' },
++ { icon: 'ov:shield', title: 'Government and bank homes', text: 'HUD Homestore, Fannie Mae HomePath and foreclosure auctions, often below market and first offered to people who will live there.' },
++ { icon: 'ov:docs', title: 'Property records', text: 'The county assessor and recorder for any US state: owners, sales, taxes and deeds, from the official sites.' },
++ { icon: 'ov:gauge', title: 'Mortgage calculator', text: 'The monthly cost of a loan with taxes, insurance and HOA, from the numbers you type.' },
++ { icon: 'ov:rent', title: 'Keep them on your board', text: 'Your OpenVibe.Rent board keeps homes from every site in one list, with your notes.', href: `${config.baseUrl}/board` },
++ ],
++ }))}`,
++ }));
++
++ // ── One place, every site ────────────────────────────────
++ const budget = ctx.limits.budget('rent.housing.search');
++ async function searchPage(req, res, { limited = false } = {}) {
++ const v = { q: str(req.query.q), kind: sites.KINDS.includes(String(req.query.kind)) ? String(req.query.kind) : 'home', max: str(req.query.max, 9), beds: str(req.query.beds, 2) };
++ let place = null;
++ let problem = null;
++ if (limited) problem = 'Too many searches from you just now. Try again in a minute.';
++ else if (v.q) {
++ try { place = await housingPlaces.geocodePlace(ctx.upstream, v.q); } catch (err) {
++ problem = err && err.code === 'housing.place.not_found' ? err.message : 'The place search is unavailable right now. Try again in a few minutes.';
++ }
++ }
++ const inUs = place && (place.country_code || 'us') === 'us' && /^[A-Z]{2}$/.test(String(place.state || ''));
++ if (place && !inUs) problem = 'OpenVibe.Homes covers the United States for now. Try a US city and state, or a ZIP code.';
++ const label = inUs ? `${place.city || place.name}, ${place.state}` : null;
++ const links = inUs ? sites.linksFor({ city: place.city, state: place.state, country: 'us', postcode: place.postcode }, { kind: v.kind, maxPrice: v.max, minBeds: v.beds }) : [];
++ let prices = null;
++ if (inUs && config.hpi && config.hpi.enabled) { try { prices = await housingHpi.areaPricesFor(s, { city: place.city, state: place.state }); } catch { prices = null; } }
++ const records = inUs ? sites.recordsUrl(place.state) : null;
++ const tab = (k) => `/search?${new URLSearchParams({ q: v.q || '', kind: k, ...(v.max ? { max: v.max } : {}), ...(v.beds ? { beds: v.beds } : {}) })}`;
++ const pct = (n) => { const x = Math.round(n * 10) / 10; return `${x > 0 ? '+' : ''}${x.toFixed(1)} %`; };
++ return page(req, res, {
++ title: label ? `${sites.KIND_TEXT[v.kind]} near ${label}` : 'Search every site for a home to buy',
++ description: 'One search across Zillow, Realtor.com, Redfin, Homes.com, Trulia, Craigslist, HUD Homestore, HomePath and foreclosure auctions.',
++ index: !v.q, cache: v.q ? null : PUBLIC_CACHE,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Search' }],
++ styles: [showcase.STYLESHEET], localStyles: ['css/everywhere.css', 'css/housing.css', 'css/homes.css'],
++ body: html`<h1>${label ? html`${sites.KIND_TEXT[v.kind]} near ${label}` : 'Search every site for a home to buy'}</h1>
++${searchForm(v)}
++${problem ? notice(problem, 'warn') : ''}
++${inUs ? html`<nav class="everywhere-kinds" aria-label="What to look for">${sites.KINDS.map((k) => (k === v.kind ? html`<span class="ew-kind on" aria-current="page">${sites.KIND_TEXT[k]}</span>` : html`<a class="ew-kind" href="${tab(k)}">${sites.KIND_TEXT[k]}</a>`))}</nav>
++<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">On every site</h2>
++${links.length ? html`<ul class="everywhere-sites">${links.map((l) => html`<li class="ew-site"><a class="ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="ew-name">${l.name}</span><span class="ew-host">${hostOf(l.url)} ↗</span></a><p class="ew-best">${l.best}</p></li>`)}</ul>
++<p class="small muted">Each link opens that site's own search in a new tab, with your filters where the site takes them. Offers, inspections and payments happen with the seller or an agent, never through a stranger's link.</p>` : html`<p class="muted">No site covers this place for this kind yet.</p>`}
++<p class="ew-board">Found one? <a href="${config.baseUrl}/board">Keep it on your board</a> on OpenVibe.Rent: homes from every site in one list, with your notes.</p></section>
++<section class="sc-sec homes-context" aria-labelledby="h-context"><h2 id="h-context">Around ${label}</h2><div class="homes-cards">
++${prices ? html`<aside class="hpi-card"><h3 class="hpi-title">Home prices around ${prices.place}</h3><div class="hpi-stats">${prices.year_change_pct != null ? html`<div class="hpi-stat"><span class="hpi-num${prices.year_change_pct < 0 ? ' hpi-down' : ''}">${pct(prices.year_change_pct)}</span><span class="hpi-label">over the past year</span></div>` : ''}${prices.five_year_change_pct != null ? html`<div class="hpi-stat"><span class="hpi-num${prices.five_year_change_pct < 0 ? ' hpi-down' : ''}">${pct(prices.five_year_change_pct)}</span><span class="hpi-label">over five years</span></div>` : ''}</div>
++<p class="hpi-note">FHFA all-transactions index, ${prices.latest}. An index of repeat sales, not a price. <a href="${prices.source.url}" rel="noopener">Source: ${prices.source.name}</a></p></aside>` : ''}
++${records ? html`<aside class="homes-card"><h3>Property records in ${STATE_TITLE[place.state] || place.state}</h3><p>Owners, past sales, assessed values, taxes and deeds are kept by each county. <a href="${records}" target="_blank" rel="noopener nofollow">Find the county's assessor and recorder ↗</a></p></aside>` : ''}
++<aside class="homes-card"><h3>What would it cost a month?</h3><p><a href="/mortgage${v.max ? `?price=${encodeURIComponent(v.max)}` : ''}">The mortgage calculator</a>: principal, interest, taxes, insurance and HOA.</p></aside>
++<aside class="homes-card"><h3>Renting instead?</h3><p><a href="${config.baseUrl}/everywhere?${new URLSearchParams({ q: v.q || '' })}">Every rental site near ${place.city}</a>, and affordable housing from HUD, on OpenVibe.Rent.</p></aside>
++</div></section>` : ''}`,
++ }, limited ? 429 : 200);
++ }
++ r.get('/search', (req, res) => {
++ if (!str(req.query.q)) return searchPage(req, res);
++ let settled = false;
++ let retryAfter = '60';
++ const allowed = () => { if (!settled) { settled = true; searchPage(req, res); } };
++ const refused = () => { if (settled) return; settled = true; res.set('Retry-After', retryAfter); searchPage(req, res, { limited: true }); };
++ return budget(req, { statusCode: 200, setHeader: (k, val) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(val); }, end: refused }, allowed);
++ });
++
++ // ── Property records ─────────────────────────────────────
++ r.get('/records', (req, res) => {
++ const chosen = /^[A-Z]{2}$/.test(String(req.query.state || '').toUpperCase()) ? String(req.query.state).toUpperCase() : null;
++ const list = Object.keys(US_STATE_NAMES).sort((a, b) => STATE_TITLE[a].localeCompare(STATE_TITLE[b]));
++ return page(req, res, {
++ title: chosen && STATE_TITLE[chosen] ? `Property records in ${STATE_TITLE[chosen]}` : 'Property records, county by county',
++ description: 'Owners, sales, assessed values, taxes and deeds: each US county\'s assessor and recorder sites, by state.',
++ index: true, cache: PUBLIC_CACHE,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Property records' }],
++ localStyles: ['css/homes.css'],
++ body: html`<h1>Property records, county by county</h1>
++<p class="sc-lede">In the United States each county keeps its own records: the <b>assessor</b> (owner, assessed value, property details), the <b>recorder</b> or clerk (deeds, mortgages, liens) and the <b>treasurer</b> (property taxes). Most are searchable online for free. Pick a state for its counties' official sites, listed by NETR Online.</p>
++<ul class="homes-states">${list.map((st) => html`<li${st === chosen ? raw(' class="on"') : ''}><a href="${sites.recordsUrl(st)}" target="_blank" rel="noopener nofollow">${STATE_TITLE[st]}</a></li>`)}</ul>
++<p class="small muted">The directory is NETR Online's (publicrecords.netronline.com); the records are each county's. OpenVibe.Homes copies none of them.</p>`,
++ });
++ });
++
++ // ── Mortgage calculator ──────────────────────────────────
++ r.get('/mortgage', (req, res) => {
++ const q = req.query;
++ const v = { price: str(q.price, 12), down: str(q.down, 5) || '20', rate: str(q.rate, 6), years: str(q.years, 2) || '30', tax: str(q.tax, 9), ins: str(q.ins, 9), hoa: str(q.hoa, 7) };
++ const asked = Boolean(v.price && v.rate);
++ const out = asked ? monthly({ price: v.price, downPercent: v.down, ratePercent: v.rate, years: Number(v.years), taxPerYear: v.tax, insurancePerYear: v.ins, hoaPerMonth: v.hoa }) : null;
++ const field = (id, label, value, hint, attrs = '') => html`<div class="field"><label for="${id}">${label}</label><input id="${id}" name="${id}" inputmode="decimal" maxlength="12" value="${value || ''}" placeholder="${hint}"${raw(attrs)}></div>`;
++ return page(req, res, {
++ title: 'Mortgage calculator', description: 'The monthly cost of a fixed-rate mortgage: principal and interest, property tax, insurance and HOA.',
++ index: !asked, cache: asked ? null : PUBLIC_CACHE,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Mortgage calculator' }],
++ localStyles: ['css/everywhere.css', 'css/homes.css'],
++ body: html`<h1>Mortgage calculator</h1>
++<p class="sc-lede">A fixed-rate loan's monthly cost, worked out from the numbers you type. Nothing is saved or sent anywhere; the rate is yours to find (a lender's quote, or today's average).</p>
++<form class="search-form homes-calc" method="get" action="/mortgage">
++${field('price', 'Home price ($)', v.price, '450000', ' required')}
++${field('down', 'Down payment (%)', v.down, '20')}
++${field('rate', 'Interest rate (% a year)', v.rate, '6.5', ' required')}
++<div class="field"><label for="years">Loan term</label><select id="years" name="years">${[30, 20, 15, 10].map((y) => html`<option value="${y}"${String(y) === v.years ? raw(' selected') : ''}>${y} years</option>`)}</select></div>
++${field('tax', 'Property tax ($ a year)', v.tax, 'Optional')}
++${field('ins', 'Home insurance ($ a year)', v.ins, 'Optional')}
++${field('hoa', 'HOA ($ a month)', v.hoa, 'Optional')}
++<p><button class="sc-btn sc-primary" type="submit">Work it out</button></p></form>
++${out && out.error ? notice(out.error, 'warn') : ''}
++${out && !out.error ? html`<section class="homes-result" aria-labelledby="h-result"><h2 id="h-result">About ${money(out.total)} a month</h2>
++<table class="homes-table"><tbody>
++<tr><th scope="row">Loan</th><td>${money(out.loan)}</td></tr>
++<tr><th scope="row">Principal and interest</th><td>${money(out.principalAndInterest)}</td></tr>
++${out.tax ? html`<tr><th scope="row">Property tax</th><td>${money(out.tax)}</td></tr>` : ''}
++${out.insurance ? html`<tr><th scope="row">Insurance</th><td>${money(out.insurance)}</td></tr>` : ''}
++${out.hoa ? html`<tr><th scope="row">HOA</th><td>${money(out.hoa)}</td></tr>` : ''}
++<tr class="homes-total"><th scope="row">Each month</th><td>${money(out.total)}</td></tr>
++<tr><th scope="row">Interest over ${out.payments} payments</th><td>${money(out.totalInterest)}</td></tr>
++</tbody></table>
++<p class="small muted">A down payment under 20 % usually adds mortgage insurance (PMI), which is not included. Lenders also charge closing costs. This is arithmetic, not a loan offer.</p></section>` : ''}`,
++ });
++ });
++
++ // ── Crawlers ─────────────────────────────────────────────
++ r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(`User-agent: *\nDisallow: /search?\nAllow: /\nSitemap: ${base}/sitemap.xml\n`));
++ r.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/search', '/records', '/mortgage'].map((p) => `<url><loc>${base}${p}</loc></url>`).join('')}</urlset>\n`));
++ r.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(`# OpenVibe.Homes\n\n> Buying a home in the United States: one search opens every for-sale site's own search (Zillow, Realtor.com, Redfin, Homes.com, Trulia, Craigslist), HUD Homestore, Fannie Mae HomePath and foreclosure auctions, with county property records and a mortgage calculator. Listings stay on those sites.\n\n- [Search every site](${base}/search)\n- [Property records by state](${base}/records)\n- [Mortgage calculator](${base}/mortgage)\n- [Renting: OpenVibe.Rent](${config.baseUrl}/everywhere)\n`));
++
++ // The rest of the Homes host: not found here (assets, /shared and /auth are served before this router).
++ r.use((req, res) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No page here. <a href="/">OpenVibe.Homes</a>.</p>` }, 404));
++ return r;
++}
++
++module.exports = { createHomesRoutes };
ddiff --git a/server/homes/sites.js b/server/homes/sites.js
nnew file mode 100644
iindex 0000000..b2b7581
---- /dev/null
++++ b/server/homes/sites.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * OpenVibe.Homes: buying, the way OpenVibe.Rent does renting. One place and a few filters open every major for-sale
++ * site's own search for it, plus the government and bank-owned homes (HUD Homestore, Fannie Mae HomePath, Auction.com)
++ * and the county's own property records. The listings stay on each site; nothing is fetched or copied from them.
++ *
++ * URL forms were opened in a real headless Chromium on 2026-10-15 (tools/rent-links-check.js) for Salt Lake City and
++ * Portland: Craigslist (real estate for sale), HUD Homestore, Auction.com (by state), HomePath, Redfin (by ZIP) and
++ * NETR Online answered with results; Zillow, Realtor.com, Homes.com, Trulia, LandWatch and LoopNet stop headless
++ * browsers at a bot wall, and these are their public, long-standing addresses that open normally for a person.
++ * Left out: Redfin by city name (its city pages need Redfin's own id), Xome (no address form that works).
++ *
++ * KINDS home | land | foreclosure | commercial
++ * linksFor(place, { kind, maxPrice, minBeds }) → [{ id, name, url, best }] (US places; place.state a two-letter code)
++ * recordsUrl(state) → NETR Online's directory of that state's county assessors and recorders
++ */
++const { US_STATE_NAMES, slug, titleSlug } = require('../aggregate/everywhere');
++
++const KINDS = ['home', 'land', 'foreclosure', 'commercial'];
++const KIND_TEXT = { home: 'Homes for sale', land: 'Land', foreclosure: 'Foreclosures and government homes', commercial: 'Commercial property' };
++
++const q = (params) => {
++ const out = new URLSearchParams();
++ for (const [k, v] of Object.entries(params)) if (v != null && v !== '') out.set(k, String(v));
++ const s = out.toString();
++ return s ? `?${s}` : '';
++};
++
++const SITES = [
++ { id: 'zillow', name: 'Zillow', host: 'zillow.com', kinds: ['home'], best: 'The largest US listing database, with Zestimates and price history.',
++ build: (p) => `https://www.zillow.com/${slug(p.city)}-${p.state.toLowerCase()}/` },
++ { id: 'realtor', name: 'Realtor.com', host: 'realtor.com', kinds: ['home'], best: 'Listings straight from the agents\' MLS feeds, updated often.',
++ build: (p, f) => `https://www.realtor.com/realestateandhomes-search/${titleSlug(p.city)}_${p.state}${f.minBeds ? `/beds-${f.minBeds}` : ''}${f.maxPrice ? `/price-na-${f.maxPrice}` : ''}` },
++ { id: 'redfin', name: 'Redfin', host: 'redfin.com', kinds: ['home'], best: 'MLS listings with its own agents and quick tour booking.',
++ build: (p) => (/^\d{5}$/.test(String(p.postcode || '')) ? `https://www.redfin.com/zipcode/${p.postcode}` : null) },
++ { id: 'homes', name: 'Homes.com', host: 'homes.com', kinds: ['home'], best: 'Agent listings with school and neighbourhood details.',
++ build: (p) => `https://www.homes.com/${slug(p.city)}-${p.state.toLowerCase()}/` },
++ { id: 'trulia', name: 'Trulia', host: 'trulia.com', kinds: ['home'], best: 'Neighbourhood notes from people who live there, with crime and commute maps.',
++ build: (p) => `https://www.trulia.com/${p.state}/${titleSlug(p.city).replace(/-/g, '_')}/` },
++ { id: 'craigslist', name: 'Craigslist', host: 'craigslist.org', kinds: ['home', 'land'], best: 'Homes and land sold by their owners, often nowhere else.',
++ build: (p, f) => `https://www.craigslist.org/search/city/${slug(p.city)}-${p.state.toLowerCase()}${q({ cat: 'rea', max_price: f.maxPrice, min_bedrooms: f.kind === 'home' ? f.minBeds : null })}` },
++ { id: 'landwatch', name: 'LandWatch', host: 'landwatch.com', kinds: ['land'], best: 'Land, farms and ranches, the largest land marketplace.',
++ build: (p) => (US_STATE_NAMES[p.state] ? `https://www.landwatch.com/${US_STATE_NAMES[p.state]}-land-for-sale/${slug(p.city)}` : null) },
++ { id: 'hud', name: 'HUD Homestore', host: 'hudhomestore.gov', kinds: ['foreclosure'], best: 'Homes HUD took back on FHA loans, offered first to people who will live in them.',
++ build: (p) => `https://www.hudhomestore.gov/searchresult${q({ citystate: p.state })}` },
++ { id: 'homepath', name: 'Fannie Mae HomePath', host: 'fanniemae.com', kinds: ['foreclosure'], best: 'Fannie Mae\'s own foreclosed homes, with a first look for buyers who will live there.',
++ build: (p) => `https://homepath.fanniemae.com/property-finder${q({ location: `${p.city}, ${p.state}` })}` },
++ { id: 'auction', name: 'Auction.com', host: 'auction.com', kinds: ['foreclosure'], best: 'Foreclosure and bank-owned auctions, online and on the courthouse steps.',
++ build: (p) => `https://www.auction.com/residential/${p.state.toLowerCase()}` },
++ { id: 'loopnet', name: 'LoopNet', host: 'loopnet.com', kinds: ['commercial'], best: 'Commercial property for sale: offices, retail, industrial, land.',
++ build: (p) => `https://www.loopnet.com/search/commercial-real-estate/${slug(p.city)}-${p.state.toLowerCase()}/for-sale/` },
++];
++
++function positiveInt(v, max) {
++ const n = Number(v);
++ return Number.isInteger(n) && n > 0 && n <= max ? n : null;
++}
++
++function linksFor(place, { kind = 'home', maxPrice = null, minBeds = null } = {}) {
++ if (!place || !place.city || !/^[A-Z]{2}$/.test(String(place.state || '')) || (place.country || 'us') !== 'us') return [];
++ const k = KINDS.includes(kind) ? kind : 'home';
++ const f = { kind: k, maxPrice: positiveInt(maxPrice, 100_000_000), minBeds: positiveInt(minBeds, 10) };
++ const out = [];
++ for (const site of SITES) {
++ if (!site.kinds.includes(k)) continue;
++ let url = null;
++ try { url = site.build(place, f); } catch { url = null; }
++ if (url && /^https:\/\//.test(url)) out.push({ id: site.id, name: site.name, url, best: site.best });
++ }
++ return out;
++}
++
++/** NETR Online's directory of a state's county assessor, recorder and treasurer sites (the official records). */
++const recordsUrl = (state) => (/^[A-Z]{2}$/.test(String(state || '')) && US_STATE_NAMES[state] ? `https://publicrecords.netronline.com/state/${state}` : null);
++
++module.exports = { KINDS, KIND_TEXT, SITES, linksFor, recordsUrl };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 8e86884..9e5da09 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -21,11 +21,13 @@ const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Find a place, or rent yours out.';
++const RENT_NAME = SITE_NAME;
++const RENT_TAGLINE = TAGLINE;
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves. /mine, /saved and the staff queue /staff are
// not here — they are for one signed-in person, and a link in everyone's navbar would be an invitation to a 401.
// They are linked from the pages that lead to them (/listings, /post, /safety).
--const NAV = [
++const RENT_NAV = [
{ label: 'Search everywhere', href: '/everywhere' },
{ label: 'Your board', href: '/board' },
{ label: 'Affordable housing', href: '/housing' },
@@@ -35,6 +37,19 @@ const NAV = [
{ label: 'What shipped', href: '/updates' },
];
++// OpenVibe.Homes (server/homes) uses this layout with its own name and navigation; sign-in and the board stay on
++// OpenVibe.Rent (one account place), so the Homes navbar sends people there.
++const HOMES = {
++ name: 'OpenVibe.Homes',
++ tagline: 'Find a home to buy.',
++ nav: [
++ { label: 'Search every site', href: '/search' },
++ { label: 'Property records', href: '/records' },
++ { label: 'Mortgage calculator', href: '/mortgage' },
++ { label: 'Renting instead', href: 'https://openvibe.rent/everywhere' },
++ ],
++};
++
const hashes = new Map();
function assetVersion(rel) {
if (hashes.has(rel)) return hashes.get(rel);
@@@ -59,22 +74,34 @@ function renderPage(o) {
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
-- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const homes = o.site === 'homes';
++ const SITE_NAME = homes ? HOMES.name : RENT_NAME;
++ const TAGLINE = homes ? HOMES.tagline : RENT_TAGLINE;
++ const NAV = homes ? HOMES.nav : RENT_NAV;
++ const base = homes ? o.config.homes.url : o.config.baseUrl;
++ const canonical = `${base}${pathNow.split('?')[0]}`;
const nav = {
service: 'rent',
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
history: { type: 'page', title: o.title || SITE_NAME },
-- silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
-- sessionUrl: '/auth/me',
-- loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
-- logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
-- notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
++ // On Homes the account lives on OpenVibe.Rent: its session probe answers signed out, and sign-in goes there.
++ ...(homes ? {
++ loginUrl: `${o.config.baseUrl}/auth/login?next=/board`,
++ } : {
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
++ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
++ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
++ }),
};
-- const footer = { service: 'rent', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
-- const account = signedIn
-- ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
-- : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ const footer = { service: 'rent', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: homes ? `${o.config.baseUrl}/updates` : '/updates' };
++ const account = homes
++ ? html`<a href="${o.config.baseUrl}/board">Your board on OpenVibe.Rent</a>`
++ : signedIn
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
const crumbs = (o.crumbs || []).length
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
@@@ -108,7 +135,7 @@ ${render(scripts)}
<main id="main" class="page">
${render(crumbs)}
${render(o.body)}
--${o.path === '/' ? frame.shipped({ service: 'rent', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++${o.path === '/' && !homes ? frame.shipped({ service: 'rent', title: `Recently shipped on ${SITE_NAME}` }) : ''}
</main>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
@@@ -130,4 +157,6 @@ function send(res, status, o) {
res.status(status).type('html').send(renderPage(o));
}
--module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
++const NAV = RENT_NAV;
++
++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, HOMES, raw, html };
ddiff --git a/test/homes.test.js b/test/homes.test.js
nnew file mode 100644
iindex 0000000..112f926
---- /dev/null
++++ b/test/homes.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++/**
++ * OpenVibe.Homes (server/homes): the buying side of Rent, served by this process for the openvibe.homes host.
++ * - one place opens every for-sale site's search, the government and bank homes, and the state's records directory;
++ * - Redfin only with a ZIP (its city pages need its own ids); a place outside the US gets nothing guessed;
++ * - the mortgage arithmetic is the standard amortisation, and bad input is refused;
++ * - the Homes host gets Homes' pages and name, Rent's host is untouched, and the records and calculator work.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const sites = require('../server/homes/sites');
++const { monthly } = require('../server/homes/mortgage');
++
++const SLC = { city: 'Salt Lake City', state: 'UT', country: 'us', postcode: '84111' };
++const urls = (links) => Object.fromEntries(links.map((l) => [l.id, l.url]));
++const nominatimSlc = [{
++ lat: '40.7596198', lon: '-111.8867975', display_name: 'Salt Lake City, Salt Lake County, Utah, 84111, United States',
++ boundingbox: ['40.6996', '40.8533', '-112.1015', '-111.7393'],
++ address: { city: 'Salt Lake City', county: 'Salt Lake County', state: 'Utah', 'ISO3166-2-lvl4': 'US-UT', postcode: '84111', country: 'United States', country_code: 'us' },
++}];
++const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'Content-Type': 'application/json' } });
++const housingFetchImpl = async (url) => (new URL(String(url)).pathname.endsWith('/search') ? json(nominatimSlc) : json({ features: [] }));
++
++(async () => {
++ await check('homes for sale: every site at the place, with the filters each takes; Redfin by ZIP only', () => {
++ const u = urls(sites.linksFor(SLC, { kind: 'home', maxPrice: 500000, minBeds: 3 }));
++ assert.deepStrictEqual(Object.keys(u), ['zillow', 'realtor', 'redfin', 'homes', 'trulia', 'craigslist']);
++ assert.strictEqual(u.zillow, 'https://www.zillow.com/salt-lake-city-ut/');
++ assert.strictEqual(u.realtor, 'https://www.realtor.com/realestateandhomes-search/Salt-Lake-City_UT/beds-3/price-na-500000');
++ assert.strictEqual(u.redfin, 'https://www.redfin.com/zipcode/84111');
++ assert.strictEqual(u.homes, 'https://www.homes.com/salt-lake-city-ut/');
++ assert.strictEqual(u.trulia, 'https://www.trulia.com/UT/Salt_Lake_City/');
++ assert.strictEqual(u.craigslist, 'https://www.craigslist.org/search/city/salt-lake-city-ut?cat=rea&max_price=500000&min_bedrooms=3');
++ assert.ok(!('redfin' in urls(sites.linksFor({ ...SLC, postcode: null }, { kind: 'home' }))), 'no ZIP, no Redfin');
++ assert.deepStrictEqual(sites.linksFor({ city: 'Toronto', state: 'ON', country: 'ca' }, { kind: 'home' }), [], 'outside the US: nothing guessed');
++ });
++
++ await check('foreclosures, land and commercial; the records directory per state', () => {
++ assert.deepStrictEqual(urls(sites.linksFor(SLC, { kind: 'foreclosure' })), {
++ hud: 'https://www.hudhomestore.gov/searchresult?citystate=UT',
++ homepath: 'https://homepath.fanniemae.com/property-finder?location=Salt+Lake+City%2C+UT',
++ auction: 'https://www.auction.com/residential/ut',
++ });
++ assert.deepStrictEqual(Object.keys(urls(sites.linksFor(SLC, { kind: 'land' }))), ['craigslist', 'landwatch']);
++ assert.strictEqual(urls(sites.linksFor(SLC, { kind: 'land' })).landwatch, 'https://www.landwatch.com/utah-land-for-sale/salt-lake-city');
++ assert.strictEqual(urls(sites.linksFor(SLC, { kind: 'commercial' })).loopnet, 'https://www.loopnet.com/search/commercial-real-estate/salt-lake-city-ut/for-sale/');
++ assert.strictEqual(sites.recordsUrl('UT'), 'https://publicrecords.netronline.com/state/UT');
++ assert.strictEqual(sites.recordsUrl('ZZ'), null);
++ });
++
++ await check('the mortgage: standard amortisation, the extras per month, bad input refused', () => {
++ const m = monthly({ price: '400,000', downPercent: 20, ratePercent: 6, years: 30, taxPerYear: 3600, insurancePerYear: 1200, hoaPerMonth: 50 });
++ assert.strictEqual(m.loan, 320000);
++ assert.strictEqual(m.principalAndInterest, 1918.56);
++ assert.strictEqual(m.total, 1918.56 + 300 + 100 + 50);
++ assert.strictEqual(m.payments, 360);
++ assert.strictEqual(monthly({ price: 120000, downPercent: 0, ratePercent: 0, years: 10 }).principalAndInterest, 1000, 'no interest: the loan over the months');
++ assert.ok(monthly({ price: 0, downPercent: 20, ratePercent: 6, years: 30 }).error);
++ assert.ok(monthly({ price: 300000, downPercent: 20, ratePercent: 6, years: 17 }).error, 'only real terms');
++ });
++
++ const t = await boot({ housingFetchImpl, env: { RENT_HOMES_URL: 'https://openvibe.homes' } });
++ // fetch() drops a Host header; the Homes host needs a plain http request.
++ const homes = (path) => new Promise((resolve, reject) => {
++ const u = new URL(t.base + path);
++ require('http').get({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers: { Host: 'openvibe.homes' } }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
++ }).on('error', reject);
++ });
++ try {
++ await check('the Homes host: its own home page and name; Rent\'s host is untouched', async () => {
++ const r = await homes('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Buying a home?') && r.text.includes('OpenVibe.Homes'));
++ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.homes/">'), 'its own canonical');
++ const rent = await t.get('/');
++ assert.ok(!rent.text.includes('Buying a home?'), 'Rent still serves Rent');
++ assert.strictEqual((await homes('/listings')).status, 404, 'Rent\'s pages are not on the Homes host');
++ });
++
++ await check('a search: every site, the area\'s records and a link back to renting', async () => {
++ const r = await homes('/search?q=Salt+Lake+City&kind=home&max=500000&beds=3');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes('Homes for sale near Salt Lake City, UT'));
++ assert.ok(r.text.includes('href="https://www.zillow.com/salt-lake-city-ut/" target="_blank" rel="noopener nofollow"'));
++ assert.ok(r.text.includes('href="https://www.redfin.com/zipcode/84111"'));
++ assert.ok(r.text.includes('https://publicrecords.netronline.com/state/UT'));
++ assert.ok(r.text.includes('https://openvibe.rent/everywhere?q=Salt+Lake+City'));
++ assert.ok(r.text.includes('noindex'), 'a result page is not indexed');
++ });
++
++ await check('records and the calculator pages; crawl files for the host', async () => {
++ const rec = await homes('/records');
++ assert.ok(rec.text.includes('href="https://publicrecords.netronline.com/state/UT"') && rec.text.includes('Utah'));
++ const calc = await homes('/mortgage?price=400000&down=20&rate=6&years=30&tax=3600&ins=1200&hoa=50');
++ assert.ok(calc.text.includes('About $2,368.56 a month'), calc.text.slice(0, 400));
++ assert.match((await homes('/robots.txt')).text, /Sitemap: https:\/\/openvibe\.homes\/sitemap\.xml/);
++ assert.ok((await homes('/sitemap.xml')).text.includes('<loc>https://openvibe.homes/mortgage</loc>'));
++ });
++ } finally { await t.close(); }
++ done();
++})();