openvibers/openvibe.rent Commits
Rent: affordable housing near you, from HUD open data (#27)
Changes
@@@ -2,7 +2,9 @@> Find a place, or rent yours out.--**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look.++**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look, and++"Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,++public housing authorities and housing counselling agencies) near a place a visitor types.**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -14,6 +16,12 @@ and no partner feeds. Other sources come later, and only when their terms are veits provenance (the source, a link back to it, and when it was read). Until then every listing here was written bythe person who posted it, and the page says so.++Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) shows++public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing++counselling agencies near a place a visitor types, from the U.S. Department of Housing and Urban Development's open++data (public domain). It is the same "near you" pattern as OpenVibe.Food's food banks, but from HUD's ArcGIS instead++of OpenStreetMap.++Three rules shape the product:- **No payments, no deposits, no messaging.** A listing is a description, a price with a currency and a period, and a@@@ -39,6 +47,10 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `since you last looked" count. `ssv_<ULID>` ids.- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so aredelivery changes nothing ([migrations/0003_account_data.sql](migrations/0003_account_data.sql)).++- **`housing_geo_cache`** — the cache in front of the two "Affordable housing near you" upstream services: every++ Nominatim geocode and HUD ArcGIS query answer, kept for `RENT_HOUSING_CACHE_TTL_MS` (7 days), so a place is++ geocoded once and an area is asked once, not once per visitor++ ([migrations/0005_housing_cache.sql](migrations/0005_housing_cache.sql)).`migrations/0001_initial.sql` creates no tables (the skeleton). Every row keys a person as `user:usr_…`, and thenamespace is `rent.*`.@@@ -71,7 +83,10 @@ namespace is `rent.*`.`service`, `limits`, `account-data`, `events`, `valkey`), plus `openvibe-shared` (`release`, `metrics`, `ready`,`legal`, `cache-policy`, `serve`, `frame`, `shell`, `seo`, `app-icon`).--It fetches from no listing source and no third party: the only outbound calls are the Network and Events above.++For listings it fetches from no source and no third party: the only outbound calls are the Network and Events above.++The "Affordable housing near you" feature adds two more outbound hosts, both open data with published usage policies —++Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the five housing layers) — paced and cached in++[server/housing/upstream.js](server/housing/upstream.js).## Capabilities@@@ -93,7 +108,8 @@ It fetches from no listing source and no third party: the only outbound calls ar| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. || Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. || Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |--| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |++| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads five HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities and housing counselling agencies — normalised, distance-sorted and grouped, with the HUD attribution on the page. |++| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/housing`, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. || Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`. || Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. || Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing and saving a search. The page form and the API route take the same budget, so the form is not a way round a limit. |@@@ -110,6 +126,7 @@ edit, report or save anything here — a listing is somebody's own — so no rou| Route | Who | ||---|---|---|| `GET /ping` | anyone | liveness |++| `GET /housing` | anyone | "Affordable housing near you": `q` (a city, ZIP code or address) and optional `kind` (comma-separated from `public_housing`, `lihtc`, `multifamily`, `housing_authority`, `counseling`); answers the geocoded `location` and normalised `results` (name, address, distance, units, phone, authority/agency, source), grouped and distance-sorted || `GET /listings` | anyone | search: `q`, `kind`, `city`, `country`, `min`, `max`, `period`, `limit`, `before`; answers `listings`, `next`, `facets` || `GET /listings/:id` | anyone | one listing; `contact_url` only when the caller is a signed-in person || `POST /listings` | person | post one (201 + `Location`); 422 on a bad field, 429 on a cap |@@@ -125,13 +142,25 @@ edit, report or save anything here — a listing is somebody's own — so no rou## Data sources and their terms--**This version has none.** Every listing is text a person typed here, under the rules above; nothing is scraped,--imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored as--text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service++**Listings have no external source.** Every listing is text a person typed here, under the rules above; nothing is++scraped, imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored++as text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this servicerequests).--The only external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its JWKS),--with its terms as the network's own service. When listing sources are added they will be named here with their++**Affordable housing near you** has two verified, open sources, read live and cached for 7 days++([server/housing/upstream.js](server/housing/upstream.js)):++++- **U.S. Department of Housing and Urban Development (HUD)** open data, on ArcGIS — public housing, LIHTC, HUD-assisted++ multifamily, public housing authorities and housing counselling agencies. Public domain (USA.gov++ [public domain label](https://www.usa.gov/government-copyright)). Attribution on every page and in every API answer:++ *"Source: U.S. Department of Housing and Urban Development (HUD), public domain"*, linked to++ https://hudgis-hud.opendata.arcgis.com/. The data covers the United States, and availability must be confirmed with++ the property or authority.++- **Nominatim** (OpenStreetMap) — the place name → coordinates and bounding box for the search box only; the housing++ results themselves come from HUD. One request a second at most, with a descriptive `RENT_USER_AGENT`.++++The only other external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its++JWKS), with its terms as the network's own service. When listing sources are added they will be named here with theirterms, and each listing will carry its provenance (source name, a link back, and `fetched_at`) both in the API andon the page.@@@ -142,9 +171,17 @@ Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database isthe Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on`/api/ready`).--The product adds no environment variables of its own beyond the OpenVibe.Events ones below: its numbers (10 active--listings, 5 a day, 30 days, 3 reports, 30 a page, the per-caller budgets) are constants next to the rules they belong--to, not deployment settings.++The listing product adds no environment variables of its own beyond the OpenVibe.Events ones below: its numbers (10++active listings, 5 a day, 30 days, 3 reports, 30 a page, the per-caller budgets) are constants next to the rules they++belong to, not deployment settings. "Affordable housing near you" adds the following (all optional, with safe++defaults):++++- `RENT_NOMINATIM_URL` — `https://nominatim.openstreetmap.org`++- `RENT_HUD_ARCGIS_URL` — `https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services`++- `RENT_USER_AGENT` — `OpenVibeRent/0.1 (+BASE_URL)`; a descriptive contact, never a browser's++- `RENT_HOUSING_CACHE_TTL_MS` — `604800000` (7 days)++- `RENT_HOUSING_MIN_INTERVAL_MS` — `1000` (one request a second to a host)++- `RENT_HOUSING_TIMEOUT_MS` — `25000`## Development and tests@@@ -177,6 +214,10 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflohidden again.- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, thesafety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.++- [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected:++ geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all five HUD layers (null for a missing++ value), distance sort, the cache (a second request does not fetch), the upstream-failure and non-US states, the page++ signed out with attribution, and the API.- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`with a stand-in Network: only the person's rows, a redelivery changes nothing, and a bad signature or forwardedrequest is refused.nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Rent: the cache in front of the two upstream services behind "Affordable housing near you"++-- (server/housing/upstream.js): every Nominatim geocode and HUD ArcGIS query answer, with its TTL++-- (RENT_HOUSING_CACHE_TTL_MS, 7 days by default), so a place is geocoded once and an area is asked once,++-- not once per visitor. Nothing here is ever a credential, a key or a token.++++CREATE TABLE housing_geo_cache (++ key text COLLATE "C" PRIMARY KEY, -- 'nominatim:<query>' | 'arcgis:<service>/<layer>:<sha256 of the query>'++ body jsonb NOT NULL, -- the upstream answer, as it came++ fetched_at text COLLATE "C" NOT NULL, -- when it was fetched (ISO 8601, UTC)++ expires_at text COLLATE "C" NOT NULL -- ISO 8601, UTC: past it the row is ignored and replaced++);++CREATE INDEX housing_geo_cache_expires ON housing_geo_cache (expires_at);nnew file mode 100644@@@ -0,0 +1,32 @@++/* OpenVibe.Rent — /housing only (affordable housing from HUD open data), so app.css stays inside its budget. */++.housing-lede { max-width: 760px; color: var(--text-secondary, #96a7c2); font-size: 1.04rem; }++.housing-form { margin-bottom: 18px; }++.housing-kinds { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.housing-kinds legend { font-size: .8rem; font-weight: 650; color: var(--text-secondary, #96a7c2); margin-bottom: 6px; }++.housing-kinds p { flex-basis: 100%; margin: 2px 0 0; }++.housing-kind { position: relative; }++.housing-kind input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }++.housing-kind span { display: inline-block; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); font-size: .9rem; background: var(--bg-secondary, #0f1729); }++.housing-kind input:checked + span { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--bg-secondary, #0f1729)); color: var(--text-primary, #e6edf7); }++.housing-kind input:focus-visible + span { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.housing-where { margin-bottom: 2px; overflow-wrap: anywhere; }++.housing-jump { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 10px 0 4px; }++.housing-jump a { display: inline-flex; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }++.housing-jump a:hover { border-color: var(--accent, #3b82f6); }++.housing-group { margin-top: 26px; }++.housing-group h3 { margin: 0 0 2px; font-size: 1.15rem; }++.housing-explain { margin: 0 0 10px; color: var(--text-secondary, #96a7c2); font-size: .92rem; max-width: 760px; }++.housing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }++.housing-card { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.housing-card p { margin: 0; }++.housing-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }++.housing-card h4 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; }++.housing-dist { flex: none; font-size: .85rem; color: var(--text-secondary, #96a7c2); }++.housing-addr { font-size: .9rem; color: var(--text-secondary, #96a7c2); }++.housing-units { font-size: .92rem; }++.housing-facts { list-style: none; padding: 0; margin: 2px 0 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }++.housing-facts li + li { margin-top: 1px; }++.housing-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: auto !important; padding-top: 6px; font-size: .9rem; }++.housing-more { margin-top: 10px; }++.housing-more summary { cursor: pointer; color: var(--accent-light, #60a5fa); padding: 6px 0; }++.housing-more .housing-list { margin-top: 8px; }@@@ -24,6 +24,8 @@ const cache = require('openvibe-shared/cache-policy');const { createSearchIndex } = require('./search-index');const configLib = require('./config');const { openStore } = require('./db');++const { createUpstream } = require('./housing/upstream');++const housingStore = require('./housing/store');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');@@@ -58,6 +60,15 @@ async function createApp(opts = {}) {ctx.search = opts.search || createSearchIndex({ config, s, log });s.search = ctx.search;++ // The only outbound client the housing feature has (Nominatim + HUD ArcGIS, paced and cached); the pages and the++ // API get it from here. Its own wall clock, not the store's, so the pace between two calls does not depend on an++ // injected test clock. A test injects a stand-in fetch through opts.housingFetchImpl (it must never reach the++ // real Nominatim or HUD hosts from a sandbox).++ ctx.upstream = createUpstream({ config, s, fetchImpl: opts.housingFetchImpl || fetchImpl, log });++ // Drop expired geocode/area answers now and hourly; an expired row is ignored anyway, this only keeps the table++ // small. The interval is unref'd and kept on ctx so the entry point clears it on shutdown (server/index.js).++ ctx.housingPruneTimer = housingStore.startCachePrune(s, { log });++// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;// a test injects a stand-in through opts.accountSend.@@@ -38,6 +38,22 @@ function load(env = process.env) {db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.RENT_PGLITE_DIR || '' },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:rent:' },++ // The two upstream services "Affordable housing near you" stands on (server/housing/places.js): Nominatim (a++ // place name → coordinates and a bounding box) and HUD's ArcGIS FeatureServer (the five housing layers). Both++ // are open data with published usage policies — Nominatim at most one request a second, HUD small bounded area++ // queries — and server/housing/upstream.js paces every call to one host at a time and caches every answer.++ housing: {++ nominatimUrl: trim(env.RENT_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),++ hudArcGisUrl: trim(env.RENT_HUD_ARCGIS_URL || 'https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services'),++ // Identify to Nominatim and HUD with a contact URL, as their policies require; never a browser User-Agent.++ userAgent: env.RENT_USER_AGENT || `OpenVibeRent/0.1 (+${baseUrl})`,++ minIntervalMs: Math.max(0, int(env.RENT_HOUSING_MIN_INTERVAL_MS, 1000)),++ timeoutMs: Math.max(1000, int(env.RENT_HOUSING_TIMEOUT_MS, 25_000)),++ cacheTtlMs: Math.max(60_000, int(env.RENT_HOUSING_CACHE_TTL_MS, 7 * 24 * 60 * 60 * 1000)),++ maxBoxKm: 50,++ maxResults: 500,++ },++// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),nnew file mode 100644@@@ -0,0 +1,346 @@++'use strict';++++/**++ * "Affordable housing near you": public housing, tax-credit (LIHTC) properties, HUD-assisted multifamily properties,++ * public housing authorities and housing counselling agencies near a place a visitor types, from the U.S. Department++ * of Housing and Urban Development's open data (public domain) on ArcGIS. The field names and the five layers are++ * from the verified HUD research; a missing value stays null (never invented), and server/housing/upstream.js paces++ * and caches every request.++ *++ * Everything this module returns is HUD public-domain data. Callers must show the attribution and link it back — the++ * pages do (server/http/pages.js), and the API returns it with every answer.++ */++++const { UpstreamError } = require('./upstream');++++const ATTRIBUTION = {++ text: 'Source: U.S. Department of Housing and Urban Development (HUD), public domain',++ url: 'https://hudgis-hud.opendata.arcgis.com/',++ license: 'Public domain (USA.gov)',++ license_url: 'https://www.usa.gov/government-copyright',++};++const SOURCE = {++ name: 'U.S. Department of Housing and Urban Development (HUD)',++ url: 'https://hudgis-hud.opendata.arcgis.com/',++ via: 'ArcGIS FeatureServer',++ license: 'Public domain',++ fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',++};++++const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling'];++const KIND_TEXT = {++ public_housing: 'Public housing',++ lihtc: 'Tax-credit (LIHTC) properties',++ multifamily: 'HUD-assisted multifamily',++ housing_authority: 'Public housing authorities',++ counseling: 'Housing counselling agencies',++};++const KIND_DESCRIPTIONS = {++ public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',++ lihtc: 'Tax-credit (LIHTC) properties: privately owned apartments with rents kept low through a federal tax credit.',++ multifamily: 'HUD-assisted multifamily: privately owned apartments where HUD helps keep the rent affordable.',++ housing_authority: 'Public housing authorities: the local agency to apply to for public housing and housing vouchers.',++ counseling: 'Housing counselling agencies: HUD-approved local help with renting, buying and keeping a home.',++};++++// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,++// -5), so a negative number is missing, never shown.++const num = (v) => {++ if (v == null || v === '') return null;++ const n = Number(v);++ return Number.isFinite(n) && n >= 0 ? n : null;++};++// A coordinate may be negative (every US longitude is); only a non-number is missing.++const coord = (v) => {++ if (v == null || v === '') return null;++ const n = Number(v);++ return Number.isFinite(n) ? n : null;++};++const str = (v) => (v == null || v === '' ? null : String(v).trim());++// A US ZIP code: numeric fields lose their leading zero (LIHTC's PROJ_ZIP gives 5401 for 05401); ZIP+4 is kept.++const zip5 = (v) => {++ const t = str(v);++ if (!t) return null;++ return /^\d{1,5}$/.test(t) ? t.padStart(5, '0') : t;++};++const MONTHS = { JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06', JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12' };++// HUD's multifamily contract dates read "30-JUN-41"; a two-digit year is 20xx (these contracts run forward).++const hudDate = (v) => {++ const m = /^(\d{2})-([A-Z]{3})-(\d{2})$/.exec(str(v) || '');++ return m && MONTHS[m[2]] ? `20${m[3]}-${MONTHS[m[2]]}-${m[1]}` : str(v);++};++const epochIso = (v) => {++ const n = num(v);++ if (n == null) return null;++ const d = new Date(n);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++};++++/** A HUD-supplied URL, kept only when it parses and speaks http(s): it must never become a hostile href. */++function safeUrl(v) {++ if (typeof v !== 'string' || !v.trim()) return null;++ try {++ const u = new URL(v.trim());++ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;++ } catch {++ return null;++ }++}++++const idOf = (prefix, ...parts) => {++ for (const p of parts) {++ const v = p == null ? '' : String(p).trim();++ if (v) return `${prefix}-${v}`;++ }++ return null;++};++++// The five layers, each normalised to the same shape. `fields` is exactly what the query asks for, nothing more.++const LAYERS = [++ {++ kind: 'public_housing',++ service: 'Public_Housing_Developments',++ layer: 0,++ fields: ['OBJECTID', 'PROJECT_NAME', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_DWELLING_UNITS', 'TOTAL_UNITS', 'HA_PHN_NUM', 'FORMAL_PARTICIPANT_NAME', 'LAT', 'LON', 'RENT_PER_MONTH', 'LAST_UPDT_DTTM'],++ normalize(a) {++ return {++ id: idOf('hud-ph', a.OBJECTID),++ name: str(a.PROJECT_NAME),++ address: str(a.STD_ADDR),++ city: str(a.STD_CITY),++ state: str(a.STD_ST),++ zip: zip5(a.STD_ZIP5),++ phone: str(a.HA_PHN_NUM),++ units: { total: num(a.TOTAL_DWELLING_UNITS) ?? num(a.TOTAL_UNITS), low_income: null },++ extra: { authority: str(a.FORMAL_PARTICIPANT_NAME), rent_per_month: num(a.RENT_PER_MONTH), last_updated: epochIso(a.LAST_UPDT_DTTM) },++ };++ },++ },++ {++ kind: 'lihtc',++ service: 'LIHTC',++ layer: 0,++ fields: ['OBJECTID', 'HUD_ID', 'PROJECT', 'PROJ_ADD', 'PROJ_CTY', 'PROJ_ST', 'PROJ_ZIP', 'N_UNITS', 'LI_UNITS', 'YR_PIS', 'YR_ALLOC', 'LAT', 'LON'],++ normalize(a) {++ return {++ id: idOf('hud-lihtc', a.HUD_ID, a.OBJECTID),++ name: str(a.PROJECT),++ address: str(a.PROJ_ADD),++ city: str(a.PROJ_CTY),++ state: str(a.PROJ_ST),++ zip: zip5(a.PROJ_ZIP),++ phone: null,++ units: { total: num(a.N_UNITS), low_income: num(a.LI_UNITS) },++ extra: { year_placed: str(a.YR_PIS), year_allocated: str(a.YR_ALLOC) },++ };++ },++ },++ {++ kind: 'multifamily',++ service: 'MULTIFAMILY_PROPERTIES_ASSISTED',++ layer: 0,++ fields: ['OBJECTID', 'PROPERTY_ID', 'PROPERTY_NAME_TEXT', 'ADDRESS_LINE1_TEXT', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_UNIT_COUNT', 'TOTAL_ASSISTED_UNIT_COUNT', 'PROPERTY_CATEGORY_NAME', 'EXPIRATION_DATE1', 'LAST_UPDT_DTTM', 'LAT', 'LON'],++ normalize(a) {++ return {++ id: idOf('hud-mf', a.PROPERTY_ID, a.OBJECTID),++ name: str(a.PROPERTY_NAME_TEXT),++ address: str(a.ADDRESS_LINE1_TEXT),++ city: str(a.STD_CITY),++ state: str(a.STD_ST),++ zip: zip5(a.STD_ZIP5),++ phone: null,++ units: { total: num(a.TOTAL_UNIT_COUNT), low_income: num(a.TOTAL_ASSISTED_UNIT_COUNT) },++ extra: { property_category: str(a.PROPERTY_CATEGORY_NAME), expiration_date: hudDate(a.EXPIRATION_DATE1), last_updated: epochIso(a.LAST_UPDT_DTTM) },++ };++ },++ },++ {++ kind: 'housing_authority',++ service: 'Public_Housing_Authorities',++ layer: 0,++ fields: ['OBJECTID', 'FORMAL_PARTICIPANT_NAME', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_UNITS', 'SECTION8_UNITS_CNT', 'HA_PHN_NUM', 'HA_EMAIL_ADDR_TEXT', 'LAST_UPDT_DTTM'],++ normalize(a) {++ return {++ id: idOf('hud-pha', a.OBJECTID),++ name: str(a.FORMAL_PARTICIPANT_NAME),++ address: str(a.STD_ADDR),++ city: str(a.STD_CITY),++ state: str(a.STD_ST),++ zip: zip5(a.STD_ZIP5),++ phone: str(a.HA_PHN_NUM),++ units: { total: num(a.TOTAL_UNITS), low_income: null },++ extra: { section8_units: num(a.SECTION8_UNITS_CNT), email: str(a.HA_EMAIL_ADDR_TEXT), last_updated: epochIso(a.LAST_UPDT_DTTM) },++ };++ },++ },++ {++ kind: 'counseling',++ service: 'Housing_Counseling_Agencies_UAUC',++ layer: 0,++ fields: ['OBJECTID', 'AGC_NAME', 'AGC_ADDRESS', 'AGC_CITY', 'AGC_STATE', 'AGC_ZIP_CODE', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'AGC_PHONE_NBR', 'AGC_WEB_SITE', 'COUNSEL_SRV_DESC', 'LANGUAGE_DESC', 'LAT', 'LON'],++ normalize(a) {++ return {++ id: idOf('hud-counsel', a.OBJECTID),++ name: str(a.AGC_NAME),++ // The agency's own address fields mix "NC" and "North Carolina"; HUD's standardised STD_* fields do not.++ address: str(a.STD_ADDR) || str(a.AGC_ADDRESS),++ city: str(a.STD_CITY) || str(a.AGC_CITY),++ state: str(a.STD_ST) || str(a.AGC_STATE),++ zip: zip5(a.STD_ZIP5) || str(a.AGC_ZIP_CODE),++ phone: str(a.AGC_PHONE_NBR),++ units: { total: null, low_income: null },++ extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },++ };++ },++ },++];++++function haversineKm(aLat, aLon, bLat, bLon) {++ const R = 6371.0088;++ const toRad = (d) => d * Math.PI / 180;++ const dLat = toRad(bLat - aLat);++ const dLon = toRad(bLon - aLon);++ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;++ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));++}++++const KM_PER_DEG_LAT = 111.32;++++/** A Nominatim boundingbox (`[south, north, west, east]`) as numbers, or null when it is unusable. */++function parseBbox(bbox) {++ if (!Array.isArray(bbox) || bbox.length !== 4) return null;++ const [south, north, west, east] = bbox.map(Number);++ if (![south, north, west, east].every(Number.isFinite)) return null;++ if (!(north > south) || !(east > west)) return null;++ return [south, north, west, east];++}++++/**++ * The ArcGIS envelope for a place, bounded to at most `maxKm` across. A place's own bounding box is used when it is++ * no larger than the cap; a bigger place (or one with no usable box) is clipped to the cap around its centre.++ */++function envelopeFor(place, maxKm) {++ const lat = place.lat;++ const lon = place.lon;++ const cosLat = Math.cos(lat * Math.PI / 180) || 0.0001;++ const halfLat = maxKm / 2 / KM_PER_DEG_LAT;++ const halfLon = maxKm / 2 / (KM_PER_DEG_LAT * cosLat);++ const centred = { xmin: lon - halfLon, ymin: lat - halfLat, xmax: lon + halfLon, ymax: lat + halfLat };++ const b = place.bbox;++ if (b) {++ const [south, north, west, east] = b;++ const wKm = Math.abs(east - west) * KM_PER_DEG_LAT * cosLat;++ const hKm = Math.abs(north - south) * KM_PER_DEG_LAT;++ if (wKm <= maxKm && hKm <= maxKm) return { xmin: west, ymin: south, xmax: east, ymax: north };++ }++ return centred;++}++++/** One ArcGIS feature → one normalised result, or null when it carries no usable identity or position. */++function normalizeFeature(feature, def) {++ const attrs = (feature && feature.attributes) || {};++ const geom = feature && feature.geometry;++ const base = def.normalize(attrs, geom);++ if (!base.id) return null; // a record with no identity is skipped, exactly as the Sources adapter does++ const lat = coord(attrs.LAT) ?? coord(geom && geom.y);++ const lon = coord(attrs.LON) ?? coord(geom && geom.x);++ if (lat == null || lon == null) return null;++ return { kind: def.kind, ...base, lat, lon, source_url: SOURCE.url };++}++++function toResult(feature, def, origin) {++ const r = normalizeFeature(feature, def);++ if (!r) return null;++ r.distance_km = Math.round(haversineKm(origin.lat, origin.lon, r.lat, r.lon) * 10) / 10;++ return r;++}++++/** Features → results, deduplicated by identity, nearest first, at most `limit`. */++function elementsToResults(features, def, origin, limit) {++ const seen = new Set();++ const out = [];++ for (const f of features || []) {++ const r = toResult(f, def, origin);++ if (!r) continue;++ if (seen.has(r.id)) continue;++ seen.add(r.id);++ out.push(r);++ }++ out.sort((a, b) => a.distance_km - b.distance_km || (a.name == null ? -1 : b.name == null ? 1 : String(a.name).localeCompare(String(b.name))));++ return limit ? out.slice(0, limit) : out;++}++++/**++ * The place search behind both the API and the pages.++ *++ * { q, kinds }++ *++ * A name (q) is geocoded with Nominatim, once, and cached; each layer's area query is cached too. Throws++ * UpstreamError (housing.place.invalid on bad input, housing.place.not_found when no place matches,++ * housing.place.upstream when Nominatim or HUD cannot be reached). A place outside the United States answers++ * `in_us: false` and no results — never an error, because the geocode itself succeeded.++ */++async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {++ const raw = kinds == null || kinds === '' ? KINDS : String(kinds).split(',').map((x) => x.trim()).filter(Boolean);++ if (!raw.length || !raw.every((k) => KINDS.includes(k))) {++ throw new UpstreamError('housing.place.invalid', `kind: one or more of ${KINDS.join(', ')}`, { status: 422 });++ }++ const requested = KINDS.filter((k) => raw.includes(k));++++ let place = null;++ if (q && String(q).trim()) {++ const g = await upstream.geocode(q, { limit: 1 });++ const hit = Array.isArray(g.body) ? g.body[0] : null;++ if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });++ const la = Number(hit.lat);++ const lo = Number(hit.lon);++ if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');++ const country = hit.address && (hit.address.country_code || hit.address.country)++ ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;++ place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox) };++ } else {++ throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });++ }++++ const maxKm = (config && config.housing && config.housing.maxBoxKm) || 50;++ const box = envelopeFor(place, maxKm);++ const inUs = place.country_code == null || place.country_code === 'us';++++ const out = {++ location: { name: place.name, lat: place.lat, lon: place.lon, from: place.from, geocoded_at: place.geocoded_at, country_code: place.country_code },++ in_us: inUs,++ box,++ kinds: requested,++ results: [],++ per_kind: {},++ counts: { total: 0 },++ fetched_at: null,++ cached: true,++ attribution: ATTRIBUTION,++ source: SOURCE,++ };++++ if (!inUs) return out;++++ const cap = (config && config.housing && config.housing.maxResults) || 500;++ let fetchedAt = null;++ let cached = true;++ for (const k of requested) {++ const def = LAYERS.find((l) => l.kind === k);++ const r = await upstream.arcGis(def.service, def.layer, box, def.fields);++ out.per_kind[k] = elementsToResults(r.body && r.body.features, def, place, cap);++ if (!r.cached) cached = false;++ if (!fetchedAt || r.fetched_at > fetchedAt) fetchedAt = r.fetched_at;++ }++ out.results = requested.flatMap((k) => out.per_kind[k] || []);++ out.counts.total = out.results.length;++ for (const k of requested) out.counts[k] = (out.per_kind[k] || []).length;++ out.fetched_at = fetchedAt;++ out.cached = cached;++ return out;++}++++module.exports = {++ searchPlaces, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,++ LAYERS, KINDS, KIND_TEXT, KIND_DESCRIPTIONS, ATTRIBUTION, SOURCE, UpstreamError,++};nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * What the "Affordable housing near you" feature keeps in Rent's own database (migrations/0005_housing_cache.sql):++ * the cache in front of the two upstream services — Nominatim (a place name → coordinates) and HUD's ArcGIS++ * FeatureServer (the five housing layers). Every function takes the store (server/db.js createStore) first; nothing++ * here is a secret, and the cache body is only ever an upstream answer, never a person's data.++ */++++const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);++++// ── housing_geo_cache ────────────────────────────────────────++/** A cached upstream answer that has not expired, or null. */++async function cacheGet(s, key) {++ const row = await s.db.maybe('SELECT body, fetched_at, expires_at FROM housing_geo_cache WHERE key = $1', [key]);++ if (!row || row.expires_at <= s.iso()) return null;++ return { body: parse(row.body), fetched_at: row.fetched_at, expires_at: row.expires_at };++}++async function cachePut(s, key, body, ttlMs) {++ const fetchedAt = s.iso();++ const expiresAt = new Date(s.now() + ttlMs).toISOString();++ await s.db.query(++ `INSERT INTO housing_geo_cache (key, body, fetched_at, expires_at) VALUES ($1, $2::jsonb, $3, $4)++ ON CONFLICT (key) DO UPDATE SET body = EXCLUDED.body, fetched_at = EXCLUDED.fetched_at, expires_at = EXCLUDED.expires_at`,++ [key, JSON.stringify(body), fetchedAt, expiresAt]);++ return { fetched_at: fetchedAt, expires_at: expiresAt };++}++/** Drop expired rows (called at boot; the table stays small because every answer is bounded). */++const cachePrune = (s) => s.db.exec('DELETE FROM housing_geo_cache WHERE expires_at <= $1', [s.iso()]);++++/**++ * Prune the geo cache now, then hourly: an expired row is ignored anyway, this only keeps the table small. The++ * interval is unref'd so it never holds the process open; the returned timer is what the entry point clears on++ * shutdown (server/index.js).++ */++function startCachePrune(s, { intervalMs = 60 * 60 * 1000, log = console } = {}) {++ const tick = () => Promise.resolve(cachePrune(s)).catch((err) => log.warn('[OpenVibe.Rent] housing cache prune:', err && err.message));++ tick();++ const timer = setInterval(tick, intervalMs);++ if (typeof timer.unref === 'function') timer.unref();++ return timer;++}++++module.exports = { cacheGet, cachePut, cachePrune, startCachePrune };nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++++/**++ * The only two hosts OpenVibe.Rent fetches from for "Affordable housing near you": Nominatim (a place name →++ * coordinates and a bounding box) and HUD's ArcGIS FeatureServer (the five housing layers). Both are open data with++ * published usage policies, and this module is what keeps us inside them:++ *++ * - identify every request carries RENT_USER_AGENT (an OpenVibe.Rent contact URL), never a browser's++ * - pace one request a second to a host, at most: calls to the same host queue behind each other, so the++ * service as a whole cannot burst at Nominatim however many visitors arrive at once++ * - cache every answer is kept in housing_geo_cache for RENT_HOUSING_CACHE_TTL_MS (7 days): a place is++ * geocoded once, an area is queried once, not once per visitor++ * - bound area queries are one envelope of at most about 50 km across, with only the fields we use++ *++ * Only a URL built from the configured base is ever fetched; nothing a caller sends becomes a URL. There are no++ * other outbound calls in this service.++ */++++const crypto = require('crypto');++const geo = require('./store');++++// An ArcGIS answer for one layer is at most 500 features; keep the byte ceiling above any honest answer but far++// below anything that would let a misbehaving upstream exhaust the process.++const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;++++class UpstreamError extends Error {++ constructor(code, detail, { status = 502 } = {}) { super(detail); this.code = code; this.status = status; }++}++++/** Serialises calls to one host and keeps at least `minIntervalMs` between the moments they start. */++function createPacer({ minIntervalMs, now = () => Date.now() }) {++ let chain = Promise.resolve();++ let last = -Infinity;++ return {++ run(fn) {++ const p = chain.then(async () => {++ const wait = last + minIntervalMs - now();++ if (wait > 0) await new Promise((r) => setTimeout(r, wait));++ last = now();++ return fn();++ });++ chain = p.then(() => undefined, () => undefined);++ return p;++ },++ };++}++++const keyOf = (q) => String(q).trim().toLowerCase().replace(/\s+/g, ' ');++++/**++ * The query URL for one HUD ArcGIS layer, built only from the configured base and fixed field names (never from++ * caller input): an envelope in WGS84, the fields we use, up to 500 records, geometry returned so a missing LAT/LON++ * attribute still gives a point. Exported so a test can assert the exact shape.++ */++function buildArcGisUrl(baseUrl, service, layer, envelope, outFields) {++ const url = new URL(baseUrl);++ url.pathname = `${url.pathname.replace(/\/+$/, '')}/${service}/FeatureServer/${layer}/query`;++ url.search = new URLSearchParams({++ where: '1=1',++ geometry: JSON.stringify({++ xmin: envelope.xmin, ymin: envelope.ymin, xmax: envelope.xmax, ymax: envelope.ymax,++ spatialReference: { wkid: 4326 },++ }),++ geometryType: 'esriGeometryEnvelope',++ inSR: '4326',++ spatialRel: 'esriSpatialRelIntersects',++ outFields: outFields.join(','),++ outSR: '4326',++ returnGeometry: 'true',++ resultRecordCount: '500',++ f: 'json',++ }).toString();++ return url;++}++++function createUpstream({ config, s, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ const housing = config.housing;++ const pacers = new Map();++ const pacerFor = (rawUrl) => {++ const host = new URL(rawUrl).host;++ if (!pacers.has(host)) pacers.set(host, createPacer({ minIntervalMs: housing.minIntervalMs, now }));++ return pacers.get(host);++ };++++ /**++ * Fetch a URL built from the configured base (never from caller input), with our User-Agent, a hard timeout and a++ * hard byte ceiling. `label` is what the failure message names: Nominatim for a geocode, HUD for a layer query.++ */++ async function fetchJson(url, init = {}, label = 'the data service') {++ let res;++ try {++ res = await fetchImpl(url.toString(), {++ ...init,++ headers: { 'User-Agent': housing.userAgent, Accept: 'application/json', ...(init.headers || {}) },++ signal: AbortSignal.timeout(housing.timeoutMs),++ });++ } catch (err) {++ throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${err && err.name === 'TimeoutError' ? 'timed out' : 'network error'})`);++ }++ if (!res.ok) throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${label} answered ${res.status})`);++ let buf;++ try { buf = await res.arrayBuffer(); } catch { throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (the answer could not be read)`); }++ if (buf.byteLength > MAX_RESPONSE_BYTES) throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (the answer was too large)`);++ let body;++ try { body = JSON.parse(Buffer.from(buf).toString('utf8')); } catch { throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${label} answered something that is not JSON)`); }++ return body;++ }++++ async function cached(key, fn) {++ const hit = await geo.cacheGet(s, key);++ if (hit) return { body: hit.body, cached: true, fetched_at: hit.fetched_at };++ const body = await fn();++ const written = await geo.cachePut(s, key, body, housing.cacheTtlMs);++ return { body, cached: false, fetched_at: written.fetched_at };++ }++++ /** A place name → up to `limit` matches (Nominatim search), cached for the TTL. */++ function geocode(q, { limit = 1 } = {}) {++ const query = String(q || '').trim().slice(0, 200);++ if (!query) return Promise.reject(new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 }));++ const url = new URL(housing.nominatimUrl);++ url.pathname = `${url.pathname.replace(/\/+$/, '')}/search`;++ url.search = new URLSearchParams({ q: query, format: 'jsonv2', limit: String(Math.min(Math.max(limit, 1), 5)), addressdetails: '1' }).toString();++ return cached(`nominatim:${keyOf(query)}:${limit}`, () => pacerFor(housing.nominatimUrl).run(() => fetchJson(url, {}, 'the place service')));++ }++++ /** One bounded HUD ArcGIS layer query → its features. Cached by the request, so the same area is asked once. */++ function arcGis(service, layer, envelope, outFields) {++ const url = buildArcGisUrl(housing.hudArcGisUrl, service, layer, envelope, outFields);++ const key = `arcgis:${service}/${layer}:${crypto.createHash('sha256').update(url.search).digest('hex')}`;++ return cached(key, () => pacerFor(housing.hudArcGisUrl).run(async () => {++ const body = await fetchJson(url, {}, 'HUD');++ if (body && body.error) {++ const message = body.error && body.error.message ? body.error.message : 'the service refused the query';++ throw new UpstreamError('housing.place.upstream', `could not reach HUD right now (${message})`);++ }++ return body;++ }));++ }++++ return { geocode, arcGis, fetchJson, buildArcGisUrl, UpstreamError };++}++++module.exports = { createUpstream, createPacer, UpstreamError, buildArcGisUrl, MAX_RESPONSE_BYTES };@@@ -36,6 +36,7 @@ const listings = require('../listings/listings');const store = require('../listings/store');const service = require('../listings/service');const { isStaff } = require('../listings/staff');++const housingPlaces = require('../housing/places');/** The listing columns a caller may set, as the validate() input names. */const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];@@@ -54,6 +55,24 @@ function createApi(ctx) {// Liveness: public, and counted per caller with the default read numbers.r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));++ // ── Affordable housing near you (public) ─────────────────++ // No token: HUD's open data is the same for everyone. It spends Nominatim's shared one-request-a-second budget, so++ // it takes the per-address budget in ./caller-limits.js rather than the default read numbers.++ r.get('/housing', limits.budget('rent.housing.search'), async (req, res) => {++ const one = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);++ const out = await housingPlaces.searchPlaces(ctx.upstream, {++ q: one(req.query.q),++ kinds: one(req.query.kind),++ }, config).catch((err) => {++ if (err && err.code) { problem(req, res, err.status || 502, err.code, err.message); return null; }++ throw err;++ });++ if (!out) return undefined;++ // HUD answers are public and change slowly; a short shared cache keeps repeat searches off the upstream.++ res.set('Cache-Control', 'public, max-age=300');++ return res.json(out);++ });++// ── Who may act ─────────────────────────────────────────/*** A person, signed in. An app, agent or service token is refused: a listing is posted and answered by a person,@@@ -39,6 +39,10 @@ const BUDGETS = {'rent.listing.report': { minute: 10, hour: 60 },'rent.listing.renew': { minute: 10, hour: 60 },'rent.saved.create': { minute: 20, hour: 100 },++ // "Affordable housing near you" spends Nominatim's shared one-request-a-second budget, so the route takes a++ // per-address budget of its own (server/housing/upstream.js paces and caches; these numbers are for callers who++ // keep asking for new places).++ 'rent.housing.search': { minute: 10, hour: 120 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[async function publicPages(s) {const static_ = [{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },++ { path: '/housing', changefreq: 'daily', priority: 0.8, title: 'Affordable housing near you', text: 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place — from U.S. Department of Housing and Urban Development open data (public domain).' },{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },// /post is a form behind sign-in and robots.txt disallows it, so it is deliberately not a sitemap page.@@@ -78,16 +79,18 @@ function createDiscoveryRoutes(ctx) {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,summary: DESCRIPTION,-- details: 'Every page is server-rendered and readable without JavaScript. In this first version the listings are posted by people on this site: there are no scraped listings and no partner feeds — other sources arrive only when their terms are verified. There are no payments, no deposits and no messaging; a listing carries a way to make contact and nothing more. A listing names a city and a neighbourhood, never a street address. OpenVibe does not handle payments for listings, and the safety rule is on every listing page: never pay or send a deposit before you have seen the place and signed an agreement.',++ details: 'Every page is server-rendered and readable without JavaScript. In this first version the listings are posted by people on this site: there are no scraped listings and no partner feeds — other sources arrive only when their terms are verified. There are no payments, no deposits and no messaging; a listing carries a way to make contact and nothing more. A listing names a city and a neighbourhood, never a street address. OpenVibe does not handle payments for listings, and the safety rule is on every listing page: never pay or send a deposit before you have seen the place and signed an agreement. Affordable housing near you comes from the U.S. Department of Housing and Urban Development open data (public domain): public housing, tax-credit and HUD-assisted properties, public housing authorities and housing counselling agencies.',sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },++ { title: 'Affordable housing', url: abs('/housing'), note: 'public housing, LIHTC and HUD-assisted properties, and where to apply, near you' },{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },{ title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },] },{ title: 'Machine-readable', links: [{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },++ { title: 'Affordable housing (JSON)', url: abs('/api/v1/housing?q=washington'), note: 'the geocoded place and normalised HUD housing results near it' },{ title: 'Sitemap', url: abs('/sitemap.xml') },{ title: 'Full text for language models', url: abs('/llms-full.txt') },{ title: 'Release metadata (JSON)', url: abs('/release.json') },@@@ -26,6 +26,7 @@ const listings = require('../listings/listings');const store = require('../listings/store');const service = require('../listings/service');const { isStaff } = require('../listings/staff');++const housingPlaces = require('../housing/places');const SITE_NAME = 'OpenVibe.Rent';const TAGLINE = 'Find a place, or rent yours out.';@@@ -165,6 +166,10 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}jsonLd: homeJsonLd(config),styles: [showcase.STYLESHEET],body: html`${raw(hero)}++<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>++<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through — from U.S. HUD open data, useful before anyone has posted here.</p>++<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>++<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2><p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>${searchForm({}, { big: true })}</section>@@@ -193,6 +198,132 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m});});++ // ── Affordable housing near you ──────────────────────────++ // HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.++ const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a>. The data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;++ // HUD's data covers the United States, so distances read in miles (the API keeps distance_km).++ const housingMi = (km) => { const mi = Number(km) * 0.621371; return `${mi < 10 ? mi.toFixed(1) : Math.round(mi)} mi`; };++ const housingQuery = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);++ const SHOWN_PER_KIND = 24;++ const phoneText = (p) => { const d = String(p).replace(/\D/g, ''); const t = d.length === 11 && d[0] === '1' ? d.slice(1) : d; return t.length === 10 ? `(${t.slice(0, 3)}) ${t.slice(3, 6)}-${t.slice(6)}` : String(p); };++ const titleCase = (v) => (v && v === v.toUpperCase() ? v.toLowerCase().replace(/\b([a-z])/g, (m) => m.toUpperCase()) : v);++++ function housingForm({ q = '', selected = new Set() } = {}) {++ return html`<form class="search-form housing-form" method="get" action="/housing" role="search">++<div class="field"><label for="q">Where are you?</label>++<input id="q" name="q" type="search" value="${q}" maxlength="200" placeholder="A city, ZIP code or address in the US" autocomplete="address-level2"></div>++<fieldset class="housing-kinds"><legend>Show</legend>++${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="checkbox" name="kind" value="${k}"${selected.has(k) ? raw(' checked') : ''}><span>${housingPlaces.KIND_TEXT[k]}</span></label>`)}++<p class="muted small">Leave them all unticked to see everything.</p></fieldset>++<p><button class="sc-btn sc-primary" type="submit">Find housing</button>${q || selected.size ? html` <a class="sc-btn" href="/housing">Clear</a>` : ''}</p>++</form>`;++ }++++ const housingUnits = (r) => {++ const bits = [];++ if (r.units && r.units.total) bits.push(`${Number(r.units.total).toLocaleString('en-US')} home${Number(r.units.total) === 1 ? '' : 's'}`);++ if (r.units && r.units.low_income != null && r.kind !== 'housing_authority') bits.push(`${Number(r.units.low_income).toLocaleString('en-US')} ${r.kind === 'multifamily' ? 'assisted' : 'low-income'}`);++ if (r.extra && r.extra.section8_units) bits.push(`${Number(r.extra.section8_units).toLocaleString('en-US')} Section 8 vouchers`);++ return bits.join(' · ') || null;++ };++ const housingDetails = (r) => {++ const x = r.extra || {};++ const out = [];++ if (x.authority) out.push(html`Run by ${x.authority}`);++ if (x.rent_per_month) out.push(html`Average tenant rent $${Number(x.rent_per_month).toLocaleString('en-US')} a month`);++ if (x.year_placed) out.push(html`In service since ${x.year_placed}`);++ if (x.property_category) out.push(html`${x.property_category}`);++ if (x.expiration_date) out.push(html`Assistance contract runs to ${x.expiration_date}`);++ if (x.services) out.push(html`${x.services}`);++ if (x.languages) out.push(html`Languages: ${x.languages}`);++ return out;++ };++++ function housingRow(r) {++ const where = [titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');++ const tel = r.phone ? String(r.phone).replace(/[^\d+]/g, '') : null;++ const details = housingDetails(r);++ const units = housingUnits(r);++ const map = r.lat != null && r.lon != null ? `https://www.openstreetmap.org/?mlat=${r.lat}&mlon=${r.lon}#map=17/${r.lat}/${r.lon}` : null;++ return html`<li class="housing-card" id="${r.id}">++<div class="housing-card-head"><h4>${titleCase(r.name) || '—'}</h4><span class="housing-dist">${housingMi(r.distance_km)}</span></div>++${where ? html`<p class="housing-addr">${where}</p>` : ''}++${units ? html`<p class="housing-units">${units}</p>` : ''}++${details.length ? html`<ul class="housing-facts">${details.map((d) => html`<li>${d}</li>`)}</ul>` : ''}++<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>++</li>`;++ }++++ function housingResults(out) {++ if (!out.results.length) return html`<p class="muted">Nothing was found in this area. Try a nearby town, or a wider place like a city.</p>`;++ const present = out.kinds.filter((k) => (out.per_kind[k] || []).length);++ return html`<ul class="housing-jump" aria-label="Found by kind">${present.map((k) => html`<li><a href="#h-${k}">${housingPlaces.KIND_TEXT[k]} <strong>${out.per_kind[k].length}</strong></a></li>`)}</ul>++${present.map((k) => {++ const rows = out.per_kind[k];++ const first = rows.slice(0, SHOWN_PER_KIND);++ const rest = rows.slice(SHOWN_PER_KIND);++ return html`<section class="housing-group" aria-labelledby="h-${k}">++<h3 id="h-${k}">${housingPlaces.KIND_TEXT[k]} <span class="muted">${rows.length}</span></h3>++<p class="housing-explain">${housingPlaces.KIND_DESCRIPTIONS[k]}</p>++<ul class="housing-list">${first.map(housingRow)}</ul>++${rest.length ? html`<details class="housing-more"><summary>Show ${rest.length} more, further away</summary><ul class="housing-list">${rest.map(housingRow)}</ul></details>` : ''}++</section>`;++ })}`;++ }++++ async function housingPage(req, res, { limited = false } = {}) {++ const q = housingQuery(req.query.q) || '';++ const rawKinds = [].concat(req.query.kind || []).flatMap((v) => String(v).split(','));++ const selected = new Set(housingPlaces.KINDS.filter((k) => rawKinds.includes(k)));++ const kindParam = [...selected].join(',');++ let out = null;++ let problem = null;++ if (limited) {++ problem = 'Too many searches from you just now. Try again in a minute.';++ } else if (q) {++ try {++ out = await housingPlaces.searchPlaces(ctx.upstream, { q, kinds: kindParam || null }, config);++ } catch (err) {++ problem = err && err.code ? err.message : 'The housing search is unavailable right now. Try again in a few minutes.';++ }++ }++ const title = out && out.location.name ? `Affordable housing near ${out.location.name.split(',').slice(0, 2).join(',')}` : 'Affordable housing near you';++ page(req, res, {++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,++ title,++ description: 'Public housing, tax-credit and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place you choose — from HUD open data.',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Affordable housing' }],++ styles: [showcase.STYLESHEET], localStyles: ['css/housing.css'],++ body: html`<h1>Affordable housing near you</h1>++<p class="housing-lede">Public housing, tax-credit (LIHTC) and HUD-assisted homes near you, and the housing authorities and counselling agencies to apply through. From the U.S. Department of Housing and Urban Development's open data.</p>++${housingForm({ q, selected })}++${problem ? notice(problem, 'warn') : ''}++${out ? html`<section class="housing-results">++<h2 class="housing-where">${out.location.name || 'Around those coordinates'}</h2>++${out.in_us++ ? html`<p class="muted small">${out.counts.total} found in and around this area, nearest first. Call before you visit: waiting lists and availability change.</p>${housingResults(out)}`++ : html`<p class="notice warn">HUD data covers the United States only.</p>`}++</section>` : html`<p class="muted">Type a city, a ZIP code or an address above.</p>`}++${hudNote(out ? out.fetched_at : null)}`,++ }, limited ? 429 : 200);++ }++++ const housingSearch = ctx.limits.budget('rent.housing.search');++ r.get('/housing', (req, res) => {++ // A page load only spends the shared Nominatim budget when it will actually search.++ if (!housingQuery(req.query.q)) return housingPage(req, res);++ let settled = false;++ let retryAfter = '60';++ const allowed = () => { if (!settled) { settled = true; housingPage(req, res); } };++ const refused = () => {++ if (settled) return;++ settled = true;++ res.set('Retry-After', retryAfter);++ housingPage(req, res, { limited: true });++ };++ housingSearch(req, { statusCode: 200, setHeader: (k, v) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(v); }, end: refused }, allowed);++ });++// ── Search ───────────────────────────────────────────────r.get('/listings', async (req, res) => {const filters = listings.normalizeFilters(req.query);@@@ -42,7 +42,7 @@ async function start() {// RENT_EVENTS_SECRET). The consumer itself is mounted in server/app.js.const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });const extra = [() => { if (subscriptions) subscriptions.stop(); }];-- createLifecycle({ server, ctx, timers: [expiry.timer], extra });++ createLifecycle({ server, ctx, timers: [expiry.timer, ctx.housingPruneTimer], extra });return { server, ctx, expiry };}@@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// 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 = [++ { label: 'Affordable housing', href: '/housing' },{ label: 'Listings', href: '/listings' },{ label: 'Post a listing', href: '/post' },{ label: 'Safety', href: '/safety' },@@@ -49,7 +50,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }/*** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names], localStyles [this service's own extra stylesheets under public/, e.g. css/housing.css]*/function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };@@@ -94,6 +95,7 @@ function renderPage(o) {${appIcon.headTags({ site: 'rent' })}<link rel="stylesheet" href="${asset('css/app.css')}">${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="rent@${esc(RELEASE)}"><script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,@@@ -21,7 +21,7 @@ const READ = 'rent.api.read';const sam = t.network.addUser('sam');await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {-- assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.housing.search', 'rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);for (const [name, own] of Object.entries(BUDGETS)) {assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "objectIdFieldName": "OBJECTID",++ "geometryType": "esriGeometryPoint",++ "features": [++ {++ "attributes": {++ "OBJECTID": 400,++ "AGC_NAME": "INLAND FAIR HOUSING AND MEDIATION BOARD",++ "AGC_ADDRESS": "1500 S Haven Ave Ste 100",++ "AGC_CITY": "Ontario",++ "AGC_STATE": "California",++ "AGC_ZIP_CODE": "91761-2970",++ "AGC_PHONE_NBR": "909-984-2254",++ "AGC_WEB_SITE": "http://www.ifhmb.com",++ "COUNSEL_SRV_DESC": "Mortgage Delinquency and Default Resolution Counseling",++ "LAT": 34.04625,++ "LON": -117.576187++ },++ "geometry": { "x": -117.576187, "y": 34.04625 }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "objectIdFieldName": "OBJECTID",++ "geometryType": "esriGeometryPoint",++ "features": [++ {++ "attributes": {++ "OBJECTID": 300,++ "FORMAL_PARTICIPANT_NAME": "Springfield Housing Authority",++ "STD_ADDR": "80 Main St",++ "STD_CITY": "Springfield",++ "STD_ST": "VT",++ "STD_ZIP5": "05156",++ "TOTAL_UNITS": 0,++ "SECTION8_UNITS_CNT": 196,++ "HA_PHN_NUM": "8028854905",++ "HA_EMAIL_ADDR_TEXT": "[email protected]",++ "LAST_UPDT_DTTM": 1783361504000++ },++ "geometry": { "x": -72.4807, "y": 43.2984 }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "objectIdFieldName": "OBJECTID",++ "geometryType": "esriGeometryPoint",++ "features": [++ {++ "attributes": {++ "OBJECTID": 100,++ "HUD_ID": "DCB20152001",++ "PROJECT": "BRIGHTWOOD COMMUNITIES",++ "PROJ_ADD": "5805 14TH STREET, NW",++ "PROJ_CTY": "WASHINGTON",++ "PROJ_ST": "DC",++ "PROJ_ZIP": "20011",++ "N_UNITS": 141,++ "LI_UNITS": 127,++ "YR_PIS": "2015",++ "YR_ALLOC": "9999",++ "TRGT_POP": "1",++ "LAT": 38.960404,++ "LON": -77.033167++ },++ "geometry": { "x": -77.033167, "y": 38.960404 }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "objectIdFieldName": "OBJECTID",++ "geometryType": "esriGeometryPoint",++ "features": [++ {++ "attributes": {++ "OBJECTID": 200,++ "PROPERTY_ID": "800022227",++ "PROPERTY_NAME_TEXT": "Graystone Village",++ "ADDRESS_LINE1_TEXT": "2 Dewitt Dr",++ "STD_CITY": "White River Junction",++ "STD_ST": "VT",++ "STD_ZIP5": "05001",++ "TOTAL_ASSISTED_UNIT_COUNT": 34,++ "TOTAL_UNIT_COUNT": 34,++ "PROPERTY_CATEGORY_NAME": "Insured-Subsidized",++ "EXPIRATION_DATE1": "19-SEP-39",++ "LAST_UPDT_DTTM": 1783361442000,++ "LAT": 43.645232,++ "LON": -72.335933++ },++ "geometry": { "x": -72.335933, "y": 43.645232 }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,14 @@++[++ {++ "place_id": 2,++ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",++ "lat": "43.6532",++ "lon": "-79.3832",++ "display_name": "Toronto, Ontario, Canada",++ "boundingbox": ["43.5810", "43.8554", "-79.6393", "-79.1158"],++ "address": {++ "country": "Canada",++ "country_code": "ca"++ }++ }++]nnew file mode 100644@@@ -0,0 +1,14 @@++[++ {++ "place_id": 3,++ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",++ "lat": "47.6062",++ "lon": "-122.3321",++ "display_name": "Seattle, King County, Washington, United States",++ "boundingbox": ["47.4955", "47.7341", "-122.4359", "-122.2363"],++ "address": {++ "country": "United States",++ "country_code": "us"++ }++ }++]nnew file mode 100644@@@ -0,0 +1,14 @@++[++ {++ "place_id": 1,++ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",++ "lat": "38.9072",++ "lon": "-77.0369",++ "display_name": "Washington, District of Columbia, United States",++ "boundingbox": ["38.7916", "38.9959", "-77.1199", "-76.9094"],++ "address": {++ "country": "United States",++ "country_code": "us"++ }++ }++]nnew file mode 100644@@@ -0,0 +1,25 @@++{++ "objectIdFieldName": "OBJECTID",++ "geometryType": "esriGeometryPoint",++ "features": [++ {++ "attributes": {++ "OBJECTID": 7685,++ "PROJECT_NAME": "WHEELER CREEK",++ "STD_ADDR": "900 Varney St SE",++ "STD_CITY": "Washington",++ "STD_ST": "DC",++ "STD_ZIP5": "20032",++ "TOTAL_DWELLING_UNITS": 100,++ "TOTAL_UNITS": 100,++ "RENT_PER_MONTH": 411,++ "LAST_UPDT_DTTM": 1783361963000,++ "LAT": 38.835227,++ "LON": -76.992088,++ "HA_PHN_NUM": "2025351500",++ "FORMAL_PARTICIPANT_NAME": "D.C Housing Authority"++ },++ "geometry": { "x": -76.992088, "y": 38.835227 }++ }++ ]++}@@@ -38,7 +38,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -70,6 +70,7 @@ async function boot(opts = {}) {logs: () => captured.join('\n'),async close() {built.ctx.search.stop();++ if (built.ctx.housingPruneTimer) clearInterval(built.ctx.housingPruneTimer);await new Promise((r) => server.close(r));built.ctx.keys.client.stop();await testdb.close();nnew file mode 100644@@@ -0,0 +1,249 @@++'use strict';++/**++ * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the five layers, and the++ * page and API. The upstream fetch is injected through opts.housingFetchImpl (the sandbox has no network or loopback++ * to reach Nominatim/HUD), and the DB cache is the real migrated housing_geo_cache table.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const places = require('../server/housing/places');++const { buildArcGisUrl } = require('../server/housing/upstream');++++const nominatimUs = require('./fixtures/housing/nominatim-us.json');++const nominatimCa = require('./fixtures/housing/nominatim-ca.json');++const nominatimSeattle = require('./fixtures/housing/nominatim-seattle.json');++const ph = require('./fixtures/housing/public-housing.json');++const lihtc = require('./fixtures/housing/lihtc.json');++const mf = require('./fixtures/housing/multifamily.json');++const pha = require('./fixtures/housing/housing-authority.json');++const counsel = require('./fixtures/housing/counseling.json');++++const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } });++const def = (kind) => places.LAYERS.find((l) => l.kind === kind);++const norm = (kind, fixture) => places.normalizeFeature(fixture.features[0], def(kind));++++let failHud = false;++const calls = [];++const mockFetch = async (url) => {++ const u = new URL(String(url));++ calls.push(u.toString());++ if (u.pathname.endsWith('/search')) {++ const q = u.searchParams.get('q') || '';++ if (/fail/i.test(q)) return jsonResponse({ error: 'boom' }, 500);++ if (/seattle/i.test(q)) return jsonResponse(nominatimSeattle);++ return jsonResponse(/toronto|montreal/i.test(q) ? nominatimCa : nominatimUs);++ }++ if (u.pathname.includes('/query')) {++ if (failHud) return jsonResponse({ error: { message: 'the service is unavailable' } }, 500);++ if (u.pathname.includes('Public_Housing_Developments')) return jsonResponse(ph);++ if (u.pathname.includes('/LIHTC/')) return jsonResponse(lihtc);++ if (u.pathname.includes('MULTIFAMILY_PROPERTIES_ASSISTED')) return jsonResponse(mf);++ if (u.pathname.includes('Public_Housing_Authorities')) return jsonResponse(pha);++ if (u.pathname.includes('Housing_Counseling_Agencies')) return jsonResponse(counsel);++ }++ return jsonResponse({ features: [] });++};++++(async () => {++ // ── Unit: geocoding and bounding-box clipping ─────────────++ await check('bounding-box: a small place keeps its own box', () => {++ const box = places.envelopeFor({ lat: 38.9, lon: -77.0, bbox: [38.8, 39.0, -77.1, -76.9] }, 50);++ assert.deepStrictEqual(box, { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0 });++ });++++ await check('bounding-box: a bigger place is clipped to ~50 km around its centre', () => {++ // A country-sized box must be ignored in favour of a 50 km box centred on the place.++ const box = places.envelopeFor({ lat: 39.0, lon: -98.0, bbox: [25.0, 49.0, -124.0, -67.0] }, 50);++ const half = 25 / 111.32;++ assert.ok(box.ymin < 39.0 && box.ymax > 39.0, 'latitude is centred on the place');++ assert.ok(box.xmin < -98.0 && box.xmax > -98.0, 'longitude is centred on the place');++ assert.ok(Math.abs((box.ymax - box.ymin) / 2 - half) < 0.001, 'the box is at most 50 km across');++ });++++ await check('bounding-box: no usable box gives a 50 km box around the point', () => {++ const box = places.envelopeFor({ lat: 38.9, lon: -77.0, bbox: null }, 50);++ const half = 25 / 111.32;++ assert.ok(box.ymin < 38.9 && box.ymax > 38.9);++ assert.ok(Math.abs((box.ymax - box.ymin) / 2 - half) < 0.001);++ });++++ // ── Unit: the ArcGIS query URL ────────────────────────────++ await check('the ArcGIS query URL carries the layer, envelope and only the fields used', () => {++ const base = 'https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services';++ const url = new URL(buildArcGisUrl(base, 'Public_Housing_Developments', 0, { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0 }, ['OBJECTID', 'PROJECT_NAME', 'LAT', 'LON']));++ assert.strictEqual(url.pathname, '/VTyQ9soqVukalItT/arcgis/rest/services/Public_Housing_Developments/FeatureServer/0/query');++ assert.strictEqual(url.searchParams.get('where'), '1=1');++ assert.strictEqual(url.searchParams.get('geometryType'), 'esriGeometryEnvelope');++ assert.strictEqual(url.searchParams.get('spatialRel'), 'esriSpatialRelIntersects');++ assert.strictEqual(url.searchParams.get('inSR'), '4326');++ assert.strictEqual(url.searchParams.get('outSR'), '4326');++ assert.strictEqual(url.searchParams.get('returnGeometry'), 'true');++ assert.strictEqual(url.searchParams.get('resultRecordCount'), '500');++ assert.strictEqual(url.searchParams.get('f'), 'json');++ assert.strictEqual(url.searchParams.get('outFields'), 'OBJECTID,PROJECT_NAME,LAT,LON');++ assert.deepStrictEqual(JSON.parse(url.searchParams.get('geometry')), { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0, spatialReference: { wkid: 4326 } });++ });++++ // ── Unit: normalisation of each layer ─────────────────────++ await check('normalises public housing developments', () => {++ const r = norm('public_housing', ph);++ assert.strictEqual(r.kind, 'public_housing');++ assert.strictEqual(r.id, 'hud-ph-7685');++ assert.strictEqual(r.name, 'WHEELER CREEK');++ assert.strictEqual(r.address, '900 Varney St SE');++ assert.strictEqual(r.city, 'Washington');++ assert.strictEqual(r.state, 'DC');++ assert.strictEqual(r.zip, '20032');++ assert.strictEqual(r.lat, 38.835227);++ assert.strictEqual(r.lon, -76.992088);++ assert.strictEqual(r.phone, '2025351500');++ assert.deepStrictEqual(r.units, { total: 100, low_income: null });++ assert.strictEqual(r.extra.authority, 'D.C Housing Authority');++ assert.strictEqual(r.extra.rent_per_month, 411);++ assert.strictEqual(r.source_url, places.SOURCE.url);++ });++++ await check('normalises LIHTC properties', () => {++ const r = norm('lihtc', lihtc);++ assert.strictEqual(r.kind, 'lihtc');++ assert.strictEqual(r.id, 'hud-lihtc-DCB20152001');++ assert.strictEqual(r.name, 'BRIGHTWOOD COMMUNITIES');++ assert.strictEqual(r.phone, null);++ assert.deepStrictEqual(r.units, { total: 141, low_income: 127 });++ assert.strictEqual(r.extra.year_placed, '2015');++ });++++ await check('normalises HUD-assisted multifamily properties', () => {++ const r = norm('multifamily', mf);++ assert.strictEqual(r.kind, 'multifamily');++ assert.strictEqual(r.id, 'hud-mf-800022227');++ assert.strictEqual(r.name, 'Graystone Village');++ assert.strictEqual(r.phone, null);++ assert.deepStrictEqual(r.units, { total: 34, low_income: 34 });++ assert.strictEqual(r.extra.property_category, 'Insured-Subsidized');++ assert.strictEqual(r.extra.expiration_date, '2039-09-19');++ });++++ await check('normalises public housing authorities, taking the position from the geometry', () => {++ const r = norm('housing_authority', pha);++ assert.strictEqual(r.kind, 'housing_authority');++ assert.strictEqual(r.id, 'hud-pha-300');++ assert.strictEqual(r.name, 'Springfield Housing Authority');++ assert.strictEqual(r.lat, 43.2984);++ assert.strictEqual(r.lon, -72.4807);++ assert.strictEqual(r.phone, '8028854905');++ assert.deepStrictEqual(r.units, { total: 0, low_income: null });++ assert.strictEqual(r.extra.section8_units, 196);++ });++++ await check('normalises housing counselling agencies', () => {++ const r = norm('counseling', counsel);++ assert.strictEqual(r.kind, 'counseling');++ assert.strictEqual(r.id, 'hud-counsel-400');++ assert.strictEqual(r.name, 'INLAND FAIR HOUSING AND MEDIATION BOARD');++ assert.strictEqual(r.phone, '909-984-2254');++ assert.deepStrictEqual(r.units, { total: null, low_income: null });++ assert.strictEqual(r.extra.services, 'Mortgage Delinquency and Default Resolution Counseling');++ assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');++ });++++ await check('a missing value stays null, never invented', () => {++ const r = places.normalizeFeature({ attributes: { OBJECTID: 1, PROJECT_NAME: 'X', TOTAL_UNITS: 12 }, geometry: { x: -77, y: 38 } }, def('public_housing'));++ assert.strictEqual(r.address, null);++ assert.strictEqual(r.city, null);++ assert.strictEqual(r.phone, null);++ assert.strictEqual(r.extra.authority, null);++ assert.strictEqual(r.extra.rent_per_month, null);++ assert.strictEqual(r.extra.last_updated, null);++ assert.deepStrictEqual(r.units, { total: 12, low_income: null });++ });++++ await check('results sort nearest first', () => {++ const features = [++ { attributes: { OBJECTID: 1, PROJECT_NAME: 'Far', LAT: 39.0, LON: -77.0 }, geometry: { x: -77.0, y: 39.0 } },++ { attributes: { OBJECTID: 2, PROJECT_NAME: 'Near', LAT: 38.9, LON: -77.0 }, geometry: { x: -77.0, y: 38.9 } },++ ];++ const results = places.elementsToResults(features, def('public_housing'), { lat: 38.9, lon: -77.0 }, 10);++ assert.strictEqual(results[0].name, 'Near');++ assert.strictEqual(results[1].name, 'Far');++ assert.ok(results[0].distance_km < results[1].distance_km);++ });++++ // ── Integration: the page and the API with an injected fetch ──++ const t = await boot({ housingFetchImpl: mockFetch });++ try {++ await check('the API geocodes the place and returns the normalised results with attribution', async () => {++ const r = await t.get('/api/v1/housing?q=Washington');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ const body = r.json();++ assert.strictEqual(body.in_us, true);++ assert.strictEqual(body.location.name, 'Washington, District of Columbia, United States');++ assert.deepStrictEqual(body.kinds, places.KINDS);++ assert.strictEqual(body.counts.total, 5);++ assert.strictEqual(body.results.length, 5);++ assert.strictEqual(body.attribution.text, places.ATTRIBUTION.text);++ assert.ok(body.results.every((x) => typeof x.distance_km === 'number'));++ });++++ await check('the cache: a second request for the same place does not fetch again', async () => {++ const before = calls.length;++ const r = await t.get('/api/v1/housing?q=Washington');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(calls.length, before, 'no new upstream calls on a cached answer');++ });++++ await check('the page signed out shows the results, each kind\'s explanation and the attribution', async () => {++ const r = await t.get('/housing?q=Washington');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.text, /action="\/housing"/);++ assert.ok(r.text.includes('Wheeler Creek'), 'a public housing result is on the page');++ assert.ok(r.text.includes('Brightwood Communities'), 'a LIHTC result is on the page');++ assert.ok(r.text.includes('Springfield Housing Authority'), 'a housing authority is on the page');++ assert.match(r.text, /Public housing: homes owned by a local housing authority/);++ assert.ok(r.text.includes('Source: U.S. Department of Housing and Urban Development (HUD), public domain'));++ assert.ok(r.text.includes('https://hudgis-hud.opendata.arcgis.com/'));++ assert.ok(r.text.includes('covers the United States'));++ assert.ok(r.text.includes('availability must be confirmed'));++ });++++ await check('a place outside the United States answers the honest US-only state, never a fetch to HUD', async () => {++ const before = calls.length;++ const api = await t.get('/api/v1/housing?q=Toronto');++ assert.strictEqual(api.status, 200);++ const body = api.json();++ assert.strictEqual(body.in_us, false);++ assert.deepStrictEqual(body.results, []);++ assert.strictEqual(body.counts.total, 0);++ assert.ok(!calls.slice(before).some((u) => u.includes('/query')), 'no HUD query for a non-US place');++++ const page = await t.get('/housing?q=Toronto');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('HUD data covers the United States only'));++ });++++ await check('an upstream failure is a clear state, never a 500', async () => {++ const api = await t.get('/api/v1/housing?q=fail');++ assert.strictEqual(api.status, 502);++ assert.strictEqual(api.json().code, 'housing.place.upstream');++++ const page = await t.get('/housing?q=fail');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('could not reach the place service right now'));++ });++++ await check('a HUD failure says it could not reach HUD right now', async () => {++ failHud = true;++ try {++ const page = await t.get('/housing?q=Seattle');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('could not reach HUD right now'));++ } finally {++ failHud = false;++ }++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
> Find a place, or rent yours out.
> Find a place, or rent yours out.
-**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look.
+**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look, and
+"Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily,
+public housing authorities and housing counselling agencies) near a place a visitor types.
**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).
@@ -14,6 +16,12 @@ and no partner feeds. Other sources come later, and only when their terms are ve
@@ -14,6 +16,12 @@ and no partner feeds. Other sources come later, and only when their terms are ve
its provenance (the source, a link back to it, and when it was read). Until then every listing here was written by
its provenance (the source, a link back to it, and when it was read). Until then every listing here was written by
the person who posted it, and the page says so.
the person who posted it, and the page says so.
+Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) shows
+public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing
+counselling agencies near a place a visitor types, from the U.S. Department of Housing and Urban Development's open
+data (public domain). It is the same "near you" pattern as OpenVibe.Food's food banks, but from HUD's ArcGIS instead
+of OpenStreetMap.
+
Three rules shape the product:
Three rules shape the product:
- **No payments, no deposits, no messaging.** A listing is a description, a price with a currency and a period, and a
- **No payments, no deposits, no messaging.** A listing is a description, a price with a currency and a period, and a
@@ -39,6 +47,10 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
@@ -39,6 +47,10 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
since you last looked" count. `ssv_<ULID>` ids.
since you last looked" count. `ssv_<ULID>` ids.
- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a
- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a
redelivery changes nothing ([migrations/0003_account_data.sql](migrations/0003_account_data.sql)).
redelivery changes nothing ([migrations/0003_account_data.sql](migrations/0003_account_data.sql)).
+- **`housing_geo_cache`** — the cache in front of the two "Affordable housing near you" upstream services: every
+ Nominatim geocode and HUD ArcGIS query answer, kept for `RENT_HOUSING_CACHE_TTL_MS` (7 days), so a place is
+ geocoded once and an area is asked once, not once per visitor
+ ([migrations/0005_housing_cache.sql](migrations/0005_housing_cache.sql)).
`migrations/0001_initial.sql` creates no tables (the skeleton). Every row keys a person as `user:usr_…`, and the
`migrations/0001_initial.sql` creates no tables (the skeleton). Every row keys a person as `user:usr_…`, and the
namespace is `rent.*`.
namespace is `rent.*`.
@@ -71,7 +83,10 @@ namespace is `rent.*`.
@@ -71,7 +83,10 @@ namespace is `rent.*`.
`service`, `limits`, `account-data`, `events`, `valkey`), plus `openvibe-shared` (`release`, `metrics`, `ready`,
`service`, `limits`, `account-data`, `events`, `valkey`), plus `openvibe-shared` (`release`, `metrics`, `ready`,
`legal`, `cache-policy`, `serve`, `frame`, `shell`, `seo`, `app-icon`).
`legal`, `cache-policy`, `serve`, `frame`, `shell`, `seo`, `app-icon`).
-It fetches from no listing source and no third party: the only outbound calls are the Network and Events above.
+For listings it fetches from no source and no third party: the only outbound calls are the Network and Events above.
+The "Affordable housing near you" feature adds two more outbound hosts, both open data with published usage policies —
+Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the five housing layers) — paced and cached in
+[server/housing/upstream.js](server/housing/upstream.js).
## Capabilities
## Capabilities
@@ -93,7 +108,8 @@ It fetches from no listing source and no third party: the only outbound calls ar
@@ -93,7 +108,8 @@ It fetches from no listing source and no third party: the only outbound calls ar
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |
-| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |
+| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads five HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities and housing counselling agencies — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
+| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/housing`, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |
| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`. |
| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`. |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. |
| Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing and saving a search. The page form and the API route take the same budget, so the form is not a way round a limit. |
| Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing and saving a search. The page form and the API route take the same budget, so the form is not a way round a limit. |
@@ -110,6 +126,7 @@ edit, report or save anything here — a listing is somebody's own — so no rou
@@ -110,6 +126,7 @@ edit, report or save anything here — a listing is somebody's own — so no rou
| Route | Who | |
| Route | Who | |
|---|---|---|
|---|---|---|
| `GET /ping` | anyone | liveness |
| `GET /ping` | anyone | liveness |
+| `GET /housing` | anyone | "Affordable housing near you": `q` (a city, ZIP code or address) and optional `kind` (comma-separated from `public_housing`, `lihtc`, `multifamily`, `housing_authority`, `counseling`); answers the geocoded `location` and normalised `results` (name, address, distance, units, phone, authority/agency, source), grouped and distance-sorted |
| `GET /listings` | anyone | search: `q`, `kind`, `city`, `country`, `min`, `max`, `period`, `limit`, `before`; answers `listings`, `next`, `facets` |
| `GET /listings` | anyone | search: `q`, `kind`, `city`, `country`, `min`, `max`, `period`, `limit`, `before`; answers `listings`, `next`, `facets` |
| `GET /listings/:id` | anyone | one listing; `contact_url` only when the caller is a signed-in person |
| `GET /listings/:id` | anyone | one listing; `contact_url` only when the caller is a signed-in person |
| `POST /listings` | person | post one (201 + `Location`); 422 on a bad field, 429 on a cap |
| `POST /listings` | person | post one (201 + `Location`); 422 on a bad field, 429 on a cap |
@@ -125,13 +142,25 @@ edit, report or save anything here — a listing is somebody's own — so no rou
@@ -125,13 +142,25 @@ edit, report or save anything here — a listing is somebody's own — so no rou
## Data sources and their terms
## Data sources and their terms
-**This version has none.** Every listing is text a person typed here, under the rules above; nothing is scraped,
-imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored as
-text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service
+**Listings have no external source.** Every listing is text a person typed here, under the rules above; nothing is
+scraped, imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored
+as text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service
requests).
requests).
-The only external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its JWKS),
-with its terms as the network's own service. When listing sources are added they will be named here with their
+**Affordable housing near you** has two verified, open sources, read live and cached for 7 days
+([server/housing/upstream.js](server/housing/upstream.js)):
+
+- **U.S. Department of Housing and Urban Development (HUD)** open data, on ArcGIS — public housing, LIHTC, HUD-assisted
+ multifamily, public housing authorities and housing counselling agencies. Public domain (USA.gov
+ [public domain label](https://www.usa.gov/government-copyright)). Attribution on every page and in every API answer:
+ *"Source: U.S. Department of Housing and Urban Development (HUD), public domain"*, linked to
+ https://hudgis-hud.opendata.arcgis.com/. The data covers the United States, and availability must be confirmed with
+ the property or authority.
+- **Nominatim** (OpenStreetMap) — the place name → coordinates and bounding box for the search box only; the housing
+ results themselves come from HUD. One request a second at most, with a descriptive `RENT_USER_AGENT`.
+
+The only other external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its
+JWKS), with its terms as the network's own service. When listing sources are added they will be named here with their
terms, and each listing will carry its provenance (source name, a link back, and `fetched_at`) both in the API and
terms, and each listing will carry its provenance (source name, a link back, and `fetched_at`) both in the API and
on the page.
on the page.
@@ -142,9 +171,17 @@ Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is
@@ -142,9 +171,17 @@ Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is
the Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on
the Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on
`/api/ready`).
`/api/ready`).
-The product adds no environment variables of its own beyond the OpenVibe.Events ones below: its numbers (10 active
-listings, 5 a day, 30 days, 3 reports, 30 a page, the per-caller budgets) are constants next to the rules they belong
-to, not deployment settings.
+The listing product adds no environment variables of its own beyond the OpenVibe.Events ones below: its numbers (10
+active listings, 5 a day, 30 days, 3 reports, 30 a page, the per-caller budgets) are constants next to the rules they
+belong to, not deployment settings. "Affordable housing near you" adds the following (all optional, with safe
+defaults):
+
+- `RENT_NOMINATIM_URL` — `https://nominatim.openstreetmap.org`
+- `RENT_HUD_ARCGIS_URL` — `https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services`
+- `RENT_USER_AGENT` — `OpenVibeRent/0.1 (+BASE_URL)`; a descriptive contact, never a browser's
+- `RENT_HOUSING_CACHE_TTL_MS` — `604800000` (7 days)
+- `RENT_HOUSING_MIN_INTERVAL_MS` — `1000` (one request a second to a host)
+- `RENT_HOUSING_TIMEOUT_MS` — `25000`
## Development and tests
## Development and tests
@@ -177,6 +214,10 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
@@ -177,6 +214,10 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
hidden again.
hidden again.
- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, the
- [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, the
safety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.
safety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped.
+- [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected:
+ geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all five HUD layers (null for a missing
+ value), distance sort, the cache (a second request does not fetch), the upstream-failure and non-US states, the page
+ signed out with attribution, and the API.
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`
- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events`
with a stand-in Network: only the person's rows, a redelivery changes nothing, and a bad signature or forwarded
with a stand-in Network: only the person's rows, a redelivery changes nothing, and a bad signature or forwarded
request is refused.
request is refused.
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Rent: the cache in front of the two upstream services behind "Affordable housing near you"
+-- (server/housing/upstream.js): every Nominatim geocode and HUD ArcGIS query answer, with its TTL
+-- (RENT_HOUSING_CACHE_TTL_MS, 7 days by default), so a place is geocoded once and an area is asked once,
+-- not once per visitor. Nothing here is ever a credential, a key or a token.
+
+CREATE TABLE housing_geo_cache (
+ key text COLLATE "C" PRIMARY KEY, -- 'nominatim:<query>' | 'arcgis:<service>/<layer>:<sha256 of the query>'
+ body jsonb NOT NULL, -- the upstream answer, as it came
+ fetched_at text COLLATE "C" NOT NULL, -- when it was fetched (ISO 8601, UTC)
+ expires_at text COLLATE "C" NOT NULL -- ISO 8601, UTC: past it the row is ignored and replaced
+);
+CREATE INDEX housing_geo_cache_expires ON housing_geo_cache (expires_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/* OpenVibe.Rent — /housing only (affordable housing from HUD open data), so app.css stays inside its budget. */
+.housing-lede { max-width: 760px; color: var(--text-secondary, #96a7c2); font-size: 1.04rem; }
+.housing-form { margin-bottom: 18px; }
+.housing-kinds { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.housing-kinds legend { font-size: .8rem; font-weight: 650; color: var(--text-secondary, #96a7c2); margin-bottom: 6px; }
+.housing-kinds p { flex-basis: 100%; margin: 2px 0 0; }
+.housing-kind { position: relative; }
+.housing-kind input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
+.housing-kind span { display: inline-block; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); font-size: .9rem; background: var(--bg-secondary, #0f1729); }
+.housing-kind input:checked + span { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--bg-secondary, #0f1729)); color: var(--text-primary, #e6edf7); }
+.housing-kind input:focus-visible + span { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.housing-where { margin-bottom: 2px; overflow-wrap: anywhere; }
+.housing-jump { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 10px 0 4px; }
+.housing-jump a { display: inline-flex; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }
+.housing-jump a:hover { border-color: var(--accent, #3b82f6); }
+.housing-group { margin-top: 26px; }
+.housing-group h3 { margin: 0 0 2px; font-size: 1.15rem; }
+.housing-explain { margin: 0 0 10px; color: var(--text-secondary, #96a7c2); font-size: .92rem; max-width: 760px; }
+.housing-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
+.housing-card { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.housing-card p { margin: 0; }
+.housing-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
+.housing-card h4 { margin: 0; font-size: 1rem; overflow-wrap: anywhere; }
+.housing-dist { flex: none; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
+.housing-addr { font-size: .9rem; color: var(--text-secondary, #96a7c2); }
+.housing-units { font-size: .92rem; }
+.housing-facts { list-style: none; padding: 0; margin: 2px 0 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
+.housing-facts li + li { margin-top: 1px; }
+.housing-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: auto !important; padding-top: 6px; font-size: .9rem; }
+.housing-more { margin-top: 10px; }
+.housing-more summary { cursor: pointer; color: var(--accent-light, #60a5fa); padding: 6px 0; }
+.housing-more .housing-list { margin-top: 8px; }
@@ -24,6 +24,8 @@ const cache = require('openvibe-shared/cache-policy');
@@ -24,6 +24,8 @@ const cache = require('openvibe-shared/cache-policy');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
+const { createUpstream } = require('./housing/upstream');
+const housingStore = require('./housing/store');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
@@ -58,6 +60,15 @@ async function createApp(opts = {}) {
@@ -58,6 +60,15 @@ async function createApp(opts = {}) {
ctx.search = opts.search || createSearchIndex({ config, s, log });
ctx.search = opts.search || createSearchIndex({ config, s, log });
s.search = ctx.search;
s.search = ctx.search;
+ // The only outbound client the housing feature has (Nominatim + HUD ArcGIS, paced and cached); the pages and the
+ // API get it from here. Its own wall clock, not the store's, so the pace between two calls does not depend on an
+ // injected test clock. A test injects a stand-in fetch through opts.housingFetchImpl (it must never reach the
+ // real Nominatim or HUD hosts from a sandbox).
+ ctx.upstream = createUpstream({ config, s, fetchImpl: opts.housingFetchImpl || fetchImpl, log });
+ // Drop expired geocode/area answers now and hourly; an expired row is ignored anyway, this only keeps the table
+ // small. The interval is unref'd and kept on ctx so the entry point clears it on shutdown (server/index.js).
+ ctx.housingPruneTimer = housingStore.startCachePrune(s, { log });
+
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// a test injects a stand-in through opts.accountSend.
// a test injects a stand-in through opts.accountSend.
@@ -38,6 +38,22 @@ function load(env = process.env) {
@@ -38,6 +38,22 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.RENT_PGLITE_DIR || '' },
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.RENT_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:rent:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:rent:' },
+ // The two upstream services "Affordable housing near you" stands on (server/housing/places.js): Nominatim (a
+ // place name → coordinates and a bounding box) and HUD's ArcGIS FeatureServer (the five housing layers). Both
+ // are open data with published usage policies — Nominatim at most one request a second, HUD small bounded area
+ // queries — and server/housing/upstream.js paces every call to one host at a time and caches every answer.
+ housing: {
+ nominatimUrl: trim(env.RENT_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),
+ hudArcGisUrl: trim(env.RENT_HUD_ARCGIS_URL || 'https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services'),
+ // Identify to Nominatim and HUD with a contact URL, as their policies require; never a browser User-Agent.
+ userAgent: env.RENT_USER_AGENT || `OpenVibeRent/0.1 (+${baseUrl})`,
+ minIntervalMs: Math.max(0, int(env.RENT_HOUSING_MIN_INTERVAL_MS, 1000)),
+ timeoutMs: Math.max(1000, int(env.RENT_HOUSING_TIMEOUT_MS, 25_000)),
+ cacheTtlMs: Math.max(60_000, int(env.RENT_HOUSING_CACHE_TTL_MS, 7 * 24 * 60 * 60 * 1000)),
+ maxBoxKm: 50,
+ maxResults: 500,
+ },
+
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,346 @@
@@ -0,0 +1,346 @@
+'use strict';
+
+/**
+ * "Affordable housing near you": public housing, tax-credit (LIHTC) properties, HUD-assisted multifamily properties,
+ * public housing authorities and housing counselling agencies near a place a visitor types, from the U.S. Department
+ * of Housing and Urban Development's open data (public domain) on ArcGIS. The field names and the five layers are
+ * from the verified HUD research; a missing value stays null (never invented), and server/housing/upstream.js paces
+ * and caches every request.
+ *
+ * Everything this module returns is HUD public-domain data. Callers must show the attribution and link it back — the
+ * pages do (server/http/pages.js), and the API returns it with every answer.
+ */
+
+const { UpstreamError } = require('./upstream');
+
+const ATTRIBUTION = {
+ text: 'Source: U.S. Department of Housing and Urban Development (HUD), public domain',
+ url: 'https://hudgis-hud.opendata.arcgis.com/',
+ license: 'Public domain (USA.gov)',
+ license_url: 'https://www.usa.gov/government-copyright',
+};
+const SOURCE = {
+ name: 'U.S. Department of Housing and Urban Development (HUD)',
+ url: 'https://hudgis-hud.opendata.arcgis.com/',
+ via: 'ArcGIS FeatureServer',
+ license: 'Public domain',
+ fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',
+};
+
+const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling'];
+const KIND_TEXT = {
+ public_housing: 'Public housing',
+ lihtc: 'Tax-credit (LIHTC) properties',
+ multifamily: 'HUD-assisted multifamily',
+ housing_authority: 'Public housing authorities',
+ counseling: 'Housing counselling agencies',
+};
+const KIND_DESCRIPTIONS = {
+ public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',
+ lihtc: 'Tax-credit (LIHTC) properties: privately owned apartments with rents kept low through a federal tax credit.',
+ multifamily: 'HUD-assisted multifamily: privately owned apartments where HUD helps keep the rent affordable.',
+ housing_authority: 'Public housing authorities: the local agency to apply to for public housing and housing vouchers.',
+ counseling: 'Housing counselling agencies: HUD-approved local help with renting, buying and keeping a home.',
+};
+
+// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,
+// -5), so a negative number is missing, never shown.
+const num = (v) => {
+ if (v == null || v === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+};
+// A coordinate may be negative (every US longitude is); only a non-number is missing.
+const coord = (v) => {
+ if (v == null || v === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+};
+const str = (v) => (v == null || v === '' ? null : String(v).trim());
+// A US ZIP code: numeric fields lose their leading zero (LIHTC's PROJ_ZIP gives 5401 for 05401); ZIP+4 is kept.
+const zip5 = (v) => {
+ const t = str(v);
+ if (!t) return null;
+ return /^\d{1,5}$/.test(t) ? t.padStart(5, '0') : t;
+};
+const MONTHS = { JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06', JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12' };
+// HUD's multifamily contract dates read "30-JUN-41"; a two-digit year is 20xx (these contracts run forward).
+const hudDate = (v) => {
+ const m = /^(\d{2})-([A-Z]{3})-(\d{2})$/.exec(str(v) || '');
+ return m && MONTHS[m[2]] ? `20${m[3]}-${MONTHS[m[2]]}-${m[1]}` : str(v);
+};
+const epochIso = (v) => {
+ const n = num(v);
+ if (n == null) return null;
+ const d = new Date(n);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+};
+
+/** A HUD-supplied URL, kept only when it parses and speaks http(s): it must never become a hostile href. */
+function safeUrl(v) {
+ if (typeof v !== 'string' || !v.trim()) return null;
+ try {
+ const u = new URL(v.trim());
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
+ } catch {
+ return null;
+ }
+}
+
+const idOf = (prefix, ...parts) => {
+ for (const p of parts) {
+ const v = p == null ? '' : String(p).trim();
+ if (v) return `${prefix}-${v}`;
+ }
+ return null;
+};
+
+// The five layers, each normalised to the same shape. `fields` is exactly what the query asks for, nothing more.
+const LAYERS = [
+ {
+ kind: 'public_housing',
+ service: 'Public_Housing_Developments',
+ layer: 0,
+ fields: ['OBJECTID', 'PROJECT_NAME', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_DWELLING_UNITS', 'TOTAL_UNITS', 'HA_PHN_NUM', 'FORMAL_PARTICIPANT_NAME', 'LAT', 'LON', 'RENT_PER_MONTH', 'LAST_UPDT_DTTM'],
+ normalize(a) {
+ return {
+ id: idOf('hud-ph', a.OBJECTID),
+ name: str(a.PROJECT_NAME),
+ address: str(a.STD_ADDR),
+ city: str(a.STD_CITY),
+ state: str(a.STD_ST),
+ zip: zip5(a.STD_ZIP5),
+ phone: str(a.HA_PHN_NUM),
+ units: { total: num(a.TOTAL_DWELLING_UNITS) ?? num(a.TOTAL_UNITS), low_income: null },
+ extra: { authority: str(a.FORMAL_PARTICIPANT_NAME), rent_per_month: num(a.RENT_PER_MONTH), last_updated: epochIso(a.LAST_UPDT_DTTM) },
+ };
+ },
+ },
+ {
+ kind: 'lihtc',
+ service: 'LIHTC',
+ layer: 0,
+ fields: ['OBJECTID', 'HUD_ID', 'PROJECT', 'PROJ_ADD', 'PROJ_CTY', 'PROJ_ST', 'PROJ_ZIP', 'N_UNITS', 'LI_UNITS', 'YR_PIS', 'YR_ALLOC', 'LAT', 'LON'],
+ normalize(a) {
+ return {
+ id: idOf('hud-lihtc', a.HUD_ID, a.OBJECTID),
+ name: str(a.PROJECT),
+ address: str(a.PROJ_ADD),
+ city: str(a.PROJ_CTY),
+ state: str(a.PROJ_ST),
+ zip: zip5(a.PROJ_ZIP),
+ phone: null,
+ units: { total: num(a.N_UNITS), low_income: num(a.LI_UNITS) },
+ extra: { year_placed: str(a.YR_PIS), year_allocated: str(a.YR_ALLOC) },
+ };
+ },
+ },
+ {
+ kind: 'multifamily',
+ service: 'MULTIFAMILY_PROPERTIES_ASSISTED',
+ layer: 0,
+ fields: ['OBJECTID', 'PROPERTY_ID', 'PROPERTY_NAME_TEXT', 'ADDRESS_LINE1_TEXT', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_UNIT_COUNT', 'TOTAL_ASSISTED_UNIT_COUNT', 'PROPERTY_CATEGORY_NAME', 'EXPIRATION_DATE1', 'LAST_UPDT_DTTM', 'LAT', 'LON'],
+ normalize(a) {
+ return {
+ id: idOf('hud-mf', a.PROPERTY_ID, a.OBJECTID),
+ name: str(a.PROPERTY_NAME_TEXT),
+ address: str(a.ADDRESS_LINE1_TEXT),
+ city: str(a.STD_CITY),
+ state: str(a.STD_ST),
+ zip: zip5(a.STD_ZIP5),
+ phone: null,
+ units: { total: num(a.TOTAL_UNIT_COUNT), low_income: num(a.TOTAL_ASSISTED_UNIT_COUNT) },
+ extra: { property_category: str(a.PROPERTY_CATEGORY_NAME), expiration_date: hudDate(a.EXPIRATION_DATE1), last_updated: epochIso(a.LAST_UPDT_DTTM) },
+ };
+ },
+ },
+ {
+ kind: 'housing_authority',
+ service: 'Public_Housing_Authorities',
+ layer: 0,
+ fields: ['OBJECTID', 'FORMAL_PARTICIPANT_NAME', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'TOTAL_UNITS', 'SECTION8_UNITS_CNT', 'HA_PHN_NUM', 'HA_EMAIL_ADDR_TEXT', 'LAST_UPDT_DTTM'],
+ normalize(a) {
+ return {
+ id: idOf('hud-pha', a.OBJECTID),
+ name: str(a.FORMAL_PARTICIPANT_NAME),
+ address: str(a.STD_ADDR),
+ city: str(a.STD_CITY),
+ state: str(a.STD_ST),
+ zip: zip5(a.STD_ZIP5),
+ phone: str(a.HA_PHN_NUM),
+ units: { total: num(a.TOTAL_UNITS), low_income: null },
+ extra: { section8_units: num(a.SECTION8_UNITS_CNT), email: str(a.HA_EMAIL_ADDR_TEXT), last_updated: epochIso(a.LAST_UPDT_DTTM) },
+ };
+ },
+ },
+ {
+ kind: 'counseling',
+ service: 'Housing_Counseling_Agencies_UAUC',
+ layer: 0,
+ fields: ['OBJECTID', 'AGC_NAME', 'AGC_ADDRESS', 'AGC_CITY', 'AGC_STATE', 'AGC_ZIP_CODE', 'STD_ADDR', 'STD_CITY', 'STD_ST', 'STD_ZIP5', 'AGC_PHONE_NBR', 'AGC_WEB_SITE', 'COUNSEL_SRV_DESC', 'LANGUAGE_DESC', 'LAT', 'LON'],
+ normalize(a) {
+ return {
+ id: idOf('hud-counsel', a.OBJECTID),
+ name: str(a.AGC_NAME),
+ // The agency's own address fields mix "NC" and "North Carolina"; HUD's standardised STD_* fields do not.
+ address: str(a.STD_ADDR) || str(a.AGC_ADDRESS),
+ city: str(a.STD_CITY) || str(a.AGC_CITY),
+ state: str(a.STD_ST) || str(a.AGC_STATE),
+ zip: zip5(a.STD_ZIP5) || str(a.AGC_ZIP_CODE),
+ phone: str(a.AGC_PHONE_NBR),
+ units: { total: null, low_income: null },
+ extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },
+ };
+ },
+ },
+];
+
+function haversineKm(aLat, aLon, bLat, bLon) {
+ const R = 6371.0088;
+ const toRad = (d) => d * Math.PI / 180;
+ const dLat = toRad(bLat - aLat);
+ const dLon = toRad(bLon - aLon);
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
+}
+
+const KM_PER_DEG_LAT = 111.32;
+
+/** A Nominatim boundingbox (`[south, north, west, east]`) as numbers, or null when it is unusable. */
+function parseBbox(bbox) {
+ if (!Array.isArray(bbox) || bbox.length !== 4) return null;
+ const [south, north, west, east] = bbox.map(Number);
+ if (![south, north, west, east].every(Number.isFinite)) return null;
+ if (!(north > south) || !(east > west)) return null;
+ return [south, north, west, east];
+}
+
+/**
+ * The ArcGIS envelope for a place, bounded to at most `maxKm` across. A place's own bounding box is used when it is
+ * no larger than the cap; a bigger place (or one with no usable box) is clipped to the cap around its centre.
+ */
+function envelopeFor(place, maxKm) {
+ const lat = place.lat;
+ const lon = place.lon;
+ const cosLat = Math.cos(lat * Math.PI / 180) || 0.0001;
+ const halfLat = maxKm / 2 / KM_PER_DEG_LAT;
+ const halfLon = maxKm / 2 / (KM_PER_DEG_LAT * cosLat);
+ const centred = { xmin: lon - halfLon, ymin: lat - halfLat, xmax: lon + halfLon, ymax: lat + halfLat };
+ const b = place.bbox;
+ if (b) {
+ const [south, north, west, east] = b;
+ const wKm = Math.abs(east - west) * KM_PER_DEG_LAT * cosLat;
+ const hKm = Math.abs(north - south) * KM_PER_DEG_LAT;
+ if (wKm <= maxKm && hKm <= maxKm) return { xmin: west, ymin: south, xmax: east, ymax: north };
+ }
+ return centred;
+}
+
+/** One ArcGIS feature → one normalised result, or null when it carries no usable identity or position. */
+function normalizeFeature(feature, def) {
+ const attrs = (feature && feature.attributes) || {};
+ const geom = feature && feature.geometry;
+ const base = def.normalize(attrs, geom);
+ if (!base.id) return null; // a record with no identity is skipped, exactly as the Sources adapter does
+ const lat = coord(attrs.LAT) ?? coord(geom && geom.y);
+ const lon = coord(attrs.LON) ?? coord(geom && geom.x);
+ if (lat == null || lon == null) return null;
+ return { kind: def.kind, ...base, lat, lon, source_url: SOURCE.url };
+}
+
+function toResult(feature, def, origin) {
+ const r = normalizeFeature(feature, def);
+ if (!r) return null;
+ r.distance_km = Math.round(haversineKm(origin.lat, origin.lon, r.lat, r.lon) * 10) / 10;
+ return r;
+}
+
+/** Features → results, deduplicated by identity, nearest first, at most `limit`. */
+function elementsToResults(features, def, origin, limit) {
+ const seen = new Set();
+ const out = [];
+ for (const f of features || []) {
+ const r = toResult(f, def, origin);
+ if (!r) continue;
+ if (seen.has(r.id)) continue;
+ seen.add(r.id);
+ out.push(r);
+ }
+ out.sort((a, b) => a.distance_km - b.distance_km || (a.name == null ? -1 : b.name == null ? 1 : String(a.name).localeCompare(String(b.name))));
+ return limit ? out.slice(0, limit) : out;
+}
+
+/**
+ * The place search behind both the API and the pages.
+ *
+ * { q, kinds }
+ *
+ * A name (q) is geocoded with Nominatim, once, and cached; each layer's area query is cached too. Throws
+ * UpstreamError (housing.place.invalid on bad input, housing.place.not_found when no place matches,
+ * housing.place.upstream when Nominatim or HUD cannot be reached). A place outside the United States answers
+ * `in_us: false` and no results — never an error, because the geocode itself succeeded.
+ */
+async function searchPlaces(upstream, { q = null, kinds = null } = {}, config) {
+ const raw = kinds == null || kinds === '' ? KINDS : String(kinds).split(',').map((x) => x.trim()).filter(Boolean);
+ if (!raw.length || !raw.every((k) => KINDS.includes(k))) {
+ throw new UpstreamError('housing.place.invalid', `kind: one or more of ${KINDS.join(', ')}`, { status: 422 });
+ }
+ const requested = KINDS.filter((k) => raw.includes(k));
+
+ let place = null;
+ if (q && String(q).trim()) {
+ const g = await upstream.geocode(q, { limit: 1 });
+ const hit = Array.isArray(g.body) ? g.body[0] : null;
+ if (!hit) throw new UpstreamError('housing.place.not_found', `no place called “${String(q).slice(0, 80)}”; try a city and state, or a ZIP code`, { status: 404 });
+ const la = Number(hit.lat);
+ const lo = Number(hit.lon);
+ if (!Number.isFinite(la) || !Number.isFinite(lo)) throw new UpstreamError('housing.place.upstream', 'could not reach the place service right now (no coordinates for that place)');
+ const country = hit.address && (hit.address.country_code || hit.address.country)
+ ? String(hit.address.country_code || hit.address.country).toLowerCase() : null;
+ place = { name: hit.display_name || String(q), lat: la, lon: lo, from: 'geocode', geocoded_at: g.fetched_at, country_code: country, bbox: parseBbox(hit.boundingbox) };
+ } else {
+ throw new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 });
+ }
+
+ const maxKm = (config && config.housing && config.housing.maxBoxKm) || 50;
+ const box = envelopeFor(place, maxKm);
+ const inUs = place.country_code == null || place.country_code === 'us';
+
+ const out = {
+ location: { name: place.name, lat: place.lat, lon: place.lon, from: place.from, geocoded_at: place.geocoded_at, country_code: place.country_code },
+ in_us: inUs,
+ box,
+ kinds: requested,
+ results: [],
+ per_kind: {},
+ counts: { total: 0 },
+ fetched_at: null,
+ cached: true,
+ attribution: ATTRIBUTION,
+ source: SOURCE,
+ };
+
+ if (!inUs) return out;
+
+ const cap = (config && config.housing && config.housing.maxResults) || 500;
+ let fetchedAt = null;
+ let cached = true;
+ for (const k of requested) {
+ const def = LAYERS.find((l) => l.kind === k);
+ const r = await upstream.arcGis(def.service, def.layer, box, def.fields);
+ out.per_kind[k] = elementsToResults(r.body && r.body.features, def, place, cap);
+ if (!r.cached) cached = false;
+ if (!fetchedAt || r.fetched_at > fetchedAt) fetchedAt = r.fetched_at;
+ }
+ out.results = requested.flatMap((k) => out.per_kind[k] || []);
+ out.counts.total = out.results.length;
+ for (const k of requested) out.counts[k] = (out.per_kind[k] || []).length;
+ out.fetched_at = fetchedAt;
+ out.cached = cached;
+ return out;
+}
+
+module.exports = {
+ searchPlaces, haversineKm, envelopeFor, parseBbox, normalizeFeature, toResult, elementsToResults, safeUrl,
+ LAYERS, KINDS, KIND_TEXT, KIND_DESCRIPTIONS, ATTRIBUTION, SOURCE, UpstreamError,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * What the "Affordable housing near you" feature keeps in Rent's own database (migrations/0005_housing_cache.sql):
+ * the cache in front of the two upstream services — Nominatim (a place name → coordinates) and HUD's ArcGIS
+ * FeatureServer (the five housing layers). Every function takes the store (server/db.js createStore) first; nothing
+ * here is a secret, and the cache body is only ever an upstream answer, never a person's data.
+ */
+
+const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
+
+// ── housing_geo_cache ────────────────────────────────────────
+/** A cached upstream answer that has not expired, or null. */
+async function cacheGet(s, key) {
+ const row = await s.db.maybe('SELECT body, fetched_at, expires_at FROM housing_geo_cache WHERE key = $1', [key]);
+ if (!row || row.expires_at <= s.iso()) return null;
+ return { body: parse(row.body), fetched_at: row.fetched_at, expires_at: row.expires_at };
+}
+async function cachePut(s, key, body, ttlMs) {
+ const fetchedAt = s.iso();
+ const expiresAt = new Date(s.now() + ttlMs).toISOString();
+ await s.db.query(
+ `INSERT INTO housing_geo_cache (key, body, fetched_at, expires_at) VALUES ($1, $2::jsonb, $3, $4)
+ ON CONFLICT (key) DO UPDATE SET body = EXCLUDED.body, fetched_at = EXCLUDED.fetched_at, expires_at = EXCLUDED.expires_at`,
+ [key, JSON.stringify(body), fetchedAt, expiresAt]);
+ return { fetched_at: fetchedAt, expires_at: expiresAt };
+}
+/** Drop expired rows (called at boot; the table stays small because every answer is bounded). */
+const cachePrune = (s) => s.db.exec('DELETE FROM housing_geo_cache WHERE expires_at <= $1', [s.iso()]);
+
+/**
+ * Prune the geo cache now, then hourly: an expired row is ignored anyway, this only keeps the table small. The
+ * interval is unref'd so it never holds the process open; the returned timer is what the entry point clears on
+ * shutdown (server/index.js).
+ */
+function startCachePrune(s, { intervalMs = 60 * 60 * 1000, log = console } = {}) {
+ const tick = () => Promise.resolve(cachePrune(s)).catch((err) => log.warn('[OpenVibe.Rent] housing cache prune:', err && err.message));
+ tick();
+ const timer = setInterval(tick, intervalMs);
+ if (typeof timer.unref === 'function') timer.unref();
+ return timer;
+}
+
+module.exports = { cacheGet, cachePut, cachePrune, startCachePrune };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+
+/**
+ * The only two hosts OpenVibe.Rent fetches from for "Affordable housing near you": Nominatim (a place name →
+ * coordinates and a bounding box) and HUD's ArcGIS FeatureServer (the five housing layers). Both are open data with
+ * published usage policies, and this module is what keeps us inside them:
+ *
+ * - identify every request carries RENT_USER_AGENT (an OpenVibe.Rent contact URL), never a browser's
+ * - pace one request a second to a host, at most: calls to the same host queue behind each other, so the
+ * service as a whole cannot burst at Nominatim however many visitors arrive at once
+ * - cache every answer is kept in housing_geo_cache for RENT_HOUSING_CACHE_TTL_MS (7 days): a place is
+ * geocoded once, an area is queried once, not once per visitor
+ * - bound area queries are one envelope of at most about 50 km across, with only the fields we use
+ *
+ * Only a URL built from the configured base is ever fetched; nothing a caller sends becomes a URL. There are no
+ * other outbound calls in this service.
+ */
+
+const crypto = require('crypto');
+const geo = require('./store');
+
+// An ArcGIS answer for one layer is at most 500 features; keep the byte ceiling above any honest answer but far
+// below anything that would let a misbehaving upstream exhaust the process.
+const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
+
+class UpstreamError extends Error {
+ constructor(code, detail, { status = 502 } = {}) { super(detail); this.code = code; this.status = status; }
+}
+
+/** Serialises calls to one host and keeps at least `minIntervalMs` between the moments they start. */
+function createPacer({ minIntervalMs, now = () => Date.now() }) {
+ let chain = Promise.resolve();
+ let last = -Infinity;
+ return {
+ run(fn) {
+ const p = chain.then(async () => {
+ const wait = last + minIntervalMs - now();
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
+ last = now();
+ return fn();
+ });
+ chain = p.then(() => undefined, () => undefined);
+ return p;
+ },
+ };
+}
+
+const keyOf = (q) => String(q).trim().toLowerCase().replace(/\s+/g, ' ');
+
+/**
+ * The query URL for one HUD ArcGIS layer, built only from the configured base and fixed field names (never from
+ * caller input): an envelope in WGS84, the fields we use, up to 500 records, geometry returned so a missing LAT/LON
+ * attribute still gives a point. Exported so a test can assert the exact shape.
+ */
+function buildArcGisUrl(baseUrl, service, layer, envelope, outFields) {
+ const url = new URL(baseUrl);
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/${service}/FeatureServer/${layer}/query`;
+ url.search = new URLSearchParams({
+ where: '1=1',
+ geometry: JSON.stringify({
+ xmin: envelope.xmin, ymin: envelope.ymin, xmax: envelope.xmax, ymax: envelope.ymax,
+ spatialReference: { wkid: 4326 },
+ }),
+ geometryType: 'esriGeometryEnvelope',
+ inSR: '4326',
+ spatialRel: 'esriSpatialRelIntersects',
+ outFields: outFields.join(','),
+ outSR: '4326',
+ returnGeometry: 'true',
+ resultRecordCount: '500',
+ f: 'json',
+ }).toString();
+ return url;
+}
+
+function createUpstream({ config, s, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ const housing = config.housing;
+ const pacers = new Map();
+ const pacerFor = (rawUrl) => {
+ const host = new URL(rawUrl).host;
+ if (!pacers.has(host)) pacers.set(host, createPacer({ minIntervalMs: housing.minIntervalMs, now }));
+ return pacers.get(host);
+ };
+
+ /**
+ * Fetch a URL built from the configured base (never from caller input), with our User-Agent, a hard timeout and a
+ * hard byte ceiling. `label` is what the failure message names: Nominatim for a geocode, HUD for a layer query.
+ */
+ async function fetchJson(url, init = {}, label = 'the data service') {
+ let res;
+ try {
+ res = await fetchImpl(url.toString(), {
+ ...init,
+ headers: { 'User-Agent': housing.userAgent, Accept: 'application/json', ...(init.headers || {}) },
+ signal: AbortSignal.timeout(housing.timeoutMs),
+ });
+ } catch (err) {
+ throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${err && err.name === 'TimeoutError' ? 'timed out' : 'network error'})`);
+ }
+ if (!res.ok) throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${label} answered ${res.status})`);
+ let buf;
+ try { buf = await res.arrayBuffer(); } catch { throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (the answer could not be read)`); }
+ if (buf.byteLength > MAX_RESPONSE_BYTES) throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (the answer was too large)`);
+ let body;
+ try { body = JSON.parse(Buffer.from(buf).toString('utf8')); } catch { throw new UpstreamError('housing.place.upstream', `could not reach ${label} right now (${label} answered something that is not JSON)`); }
+ return body;
+ }
+
+ async function cached(key, fn) {
+ const hit = await geo.cacheGet(s, key);
+ if (hit) return { body: hit.body, cached: true, fetched_at: hit.fetched_at };
+ const body = await fn();
+ const written = await geo.cachePut(s, key, body, housing.cacheTtlMs);
+ return { body, cached: false, fetched_at: written.fetched_at };
+ }
+
+ /** A place name → up to `limit` matches (Nominatim search), cached for the TTL. */
+ function geocode(q, { limit = 1 } = {}) {
+ const query = String(q || '').trim().slice(0, 200);
+ if (!query) return Promise.reject(new UpstreamError('housing.place.invalid', 'q: a place to search for', { status: 422 }));
+ const url = new URL(housing.nominatimUrl);
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/search`;
+ url.search = new URLSearchParams({ q: query, format: 'jsonv2', limit: String(Math.min(Math.max(limit, 1), 5)), addressdetails: '1' }).toString();
+ return cached(`nominatim:${keyOf(query)}:${limit}`, () => pacerFor(housing.nominatimUrl).run(() => fetchJson(url, {}, 'the place service')));
+ }
+
+ /** One bounded HUD ArcGIS layer query → its features. Cached by the request, so the same area is asked once. */
+ function arcGis(service, layer, envelope, outFields) {
+ const url = buildArcGisUrl(housing.hudArcGisUrl, service, layer, envelope, outFields);
+ const key = `arcgis:${service}/${layer}:${crypto.createHash('sha256').update(url.search).digest('hex')}`;
+ return cached(key, () => pacerFor(housing.hudArcGisUrl).run(async () => {
+ const body = await fetchJson(url, {}, 'HUD');
+ if (body && body.error) {
+ const message = body.error && body.error.message ? body.error.message : 'the service refused the query';
+ throw new UpstreamError('housing.place.upstream', `could not reach HUD right now (${message})`);
+ }
+ return body;
+ }));
+ }
+
+ return { geocode, arcGis, fetchJson, buildArcGisUrl, UpstreamError };
+}
+
+module.exports = { createUpstream, createPacer, UpstreamError, buildArcGisUrl, MAX_RESPONSE_BYTES };
@@ -36,6 +36,7 @@ const listings = require('../listings/listings');
@@ -36,6 +36,7 @@ const listings = require('../listings/listings');
const store = require('../listings/store');
const store = require('../listings/store');
const service = require('../listings/service');
const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const { isStaff } = require('../listings/staff');
+const housingPlaces = require('../housing/places');
/** The listing columns a caller may set, as the validate() input names. */
/** The listing columns a caller may set, as the validate() input names. */
const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
@@ -54,6 +55,24 @@ function createApi(ctx) {
@@ -54,6 +55,24 @@ function createApi(ctx) {
// Liveness: public, and counted per caller with the default read numbers.
// Liveness: public, and counted per caller with the default read numbers.
r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
+ // ── Affordable housing near you (public) ─────────────────
+ // No token: HUD's open data is the same for everyone. It spends Nominatim's shared one-request-a-second budget, so
+ // it takes the per-address budget in ./caller-limits.js rather than the default read numbers.
+ r.get('/housing', limits.budget('rent.housing.search'), async (req, res) => {
+ const one = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
+ const out = await housingPlaces.searchPlaces(ctx.upstream, {
+ q: one(req.query.q),
+ kinds: one(req.query.kind),
+ }, config).catch((err) => {
+ if (err && err.code) { problem(req, res, err.status || 502, err.code, err.message); return null; }
+ throw err;
+ });
+ if (!out) return undefined;
+ // HUD answers are public and change slowly; a short shared cache keeps repeat searches off the upstream.
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json(out);
+ });
+
// ── Who may act ─────────────────────────────────────────
// ── Who may act ─────────────────────────────────────────
/**
/**
* A person, signed in. An app, agent or service token is refused: a listing is posted and answered by a person,
* A person, signed in. An app, agent or service token is refused: a listing is posted and answered by a person,
@@ -39,6 +39,10 @@ const BUDGETS = {
@@ -39,6 +39,10 @@ const BUDGETS = {
'rent.listing.report': { minute: 10, hour: 60 },
'rent.listing.report': { minute: 10, hour: 60 },
'rent.listing.renew': { minute: 10, hour: 60 },
'rent.listing.renew': { minute: 10, hour: 60 },
'rent.saved.create': { minute: 20, hour: 100 },
'rent.saved.create': { minute: 20, hour: 100 },
+ // "Affordable housing near you" spends Nominatim's shared one-request-a-second budget, so the route takes a
+ // per-address budget of its own (server/housing/upstream.js paces and caches; these numbers are for callers who
+ // keep asking for new places).
+ 'rent.housing.search': { minute: 10, hour: 120 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[
@@ -48,6 +48,7 @@ const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[
async function publicPages(s) {
async function publicPages(s) {
const static_ = [
const static_ = [
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },
+ { path: '/housing', changefreq: 'daily', priority: 0.8, title: 'Affordable housing near you', text: 'Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place — from U.S. Department of Housing and Urban Development open data (public domain).' },
{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },
{ path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },
{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
{ path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
// /post is a form behind sign-in and robots.txt disallows it, so it is deliberately not a sitemap page.
// /post is a form behind sign-in and robots.txt disallows it, so it is deliberately not a sitemap page.
@@ -78,16 +79,18 @@ function createDiscoveryRoutes(ctx) {
@@ -78,16 +79,18 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: DESCRIPTION,
summary: DESCRIPTION,
- details: 'Every page is server-rendered and readable without JavaScript. In this first version the listings are posted by people on this site: there are no scraped listings and no partner feeds — other sources arrive only when their terms are verified. There are no payments, no deposits and no messaging; a listing carries a way to make contact and nothing more. A listing names a city and a neighbourhood, never a street address. OpenVibe does not handle payments for listings, and the safety rule is on every listing page: never pay or send a deposit before you have seen the place and signed an agreement.',
+ details: 'Every page is server-rendered and readable without JavaScript. In this first version the listings are posted by people on this site: there are no scraped listings and no partner feeds — other sources arrive only when their terms are verified. There are no payments, no deposits and no messaging; a listing carries a way to make contact and nothing more. A listing names a city and a neighbourhood, never a street address. OpenVibe does not handle payments for listings, and the safety rule is on every listing page: never pay or send a deposit before you have seen the place and signed an agreement. Affordable housing near you comes from the U.S. Department of Housing and Urban Development open data (public domain): public housing, tax-credit and HUD-assisted properties, public housing authorities and housing counselling agencies.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },
{ title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },
+ { title: 'Affordable housing', url: abs('/housing'), note: 'public housing, LIHTC and HUD-assisted properties, and where to apply, near you' },
{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },
{ title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },
{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },
{ title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },
{ title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },
{ title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },
{ title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },
+ { title: 'Affordable housing (JSON)', url: abs('/api/v1/housing?q=washington'), note: 'the geocoded place and normalised HUD housing results near it' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
@@ -26,6 +26,7 @@ const listings = require('../listings/listings');
@@ -26,6 +26,7 @@ const listings = require('../listings/listings');
const store = require('../listings/store');
const store = require('../listings/store');
const service = require('../listings/service');
const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
const { isStaff } = require('../listings/staff');
+const housingPlaces = require('../housing/places');
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.';
@@ -165,6 +166,10 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -165,6 +166,10 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
body: html`${raw(hero)}
+<section class="sc-sec" aria-labelledby="h-housing"><h2 id="h-housing">Affordable housing near you</h2>
+<p class="sc-lede">Public housing, tax-credit (LIHTC) and HUD-assisted properties, and the authorities and counselling agencies to apply through — from U.S. HUD open data, useful before anyone has posted here.</p>
+<p><a class="sc-btn sc-primary" href="/housing">Find housing near you</a></p></section>
+
<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
<p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>
<p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>
${searchForm({}, { big: true })}</section>
${searchForm({}, { big: true })}</section>
@@ -193,6 +198,132 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m
@@ -193,6 +198,132 @@ ${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a m
});
});
});
});
+ // ── Affordable housing near you ──────────────────────────
+ // HUD's data is public domain, but the licence still asks for credit; every /housing page carries it, linked back.
+ const hudNote = (fetchedAt) => html`<p class="attribution small muted"><a href="${housingPlaces.ATTRIBUTION.url}" rel="noopener">${housingPlaces.ATTRIBUTION.text}</a>. The data covers the United States, and availability must be confirmed with the property or authority.${fetchedAt ? html` Fetched ${time(fetchedAt)}.` : ''}</p>`;
+ // HUD's data covers the United States, so distances read in miles (the API keeps distance_km).
+ const housingMi = (km) => { const mi = Number(km) * 0.621371; return `${mi < 10 ? mi.toFixed(1) : Math.round(mi)} mi`; };
+ const housingQuery = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : null);
+ const SHOWN_PER_KIND = 24;
+ const phoneText = (p) => { const d = String(p).replace(/\D/g, ''); const t = d.length === 11 && d[0] === '1' ? d.slice(1) : d; return t.length === 10 ? `(${t.slice(0, 3)}) ${t.slice(3, 6)}-${t.slice(6)}` : String(p); };
+ const titleCase = (v) => (v && v === v.toUpperCase() ? v.toLowerCase().replace(/\b([a-z])/g, (m) => m.toUpperCase()) : v);
+
+ function housingForm({ q = '', selected = new Set() } = {}) {
+ return html`<form class="search-form housing-form" method="get" action="/housing" role="search">
+<div class="field"><label for="q">Where are you?</label>
+<input id="q" name="q" type="search" value="${q}" maxlength="200" placeholder="A city, ZIP code or address in the US" autocomplete="address-level2"></div>
+<fieldset class="housing-kinds"><legend>Show</legend>
+${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="checkbox" name="kind" value="${k}"${selected.has(k) ? raw(' checked') : ''}><span>${housingPlaces.KIND_TEXT[k]}</span></label>`)}
+<p class="muted small">Leave them all unticked to see everything.</p></fieldset>
+<p><button class="sc-btn sc-primary" type="submit">Find housing</button>${q || selected.size ? html` <a class="sc-btn" href="/housing">Clear</a>` : ''}</p>
+</form>`;
+ }
+
+ const housingUnits = (r) => {
+ const bits = [];
+ if (r.units && r.units.total) bits.push(`${Number(r.units.total).toLocaleString('en-US')} home${Number(r.units.total) === 1 ? '' : 's'}`);
+ if (r.units && r.units.low_income != null && r.kind !== 'housing_authority') bits.push(`${Number(r.units.low_income).toLocaleString('en-US')} ${r.kind === 'multifamily' ? 'assisted' : 'low-income'}`);
+ if (r.extra && r.extra.section8_units) bits.push(`${Number(r.extra.section8_units).toLocaleString('en-US')} Section 8 vouchers`);
+ return bits.join(' · ') || null;
+ };
+ const housingDetails = (r) => {
+ const x = r.extra || {};
+ const out = [];
+ if (x.authority) out.push(html`Run by ${x.authority}`);
+ if (x.rent_per_month) out.push(html`Average tenant rent $${Number(x.rent_per_month).toLocaleString('en-US')} a month`);
+ if (x.year_placed) out.push(html`In service since ${x.year_placed}`);
+ if (x.property_category) out.push(html`${x.property_category}`);
+ if (x.expiration_date) out.push(html`Assistance contract runs to ${x.expiration_date}`);
+ if (x.services) out.push(html`${x.services}`);
+ if (x.languages) out.push(html`Languages: ${x.languages}`);
+ return out;
+ };
+
+ function housingRow(r) {
+ const where = [titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');
+ const tel = r.phone ? String(r.phone).replace(/[^\d+]/g, '') : null;
+ const details = housingDetails(r);
+ const units = housingUnits(r);
+ const map = r.lat != null && r.lon != null ? `https://www.openstreetmap.org/?mlat=${r.lat}&mlon=${r.lon}#map=17/${r.lat}/${r.lon}` : null;
+ return html`<li class="housing-card" id="${r.id}">
+<div class="housing-card-head"><h4>${titleCase(r.name) || '—'}</h4><span class="housing-dist">${housingMi(r.distance_km)}</span></div>
+${where ? html`<p class="housing-addr">${where}</p>` : ''}
+${units ? html`<p class="housing-units">${units}</p>` : ''}
+${details.length ? html`<ul class="housing-facts">${details.map((d) => html`<li>${d}</li>`)}</ul>` : ''}
+<p class="housing-actions">${r.phone ? html`<a href="tel:${tel}">${phoneText(r.phone)}</a>` : ''}${r.extra && r.extra.email ? html`<a href="mailto:${r.extra.email}">Email</a>` : ''}${r.extra && r.extra.website ? html`<a href="${r.extra.website}" rel="nofollow noopener external">Website</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>
+</li>`;
+ }
+
+ function housingResults(out) {
+ if (!out.results.length) return html`<p class="muted">Nothing was found in this area. Try a nearby town, or a wider place like a city.</p>`;
+ const present = out.kinds.filter((k) => (out.per_kind[k] || []).length);
+ return html`<ul class="housing-jump" aria-label="Found by kind">${present.map((k) => html`<li><a href="#h-${k}">${housingPlaces.KIND_TEXT[k]} <strong>${out.per_kind[k].length}</strong></a></li>`)}</ul>
+${present.map((k) => {
+ const rows = out.per_kind[k];
+ const first = rows.slice(0, SHOWN_PER_KIND);
+ const rest = rows.slice(SHOWN_PER_KIND);
+ return html`<section class="housing-group" aria-labelledby="h-${k}">
+<h3 id="h-${k}">${housingPlaces.KIND_TEXT[k]} <span class="muted">${rows.length}</span></h3>
+<p class="housing-explain">${housingPlaces.KIND_DESCRIPTIONS[k]}</p>
+<ul class="housing-list">${first.map(housingRow)}</ul>
+${rest.length ? html`<details class="housing-more"><summary>Show ${rest.length} more, further away</summary><ul class="housing-list">${rest.map(housingRow)}</ul></details>` : ''}
+</section>`;
+ })}`;
+ }
+
+ async function housingPage(req, res, { limited = false } = {}) {
+ const q = housingQuery(req.query.q) || '';
+ const rawKinds = [].concat(req.query.kind || []).flatMap((v) => String(v).split(','));
+ const selected = new Set(housingPlaces.KINDS.filter((k) => rawKinds.includes(k)));
+ const kindParam = [...selected].join(',');
+ let out = null;
+ let problem = null;
+ if (limited) {
+ problem = 'Too many searches from you just now. Try again in a minute.';
+ } else if (q) {
+ try {
+ out = await housingPlaces.searchPlaces(ctx.upstream, { q, kinds: kindParam || null }, config);
+ } catch (err) {
+ problem = err && err.code ? err.message : 'The housing search is unavailable right now. Try again in a few minutes.';
+ }
+ }
+ const title = out && out.location.name ? `Affordable housing near ${out.location.name.split(',').slice(0, 2).join(',')}` : 'Affordable housing near you';
+ page(req, res, {
+ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ title,
+ description: 'Public housing, tax-credit and HUD-assisted properties, and the authorities and counselling agencies to apply through, near a place you choose — from HUD open data.',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Affordable housing' }],
+ styles: [showcase.STYLESHEET], localStyles: ['css/housing.css'],
+ body: html`<h1>Affordable housing near you</h1>
+<p class="housing-lede">Public housing, tax-credit (LIHTC) and HUD-assisted homes near you, and the housing authorities and counselling agencies to apply through. From the U.S. Department of Housing and Urban Development's open data.</p>
+${housingForm({ q, selected })}
+${problem ? notice(problem, 'warn') : ''}
+${out ? html`<section class="housing-results">
+<h2 class="housing-where">${out.location.name || 'Around those coordinates'}</h2>
+${out.in_us
+ ? html`<p class="muted small">${out.counts.total} found in and around this area, nearest first. Call before you visit: waiting lists and availability change.</p>${housingResults(out)}`
+ : html`<p class="notice warn">HUD data covers the United States only.</p>`}
+</section>` : html`<p class="muted">Type a city, a ZIP code or an address above.</p>`}
+${hudNote(out ? out.fetched_at : null)}`,
+ }, limited ? 429 : 200);
+ }
+
+ const housingSearch = ctx.limits.budget('rent.housing.search');
+ r.get('/housing', (req, res) => {
+ // A page load only spends the shared Nominatim budget when it will actually search.
+ if (!housingQuery(req.query.q)) return housingPage(req, res);
+ let settled = false;
+ let retryAfter = '60';
+ const allowed = () => { if (!settled) { settled = true; housingPage(req, res); } };
+ const refused = () => {
+ if (settled) return;
+ settled = true;
+ res.set('Retry-After', retryAfter);
+ housingPage(req, res, { limited: true });
+ };
+ housingSearch(req, { statusCode: 200, setHeader: (k, v) => { if (String(k).toLowerCase() === 'retry-after') retryAfter = String(v); }, end: refused }, allowed);
+ });
+
// ── Search ───────────────────────────────────────────────
// ── Search ───────────────────────────────────────────────
r.get('/listings', async (req, res) => {
r.get('/listings', async (req, res) => {
const filters = listings.normalizeFilters(req.query);
const filters = listings.normalizeFilters(req.query);
@@ -42,7 +42,7 @@ async function start() {
@@ -42,7 +42,7 @@ async function start() {
// RENT_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
// RENT_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
- createLifecycle({ server, ctx, timers: [expiry.timer], extra });
+ createLifecycle({ server, ctx, timers: [expiry.timer, ctx.housingPruneTimer], extra });
return { server, ctx, expiry };
return { server, ctx, expiry };
}
}
@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// 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 NAV = [
+ { label: 'Affordable housing', href: '/housing' },
{ label: 'Listings', href: '/listings' },
{ label: 'Listings', href: '/listings' },
{ label: 'Post a listing', href: '/post' },
{ label: 'Post a listing', href: '/post' },
{ label: 'Safety', href: '/safety' },
{ label: 'Safety', href: '/safety' },
@@ -49,7 +50,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -49,7 +50,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
+ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names], localStyles [this service's own extra stylesheets under public/, e.g. css/housing.css]
*/
*/
function renderPage(o) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -94,6 +95,7 @@ function renderPage(o) {
@@ -94,6 +95,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'rent' })}
${appIcon.headTags({ site: 'rent' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="rent@${esc(RELEASE)}">
<meta name="ov-boost" content="rent@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -21,7 +21,7 @@ const READ = 'rent.api.read';
@@ -21,7 +21,7 @@ const READ = 'rent.api.read';
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
- assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);
+ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.housing.search', 'rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);
for (const [name, own] of Object.entries(BUDGETS)) {
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "objectIdFieldName": "OBJECTID",
+ "geometryType": "esriGeometryPoint",
+ "features": [
+ {
+ "attributes": {
+ "OBJECTID": 400,
+ "AGC_NAME": "INLAND FAIR HOUSING AND MEDIATION BOARD",
+ "AGC_ADDRESS": "1500 S Haven Ave Ste 100",
+ "AGC_CITY": "Ontario",
+ "AGC_STATE": "California",
+ "AGC_ZIP_CODE": "91761-2970",
+ "AGC_PHONE_NBR": "909-984-2254",
+ "AGC_WEB_SITE": "http://www.ifhmb.com",
+ "COUNSEL_SRV_DESC": "Mortgage Delinquency and Default Resolution Counseling",
+ "LAT": 34.04625,
+ "LON": -117.576187
+ },
+ "geometry": { "x": -117.576187, "y": 34.04625 }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "objectIdFieldName": "OBJECTID",
+ "geometryType": "esriGeometryPoint",
+ "features": [
+ {
+ "attributes": {
+ "OBJECTID": 300,
+ "FORMAL_PARTICIPANT_NAME": "Springfield Housing Authority",
+ "STD_ADDR": "80 Main St",
+ "STD_CITY": "Springfield",
+ "STD_ST": "VT",
+ "STD_ZIP5": "05156",
+ "TOTAL_UNITS": 0,
+ "SECTION8_UNITS_CNT": 196,
+ "HA_PHN_NUM": "8028854905",
+ "HA_EMAIL_ADDR_TEXT": "[email protected]",
+ "LAST_UPDT_DTTM": 1783361504000
+ },
+ "geometry": { "x": -72.4807, "y": 43.2984 }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "objectIdFieldName": "OBJECTID",
+ "geometryType": "esriGeometryPoint",
+ "features": [
+ {
+ "attributes": {
+ "OBJECTID": 100,
+ "HUD_ID": "DCB20152001",
+ "PROJECT": "BRIGHTWOOD COMMUNITIES",
+ "PROJ_ADD": "5805 14TH STREET, NW",
+ "PROJ_CTY": "WASHINGTON",
+ "PROJ_ST": "DC",
+ "PROJ_ZIP": "20011",
+ "N_UNITS": 141,
+ "LI_UNITS": 127,
+ "YR_PIS": "2015",
+ "YR_ALLOC": "9999",
+ "TRGT_POP": "1",
+ "LAT": 38.960404,
+ "LON": -77.033167
+ },
+ "geometry": { "x": -77.033167, "y": 38.960404 }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "objectIdFieldName": "OBJECTID",
+ "geometryType": "esriGeometryPoint",
+ "features": [
+ {
+ "attributes": {
+ "OBJECTID": 200,
+ "PROPERTY_ID": "800022227",
+ "PROPERTY_NAME_TEXT": "Graystone Village",
+ "ADDRESS_LINE1_TEXT": "2 Dewitt Dr",
+ "STD_CITY": "White River Junction",
+ "STD_ST": "VT",
+ "STD_ZIP5": "05001",
+ "TOTAL_ASSISTED_UNIT_COUNT": 34,
+ "TOTAL_UNIT_COUNT": 34,
+ "PROPERTY_CATEGORY_NAME": "Insured-Subsidized",
+ "EXPIRATION_DATE1": "19-SEP-39",
+ "LAST_UPDT_DTTM": 1783361442000,
+ "LAT": 43.645232,
+ "LON": -72.335933
+ },
+ "geometry": { "x": -72.335933, "y": 43.645232 }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+[
+ {
+ "place_id": 2,
+ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",
+ "lat": "43.6532",
+ "lon": "-79.3832",
+ "display_name": "Toronto, Ontario, Canada",
+ "boundingbox": ["43.5810", "43.8554", "-79.6393", "-79.1158"],
+ "address": {
+ "country": "Canada",
+ "country_code": "ca"
+ }
+ }
+]
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+[
+ {
+ "place_id": 3,
+ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",
+ "lat": "47.6062",
+ "lon": "-122.3321",
+ "display_name": "Seattle, King County, Washington, United States",
+ "boundingbox": ["47.4955", "47.7341", "-122.4359", "-122.2363"],
+ "address": {
+ "country": "United States",
+ "country_code": "us"
+ }
+ }
+]
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+[
+ {
+ "place_id": 1,
+ "licence": "Data © OpenStreetMap contributors, ODbL 1.0",
+ "lat": "38.9072",
+ "lon": "-77.0369",
+ "display_name": "Washington, District of Columbia, United States",
+ "boundingbox": ["38.7916", "38.9959", "-77.1199", "-76.9094"],
+ "address": {
+ "country": "United States",
+ "country_code": "us"
+ }
+ }
+]
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+{
+ "objectIdFieldName": "OBJECTID",
+ "geometryType": "esriGeometryPoint",
+ "features": [
+ {
+ "attributes": {
+ "OBJECTID": 7685,
+ "PROJECT_NAME": "WHEELER CREEK",
+ "STD_ADDR": "900 Varney St SE",
+ "STD_CITY": "Washington",
+ "STD_ST": "DC",
+ "STD_ZIP5": "20032",
+ "TOTAL_DWELLING_UNITS": 100,
+ "TOTAL_UNITS": 100,
+ "RENT_PER_MONTH": 411,
+ "LAST_UPDT_DTTM": 1783361963000,
+ "LAT": 38.835227,
+ "LON": -76.992088,
+ "HA_PHN_NUM": "2025351500",
+ "FORMAL_PARTICIPANT_NAME": "D.C Housing Authority"
+ },
+ "geometry": { "x": -76.992088, "y": 38.835227 }
+ }
+ ]
+}
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -70,6 +70,7 @@ async function boot(opts = {}) {
@@ -70,6 +70,7 @@ async function boot(opts = {}) {
logs: () => captured.join('\n'),
logs: () => captured.join('\n'),
async close() {
async close() {
built.ctx.search.stop();
built.ctx.search.stop();
+ if (built.ctx.housingPruneTimer) clearInterval(built.ctx.housingPruneTimer);
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
await testdb.close();
await testdb.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+'use strict';
+/**
+ * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the five layers, and the
+ * page and API. The upstream fetch is injected through opts.housingFetchImpl (the sandbox has no network or loopback
+ * to reach Nominatim/HUD), and the DB cache is the real migrated housing_geo_cache table.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const places = require('../server/housing/places');
+const { buildArcGisUrl } = require('../server/housing/upstream');
+
+const nominatimUs = require('./fixtures/housing/nominatim-us.json');
+const nominatimCa = require('./fixtures/housing/nominatim-ca.json');
+const nominatimSeattle = require('./fixtures/housing/nominatim-seattle.json');
+const ph = require('./fixtures/housing/public-housing.json');
+const lihtc = require('./fixtures/housing/lihtc.json');
+const mf = require('./fixtures/housing/multifamily.json');
+const pha = require('./fixtures/housing/housing-authority.json');
+const counsel = require('./fixtures/housing/counseling.json');
+
+const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } });
+const def = (kind) => places.LAYERS.find((l) => l.kind === kind);
+const norm = (kind, fixture) => places.normalizeFeature(fixture.features[0], def(kind));
+
+let failHud = false;
+const calls = [];
+const mockFetch = async (url) => {
+ const u = new URL(String(url));
+ calls.push(u.toString());
+ if (u.pathname.endsWith('/search')) {
+ const q = u.searchParams.get('q') || '';
+ if (/fail/i.test(q)) return jsonResponse({ error: 'boom' }, 500);
+ if (/seattle/i.test(q)) return jsonResponse(nominatimSeattle);
+ return jsonResponse(/toronto|montreal/i.test(q) ? nominatimCa : nominatimUs);
+ }
+ if (u.pathname.includes('/query')) {
+ if (failHud) return jsonResponse({ error: { message: 'the service is unavailable' } }, 500);
+ if (u.pathname.includes('Public_Housing_Developments')) return jsonResponse(ph);
+ if (u.pathname.includes('/LIHTC/')) return jsonResponse(lihtc);
+ if (u.pathname.includes('MULTIFAMILY_PROPERTIES_ASSISTED')) return jsonResponse(mf);
+ if (u.pathname.includes('Public_Housing_Authorities')) return jsonResponse(pha);
+ if (u.pathname.includes('Housing_Counseling_Agencies')) return jsonResponse(counsel);
+ }
+ return jsonResponse({ features: [] });
+};
+
+(async () => {
+ // ── Unit: geocoding and bounding-box clipping ─────────────
+ await check('bounding-box: a small place keeps its own box', () => {
+ const box = places.envelopeFor({ lat: 38.9, lon: -77.0, bbox: [38.8, 39.0, -77.1, -76.9] }, 50);
+ assert.deepStrictEqual(box, { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0 });
+ });
+
+ await check('bounding-box: a bigger place is clipped to ~50 km around its centre', () => {
+ // A country-sized box must be ignored in favour of a 50 km box centred on the place.
+ const box = places.envelopeFor({ lat: 39.0, lon: -98.0, bbox: [25.0, 49.0, -124.0, -67.0] }, 50);
+ const half = 25 / 111.32;
+ assert.ok(box.ymin < 39.0 && box.ymax > 39.0, 'latitude is centred on the place');
+ assert.ok(box.xmin < -98.0 && box.xmax > -98.0, 'longitude is centred on the place');
+ assert.ok(Math.abs((box.ymax - box.ymin) / 2 - half) < 0.001, 'the box is at most 50 km across');
+ });
+
+ await check('bounding-box: no usable box gives a 50 km box around the point', () => {
+ const box = places.envelopeFor({ lat: 38.9, lon: -77.0, bbox: null }, 50);
+ const half = 25 / 111.32;
+ assert.ok(box.ymin < 38.9 && box.ymax > 38.9);
+ assert.ok(Math.abs((box.ymax - box.ymin) / 2 - half) < 0.001);
+ });
+
+ // ── Unit: the ArcGIS query URL ────────────────────────────
+ await check('the ArcGIS query URL carries the layer, envelope and only the fields used', () => {
+ const base = 'https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services';
+ const url = new URL(buildArcGisUrl(base, 'Public_Housing_Developments', 0, { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0 }, ['OBJECTID', 'PROJECT_NAME', 'LAT', 'LON']));
+ assert.strictEqual(url.pathname, '/VTyQ9soqVukalItT/arcgis/rest/services/Public_Housing_Developments/FeatureServer/0/query');
+ assert.strictEqual(url.searchParams.get('where'), '1=1');
+ assert.strictEqual(url.searchParams.get('geometryType'), 'esriGeometryEnvelope');
+ assert.strictEqual(url.searchParams.get('spatialRel'), 'esriSpatialRelIntersects');
+ assert.strictEqual(url.searchParams.get('inSR'), '4326');
+ assert.strictEqual(url.searchParams.get('outSR'), '4326');
+ assert.strictEqual(url.searchParams.get('returnGeometry'), 'true');
+ assert.strictEqual(url.searchParams.get('resultRecordCount'), '500');
+ assert.strictEqual(url.searchParams.get('f'), 'json');
+ assert.strictEqual(url.searchParams.get('outFields'), 'OBJECTID,PROJECT_NAME,LAT,LON');
+ assert.deepStrictEqual(JSON.parse(url.searchParams.get('geometry')), { xmin: -77.1, ymin: 38.8, xmax: -76.9, ymax: 39.0, spatialReference: { wkid: 4326 } });
+ });
+
+ // ── Unit: normalisation of each layer ─────────────────────
+ await check('normalises public housing developments', () => {
+ const r = norm('public_housing', ph);
+ assert.strictEqual(r.kind, 'public_housing');
+ assert.strictEqual(r.id, 'hud-ph-7685');
+ assert.strictEqual(r.name, 'WHEELER CREEK');
+ assert.strictEqual(r.address, '900 Varney St SE');
+ assert.strictEqual(r.city, 'Washington');
+ assert.strictEqual(r.state, 'DC');
+ assert.strictEqual(r.zip, '20032');
+ assert.strictEqual(r.lat, 38.835227);
+ assert.strictEqual(r.lon, -76.992088);
+ assert.strictEqual(r.phone, '2025351500');
+ assert.deepStrictEqual(r.units, { total: 100, low_income: null });
+ assert.strictEqual(r.extra.authority, 'D.C Housing Authority');
+ assert.strictEqual(r.extra.rent_per_month, 411);
+ assert.strictEqual(r.source_url, places.SOURCE.url);
+ });
+
+ await check('normalises LIHTC properties', () => {
+ const r = norm('lihtc', lihtc);
+ assert.strictEqual(r.kind, 'lihtc');
+ assert.strictEqual(r.id, 'hud-lihtc-DCB20152001');
+ assert.strictEqual(r.name, 'BRIGHTWOOD COMMUNITIES');
+ assert.strictEqual(r.phone, null);
+ assert.deepStrictEqual(r.units, { total: 141, low_income: 127 });
+ assert.strictEqual(r.extra.year_placed, '2015');
+ });
+
+ await check('normalises HUD-assisted multifamily properties', () => {
+ const r = norm('multifamily', mf);
+ assert.strictEqual(r.kind, 'multifamily');
+ assert.strictEqual(r.id, 'hud-mf-800022227');
+ assert.strictEqual(r.name, 'Graystone Village');
+ assert.strictEqual(r.phone, null);
+ assert.deepStrictEqual(r.units, { total: 34, low_income: 34 });
+ assert.strictEqual(r.extra.property_category, 'Insured-Subsidized');
+ assert.strictEqual(r.extra.expiration_date, '2039-09-19');
+ });
+
+ await check('normalises public housing authorities, taking the position from the geometry', () => {
+ const r = norm('housing_authority', pha);
+ assert.strictEqual(r.kind, 'housing_authority');
+ assert.strictEqual(r.id, 'hud-pha-300');
+ assert.strictEqual(r.name, 'Springfield Housing Authority');
+ assert.strictEqual(r.lat, 43.2984);
+ assert.strictEqual(r.lon, -72.4807);
+ assert.strictEqual(r.phone, '8028854905');
+ assert.deepStrictEqual(r.units, { total: 0, low_income: null });
+ assert.strictEqual(r.extra.section8_units, 196);
+ });
+
+ await check('normalises housing counselling agencies', () => {
+ const r = norm('counseling', counsel);
+ assert.strictEqual(r.kind, 'counseling');
+ assert.strictEqual(r.id, 'hud-counsel-400');
+ assert.strictEqual(r.name, 'INLAND FAIR HOUSING AND MEDIATION BOARD');
+ assert.strictEqual(r.phone, '909-984-2254');
+ assert.deepStrictEqual(r.units, { total: null, low_income: null });
+ assert.strictEqual(r.extra.services, 'Mortgage Delinquency and Default Resolution Counseling');
+ assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');
+ });
+
+ await check('a missing value stays null, never invented', () => {
+ const r = places.normalizeFeature({ attributes: { OBJECTID: 1, PROJECT_NAME: 'X', TOTAL_UNITS: 12 }, geometry: { x: -77, y: 38 } }, def('public_housing'));
+ assert.strictEqual(r.address, null);
+ assert.strictEqual(r.city, null);
+ assert.strictEqual(r.phone, null);
+ assert.strictEqual(r.extra.authority, null);
+ assert.strictEqual(r.extra.rent_per_month, null);
+ assert.strictEqual(r.extra.last_updated, null);
+ assert.deepStrictEqual(r.units, { total: 12, low_income: null });
+ });
+
+ await check('results sort nearest first', () => {
+ const features = [
+ { attributes: { OBJECTID: 1, PROJECT_NAME: 'Far', LAT: 39.0, LON: -77.0 }, geometry: { x: -77.0, y: 39.0 } },
+ { attributes: { OBJECTID: 2, PROJECT_NAME: 'Near', LAT: 38.9, LON: -77.0 }, geometry: { x: -77.0, y: 38.9 } },
+ ];
+ const results = places.elementsToResults(features, def('public_housing'), { lat: 38.9, lon: -77.0 }, 10);
+ assert.strictEqual(results[0].name, 'Near');
+ assert.strictEqual(results[1].name, 'Far');
+ assert.ok(results[0].distance_km < results[1].distance_km);
+ });
+
+ // ── Integration: the page and the API with an injected fetch ──
+ const t = await boot({ housingFetchImpl: mockFetch });
+ try {
+ await check('the API geocodes the place and returns the normalised results with attribution', async () => {
+ const r = await t.get('/api/v1/housing?q=Washington');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ const body = r.json();
+ assert.strictEqual(body.in_us, true);
+ assert.strictEqual(body.location.name, 'Washington, District of Columbia, United States');
+ assert.deepStrictEqual(body.kinds, places.KINDS);
+ assert.strictEqual(body.counts.total, 5);
+ assert.strictEqual(body.results.length, 5);
+ assert.strictEqual(body.attribution.text, places.ATTRIBUTION.text);
+ assert.ok(body.results.every((x) => typeof x.distance_km === 'number'));
+ });
+
+ await check('the cache: a second request for the same place does not fetch again', async () => {
+ const before = calls.length;
+ const r = await t.get('/api/v1/housing?q=Washington');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(calls.length, before, 'no new upstream calls on a cached answer');
+ });
+
+ await check('the page signed out shows the results, each kind\'s explanation and the attribution', async () => {
+ const r = await t.get('/housing?q=Washington');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.text, /action="\/housing"/);
+ assert.ok(r.text.includes('Wheeler Creek'), 'a public housing result is on the page');
+ assert.ok(r.text.includes('Brightwood Communities'), 'a LIHTC result is on the page');
+ assert.ok(r.text.includes('Springfield Housing Authority'), 'a housing authority is on the page');
+ assert.match(r.text, /Public housing: homes owned by a local housing authority/);
+ assert.ok(r.text.includes('Source: U.S. Department of Housing and Urban Development (HUD), public domain'));
+ assert.ok(r.text.includes('https://hudgis-hud.opendata.arcgis.com/'));
+ assert.ok(r.text.includes('covers the United States'));
+ assert.ok(r.text.includes('availability must be confirmed'));
+ });
+
+ await check('a place outside the United States answers the honest US-only state, never a fetch to HUD', async () => {
+ const before = calls.length;
+ const api = await t.get('/api/v1/housing?q=Toronto');
+ assert.strictEqual(api.status, 200);
+ const body = api.json();
+ assert.strictEqual(body.in_us, false);
+ assert.deepStrictEqual(body.results, []);
+ assert.strictEqual(body.counts.total, 0);
+ assert.ok(!calls.slice(before).some((u) => u.includes('/query')), 'no HUD query for a non-US place');
+
+ const page = await t.get('/housing?q=Toronto');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('HUD data covers the United States only'));
+ });
+
+ await check('an upstream failure is a clear state, never a 500', async () => {
+ const api = await t.get('/api/v1/housing?q=fail');
+ assert.strictEqual(api.status, 502);
+ assert.strictEqual(api.json().code, 'housing.place.upstream');
+
+ const page = await t.get('/housing?q=fail');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('could not reach the place service right now'));
+ });
+
+ await check('a HUD failure says it could not reach HUD right now', async () => {
+ failHud = true;
+ try {
+ const page = await t.get('/housing?q=Seattle');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('could not reach HUD right now'));
+ } finally {
+ failHud = false;
+ }
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });