openvibers/openvibe.rent Commits
Rent: OpenVibe.Homes, the buying side, on openvibe.homes (#58)
Changes
@@@ -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 isThe rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,nnew file mode 100644@@@ -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;++ }++}nnew file mode 100644@@@ -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; }@@@ -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>` }));@@@ -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() },nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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();++})();
@@ -9,6 +9,15 @@ public housing authorities and housing counselling agencies) near a place a visi
@@ -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`
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**License:** AGPL-3.0 (same as every OpenVibe service).
**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
## What it is
The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,
The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site,
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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; }
@@ -188,6 +188,10 @@ async function createApp(opts = {}) {
@@ -188,6 +188,10 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.RENT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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(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>` }));
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>` }));
@@ -24,6 +24,10 @@ function load(env = process.env) {
@@ -24,6 +24,10 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
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
// 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.
// 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() },
indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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 };
@@ -21,11 +21,13 @@ const { html, raw, esc } = require('./html');
@@ -21,11 +21,13 @@ const { html, raw, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Rent';
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Find a place, or rent yours out.';
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');
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
// 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.
// 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).
// They are linked from the pages that lead to them (/listings, /post, /safety).
-const NAV = [
+const RENT_NAV = [
{ label: 'Search everywhere', href: '/everywhere' },
{ label: 'Search everywhere', href: '/everywhere' },
{ label: 'Your board', href: '/board' },
{ label: 'Your board', href: '/board' },
{ label: 'Affordable housing', href: '/housing' },
{ label: 'Affordable housing', href: '/housing' },
@@ -35,6 +37,19 @@ const NAV = [
@@ -35,6 +37,19 @@ const NAV = [
{ label: 'What shipped', href: '/updates' },
{ 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();
const hashes = new Map();
function assetVersion(rel) {
function assetVersion(rel) {
if (hashes.has(rel)) return hashes.get(rel);
if (hashes.has(rel)) return hashes.get(rel);
@@ -59,22 +74,34 @@ function renderPage(o) {
@@ -59,22 +74,34 @@ function renderPage(o) {
const signedIn = viewer.kind === 'user';
const signedIn = viewer.kind === 'user';
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
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 = {
const nav = {
service: 'rent',
service: 'rent',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
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 },
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
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>`
? 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)}
@@ -108,7 +135,7 @@ ${render(scripts)}
<main id="main" class="page">
<main id="main" class="page">
${render(crumbs)}
${render(crumbs)}
${render(o.body)}
${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>
</main>
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
@@ -130,4 +157,6 @@ function send(res, status, o) {
@@ -130,4 +157,6 @@ function send(res, status, o) {
res.status(status).type('html').send(renderPage(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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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();
+})();