openvibers/openvibe.rent README.md
README.md
| 1 | # OpenVibe.Rent |
| 2 | |
| 3 | > Every rental site, one search. |
| 4 | |
| 5 | **Live site:** https://openvibe.rent · **API docs:** https://openvibe.services · **For AI:** https://openvibe.rent/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** a rental aggregator. **Search everywhere** (`/everywhere`) opens every major rental site's own search for one |
| 9 | place and set of filters. People post listings here too, anyone searches them, and three reports hide one until staff |
| 10 | look. "Affordable housing near you" shows HUD's public-domain housing data (public housing, LIHTC, HUD-assisted multifamily, |
| 11 | public housing authorities and housing counselling agencies) near a place a visitor types, with FHFA home-price context beside it. |
| 12 | **Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT` |
| 13 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 14 | |
| 15 | |
| 16 | **Rent by city** (`/in`, `/in/<state>`, `/in/<state>/<city>`, `/fair`; [server/http/city-pages.js](server/http/city-pages.js), |
| 17 | [server/aggregate/cities.js](server/aggregate/cities.js)): a page for every US place of 50,000 people or more and each |
| 18 | state's largest (885, all 50 states, DC and Puerto Rico) with the median gross rent by number of bedrooms and its margin |
| 19 | of error, every rental site's search for that city, affordable housing, homes for sale and nearby cities; state pages |
| 20 | and an index; and **Is this rent fair?**, which places a quoted rent against the city's median for that size (within the |
| 21 | margin is typical; far below warns of scams). A listing posted here in a listed US city shows the same line. All of them |
| 22 | are in the sitemap and llms.txt, and each city page is an OpenVibe.Search document (type `city`, id `<state>-<slug>`, |
| 23 | sent by the search sweep; contracts 0.197.0). The data is `server/data/cities.json`, built by `node tools/build-cities.js <dir>` from the |
| 24 | Census Bureau's keyless ACS 5-year table files and gazetteer (see the script for the URLs); nothing is fetched at run time. |
| 25 | Beside the Census medians, each city page shows **HUD's Fair Market Rents** for the city's FMR area (a studio to four |
| 26 | bedrooms, rent plus essential utilities, what Housing Choice Vouchers are measured against), labelled with the |
| 27 | percentile and the date Sources read the figure, and the fair check names the one for that size. A listing posted here |
| 28 | in a listed US city shows the asking rent, the Census median and HUD's FMR as separate labelled lines, never averaged. |
| 29 | Rent reads them from OpenVibe.Sources (`hud-fair-market-rents`, category `housing`, |
| 30 | `sources.item.read`), never from HUD ([server/housing/fair-market-rents.js](server/housing/fair-market-rents.js)); which |
| 31 | area a city lies in is `server/data/city-fmr-areas.json`, built once by `node tools/build-fmr-areas.js` from each city's |
| 32 | Census point on HUD's own FMR layer. |
| 33 | |
| 34 | Each city page also draws a **BLS CPI rent trend line** for the last 13 months, with **month on month** and **year on |
| 35 | year** as two separate fields (each naming the months it compares; a missing comparison reads "not measured yet"), and |
| 36 | the fair check repeats the 12-month figure for the city. |
| 37 | The national line is always shown; a city BLS publishes a metro rent series for gets its metro line too. Rent |
| 38 | reads it from OpenVibe.Sources (`bls-cpi-rent`, category `housing`; the national series and the 19 largest metro |
| 39 | series), never from BLS ([server/housing/rent-cpi.js](server/housing/rent-cpi.js), `rent_cpi_points`), with BLS's |
| 40 | citation and its note that it cannot vouch for data once they leave BLS.gov. |
| 41 | |
| 42 | **OpenVibe.Homes** (since 2026-10-15, https://openvibe.homes): the buying side, served by this process for its own host |
| 43 | (`server/homes`, `deploy/nginx/openvibe.homes.conf`). |
| 44 | - **Search:** one place opens every for-sale site's own search (Zillow, Realtor.com, Redfin by ZIP, Homes.com, Trulia, |
| 45 | Craigslist), the government and bank homes (HUD Homestore, Fannie Mae HomePath, Auction.com), land (LandWatch) and |
| 46 | commercial property (LoopNet). |
| 47 | - **Around the place:** the state's county records (NETR Online's directory) and the FHFA price index for the area. |
| 48 | - **Mortgage calculator:** server-side arithmetic, nothing saved. |
| 49 | - **Homes by city** (`/in`, `/in/<state>`, `/in/<state>/<city>`; `server/homes/city-pages.js`): the median home value |
| 50 | (Census ACS B25077, with its margin; the $2,000,001 top code reads "$2,000,000 or more") for the same 885 cities as |
| 51 | Rent by city, the FHFA trend for the area, the calculator prefilled with the median price (the rate stays the |
| 52 | person's), what renting there costs with a link to the Rent city page (and back), every for-sale site, the county |
| 53 | records and nearby cities. In the Homes sitemap and llms.txt, and OpenVibe.Search documents (type `homes_city`, sent |
| 54 | by the same sweep through a second search feed on the Homes origin; contracts 0.198.0). |
| 55 | - **Links back to Rent:** renting and the board stay on OpenVibe.Rent. |
| 56 | ## What it is |
| 57 | |
| 58 | The rental aggregator the owner asked for (2026-09-28): one consumer-friendly place that combines every rental site, |
| 59 | from apartments to commercial space and much more. |
| 60 | |
| 61 | **Search everywhere** (`/everywhere`, `GET /api/v1/everywhere`, [server/aggregate/everywhere.js](server/aggregate/everywhere.js)) |
| 62 | takes one place (a city and state, or a ZIP code) and what you are looking for, and opens each site's own search there, |
| 63 | with the price and bedroom filters where the site takes them: |
| 64 | |
| 65 | - **Apartments and houses:** Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe, |
| 66 | Apartment List, PadMapper, HotPads, Trulia and Realtor.com. |
| 67 | - **Rooms:** Craigslist, Facebook, Zumper, Roomies and Furnished Finder. |
| 68 | - **Furnished and monthly stays:** Craigslist sublets, Furnished Finder and Airbnb monthly. |
| 69 | - **Commercial space:** Craigslist and LoopNet. |
| 70 | - **Parking:** Craigslist and SpotHero. |
| 71 | - **Storage:** Craigslist and SpareFoot. |
| 72 | - **Cars:** Turo. |
| 73 | |
| 74 | Every URL form was opened in a real browser for several cities, including one-word and multi-word names, on |
| 75 | 2026-10-15 (`test/everywhere.test.js` pins them). The listings, messages and payments stay on each site; the person |
| 76 | sees them in their own browser and account. |
| 77 | |
| 78 | This is the permitted way to aggregate sites whose terms forbid automated collection, as the owner decided on |
| 79 | 2026-09-28 (deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting). |
| 80 | **The board** (`/board`) keeps what you find there: the listings from any site in one OpenVibe list, with your notes |
| 81 | and how far you got. The [OpenVibe Companion](https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest) browser |
| 82 | extension keeps a listing on the board from the page you are on (its *Keep on my Rent board* button opens |
| 83 | `/board?url=&title=`). Next: licensed listing feeds through OpenVibe.Sources. |
| 84 | |
| 85 | The United States comes first. A place elsewhere gets the sites that work worldwide. |
| 86 | |
| 87 | People also **post listings here** (apartments, rooms, houses, commercial space, parking and storage, equipment), and |
| 88 | each one is written by the person who posted it. |
| 89 | |
| 90 | Separately, the site is useful before anyone has posted anything: **"Affordable housing near you"** (`/housing`) shows |
| 91 | public housing, tax-credit (LIHTC) and HUD-assisted multifamily properties, public housing authorities and housing |
| 92 | counselling agencies near a place a visitor types, from the U.S. Department of Housing and Urban Development's open |
| 93 | data (public domain). It is the same "near you" pattern as OpenVibe.Food's food banks, but from HUD's ArcGIS instead |
| 94 | of OpenStreetMap. Beside those results, a small card shows what is happening to home prices there, from the FHFA House |
| 95 | Price Index — a repeat-sales index, not a price. |
| 96 | |
| 97 | Three rules shape the product: |
| 98 | |
| 99 | - **No payments, no deposits, no messaging.** A listing is a description, a price with a currency and a period, and a |
| 100 | way to make contact. OpenVibe handles no money for listings, and there is no inbox here. |
| 101 | - **Never a street address.** A listing names a city, and a neighbourhood if the poster gave one. A house number |
| 102 | together with a street is refused when the listing is posted or edited, in the form and in the API alike, with a |
| 103 | message that says what to write instead. |
| 104 | - **The safety note is on every listing page**, word for word: *Never pay or send a deposit before you have seen the |
| 105 | place and signed an agreement. OpenVibe does not handle payments for listings.* |
| 106 | |
| 107 | ## Owns |
| 108 | |
| 109 | Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `NNNN_*.sql` files in |
| 110 | [migrations/](migrations/) are applied at boot. It is the authority for: |
| 111 | |
| 112 | - **`rent_listings`** — the listings people post: kind, title, description, price with currency and period, city and |
| 113 | country with the optional region, neighbourhood, bedrooms and available-from, the contact URL, the state |
| 114 | (`active`, `hidden`, `expired`, `removed`), the report count and `expires_at`. `rnt_<ULID>` ids |
| 115 | ([migrations/0002_rent.sql](migrations/0002_rent.sql)). |
| 116 | - **`rent_reports`** — one report per person per listing (unique index `rent_reports_once`), what hides a listing at |
| 117 | three. `rpt_<ULID>` ids. |
| 118 | - **`rent_listing_checks`** (0008) — the latest AI screen of each listing (below): the content hash it read, `allow` or |
| 119 | `review`, the signs and the quoted words, and which member of staff marked it reviewed. It goes with its listing. |
| 120 | - **`rent_board_items`** (0009) — the board: the listings a person keeps from any site, each with its link on that site, |
| 121 | the site's host, a title, an optional price with currency and period, place and notes, and how far they got |
| 122 | (`interested`, `contacted`, `viewing`, `applied`, `declined`, `rented`). One row per person per link, at most 500. |
| 123 | `rbi_<ULID>` ids. |
| 124 | - **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "new |
| 125 | since you last looked" count. `ssv_<ULID>` ids. |
| 126 | - **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a |
| 127 | redelivery changes nothing ([migrations/0003_account_data.sql](migrations/0003_account_data.sql)). |
| 128 | - **`housing_geo_cache`** — the cache in front of the two "Affordable housing near you" upstream services: every |
| 129 | Nominatim geocode and HUD ArcGIS query answer, kept for `RENT_HOUSING_CACHE_TTL_MS` (7 days), so a place is |
| 130 | geocoded once and an area is asked once, not once per visitor |
| 131 | ([migrations/0005_housing_cache.sql](migrations/0005_housing_cache.sql)). |
| 132 | - **`area_price_index`** — the FHFA House Price Index rows behind "Home prices around here": the last six years of |
| 133 | traditional, all-transactions, quarterly State and MSA index values, replaced whole on each refresh |
| 134 | ([migrations/0006_area_price_index.sql](migrations/0006_area_price_index.sql)). |
| 135 | - **`rent_fmr_areas`**, **`rent_ingest_cursor`** (0010) — HUD's Fair Market Rents as OpenVibe.Sources holds them, one row |
| 136 | per FMR area (HUD's code and name, the five monthly rents), and the cursor in Sources' change feed; an area Sources |
| 137 | removes is deleted. Public domain; nothing about a person |
| 138 | ([migrations/0010_fair_market_rents.sql](migrations/0010_fair_market_rents.sql)). |
| 139 | - **`rent_cpi_points`** (0011) — BLS CPI rent of primary residence, one row per series and month, for the national |
| 140 | and 19 metro trend lines; a point Sources removes is deleted. Public domain; nothing about a person |
| 141 | ([migrations/0011_rent_cpi.sql](migrations/0011_rent_cpi.sql)). |
| 142 | - **`telemetry_samples`** — request telemetry (`platform.telemetry-sample@1`): each request by route template, method |
| 143 | and status class, never the raw URL. Once a minute one `http.request` sample per key, plus the active-requests, |
| 144 | p95 latency and event-loop-lag gauges, is written; rows over 14 days old are deleted daily. `/shared` files, |
| 145 | static assets, health, readiness and `/metrics` are not counted; wired in [server/app.js](server/app.js), |
| 146 | [server/index.js](server/index.js) ([migrations/0007_telemetry.sql](migrations/0007_telemetry.sql)). |
| 147 | |
| 148 | `migrations/0001_initial.sql` creates no tables (the skeleton). Every row keys a person as `user:usr_…`, and the |
| 149 | namespace is `rent.*`. |
| 150 | |
| 151 | ## Does not own |
| 152 | |
| 153 | - **Accounts, sign-in and roles** — a person's account and the `role` claim that decides staff are |
| 154 | OpenVibe.Network's. Rent holds no password, profile or role row: it reads the Network session |
| 155 | ([server/auth/sso.js](server/auth/sso.js)) and reads the role claim ([server/listings/staff.js](server/listings/staff.js)). |
| 156 | - **The shared site frame** — the navbar, footer and boost shell are OpenVibe.Network's; Rent renders the page body |
| 157 | ([server/render/layout.js](server/render/layout.js)). |
| 158 | - **Event delivery and subscriptions** — the queue, retries and subscription records are OpenVibe.Events'; Rent only |
| 159 | consumes two topics ([server/events-consumer.js](server/events-consumer.js)). |
| 160 | - **Files and media** — uploaded files and images are OpenVibe.Media's. Rent stores no upload, serves no user file, |
| 161 | and a listing carries no image; its contact URL is stored as text and never fetched. |
| 162 | |
| 163 | ## Depends on |
| 164 | |
| 165 | - **OpenVibe.Network** — OAuth 2 authorization code with PKCE (`/oauth/authorize`, `/oauth/token`, `/oauth/revoke`) |
| 166 | and its JWKS (the signing key). Env: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL` (default `http://127.0.0.1:4000`), |
| 167 | `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID` (`rent`), `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, |
| 168 | `RENT_AUDIENCE`, `OV_SESSION_AUDIENCE` ([server/auth/sso.js](server/auth/sso.js), [server/auth/keys.js](server/auth/keys.js)). |
| 169 | - **OpenVibe.Events** — the two ADR-033 subscriptions and their delivery. Env: `RENT_EVENTS_URL` (or `EVENTS_URL`), |
| 170 | `RENT_EVENTS_SECRET`, `RENT_EVENTS_ENDPOINT`, `RENT_EVENTS_SUBSCRIBE` ([server/events-consumer.js](server/events-consumer.js)). |
| 171 | - **PostgreSQL** — the store, through `openvibe-sdk/db`. Env: `DATABASE_URL` (serving, through PgBouncer) and |
| 172 | `DATABASE_DIRECT_URL` (migrations, owner role); `RENT_PGLITE_DIR` in development ([server/db.js](server/db.js)). |
| 173 | - **Valkey** — shared per-caller limit counters (optional). Env: `VALKEY_URL`, `VALKEY_PREFIX` |
| 174 | ([server/http/caller-limits.js](server/http/caller-limits.js)). |
| 175 | - **Packages** — `openvibe-contracts` (ids, error envelope, capability check) and `openvibe-sdk` (`db`, `auth`, |
| 176 | `service`, `limits`, `account-data`, `events`, `valkey`), plus `openvibe-shared` (`release`, `metrics`, `ready`, |
| 177 | `legal`, `cache-policy`, `serve`, `frame`, `shell`, `seo`, `app-icon`). |
| 178 | |
| 179 | For listings it fetches from no source and no third party: the only outbound calls are the Network and Events above. |
| 180 | The "Affordable housing near you" feature adds three more outbound hosts, all open data with published usage policies — |
| 181 | Nominatim (a place name → coordinates), HUD's ArcGIS FeatureServer (the six housing layers) and the FHFA House Price |
| 182 | Index master CSV — paced and cached in [server/housing/upstream.js](server/housing/upstream.js) and streamed into |
| 183 | [server/housing/hpi.js](server/housing/hpi.js). |
| 184 | |
| 185 | ## Capabilities |
| 186 | |
| 187 | - **Declared:** none. The service manifest lists an empty `capabilities` array, and the service's own list is empty |
| 188 | ([server/http/principal.js](server/http/principal.js)) — no route names a `rent.*` capability. |
| 189 | - **Called on another service:** none. Rent uses OpenVibe.Network's OAuth endpoints and OpenVibe.Events' subscription |
| 190 | API directly with its own service token (scope `events.subscription.manage`), not a capability-guarded route. |
| 191 | - **Namespace owned:** `rent.*`. |
| 192 | - **Events:** produces none; consumes `network.account.export_requested` and `network.account.deleted`. |
| 193 | |
| 194 | ## What works |
| 195 | |
| 196 | | Piece | Where | What it does | |
| 197 | |---|---|---| |
| 198 | | Posting rules | [server/listings/listings.js](server/listings/listings.js), [service.js](server/listings/service.js) | A signed-in **person** posts (never an app, agent or service): at most 10 active listings and 5 new ones a day. Kind, title ≤120, plain-text description ≤4000, price with an ISO 4217 currency from a short list, period, city, ISO 3166-1 alpha-2 country, optional region/neighbourhood/bedrooms/available-from, and an optional https:// or mailto: contact link. | |
| 199 | | Expiry | [server/listings/expiry.js](server/listings/expiry.js), [store.js](server/listings/store.js) | A listing lasts 30 days. A timer (started by [server/index.js](server/index.js), never by a request) marks the ones that are up expired; an expired listing leaves search and its owner can renew it for 30 more days from now. | |
| 200 | | Search | [server/listings/store.js](server/listings/store.js), [server/http/pages.js](server/http/pages.js) | `/listings?q=&kind=&city=&country=&min=&max=&period=`, newest first, 30 a page with a cursor, and facets for kind and country (each counted without its own filter). A filter we do not know is ignored, never an error. | |
| 201 | | One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. | |
| 202 | | Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500): the listing page has a one-tap **Report this listing** to the staff queue, with the detailed reason form folded under it. **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. The page says plainly that OpenVibe takes no payment from renter or landlord. | |
| 203 | | 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. | |
| 204 | | AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words, and the listing's own page shows the check result, its date and the quoted words in a reader's terms; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). | |
| 205 | | 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. | |
| 206 | | The board | [server/board/board.js](server/board/board.js), [server/http/board-pages.js](server/http/board-pages.js), `/board` | Keep a listing from any site with its link, title, price, place and notes, and move it along as you go (interested, contacted, viewing booked, applied, rented, not for me). The same link twice is one item; at most 500. A bookmarklet (shown as text to copy) opens `/board?url=&title=` from the listing you are on, with the site's name taken off the page title. Rent stores what you send and never fetches the listing: it, the messages and any payment stay on its site. | |
| 207 | | Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. | |
| 208 | | 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. | |
| 209 | | Home prices | [server/housing/hpi.js](server/housing/hpi.js) | A small card on `/housing` shows the FHFA House Price Index change over the past year and five years for the matching MSA (or the state), as a repeat-sales index — never a price — with the source linked back. | |
| 210 | | Pages | [server/http/pages.js](server/http/pages.js) | Home, `/everywhere`, `/board`, `/housing`, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. | |
| 211 | | Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/board`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`; with `INDEXNOW_KEY`, each listing page Search gains or changes is sent to IndexNow (through `openvibe-publishing/search-feed`). | |
| 212 | | 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. | |
| 213 | | Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing, saving a search and changing the board. The page form and the API route take the same budget, so the form is not a way round a limit. | |
| 214 | | Store | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`, `NNNN_*.sql` applied at boot, PGlite in development and in tests. | |
| 215 | |
| 216 | ## API |
| 217 | |
| 218 | Everything under `/api/v1`. A **person** is a Network token as a Bearer, or this site's session cookie; a write made |
| 219 | with the cookie must come from `openvibe.rent` itself (same-origin). An app, agent or service token cannot post, |
| 220 | edit, report or save anything here — a listing is somebody's own — so no route names a capability |
| 221 | (`CAPABILITIES` in [server/http/principal.js](server/http/principal.js) is empty on purpose). Errors are RFC 9457 |
| 222 | `application/problem+json` with a stable `code`. |
| 223 | |
| 224 | | Route | Who | | |
| 225 | |---|---|---| |
| 226 | | `GET /ping` | anyone | liveness | |
| 227 | | `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 | |
| 228 | | `GET /listings` | anyone | search: `q`, `kind`, `city`, `country`, `min`, `max`, `period`, `limit`, `before`; answers `listings`, `next`, `facets` | |
| 229 | | `GET /listings/:id` | anyone | one listing; `contact_url` only when the caller is a signed-in person | |
| 230 | | `POST /listings` | person | post one (201 + `Location`); 422 on a bad field, 429 on a cap | |
| 231 | | `PATCH /listings/:id` | owner | edit it, or `{ "state": "hidden" \| "active" }` | |
| 232 | | `DELETE /listings/:id` | owner | delete it (204) | |
| 233 | | `POST /listings/:id/renew` | owner | 30 more days from now | |
| 234 | | `POST /listings/:id/reports` | person | report it (`reason`, `note`); 409 if you already have | |
| 235 | | `GET /saved-searches` | person | yours, each with `new_count` | |
| 236 | | `POST /saved-searches` | person | save the filters in the body | |
| 237 | | `DELETE /saved-searches/:id` | person | forget one (204) | |
| 238 | | `GET /board` | person | your board: `items` (newest change first), `statuses`, `max_items` | |
| 239 | | `POST /board` | person | keep one: `url` (http or https), optional `title`, `price`, `currency`, `period`, `place`, `notes`, `status`; 201 + `Location`, or 200 when that link was already there (it is updated); 409 `board.full` at 500 | |
| 240 | | `PATCH /board/:id` | person | change any of those fields; 409 `board.duplicate` if the new link is already on the board | |
| 241 | | `DELETE /board/:id` | person | forget one (204) | |
| 242 | | `GET /staff/reports` | staff | the queue, `?state=hidden\|active\|removed\|all` | |
| 243 | | `POST /listings/:id/state` | staff | `{ "state": "active" \| "hidden" \| "removed" }` | |
| 244 | |
| 245 | ## Data sources and their terms |
| 246 | |
| 247 | **Listings have no external source.** Every listing is text a person typed here, under the rules above; nothing is |
| 248 | scraped, imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored |
| 249 | as text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service |
| 250 | requests). |
| 251 | |
| 252 | **Typical rents** (Rent by city, `/fair`) come from the **U.S. Census Bureau's American Community Survey** 5-year |
| 253 | estimates, table B25031 (median gross rent by bedrooms) and B01003 (population), with place points from the Census |
| 254 | gazetteer: public domain, cited on every page with the years. A median the Census does not publish is shown as "not |
| 255 | published", and its top code is shown as "$3,500 or more", never as a number. |
| 256 | |
| 257 | **Fair Market Rents** come from **HUD** through **OpenVibe.Sources**, which reads HUD's Fair Market Rents layer once a |
| 258 | week under its robots and terms checks: public domain (US federal work), cited under every city table with a link to |
| 259 | https://www.huduser.gov/portal/datasets/fmr.html. Rent itself never calls HUD for them; the one-time area build |
| 260 | (`tools/build-fmr-areas.js`) is a developer step, about 900 point queries at most one a second. |
| 261 | |
| 262 | **Affordable housing near you** has three verified, open sources ([server/housing/upstream.js](server/housing/upstream.js) |
| 263 | and [server/housing/hpi.js](server/housing/hpi.js)): |
| 264 | |
| 265 | - **U.S. Department of Housing and Urban Development (HUD)** open data, on ArcGIS — public housing, LIHTC, HUD-assisted |
| 266 | multifamily, public housing authorities and housing counselling agencies. Public domain (USA.gov |
| 267 | [public domain label](https://www.usa.gov/government-copyright)). Attribution on every page and in every API answer: |
| 268 | *"Source: U.S. Department of Housing and Urban Development (HUD), public domain"*, linked to |
| 269 | https://hudgis-hud.opendata.arcgis.com/. The data covers the United States, and availability must be confirmed with |
| 270 | the property or authority. |
| 271 | - **Nominatim** (OpenStreetMap) — the place name → coordinates and bounding box for the search box only; the housing |
| 272 | results themselves come from HUD. One request a second at most, with a descriptive `RENT_USER_AGENT`. |
| 273 | - **FHFA House Price Index** (U.S. Federal Housing Finance Agency) — the `hpi_master.csv` file, streamed line by line |
| 274 | and kept for the last six years; public domain (U.S. government work). The card says it is a repeat-sales index, not |
| 275 | a price, and links to https://www.fhfa.gov/data/hpi. |
| 276 | |
| 277 | The only other external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its |
| 278 | JWKS), with its terms as the network's own service. When listing sources are added they will be named here with their |
| 279 | terms, and each listing will carry its provenance (source name, a link back, and `fetched_at`) both in the API and |
| 280 | on the page. |
| 281 | |
| 282 | ## Configuration |
| 283 | |
| 284 | See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `rent` OAuth client on the |
| 285 | Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; |
| 286 | the Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on |
| 287 | `/api/ready`). |
| 288 | |
| 289 | The listing product adds no environment variables of its own beyond the OpenVibe.Events ones below: its numbers (10 |
| 290 | active listings, 5 a day, 30 days, 3 reports, 30 a page, the per-caller budgets) are constants next to the rules they |
| 291 | belong to, not deployment settings. "Affordable housing near you" adds the following (all optional, with safe |
| 292 | defaults): |
| 293 | |
| 294 | - `RENT_NOMINATIM_URL` — `https://nominatim.openstreetmap.org` |
| 295 | - `RENT_HUD_ARCGIS_URL` — `https://services.arcgis.com/VTyQ9soqVukalItT/arcgis/rest/services` |
| 296 | - `RENT_USER_AGENT` — `OpenVibeRent/0.1 (+BASE_URL)`; a descriptive contact, never a browser's |
| 297 | - `RENT_HOUSING_CACHE_TTL_MS` — `604800000` (7 days) |
| 298 | - `RENT_HOUSING_MIN_INTERVAL_MS` — `1000` (one request a second to a host) |
| 299 | - `RENT_HOUSING_TIMEOUT_MS` — `25000` |
| 300 | - `RENT_HPI` — on in production, off elsewhere; turns the FHFA House Price Index ingest and card on |
| 301 | - `RENT_HPI_URL` — `https://www.fhfa.gov/hpi/download/monthly/hpi_master.csv` |
| 302 | - `RENT_HPI_TIMEOUT_MS` — `60000` |
| 303 | - `RENT_HPI_REFRESH_TTL_MS` — `604800000` (one fetch at most every 7 days) |
| 304 | |
| 305 | ## Development and tests |
| 306 | |
| 307 | ```bash |
| 308 | npm install |
| 309 | fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network |
| 310 | fnm exec --using=22 npm run dev # http://localhost:5010 |
| 311 | ``` |
| 312 | |
| 313 | The suite covers the posting rules (sign-in, the caps, the street-address refusal, currency and country validation), |
| 314 | search and paging and facets, the contact link being hidden from signed-out viewers (page and API), a report |
| 315 | hiding a listing at 3 and staff restoring it, expiry, owner-only edits, same-origin writes, saved searches with |
| 316 | their new counts, and hostile text staying text everywhere it is shown. `npm run test:pg` runs the same suite |
| 317 | through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)). |
| 318 | |
| 319 | ## Acceptance |
| 320 | |
| 321 | `npm test` (`node test/run.js`) runs every `test/*.test.js` against a temporary PGlite database and a mock Network; |
| 322 | `npm run test:pg` runs the same suite through PostgreSQL and PgBouncer. The main files and what they prove: |
| 323 | |
| 324 | - [test/listings.test.js](test/listings.test.js) — posting through the API: a person only (never an app), same-origin |
| 325 | session writes, the field rules (kind, price, currency, country, period, contact URL), the street-address refusal in |
| 326 | title, description, city, region and neighbourhood, the 10-active and 5-a-day caps, 30-day expiry, owner-only |
| 327 | edit/renew/hide/delete, and hostile text staying text. |
| 328 | - [test/listings-search.test.js](test/listings-search.test.js) — the search filters, newest-first cursor paging, the |
| 329 | kind and country facets, the contact URL for a signed-in person only, hidden/expired/removed never listed, and an |
| 330 | unknown filter ignored. |
| 331 | - [test/listings-reports.test.js](test/listings-reports.test.js) — one report per person, never your own, three |
| 332 | distinct reports hide a listing, the staff queue and role claim, restore/hide/remove, and a restored listing not |
| 333 | hidden again. |
| 334 | - [test/listings-pages.test.js](test/listings-pages.test.js) — the server-rendered pages and plain form POSTs, the |
| 335 | safety note word for word, saved-search new counts, and a refused form re-rendering what was typed, escaped. |
| 336 | - [test/housing.test.js](test/housing.test.js) — "Affordable housing near you", with the upstream fetch injected: |
| 337 | geocoding and bounding-box clipping, the ArcGIS query URL, normalisation of all six HUD layers (null for a missing |
| 338 | value), distance sort, the cache (a second request does not fetch), the upstream-failure and non-US states, the page |
| 339 | signed out with attribution, and the API. |
| 340 | - [test/housing-hpi.test.js](test/housing-hpi.test.js) — the FHFA House Price Index ingest (streamed, matching rows only, |
| 341 | replaced whole), the MSA-then-state match, the percentages and the missing-quarter rule, the page card and its |
| 342 | absence without data, and the API `area_prices` field. |
| 343 | - [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events` |
| 344 | with a stand-in Network: only the person's rows, a redelivery changes nothing, and a bad signature or forwarded |
| 345 | request is refused. |
| 346 | - [test/caller-limits.test.js](test/caller-limits.test.js) — a caller past a limit gets 429 `rate_limited` with |
| 347 | `Retry-After` before the route works, per person and per address, with the window reopening; health, ready, |
| 348 | release.json and metrics are never limited. |
| 349 | - [test/security-session.test.js](test/security-session.test.js) — the `rent_at` cookie must hold a Network session |
| 350 | token; a FedCM assertion or an app/service token is not a session. |
| 351 | - [test/security-secrets.test.js](test/security-secrets.test.js) — the OAuth client secret never leaves in a response, |
| 352 | an event, a log line or the database. |
| 353 | - [test/auth-ops.test.js](test/auth-ops.test.js) — sign-in with PKCE S256, the session cookies, truthful readiness, |
| 354 | `/release.json`, loopback-only `/metrics`. |
| 355 | - [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network key is fetched and verified, cached keys survive an |
| 356 | outage, a rotation is honoured, and readiness shows the cache state. |
| 357 | - [test/discovery.test.js](test/discovery.test.js) — `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` |
| 358 | serve the public pages only. |
| 359 | - [test/open-redirect.test.js](test/open-redirect.test.js) — the sign-in `next` never leaves the site. |
| 360 | - [test/no-internal-key.test.js](test/no-internal-key.test.js) — no `X-Internal-Key` anywhere, and the running service |
| 361 | never sends one. |
| 362 | - [test/service-kit.test.js](test/service-kit.test.js) — the graceful stop runs its close steps in order and exits 0. |
| 363 | - [test/layout.test.js](test/layout.test.js), [test/asset-cache.test.js](test/asset-cache.test.js), |
| 364 | [test/nginx-auth-limit.test.js](test/nginx-auth-limit.test.js), [test/perf-budget.test.js](test/perf-budget.test.js) |
| 365 | — the shared boost marker and navbar, the asset cache headers, the nginx `/auth/me` zone, and the home-page size |
| 366 | budget. |
| 367 | |
| 368 | ## Deploy (for the lead) |
| 369 | |
| 370 | - **Deploy:** `sudo ovhost deploy rent` on the host (git checkout at `/opt/openvibe.rent`, unit |
| 371 | `openvibe-rent.service` on 127.0.0.1:5010, env `/etc/openvibe/rent.env`, database `ov_rent` on the data role). |
| 372 | - **nginx:** [deploy/nginx/openvibe.rent.conf](deploy/nginx/openvibe.rent.conf), installed with `ov-vhost-install`. |
| 373 | - **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart. |
| 374 | - Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: rent` in CI) and with |
| 375 | **OpenVibe.Services** before the first deploy. |
| 376 | |
| 377 | ## Account export and deletion |
| 378 | |
| 379 | A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its |
| 380 | part (ADR-033). Rent receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events` |
| 381 | (loopback only) — the four tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and |
| 382 | the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js): |
| 383 | |
| 384 | - **Exported:** the listings a person posted (`listings.json`), the reports they filed (`reports.json`), the searches |
| 385 | they saved (`saved_searches.json`) and their board (`board.json`), pushed to `POST /internal/account-exports/:id/parts` with this service's own |
| 386 | token. Nothing here is a secret — Rent stores no token, key or credential. |
| 387 | - **Erased:** all four tables hold the person's own rows, so they are deleted whole and nothing is kept. Rent then |
| 388 | confirms with `POST /internal/account-deletions/:id/confirmations` and the counts. |
| 389 | - **Anonymized:** nothing. Rent keeps no row by this person that another person's page must still read — the reports |
| 390 | other people left on a deleted listing cascade away with it. |
| 391 | |
| 392 | Environment: `RENT_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer |
| 393 | 503), `RENT_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and |
| 394 | `RENT_EVENTS_ENDPOINT` overrides the loopback endpoint; `RENT_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off. |
| 395 | |
| 396 | ## Security (threat notes) |
| 397 | |
| 398 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 399 | |
| 400 | - Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session. |
| 401 | - Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged. |
| 402 | - Request bodies are never logged. |
| 403 | - Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard |
| 404 | decides what may be fetched. A listing's contact URL is never fetched here at all. |
| 405 | - A listing is public text: it is escaped by the `html` template everywhere it is rendered, and a street address is |
| 406 | refused at the door. |
| 407 | |
| 408 | --- |
| 409 | |
| 410 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 411 | |
| 412 | <!-- versions:start --> |
| 413 | - openvibe-contracts: v0.419.0 |
| 414 | - openvibe-sdk: v0.161.0 |
| 415 | - openvibe-shared: v4.241.0 |
| 416 | - openvibe-publishing: v1.7.0 |
| 417 | <!-- versions:end --> |
| 418 |