openvibers/openvibe.rent Commits

Rent: HUD homes for sale near you (HUD's SF_REO layer, checked against HUD Homestore) (#29)

OP OpenVibers committed 12 Oct 2026 · cdfdafa22d · 4 files, +53 −7

A sixth kind on "Affordable housing near you": **HUD homes for sale**, from HUD's `SF_REO` layer on the same ArcGIS service (public domain). It lists 622 FHA homes HUD took back and sells through HUD Homestore.

- **Fields:** the padded street fields are trimmed, and the numeric ZIP keeps its leading zero. Each card shows the HUD case number, the month HUD took the home back, and a "Check it on HUD Homestore" link (its city search; HUD publishes no per-case link).
- **Freshness:** HUD's layer is a March 2026 snapshot, so the kind's description says a home may have sold since and asks people to confirm it on HUD Homestore.
- **Found by research:** a Command Code research job checked the alternatives. HUD Homestore and GSA disallow robots, the VA, Fannie Mae and Freddie Mac sites forbid republishing, and USDA's endpoints are broken. `SF_REO` is the one listings source that qualifies.
- **Verified live:** Gadsden, AL returns three HUD homes alongside the other five kinds.

Tests: a normalisation case in `test/housing.test.js`; the full suite passes (20/20).

Changes

ddiff --git a/README.md b/README.md
iindex 2fafdbf..710cf89 100644
---- a/README.md
++++ b/README.md
@@@ -85,7 +85,7 @@ namespace is `rent.*`.
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
++Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the six housing layers) — paced and cached in
[server/housing/upstream.js](server/housing/upstream.js).
## Capabilities
@@@ -108,7 +108,7 @@ Nominatim (a place name → coordinates) and HUD's ArcGIS FeatureServer (the fiv
| 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. |
--| 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. |
++| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — 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. |
@@@ -215,7 +215,7 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
- [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
++ geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all six 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`
ddiff --git a/server/housing/places.js b/server/housing/places.js
iindex c85efa9..f17f6db 100644
---- a/server/housing/places.js
++++ b/server/housing/places.js
@@@ -27,13 +27,14 @@ const SOURCE = {
fetched_note: 'fetched by OpenVibe.Rent and cached for 7 days',
};
--const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling'];
++const KINDS = ['public_housing', 'lihtc', 'multifamily', 'housing_authority', 'counseling', 'hud_home'];
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',
++ hud_home: 'HUD homes for sale',
};
const KIND_DESCRIPTIONS = {
public_housing: 'Public housing: homes owned by a local housing authority, rent based on income.',
@@@ -41,6 +42,7 @@ const KIND_DESCRIPTIONS = {
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.',
++ hud_home: 'HUD homes for sale: houses HUD took back after an FHA loan, sold through HUD Homestore. From HUD\'s published snapshot (March 2026), so a home may have sold since: confirm it on HUD Homestore before you plan around it.',
};
// Counts, rents and coordinates-free numbers: HUD marks a missing or suppressed value with a negative sentinel (-1, -4,
@@@ -192,6 +194,33 @@ const LAYERS = [
extra: { website: safeUrl(a.AGC_WEB_SITE), services: str(a.COUNSEL_SRV_DESC), languages: str(a.LANGUAGE_DESC) },
};
},
++ }, {
++ kind: 'hud_home',
++ service: 'SF_REO',
++ layer: 0,
++ fields: ['OBJECTID', 'CASE_NUM', 'STREET_NUM', 'DIRECTION_PREFIX', 'STREET_NAME', 'ADDRESS', 'CITY', 'STATE_CODE', 'DISPLAY_ZIP_CODE', 'DATE_ACQUIRED', 'DATE_CLOSED', 'MAP_LATITUDE', 'MAP_LONGITUDE'],
++ normalize(a) {
++ // The street fields are padded; ADDRESS already joins number, prefix and street when it is set.
++ const street = str(a.ADDRESS) || [str(a.STREET_NUM), str(a.DIRECTION_PREFIX), str(a.STREET_NAME)].filter(Boolean).join(' ') || null;
++ const city = str(a.CITY);
++ const state = str(a.STATE_CODE);
++ return {
++ id: idOf('hud-reo', a.CASE_NUM, a.OBJECTID),
++ name: street,
++ address: street,
++ city,
++ state,
++ zip: zip5(a.DISPLAY_ZIP_CODE),
++ phone: null,
++ units: { total: null, low_income: null },
++ extra: {
++ case_number: str(a.CASE_NUM),
++ acquired: epochIso(a.DATE_ACQUIRED),
++ // HUD Homestore has no per-case link HUD publishes; its city search finds the home by its case number.
++ homestore_url: city && state ? `https://www.hudhomestore.gov/searchresult?citystate=${encodeURIComponent(`${city}, ${state}`)}` : 'https://www.hudhomestore.gov/',
++ },
++ };
++ },
},
];
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 028ad0b..91265aa 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -236,11 +236,14 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
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}`);
++ if (x.case_number) out.push(html`HUD case ${x.case_number}`);
++ if (x.acquired) out.push(html`Taken back by HUD in ${new Date(x.acquired).toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' })}`);
return out;
};
function housingRow(r) {
-- const where = [titleCase(r.address), titleCase(r.city), r.state, r.zip].filter(Boolean).join(', ');
++ // A HUD home is named by its street, so its address line starts at the city.
++ const where = [r.kind === 'hud_home' ? null : 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);
@@@ -250,7 +253,7 @@ ${housingPlaces.KINDS.map((k) => html`<label class="housing-kind"><input type="c
${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>
++<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>` : ''}${r.extra && r.extra.homestore_url ? html`<a href="${r.extra.homestore_url}" rel="noopener external">Check it on HUD Homestore</a>` : ''}${map ? html`<a href="${map}" rel="noopener">Map</a>` : ''}</p>
</li>`;
}
ddiff --git a/test/housing.test.js b/test/housing.test.js
iindex b1d2403..114ba9d 100644
---- a/test/housing.test.js
++++ b/test/housing.test.js
@@@ -1,6 +1,6 @@
'use strict';
/**
-- * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the five layers, and the
++ * "Affordable housing near you" — the two upstream services (Nominatim + HUD ArcGIS), the six 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.
*/
@@@ -147,6 +147,20 @@ const mockFetch = async (url) => {
assert.strictEqual(r.extra.website, 'http://www.ifhmb.com/');
});
++ await check('normalises HUD homes for sale (SF_REO): padded street fields, ZIP, case number, HUD Homestore link', () => {
++ const r = places.normalizeFeature({ attributes: { OBJECTID: 9, CASE_NUM: '011-662347', STREET_NUM: '2220 ', DIRECTION_PREFIX: null, STREET_NAME: 'E TUSCALOOSA A ', ADDRESS: '2220 E TUSCALOOSA A ', CITY: 'GADSDEN', STATE_CODE: 'AL', DISPLAY_ZIP_CODE: 5904, DATE_ACQUIRED: 1713225600000, DATE_CLOSED: null }, geometry: { x: -86.03811, y: 34.028436 } }, def('hud_home'));
++ assert.strictEqual(r.kind, 'hud_home');
++ assert.strictEqual(r.id, 'hud-reo-011-662347');
++ assert.strictEqual(r.name, '2220 E TUSCALOOSA A');
++ assert.strictEqual(r.zip, '05904', 'a numeric ZIP keeps its leading zero');
++ assert.strictEqual(r.extra.case_number, '011-662347');
++ assert.strictEqual(r.extra.acquired, '2024-04-16T00:00:00.000Z');
++ assert.strictEqual(r.extra.homestore_url, 'https://www.hudhomestore.gov/searchresult?citystate=GADSDEN%2C%20AL');
++ assert.deepStrictEqual([r.lat, r.lon], [34.028436, -86.03811]);
++ assert.ok(places.KINDS.includes('hud_home'));
++ assert.match(places.KIND_DESCRIPTIONS.hud_home, /snapshot \(March 2026\)/);
++ });
++
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);