openvibers/openvibe.rent Commits

Rent: affordable housing near you, from HUD open data (#27)

OP OpenVibers committed 12 Oct 2026 · 6491df3a3c · 25 files, +1233 −16

Rent becomes useful before anyone posts a listing: **affordable housing near you**, from the U.S. Department of Housing and Urban Development's open data (public domain, credited on every page).

- **Five HUD layers, queried live by area:** public housing developments, tax-credit (LIHTC) properties, HUD-assisted multifamily, public housing authorities, and housing counselling agencies. Each record is normalised to name, address, homes and low-income or assisted homes, phone, authority and facts. A missing value stays empty.
- **Two upstream hosts only**, copied from Food's pattern:
  - Nominatim, for the place you type;
  - HUD's ArcGIS FeatureServer, queried with a bounded envelope and at most 500 records per layer.
  - Both are paced, with timeouts, a size ceiling, and a 7-day `housing_geo_cache` (migration 0005). An outage shows "could not reach HUD right now", never a 500. A place outside the US says that HUD covers the United States only.
- **Checked against live data** (Burlington VT and Baltimore MD). Fixes found on the way:
  - ZIP codes keep their leading zero (LIHTC gives 5401 for 05401);
  - counselling agencies use HUD's standardised address fields (their own mix "VT" and "Vermont");
  - HUD's "30-JUN-41" contract dates become 2041-06-30;
  - HUD's negative sentinels (-4) count as missing, never "$-4 a month";
  - coordinates keep their sign.
- **Page `/housing`:**
  - a search panel with kind chips;
  - a count per kind;
  - cards with distance in miles, address, homes, facts (average tenant rent, authority, contract end date, services, languages), and phone, email, website and map links;
  - the first 24 of each kind shown, the rest behind "Show N more, further away".
  - Styles are in `public/css/housing.css`, loaded only there.
- **API `GET /api/v1/housing?q=&kind=`:** public, with a per-address budget that protects Nominatim. Listed in the discovery files.
- The home page and nav lead to it. README covers config, hosts and licence.

Tests: `test/housing.test.js` with fixture responses; the full suite passes (20/20).

Changes

ddiff --git a/README.md b/README.md
iindex d269776..2fafdbf 100644
---- a/README.md
++++ b/README.md
@@@ -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 ve
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.
++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 a
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
namespace 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 service
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
on the page.
@@@ -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
`/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/workflo
hidden again.
- [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.
++- [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 forwarded
request is refused.
ddiff --git a/migrations/0005_housing_cache.sql b/migrations/0005_housing_cache.sql
nnew file mode 100644
iindex 0000000..547faac
---- /dev/null
++++ b/migrations/0005_housing_cache.sql
@@@ -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);
ddiff --git a/public/css/housing.css b/public/css/housing.css
nnew file mode 100644
iindex 0000000..22d60b0
---- /dev/null
++++ b/public/css/housing.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 5a3187f..32406c8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/config.js b/server/config.js
iindex 54444b0..f3ab92b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
ddiff --git a/server/housing/places.js b/server/housing/places.js
nnew file mode 100644
iindex 0000000..c85efa9
---- /dev/null
++++ b/server/housing/places.js
@@@ -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,
++};
ddiff --git a/server/housing/store.js b/server/housing/store.js
nnew file mode 100644
iindex 0000000..1a301d2
---- /dev/null
++++ b/server/housing/store.js
@@@ -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 };
ddiff --git a/server/housing/upstream.js b/server/housing/upstream.js
nnew file mode 100644
iindex 0000000..159984f
---- /dev/null
++++ b/server/housing/upstream.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex f6bdd7f..4b7a0c7 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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,
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex ae29f09..1db44b5 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 }) {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 8e960ce..26ccabc 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a942e47..028ad0b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 8b78576..0e1b1b6 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 0b4ddc0..ba1eefb 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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>`,
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex a1c4272..cc87640 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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`);
}
ddiff --git a/test/fixtures/housing/counseling.json b/test/fixtures/housing/counseling.json
nnew file mode 100644
iindex 0000000..6be8ba8
---- /dev/null
++++ b/test/fixtures/housing/counseling.json
@@@ -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 }
++ }
++ ]
++}
ddiff --git a/test/fixtures/housing/housing-authority.json b/test/fixtures/housing/housing-authority.json
nnew file mode 100644
iindex 0000000..f5eb013
---- /dev/null
++++ b/test/fixtures/housing/housing-authority.json
@@@ -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 }
++ }
++ ]
++}
ddiff --git a/test/fixtures/housing/lihtc.json b/test/fixtures/housing/lihtc.json
nnew file mode 100644
iindex 0000000..fa77efd
---- /dev/null
++++ b/test/fixtures/housing/lihtc.json
@@@ -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 }
++ }
++ ]
++}
ddiff --git a/test/fixtures/housing/multifamily.json b/test/fixtures/housing/multifamily.json
nnew file mode 100644
iindex 0000000..c7cc909
---- /dev/null
++++ b/test/fixtures/housing/multifamily.json
@@@ -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 }
++ }
++ ]
++}
ddiff --git a/test/fixtures/housing/nominatim-ca.json b/test/fixtures/housing/nominatim-ca.json
nnew file mode 100644
iindex 0000000..8fc3cdb
---- /dev/null
++++ b/test/fixtures/housing/nominatim-ca.json
@@@ -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"
++ }
++ }
++]
ddiff --git a/test/fixtures/housing/nominatim-seattle.json b/test/fixtures/housing/nominatim-seattle.json
nnew file mode 100644
iindex 0000000..11582e0
---- /dev/null
++++ b/test/fixtures/housing/nominatim-seattle.json
@@@ -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"
++ }
++ }
++]
ddiff --git a/test/fixtures/housing/nominatim-us.json b/test/fixtures/housing/nominatim-us.json
nnew file mode 100644
iindex 0000000..704016f
---- /dev/null
++++ b/test/fixtures/housing/nominatim-us.json
@@@ -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"
++ }
++ }
++]
ddiff --git a/test/fixtures/housing/public-housing.json b/test/fixtures/housing/public-housing.json
nnew file mode 100644
iindex 0000000..310be18
---- /dev/null
++++ b/test/fixtures/housing/public-housing.json
@@@ -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 }
++ }
++ ]
++}
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 2b83336..195f4a3 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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();
ddiff --git a/test/housing.test.js b/test/housing.test.js
nnew file mode 100644
iindex 0000000..b1d2403
---- /dev/null
++++ b/test/housing.test.js
@@@ -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); });