openvibers/openvibe.rent Commits

Rent: the board keeps listings from any site in one list (#55)

OP OpenVibers committed 29 days ago · 9263b292ba · 15 files, +602 −50

* Rent: the navbar leads with Search everywhere

Rent's navbar now leads with what the site is: **Search everywhere** (`/everywhere`, Rent#53), then Affordable housing. "Listings" becomes **Posted here**, because the listings people post here are one source among many.

* Rent: the board keeps listings from any site in one list

Search everywhere opens every rental site; the board is where a person keeps
what they find there: the link to the listing on its own site, title, price,
place, notes, and how far they got (interested, contacted, viewing booked,
applied, rented, not for me).

- migration 0009: rent_board_items, one row per person per link, at most 500
- server/board/board.js: validation (http/https links only, no credentials,
  the site's name taken off a page title), store, wire shape
- /api/v1/board GET/POST/PATCH/DELETE, person only, budget rent.board.write
- /board pages without JavaScript: add form prefilled from ?url=&title=,
  groups by status, move/edit/remove forms, a bookmarklet shown as text
- everywhere catalog carries each site's host (siteForHost)
- account export/erase covers the board (board.json)
- robots.txt disallows /board; the navbar and /everywhere link to it
- the home's feature cards drop two that repeated sections above them, which
  brings the home back under its 8 KB brotli budget (Rent#54 went over)

Rent never fetches the listing; it, the messages and any payment stay on its
site.

Changes

ddiff --git a/README.md b/README.md
iindex a0d8eaa..3459ef5 100644
---- a/README.md
++++ b/README.md
@@@ -33,9 +33,9 @@ sees them in their own browser and account.
This is the permitted way to aggregate sites whose terms forbid automated collection, as the owner decided on
2026-09-28 (deep links out, a browser extension inside the person's own sessions, licensed APIs, people posting).
--Next:
--- a board that saves listings from any site into one OpenVibe list;
--- the OpenVibe Companion extension;
++**The board** (`/board`) keeps what you find there: the listings from any site in one OpenVibe list, with your notes
++and how far you got. Next:
++- the OpenVibe Companion extension (keep a listing to the board from the page you are on);
- licensed listing feeds through OpenVibe.Sources.
The United States comes first. A place elsewhere gets the sites that work worldwide.
@@@ -73,6 +73,10 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
three. `rpt_<ULID>` ids.
- **`rent_listing_checks`** (0008) — the latest AI screen of each listing (below): the content hash it read, `allow` or
`review`, the signs and the quoted words, and which member of staff marked it reviewed. It goes with its listing.
++- **`rent_board_items`** (0009) — the board: the listings a person keeps from any site, each with its link on that site,
++ the site's host, a title, an optional price with currency and period, place and notes, and how far they got
++ (`interested`, `contacted`, `viewing`, `applied`, `declined`, `rented`). One row per person per link, at most 500.
++ `rbi_<ULID>` ids.
- **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "new
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
@@@ -143,13 +147,14 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
| 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. |
| 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; 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). |
| 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. |
++| 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. |
| 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. |
| 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. |
| 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. |
--| 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`; with `INDEXNOW_KEY`, each listing page Search gains or changes is sent to IndexNow (through `openvibe-publishing/search-feed`). |
++| 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. |
++| 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`). |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. |
--| Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing and saving a search. The page form and the API route take the same budget, so the form is not a way round a limit. |
++| Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing, 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. |
| 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. |
## API
@@@ -174,6 +179,10 @@ edit, report or save anything here — a listing is somebody's own — so no rou
| `GET /saved-searches` | person | yours, each with `new_count` |
| `POST /saved-searches` | person | save the filters in the body |
| `DELETE /saved-searches/:id` | person | forget one (204) |
++| `GET /board` | person | your board: `items` (newest change first), `statuses`, `max_items` |
++| `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 |
++| `PATCH /board/:id` | person | change any of those fields; 409 `board.duplicate` if the new link is already on the board |
++| `DELETE /board/:id` | person | forget one (204) |
| `GET /staff/reports` | staff | the queue, `?state=hidden\|active\|removed\|all` |
| `POST /listings/:id/state` | staff | `{ "state": "active" \| "hidden" \| "removed" }` |
@@@ -303,13 +312,13 @@ through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflo
A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
part (ADR-033). Rent receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`
--(loopback only) — the three tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and
++(loopback only) — the four tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and
the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
--- **Exported:** the listings a person posted (`listings.json`), the reports they filed (`reports.json`) and the searches
-- they saved (`saved_searches.json`), pushed to `POST /internal/account-exports/:id/parts` with this service's own
++- **Exported:** the listings a person posted (`listings.json`), the reports they filed (`reports.json`), the searches
++ they saved (`saved_searches.json`) and their board (`board.json`), pushed to `POST /internal/account-exports/:id/parts` with this service's own
token. Nothing here is a secret — Rent stores no token, key or credential.
--- **Erased:** all three tables hold the person's own rows, so they are deleted whole and nothing is kept. Rent then
++- **Erased:** all four tables hold the person's own rows, so they are deleted whole and nothing is kept. Rent then
confirms with `POST /internal/account-deletions/:id/confirmations` and the counts.
- **Anonymized:** nothing. Rent keeps no row by this person that another person's page must still read — the reports
other people left on a deleted listing cascade away with it.
ddiff --git a/migrations/0009_board.sql b/migrations/0009_board.sql
nnew file mode 100644
iindex 0000000..15ab715
---- /dev/null
++++ b/migrations/0009_board.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- OpenVibe.Rent: the board (server/board/board.js). A person keeps the listings they found on any site in one
++-- OpenVibe list: the link to the listing on its own site, what they want to remember (title, price, place, notes)
++-- and how far they got (interested → contacted → viewing → applied → declined or rented). Rent never fetches the
++-- listing's page: the person gives the details, or their own browser does (the OpenVibe Companion extension, a
++-- bookmarklet). One row per person per link; it is the person's own, exported and erased with their account.
++CREATE TABLE IF NOT EXISTS rent_board_items (
++ id text COLLATE "C" PRIMARY KEY, -- rbi_<ULID>
++ subject text COLLATE "C" NOT NULL, -- user:usr_… (the person whose board it is)
++ url text NOT NULL, -- the listing on its own site (http or https)
++ site text COLLATE "C" NOT NULL, -- that site's host, e.g. craigslist.org
++ title text NOT NULL,
++ price numeric(12, 2),
++ currency text COLLATE "C",
++ period text COLLATE "C",
++ place text,
++ notes text,
++ status text COLLATE "C" NOT NULL DEFAULT 'interested'
++ CHECK (status IN ('interested', 'contacted', 'viewing', 'applied', 'declined', 'rented')),
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++CREATE INDEX IF NOT EXISTS rent_board_items_subject ON rent_board_items (subject, updated_at DESC);
++CREATE UNIQUE INDEX IF NOT EXISTS rent_board_items_once ON rent_board_items (subject, url);
ddiff --git a/public/css/board.css b/public/css/board.css
nnew file mode 100644
iindex 0000000..a07d0b4
---- /dev/null
++++ b/public/css/board.css
@@@ -0,0 +1,27 @@
++/* OpenVibe.Rent — /board only (listings kept from any site), so app.css stays inside its budget. */
++.bd-summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 4px; }
++.bd-tab { display: inline-flex; gap: 6px; align-items: baseline; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }
++.bd-tab b { font-variant-numeric: tabular-nums; color: var(--accent-text, var(--accent, #3b82f6)); }
++a.bd-tab:hover { border-color: var(--accent, #3b82f6); }
++.bd-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; }
++.bd-item { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); scroll-margin-top: 80px; }
++.bd-item:target { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 1px var(--accent, #3b82f6); }
++.bd-head { display: flex; gap: 12px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; }
++.bd-title { font-weight: 650; font-size: 1.04rem; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; min-width: 0; }
++.bd-title:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.bd-out { color: var(--text-muted, #7386a3); font-weight: 400; }
++.bd-price { white-space: nowrap; color: var(--text-primary, #e6edf7); }
++.bd-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
++.bd-notes { margin: 0; padding: 8px 10px; border-radius: 8px; background: var(--bg-secondary, #0f1729); font-size: .92rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.bd-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
++.bd-move select { max-width: 100%; }
++.bd-edit summary { cursor: pointer; font-size: .9rem; color: var(--accent-text, var(--accent, #3b82f6)); width: fit-content; }
++.bd-edit[open] summary { margin-bottom: 8px; }
++.bd-add-from { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, var(--bg-card, #131c2e)); }
++.bd-add-from h2 { margin-top: 0; }
++.bd-empty { margin: 18px 0; padding: 18px; border-radius: 14px; border: 1px dashed var(--border-light, #2c3d5c); text-align: center; }
++.bd-empty p { margin: 4px 0; }
++.bd-code { width: 100%; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .82rem; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); resize: vertical; overflow-wrap: anywhere; }
++.bd-add textarea, .bd-edit textarea { font: inherit; width: 100%; box-sizing: border-box; min-height: 84px; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); resize: vertical; }
++.bd-add textarea:focus-visible, .bd-edit textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); outline-offset: 1px; border-color: var(--accent, #3b82f6); }
++.bd-move select { font: inherit; font-size: .92rem; height: 38px; max-width: 100%; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
ddiff --git a/server/aggregate/everywhere.js b/server/aggregate/everywhere.js
iindex 1509d77..646d4f1 100644
---- a/server/aggregate/everywhere.js
++++ b/server/aggregate/everywhere.js
@@@ -47,11 +47,12 @@ const q = (params) => {
/**
* The sites. `kinds` says which searches each one answers; `build(place, f)` returns the URL for a place
* ({ city, state, country, postcode, lat, lon }) and filters ({ kind, maxPrice, minBeds }), or null when it cannot
-- * serve that place. `us: true` sites need a US city and state.
++ * serve that place. `us: true` sites need a US city and state. `host` is the site's domain (its listings may sit on a
++ * subdomain, e.g. saltlakecity.craigslist.org).
*/
const SITES = [
{
-- id: 'craigslist', name: 'Craigslist', us: true,
++ id: 'craigslist', name: 'Craigslist', host: 'craigslist.org', us: true,
kinds: ['apartment', 'house', 'room', 'stay', 'commercial', 'parking', 'storage'],
best: 'Local listings from owners and small landlords, often not anywhere else.',
build: (p, f) => {
@@@ -61,18 +62,18 @@ const SITES = [
},
},
{
-- id: 'facebook', name: 'Facebook Marketplace', us: false,
++ id: 'facebook', name: 'Facebook Marketplace', host: 'facebook.com', us: false,
kinds: ['apartment', 'house', 'room'],
best: 'Rooms, sublets and homes from people nearby; sign in to Facebook to see them all.',
build: (p) => `https://www.facebook.com/marketplace/${slug(p.city).replace(/-/g, '')}/propertyrentals`,
},
{
-- id: 'zillow', name: 'Zillow', us: true, kinds: ['apartment', 'house'],
++ id: 'zillow', name: 'Zillow', host: 'zillow.com', us: true, kinds: ['apartment', 'house'],
best: 'The largest US listing database, with a map and price history.',
build: (p, f) => `https://www.zillow.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'rent-houses' : 'rentals'}/`,
},
{
-- id: 'apartments', name: 'Apartments.com', us: true, kinds: ['apartment'],
++ id: 'apartments', name: 'Apartments.com', host: 'apartments.com', us: true, kinds: ['apartment'],
best: 'Managed apartment buildings with floor plans and availability.',
build: (p, f) => {
const parts = [f.minBeds ? `${f.minBeds}-bedrooms` : null, f.maxPrice ? `under-${f.maxPrice}` : null].filter(Boolean).join('-');
@@@ -80,77 +81,77 @@ const SITES = [
},
},
{
-- id: 'zumper', name: 'Zumper', us: true, kinds: ['apartment', 'house', 'room'],
++ id: 'zumper', name: 'Zumper', host: 'zumper.com', us: true, kinds: ['apartment', 'house', 'room'],
best: 'Fresh listings updated daily, including rooms and houses.',
build: (p, f) => `https://www.zumper.com/${{ house: 'houses-for-rent', room: 'rooms-for-rent' }[f.kind] || 'apartments-for-rent'}/${slug(p.city)}-${p.state.toLowerCase()}`,
},
{
-- id: 'rentcom', name: 'Rent.com', us: true, kinds: ['apartment', 'house'],
++ id: 'rentcom', name: 'Rent.com', host: 'rent.com', us: true, kinds: ['apartment', 'house'],
best: 'Apartments and houses with verified photos and pricing.',
build: (p, f) => (US_STATE_NAMES[p.state] ? `https://www.rent.com/${US_STATE_NAMES[p.state]}/${slug(p.city)}-${f.kind === 'house' ? 'houses' : 'apartments'}` : null),
},
{
-- id: 'rentcafe', name: 'RentCafe', us: true, kinds: ['apartment'],
++ id: 'rentcafe', name: 'RentCafe', host: 'rentcafe.com', us: true, kinds: ['apartment'],
best: 'Apartment communities with online applications.',
build: (p) => `https://www.rentcafe.com/apartments-for-rent/${slug(p.city)}-${p.state.toLowerCase()}/`,
},
{
-- id: 'apartmentlist', name: 'Apartment List', us: true, kinds: ['apartment'],
++ id: 'apartmentlist', name: 'Apartment List', host: 'apartmentlist.com', us: true, kinds: ['apartment'],
best: 'Matches you to buildings by budget and commute.',
build: (p) => `https://www.apartmentlist.com/${p.state.toLowerCase()}/${slug(p.city)}`,
},
{
-- id: 'padmapper', name: 'PadMapper', us: true, kinds: ['apartment', 'house'],
++ id: 'padmapper', name: 'PadMapper', host: 'padmapper.com', us: true, kinds: ['apartment', 'house'],
best: 'A map view across many listing sources.',
build: (p) => `https://www.padmapper.com/apartments/${slug(p.city)}-${p.state.toLowerCase()}`,
},
{
-- id: 'hotpads', name: 'HotPads', us: true, kinds: ['apartment', 'house'],
++ id: 'hotpads', name: 'HotPads', host: 'hotpads.com', us: true, kinds: ['apartment', 'house'],
best: 'Map-first search with many houses and condos.',
build: (p, f) => `https://hotpads.com/${slug(p.city)}-${p.state.toLowerCase()}/${f.kind === 'house' ? 'houses-for-rent' : 'apartments-for-rent'}`,
},
{
-- id: 'trulia', name: 'Trulia', us: true, kinds: ['apartment', 'house'],
++ id: 'trulia', name: 'Trulia', host: 'trulia.com', us: true, kinds: ['apartment', 'house'],
best: 'Neighbourhood details: commute, schools, crime.',
build: (p) => `https://www.trulia.com/for_rent/${titleSlug(p.city).replace(/-/g, '_')},${p.state}/`,
},
{
-- id: 'realtor', name: 'Realtor.com', us: true, kinds: ['apartment', 'house'],
++ id: 'realtor', name: 'Realtor.com', host: 'realtor.com', us: true, kinds: ['apartment', 'house'],
best: 'Rentals listed by agents.',
build: (p) => `https://www.realtor.com/apartments/${titleSlug(p.city)}_${p.state}`,
},
{
-- id: 'roomies', name: 'Roomies', us: true, kinds: ['room'],
++ id: 'roomies', name: 'Roomies', host: 'roomies.com', us: true, kinds: ['room'],
best: 'Rooms and roommates.',
build: (p) => `https://www.roomies.com/rooms/${slug(p.city)}-${p.state.toLowerCase()}`,
},
{
-- id: 'furnishedfinder', name: 'Furnished Finder', us: true, kinds: ['stay', 'room'],
++ id: 'furnishedfinder', name: 'Furnished Finder', host: 'furnishedfinder.com', us: true, kinds: ['stay', 'room'],
best: 'Furnished monthly rentals (made for travel nurses, open to all).',
build: (p) => `https://www.furnishedfinder.com/housing/us--${p.state.toLowerCase()}--${slug(p.city)}`,
},
{
-- id: 'airbnb', name: 'Airbnb (monthly)', us: false, kinds: ['stay'],
++ id: 'airbnb', name: 'Airbnb (monthly)', host: 'airbnb.com', us: false, kinds: ['stay'],
best: 'Furnished stays of a month or more, worldwide.',
build: (p) => `https://www.airbnb.com/s/${titleSlug(p.city)}--${p.state || titleSlug(p.countryName || '')}/homes?monthly_stay=true`,
},
{
-- id: 'loopnet', name: 'LoopNet', us: true, kinds: ['commercial'],
++ id: 'loopnet', name: 'LoopNet', host: 'loopnet.com', us: true, kinds: ['commercial'],
best: 'Office, retail and industrial space for lease.',
build: (p) => `https://www.loopnet.com/search/commercial-real-estate/${slug(p.city)}-${p.state.toLowerCase()}/for-lease/`,
},
{
-- id: 'spothero', name: 'SpotHero', us: true, kinds: ['parking'],
++ id: 'spothero', name: 'SpotHero', host: 'spothero.com', us: true, kinds: ['parking'],
best: 'Monthly and daily parking in garages and lots.',
build: (p) => (p.lat != null && p.lon != null ? `https://spothero.com/search${q({ latitude: Number(p.lat).toFixed(4), longitude: Number(p.lon).toFixed(4), kind: 'monthly' })}` : null),
},
{
-- id: 'sparefoot', name: 'SpareFoot', us: true, kinds: ['storage'],
++ id: 'sparefoot', name: 'SpareFoot', host: 'sparefoot.com', us: true, kinds: ['storage'],
best: 'Self-storage units compared by price and size.',
build: (p) => `https://www.sparefoot.com/${titleSlug(p.city)}-${p.state}-self-storage.html`,
},
{
-- id: 'turo', name: 'Turo', us: true, kinds: ['vehicle'],
++ id: 'turo', name: 'Turo', host: 'turo.com', us: true, kinds: ['vehicle'],
best: 'Cars rented from their owners.',
build: (p) => `https://turo.com/us/en/search${q({ country: 'US', location: `${p.city}, ${p.state}` })}`,
},
@@@ -177,9 +178,15 @@ function linksFor(place, { kind = 'apartment', maxPrice = null, minBeds = null }
return out;
}
++/** The catalog site a host belongs to (www. and city subdomains included), or null. */
++function siteForHost(host) {
++ const h = String(host || '').toLowerCase().replace(/\.$/, '');
++ return SITES.find((x) => h === x.host || h.endsWith(`.${x.host}`)) || null;
++}
++
function positiveInt(v, max) {
const n = Number(v);
return Number.isInteger(n) && n > 0 && n <= max ? n : null;
}
--module.exports = { KINDS, KIND_TEXT, SITES, US_STATE_NAMES, linksFor, slug, titleSlug };
++module.exports = { KINDS, KIND_TEXT, SITES, US_STATE_NAMES, linksFor, siteForHost, slug, titleSlug };
ddiff --git a/server/board/board.js b/server/board/board.js
nnew file mode 100644
iindex 0000000..f8230f4
---- /dev/null
++++ b/server/board/board.js
@@@ -0,0 +1,132 @@
++'use strict';
++
++/**
++ * The board: listings from any site, in one OpenVibe list. Search everywhere (server/aggregate/everywhere.js) opens
++ * every rental site's own search; the board is where a person keeps what they found there — the link to the listing
++ * on its own site, what they want to remember (title, price, place, notes) and how far they got.
++ *
++ * Rent never fetches the listing: the details come from the person, or from their own browser (the OpenVibe
++ * Companion extension, a bookmarklet that opens /board?url=…&title=… with the page they are on). That keeps every site
++ * in its own terms while the person gets one place for all of them (the owner's 2026-09-28 Q8).
++ *
++ * At most MAX_ITEMS per person; one row per link (saving it again updates it).
++ */
++const listings = require('../listings/listings');
++const everywhere = require('../aggregate/everywhere');
++
++const STATUSES = ['interested', 'contacted', 'viewing', 'applied', 'declined', 'rented'];
++const STATUS_TEXT = { interested: 'Interested', contacted: 'Contacted', viewing: 'Viewing booked', applied: 'Applied', declined: 'Not for me', rented: 'Rented it' };
++const MAX_ITEMS = 500;
++const ID_RE = /^rbi_[0-9A-HJKMNP-TV-Z]{26}$/;
++const LIMITS = { url: 2000, title: 200, place: 120, notes: 2000 };
++
++/** The host a link lives on, without "www."; null when it is not an http(s) URL. */
++function siteOf(url) {
++ try {
++ const u = new URL(String(url));
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
++ if (u.username || u.password) return null;
++ return u.hostname.replace(/^www\./, '').toLowerCase();
++ } catch { return null; }
++}
++
++/** A friendly name for a site: the search-everywhere catalog's name when it is one of those sites, else the host. */
++function siteName(site) {
++ const known = everywhere.siteForHost(site);
++ return known ? known.name.replace(/ \(.*\)$/, '') : String(site || '');
++}
++
++/** A page title as a browser gives it, "9 Elm St | Zillow", without the site's own name at the end. */
++function withoutSiteSuffix(title, site) {
++ const known = site ? everywhere.siteForHost(site) : null;
++ const names = [site, known && known.name.replace(/ \(.*\)$/, ''), known && known.host.split('.')[0]].filter(Boolean).map((x) => x.toLowerCase());
++ const m = /^(.{3,}?)\s+[|\-–—·:]\s+([^|\-–—·:]{1,60})$/.exec(title);
++ return m && names.some((n) => m[2].toLowerCase().includes(n)) ? m[1].trim() : title;
++}
++
++/**
++ * Check and normalise what a person sends for one item. { ok: true, item } or { ok: false, code, detail }. `partial`
++ * (an update) lets fields be left out; a full item needs a link.
++ */
++function validate(input = {}, { partial = false } = {}) {
++ const out = {};
++ if (!partial || input.url !== undefined) {
++ const url = String(input.url || '').trim();
++ if (!url) return { ok: false, code: 'board.invalid', detail: 'url: the listing\'s address on its own site' };
++ if (url.length > LIMITS.url || !siteOf(url)) return { ok: false, code: 'board.invalid', detail: 'url: an http or https address' };
++ out.url = new URL(url).href;
++ out.site = siteOf(url);
++ }
++ if (!partial || input.title !== undefined) {
++ const title = withoutSiteSuffix(listings.cleanText(input.title, LIMITS.title + 1).slice(0, LIMITS.title).trim(), out.site);
++ out.title = title || (out.site ? `A listing on ${siteName(out.site)}` : '');
++ if (partial && !title) return { ok: false, code: 'board.invalid', detail: 'title: a few words' };
++ }
++ if (input.price !== undefined) {
++ const text = String(input.price == null ? '' : input.price).replace(/[,$€£\s]/g, '');
++ if (text === '') out.price = null;
++ else {
++ const n = Number(text);
++ if (!Number.isFinite(n) || n < 0 || n > listings.MAX_PRICE) return { ok: false, code: 'board.invalid', detail: `price: a number from 0 to ${listings.MAX_PRICE}` };
++ out.price = Math.round(n * 100) / 100;
++ }
++ }
++ if (input.currency !== undefined) {
++ const c = String(input.currency || '').trim().toUpperCase();
++ if (c && !listings.isCurrency(c)) return { ok: false, code: 'board.invalid', detail: 'currency: a three-letter code such as USD' };
++ out.currency = c || null;
++ }
++ if (input.period !== undefined) {
++ const p = String(input.period || '').trim().toLowerCase();
++ if (p && !listings.PERIODS.includes(p)) return { ok: false, code: 'board.invalid', detail: `period: one of ${listings.PERIODS.join(', ')}` };
++ out.period = p || null;
++ }
++ if (input.place !== undefined) out.place = listings.cleanText(input.place, LIMITS.place + 1).slice(0, LIMITS.place).trim() || null;
++ if (input.notes !== undefined) out.notes = String(input.notes == null ? '' : input.notes).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, LIMITS.notes).trim() || null;
++ if (input.status !== undefined) {
++ const st = String(input.status || '').trim().toLowerCase();
++ if (!STATUSES.includes(st)) return { ok: false, code: 'board.invalid', detail: `status: one of ${STATUSES.join(', ')}` };
++ out.status = st;
++ }
++ if (out.price != null && !out.currency && !partial) out.currency = 'USD';
++ return { ok: true, item: out };
++}
++
++// ── store ───────────────────────────────────────────────────
++
++const COLS = ['url', 'site', 'title', 'price', 'currency', 'period', 'place', 'notes', 'status'];
++
++const list = (s, subject) => s.db.many('SELECT * FROM rent_board_items WHERE subject = $1 ORDER BY updated_at DESC, id DESC', [subject]);
++const get = (s, id) => s.db.maybe('SELECT * FROM rent_board_items WHERE id = $1', [id]);
++const count = async (s, subject) => Number(await s.db.value('SELECT count(*) FROM rent_board_items WHERE subject = $1', [subject]));
++
++/** Save a link to a person's board; the same link again updates the row. { ok, row, created } or a refusal. */
++async function save(s, subject, item) {
++ const existing = await s.db.maybe('SELECT * FROM rent_board_items WHERE subject = $1 AND url = $2', [subject, item.url]);
++ if (existing) return { ok: true, row: await update(s, existing.id, item), created: false };
++ if (await count(s, subject) >= MAX_ITEMS) return { ok: false, code: 'board.full', detail: `Your board holds ${MAX_ITEMS} listings, the most it can. Remove some you no longer need first.` };
++ const now = s.iso();
++ const row = { id: s.newId('rbi'), subject, status: 'interested', price: null, currency: null, period: null, place: null, notes: null, ...item, created_at: now, updated_at: now };
++ await s.db.query(
++ `INSERT INTO rent_board_items (id, subject, url, site, title, price, currency, period, place, notes, status, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
++ [row.id, subject, row.url, row.site, row.title, row.price, row.currency, row.period, row.place, row.notes, row.status, now, now]);
++ return { ok: true, row: await get(s, row.id), created: true };
++}
++
++async function update(s, id, patch) {
++ const cols = COLS.filter((c) => patch[c] !== undefined);
++ if (!cols.length) return get(s, id);
++ await s.db.query(`UPDATE rent_board_items SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
++ return get(s, id);
++}
++
++const remove = (s, id, subject) => s.db.exec('DELETE FROM rent_board_items WHERE id = $1 AND subject = $2', [id, subject]);
++
++const toWire = (row) => ({
++ id: row.id, url: row.url, site: row.site, site_name: siteName(row.site), title: row.title,
++ price: row.price == null ? null : Number(row.price), currency: row.currency || null, period: row.period || null,
++ place: row.place || null, notes: row.notes || null, status: row.status, created_at: row.created_at, updated_at: row.updated_at,
++});
++
++module.exports = { STATUSES, STATUS_TEXT, MAX_ITEMS, LIMITS, ID_RE, isItemId: (id) => ID_RE.test(String(id || '')), siteOf, siteName, withoutSiteSuffix, validate, list, get, count, save, update, remove, toWire };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 55b3773..43cc762 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -13,6 +13,10 @@
* GET /saved-searches person your saved searches, each with its count of new listings
* POST /saved-searches person save the current filters
* DELETE /saved-searches/:id person forget one
++ * GET /board person your board: listings you keep from any site, with how far you got
++ * POST /board person keep one (its link, title, price, place, notes); the same link again updates it
++ * PATCH /board/:id person change one (status: interested, contacted, viewing, applied, declined, rented)
++ * DELETE /board/:id person forget one
* GET /staff/reports staff the report queue
* POST /listings/:id/state staff restore (active), hide or remove
*
@@@ -36,6 +40,7 @@ const listings = require('../listings/listings');
const store = require('../listings/store');
const service = require('../listings/service');
const { isStaff } = require('../listings/staff');
++const board = require('../board/board');
const housingPlaces = require('../housing/places');
const housingHpi = require('../housing/hpi');
const everywhere = require('../aggregate/everywhere');
@@@ -309,6 +314,52 @@ function createApi(ctx) {
return res.status(204).end();
});
++ // ── The board ───────────────────────────────────────────
++ // Listings a person keeps from any site (server/board/board.js). Rent stores what the person sends and never
++ // fetches the link.
++ r.get('/board', person, limits.reads('rent.board.read'), async (req, res) => {
++ const rows = await board.list(s, req.principal.requester);
++ return res.json({ items: rows.map(board.toWire), statuses: board.STATUSES, max_items: board.MAX_ITEMS });
++ });
++
++ r.post('/board', person, limits.budget('rent.board.write'), async (req, res) => {
++ const v = board.validate(req.body || {});
++ if (!v.ok) return problem(req, res, 422, v.code, v.detail);
++ const out = await board.save(s, req.principal.requester, v.item);
++ if (!out.ok) return problem(req, res, 409, out.code, out.detail);
++ if (out.created) res.set('Location', `/api/v1/board/${out.row.id}`);
++ return res.status(out.created ? 201 : 200).json(board.toWire(out.row));
++ });
++
++ async function loadItem(req, res) {
++ const id = String(req.params.id || '');
++ const row = board.isItemId(id) ? await board.get(s, id) : null;
++ if (!row || row.subject !== req.principal.requester) {
++ problem(req, res, 404, 'board.not_found', 'No such listing on your board.');
++ return null;
++ }
++ return row;
++ }
++
++ r.patch('/board/:id', person, limits.budget('rent.board.write'), async (req, res) => {
++ const row = await loadItem(req, res);
++ if (!row) return undefined;
++ const v = board.validate(req.body || {}, { partial: true });
++ if (!v.ok) return problem(req, res, 422, v.code, v.detail);
++ if (v.item.url && v.item.url !== row.url) {
++ const clash = await s.db.maybe('SELECT id FROM rent_board_items WHERE subject = $1 AND url = $2', [row.subject, v.item.url]);
++ if (clash) return problem(req, res, 409, 'board.duplicate', 'That link is already on your board.');
++ }
++ return res.json(board.toWire(await board.update(s, row.id, v.item)));
++ });
++
++ r.delete('/board/:id', person, limits.budget('rent.board.write'), async (req, res) => {
++ const row = await loadItem(req, res);
++ if (!row) return undefined;
++ await board.remove(s, row.id, req.principal.requester);
++ return res.status(204).end();
++ });
++
// ── Staff ───────────────────────────────────────────────
r.get('/staff/reports', staff, limits.reads('rent.report.staff'), async (req, res) => {
const state = ['hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
ddiff --git a/server/http/board-pages.js b/server/http/board-pages.js
nnew file mode 100644
iindex 0000000..fbc5595
---- /dev/null
++++ b/server/http/board-pages.js
@@@ -0,0 +1,141 @@
++'use strict';
++
++/**
++ * /board — the listings a person keeps from any site (server/board/board.js), as pages that work without JavaScript:
++ * an add form (prefilled from ?url=&title= when the bookmarklet or the OpenVibe Companion extension opens it from a
++ * listing's page), the listings grouped by how far the person got, and a form per listing to move, edit or remove it.
++ *
++ * The listing's link opens its own site in a new tab; OpenVibe.Rent never fetches it. The forms take the API's
++ * budget (rent.board.write) and its same-origin rule, through pages.js's budgeted() and sameSite().
++ */
++const showcase = require('openvibe-shared/showcase');
++const { html, raw, notice, time } = require('../render/html');
++const listings = require('../listings/listings');
++const board = require('../board/board');
++
++/** The order the groups show in: what still needs doing first, the finished ones last. */
++const GROUP_ORDER = ['interested', 'contacted', 'viewing', 'applied', 'rented', 'declined'];
++const GROUP_TITLE = { interested: 'Interested', contacted: 'Contacted', viewing: 'Viewing booked', applied: 'Applied', rented: 'Rented', declined: 'Not for me' };
++
++function mountBoardPages(r, { config, s, page, signedIn, requesterOf, mustSignIn, budgeted, sameSite, signInPrompt }) {
++ const base = () => String(config.baseUrl).replace(/\/+$/, '');
++ const crumbs = [{ label: 'Home', href: '/' }, { label: 'Your board' }];
++ const str = (v, n = 2000) => (typeof v === 'string' ? v.slice(0, n) : '');
++
++ /** The bookmarklet: opens this page with the listing the person is looking at. Shown as text to copy, never as a javascript: link. */
++ const bookmarklet = () => `javascript:(()=>{open('${base()}/board?url='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title.slice(0,200)))})()`;
++
++ const priceOf = (row) => (row.price == null ? null : listings.priceText({ price: row.price, currency: row.currency || 'USD', period: row.period || 'month' }));
++ const periodOptions = (selected) => listings.PERIODS.map((p) => html`<option value="${p}"${p === (selected || 'month') ? raw(' selected') : ''}>${listings.PERIOD_TEXT[p]}</option>`);
++
++ /** The add form, and the edit form inside each listing: the same fields, `id` keeps their labels apart. */
++ function fields(v = {}, id = 'bd') {
++ return html`<div class="field"><label for="${id}-url">Link to the listing</label>
++<input id="${id}-url" name="url" type="url" inputmode="url" required maxlength="${board.LIMITS.url}" placeholder="https://…" value="${v.url || ''}"></div>
++<div class="field"><label for="${id}-title">Title</label>
++<input id="${id}-title" name="title" type="text" maxlength="${board.LIMITS.title}" placeholder="2-bed near the park, the one with the yard" value="${v.title || ''}"></div>
++<div class="field-row">
++<div class="field"><label for="${id}-price">Price</label><input id="${id}-price" name="price" type="text" inputmode="decimal" maxlength="16" placeholder="1450" value="${v.price == null ? '' : v.price}"></div>
++<div class="field"><label for="${id}-period">Per</label><select id="${id}-period" name="period">${periodOptions(v.period)}</select></div>
++</div>
++<div class="field"><label for="${id}-place">Where</label><input id="${id}-place" name="place" type="text" maxlength="${board.LIMITS.place}" placeholder="Neighbourhood or city" value="${v.place || ''}"></div>
++<div class="field"><label for="${id}-notes">Notes</label>
++<textarea id="${id}-notes" name="notes" rows="3" maxlength="${board.LIMITS.notes}" placeholder="Who you talked to, when you can see it, what to ask">${v.notes || ''}</textarea></div>`;
++ }
++
++ function addForm(v, { fromPage = false } = {}) {
++ return html`<section class="sc-sec bd-add${fromPage ? ' bd-add-from' : ''}" aria-labelledby="h-add">
++<h2 id="h-add">${fromPage ? 'Keep this listing' : 'Keep a listing'}</h2>
++${fromPage ? html`<p class="muted">From the page you were on${v.site ? html` on <b>${board.siteName(v.site)}</b>` : ''}. Add what you want to remember, then keep it.</p>` : html`<p class="muted">Paste the link to a listing on any site: Craigslist, Facebook Marketplace, Zillow, Apartments.com or anywhere else.</p>`}
++<form class="search-form" method="post" action="/board">
++${fields(v)}
++<p><button class="sc-btn sc-primary" type="submit">Keep it on my board</button>${fromPage ? html` <a class="sc-btn" href="/board">Not now</a>` : ''}</p>
++</form></section>`;
++ }
++
++ function item(row) {
++ const price = priceOf(row);
++ const statusId = `st-${row.id}`;
++ return html`<li class="bd-item" id="${row.id}">
++<div class="bd-head"><a class="bd-title" href="${row.url}" target="_blank" rel="noopener nofollow ugc">${row.title}<span class="bd-out" aria-hidden="true"> ↗</span></a>${price ? html`<b class="bd-price">${price}</b>` : ''}</div>
++<div class="bd-meta"><span class="chip">${board.siteName(row.site)}</span>${row.place ? html`<span class="chip">${row.place}</span>` : ''}<span class="small muted">kept ${time(row.created_at)}${row.updated_at !== row.created_at ? html` · changed ${time(row.updated_at)}` : ''}</span></div>
++${row.notes ? html`<p class="txt bd-notes">${row.notes}</p>` : ''}
++<div class="bd-actions">
++<form class="inline bd-move" method="post" action="/board/${row.id}"><label class="sr-only" for="${statusId}">Where you are with it</label><select id="${statusId}" name="status">${board.STATUSES.map((st) => html`<option value="${st}"${st === row.status ? raw(' selected') : ''}>${board.STATUS_TEXT[st]}</option>`)}</select> <button class="sc-btn" type="submit">Move</button></form>
++<form class="inline" method="post" action="/board/${row.id}/delete"><button class="sc-btn" type="submit">Remove</button></form>
++</div>
++<details class="bd-edit"><summary>Edit the details</summary>
++<form class="search-form" method="post" action="/board/${row.id}">${fields({ ...row, price: row.price == null ? '' : Number(row.price) }, `ed-${row.id}`)}
++<p><button class="sc-btn sc-primary" type="submit">Save</button></p></form></details>
++</li>`;
++ }
++
++ async function boardPage(req, res, { values = null, error = null, status = 200 } = {}) {
++ const fromQuery = req.method === 'GET' && str(req.query.url) ? { url: str(req.query.url), title: str(req.query.title, 200), price: str(req.query.price, 16), place: str(req.query.place, 120) } : null;
++ if (fromQuery) fromQuery.site = board.siteOf(fromQuery.url);
++ if (!signedIn(req)) {
++ return page(req, res, {
++ title: 'Your board', crumbs, styles: [showcase.STYLESHEET], localStyles: ['css/board.css'],
++ body: html`<h1>Your board</h1>
++<p class="sc-lede">Keep the listings you find on any site in one list, with your notes and how far you got: interested, contacted, viewing booked, applied.</p>
++${signInPrompt(fromQuery ? 'Sign in to keep this listing on your board.' : 'Your board belongs to your OpenVibe account.', req.originalUrl.slice(0, 1500))}`,
++ }, 401);
++ }
++ const rows = await board.list(s, requesterOf(req));
++ const groups = GROUP_ORDER.map((st) => ({ st, rows: rows.filter((x) => x.status === st) })).filter((g) => g.rows.length);
++ const v = values || fromQuery || {};
++ return page(req, res, {
++ title: 'Your board', crumbs, styles: [showcase.STYLESHEET], localStyles: ['css/board.css'],
++ body: html`<h1>Your board</h1>
++<p class="sc-lede">The listings you found on any site, in one list: the link to each on its own site, your notes and how far you got. The listing, the messages and any payment stay on that site.</p>
++${error ? notice(error, 'warn') : ''}
++${fromQuery || error ? addForm(v, { fromPage: Boolean(fromQuery) }) : ''}
++${rows.length ? html`<nav class="bd-summary" aria-label="Your board by status">${groups.map((g) => html`<a class="bd-tab" href="#g-${g.st}">${GROUP_TITLE[g.st]} <b>${g.rows.length}</b></a>`)}</nav>
++${groups.map((g) => html`<section class="sc-sec" aria-labelledby="g-${g.st}"><h2 id="g-${g.st}">${GROUP_TITLE[g.st]} <span class="muted">${g.rows.length}</span></h2>
++<ul class="bd-list">${g.rows.map(item)}</ul></section>`)}
++<p class="small muted">${rows.length} of the ${board.MAX_ITEMS} listings a board holds.</p>`
++ : html`<div class="bd-empty"><p><b>Nothing on your board yet.</b></p><p class="muted"><a href="/everywhere">Search every rental site</a>, open a listing on its own site, and keep it here with its link.</p></div>`}
++${fromQuery || error ? '' : addForm(v)}
++<section class="sc-sec" aria-labelledby="h-bm"><h2 id="h-bm">Keep listings from any site in one click</h2>
++<p>Make a new bookmark in your browser and paste this as its address (URL). On a listing's page, open the bookmark: this page opens with the listing's link and title filled in.</p>
++<label class="sr-only" for="bd-bookmarklet">The bookmark's address</label>
++<textarea id="bd-bookmarklet" class="bd-code" rows="3" readonly spellcheck="false">${bookmarklet()}</textarea>
++<p class="small muted">It sends only the page's address and title to ${base().replace(/^https?:\/\//, '')}, and only when you open it.</p></section>`,
++ }, status);
++ }
++
++ r.get('/board', (req, res) => boardPage(req, res));
++
++ r.post('/board', mustSignIn('/board'), budgeted('rent.board.write'), async (req, res) => {
++ if (!sameSite(req, res, '/board')) return undefined;
++ const v = board.validate(req.body || {});
++ if (!v.ok) return boardPage(req, res, { values: req.body || {}, error: v.detail, status: 422 });
++ const out = await board.save(s, requesterOf(req), v.item);
++ if (!out.ok) return boardPage(req, res, { values: req.body || {}, error: out.detail, status: 409 });
++ return res.redirect(303, `/board#${out.row.id}`);
++ });
++
++ r.post('/board/:id', mustSignIn('/board'), budgeted('rent.board.write'), async (req, res) => {
++ if (!sameSite(req, res, '/board')) return undefined;
++ const id = String(req.params.id || '');
++ const row = board.isItemId(id) ? await board.get(s, id) : null;
++ if (!row || row.subject !== requesterOf(req)) return res.redirect(303, '/board');
++ const body = req.body || {};
++ const v = board.validate(body, { partial: true });
++ if (!v.ok) return boardPage(req, res, { error: v.detail, status: 422 });
++ if (v.item.url && v.item.url !== row.url && await s.db.maybe('SELECT id FROM rent_board_items WHERE subject = $1 AND url = $2', [row.subject, v.item.url])) {
++ return boardPage(req, res, { error: 'That link is already on your board.', status: 409 });
++ }
++ await board.update(s, row.id, v.item);
++ return res.redirect(303, `/board#${row.id}`);
++ });
++
++ r.post('/board/:id/delete', mustSignIn('/board'), budgeted('rent.board.write'), async (req, res) => {
++ if (!sameSite(req, res, '/board')) return undefined;
++ const id = String(req.params.id || '');
++ if (board.isItemId(id)) await board.remove(s, id, requesterOf(req));
++ return res.redirect(303, '/board');
++ });
++}
++
++module.exports = { mountBoardPages, GROUP_ORDER };
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 1db44b5..03fc8f3 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -39,6 +39,8 @@ const BUDGETS = {
'rent.listing.report': { minute: 10, hour: 60 },
'rent.listing.renew': { minute: 10, hour: 60 },
'rent.saved.create': { minute: 20, hour: 100 },
++ // Keeping listings from other sites on the board (server/board/board.js): adding, updating and removing them.
++ 'rent.board.write': { minute: 30, hour: 300 },
// "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).
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 9bf52c3..118fa28 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -20,7 +20,7 @@ const listings = require('../listings/listings');
const SITE_NAME = 'OpenVibe.Rent';
const DESCRIPTION = 'OpenVibe.Rent — one place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; no payments are handled here.';
--const DISALLOW = ['/auth/', '/api/', '/post', '/mine', '/saved', '/staff'];
++const DISALLOW = ['/auth/', '/api/', '/post', '/mine', '/saved', '/board', '/staff'];
// A sitemap floor and a ceiling that keeps llms-full.txt inside its own byte budget, so the two stay in step.
const LISTINGS_MAX = 1000;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0b44c26..7ebd033 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Public pages: home, search (/listings), one listing, /post, /mine, /saved, /safety, the staff report queue and
++ * Public pages: home, search (/listings), one listing, /post, /mine, /saved, /board (http/board-pages.js), /safety, the staff report queue and
* the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
*
* Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js):
@@@ -29,6 +29,7 @@ const { isStaff } = require('../listings/staff');
const housingPlaces = require('../housing/places');
const housingHpi = require('../housing/hpi');
const everywhere = require('../aggregate/everywhere');
++const { mountBoardPages } = require('./board-pages');
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Every rental site, one search.';
@@@ -190,9 +191,8 @@ ${raw(showcase.features({
title: 'How it works',
lede: 'One account for every rental search you make, wherever the listing lives.',
items: [
-- { icon: 'ov:search', title: 'One search, every site', text: 'Craigslist, Facebook Marketplace, Zillow, Apartments.com, Zumper, Rent.com, RentCafe, Apartment List, PadMapper, HotPads, Trulia, Realtor.com, Roomies, Furnished Finder, Airbnb, LoopNet, SpotHero, SpareFoot and Turo, opened at your place.', href: '/everywhere' },
-- { icon: 'ov:map', title: 'Affordable housing', text: 'Public housing, tax-credit and HUD-assisted homes near any U.S. place, with the agencies to apply through.', href: '/housing' },
-- { icon: 'ov:account', title: 'Post your own', text: 'Anyone signed in can list a place, a room, a parking spot or equipment here, and edit, renew or hide it at any time.', href: '/post' },
++ { icon: 'ov:search', title: 'One search, every site', text: 'Type a place once and every site above opens its own search there, with your price and bedrooms.', href: '/everywhere' },
++ { icon: 'ov:check', title: 'Your board', text: 'Keep the listings you like from any site in one list, with your notes and how far you got.', href: '/board' },
{ icon: 'ov:bell', title: 'Alerts that keep looking', text: 'Save a search and get alerts through OpenVibe.Watch when something new matches.' },
{ icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. Reported listings are hidden until staff look.', href: '/safety' },
],
@@@ -248,7 +248,8 @@ ${problem ? notice(problem, 'warn') : ''}
${place ? html`<nav class="everywhere-kinds" aria-label="What to look for">${everywhere.KINDS.map((k) => (k === v.kind ? html`<span class="ew-kind on" aria-current="page">${everywhere.KIND_TEXT[k]}</span>` : html`<a class="ew-kind" href="${tab(k)}">${everywhere.KIND_TEXT[k]}</a>`))}</nav>
<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">On every site</h2>
${links.length ? html`<ul class="everywhere-sites">${links.map((l) => html`<li class="ew-site"><a class="ew-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="ew-name">${l.name}</span><span class="ew-host">${hostOf(l.url)} ↗</span></a><p class="ew-best">${l.best}</p></li>`)}</ul>
--<p class="small muted">Each link opens that site's own search for this place in a new tab, with your filters where the site takes them. Listings, messages and payments stay on that site: never pay a deposit before you have seen a place (<a href="/safety">the safety rules</a>).</p>`
++<p class="small muted">Each link opens that site's own search for this place in a new tab, with your filters where the site takes them. Listings, messages and payments stay on that site: never pay a deposit before you have seen a place (<a href="/safety">the safety rules</a>).</p>
++<p class="ew-board">Found one you like? <a href="/board">Keep it on your board</a>: every listing from every site in one list, with your notes and how far you got.</p>`
: html`<p class="muted">No site in the list covers this place and kind yet. The list starts with the United States; more countries come next.</p>`}
</section>
<section class="sc-sec" aria-labelledby="h-here"><h2 id="h-here">On ${SITE_NAME}</h2><ul class="everywhere-here">
@@@ -838,6 +839,9 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
return res.redirect(303, '/saved');
});
++ // ── The board: listings kept from any site (http/board-pages.js) ──
++ mountBoardPages(r, { config, s, page, signedIn, requesterOf, mustSignIn, budgeted, sameSite, signInPrompt });
++
// ── Reporting ────────────────────────────────────────────
r.post('/listings/:id/reports', mustSignIn('/listings'), budgeted('rent.listing.report'), async (req, res) => {
if (!sameSite(req, res, '/listings')) return;
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 33ad93b..3612063 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,15 +1,15 @@
'use strict';
/**
-- * Account export and deletion → Rent (ADR-033; openvibe-sdk/account-data). Rent holds three things a person made:
-- * a listing they posted, the reports they filed against listings, and the searches they saved. All three key the
-- * person as `user:usr_…` — server/http/principal.js builds the principal's requester that way and server/http/api.js
++ * Account export and deletion → Rent (ADR-033; openvibe-sdk/account-data). Rent holds four things a person made:
++ * a listing they posted, the reports they filed against listings, the searches they saved and the listings from other
++ * sites they keep on their board (server/board/board.js). All four key the person as `user:usr_…` — server/http/principal.js builds the principal's requester that way and server/http/api.js
* writes it straight into the store (server/listings/store.js).
*
-- * network.account.export_requested the person's listings (listings.json), reports (reports.json) and saved
-- * searches (saved_searches.json), newest first, pushed to Network
++ * network.account.export_requested the person's listings (listings.json), reports (reports.json), saved
++ * searches (saved_searches.json) and board (board.json), newest first, pushed to Network
* (POST /internal/account-exports/:id/parts) with this service's token.
-- * network.account.deleted the person's listings, reports and saved searches go, and Rent confirms with
++ * network.account.deleted the person's listings, reports, saved searches and board go, and Rent confirms with
* the counts.
*
* Nothing is anonymized: Rent keeps no row by this person that another person's page must still read. A listing is
@@@ -18,19 +18,20 @@
* longer exists has nothing left to say. So does the AI screen's check of a listing (rent_listing_checks, migration
* 0008): a staff-only moderation note about the listing, not a record of the person, gone with the listing and not
* exported. Nothing here is a secret: no token, key or credential is stored, so every
-- * column of the three tables may be exported.
++ * column of the four tables may be exported.
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
/**
* The tables that hold a person's rows, with the value the subject column really stores. Listings key on `owner`,
-- * reports on `reporter` and saved searches on `subject`; all three store `user:usr_…` (server/listings/store.js takes
++ * reports on `reporter`, saved searches and board items on `subject`; all four store `user:usr_…` (server/listings/store.js takes
* the value straight from the principal's requester).
*/
const TABLES = [
{ table: 'rent_listings', subject: 'owner', value: (usr) => `user:${usr}`, file: 'listings.json' },
{ table: 'rent_reports', subject: 'reporter', value: (usr) => `user:${usr}`, file: 'reports.json' },
{ table: 'rent_saved_searches', subject: 'subject', value: (usr) => `user:${usr}`, file: 'saved_searches.json' },
++ { table: 'rent_board_items', subject: 'subject', value: (usr) => `user:${usr}`, file: 'board.json' },
];
/** The account-data handle for Rent's store (server/db.js createStore). */
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 96531a5..8e86884 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -26,8 +26,10 @@ 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: 'Search everywhere', href: '/everywhere' },
++ { label: 'Your board', href: '/board' },
{ label: 'Affordable housing', href: '/housing' },
-- { label: 'Listings', href: '/listings' },
++ { label: 'Posted here', href: '/listings' },
{ label: 'Post a listing', href: '/post' },
{ label: 'Safety', href: '/safety' },
{ label: 'What shipped', href: '/updates' },
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex fcd7dfe..92d9999 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -14,6 +14,7 @@ const { signDeliveryHeaders } = require('openvibe-sdk/events');
const rentStore = require('../server/listings/store');
const rentService = require('../server/listings/service');
const listings = require('../server/listings/listings');
++const board = require('../server/board/board');
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
@@@ -77,8 +78,16 @@ async function makeReport(t, listingId, subject, note) {
return r.report;
}
++/** Keep one listing from another site on a person's board (server/board/board.js). */
++async function makeBoardItem(t, subject, title) {
++ const v = board.validate({ url: `https://www.craigslist.org/apa/${title}.html`, title });
++ const r = await board.save(t.ctx.s, `user:${subject}`, v.item);
++ assert.ok(r.ok, `board item for ${subject}`);
++ return r.row;
++}
++
/**
-- * A listing, a saved search and a report each for A and B. The reports cross: A's is on B's listing and B's is on
++ * A listing, a saved search, a board item and a report each for A and B. The reports cross: A's is on B's listing and B's is on
* A's — so A's part must carry A's report and not B's, and deleting A takes B's report on A's listing with it
* (rent_reports.listing_id REFERENCES rent_listings(id) ON DELETE CASCADE), which is the documented cascade.
*/
@@@ -87,6 +96,8 @@ async function seed(t) {
const b = await makeListing(t, B, 'person-b');
await makeSearch(t, A, 'Aton');
await makeSearch(t, B, 'Bville');
++ await makeBoardItem(t, A, 'a-board-item');
++ await makeBoardItem(t, B, 'b-board-item');
await makeReport(t, b.id, A, 'a-report-note');
await makeReport(t, a.id, B, 'b-report-note');
return { a, b };
@@@ -108,11 +119,13 @@ async function seed(t) {
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_rent', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['listings.json', 'reports.json', 'saved_searches.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['board.json', 'listings.json', 'reports.json', 'saved_searches.json']);
const body = JSON.stringify(part.body);
assert.ok(body.includes('person-a'), 'person A\'s listing');
assert.ok(body.includes('a-report-note'), 'person A\'s report');
assert.ok(body.includes('Aton'), 'person A\'s saved search');
++ assert.ok(body.includes('a-board-item'), 'person A\'s board');
++ assert.ok(!body.includes('b-board-item'), 'nobody else\'s board');
assert.ok(!body.includes('person-b'), 'nobody else\'s listing');
assert.ok(!body.includes('b-report-note'), 'nobody else\'s report');
assert.ok(!body.includes('Bville'), 'nobody else\'s saved search');
@@@ -137,12 +150,14 @@ async function seed(t) {
assert.strictEqual(await count('SELECT count(*)::int FROM rent_saved_searches WHERE subject = $1', A), 0, 'the person\'s saved search is gone');
assert.strictEqual(await listingsFor(B), 1, 'someone else\'s listing stays');
assert.strictEqual(await count('SELECT count(*)::int FROM rent_saved_searches WHERE subject = $1', B), 1, 'someone else\'s saved search stays');
++ assert.strictEqual(await count('SELECT count(*)::int FROM rent_board_items WHERE subject = $1', A), 0, 'the person\'s board is gone');
++ assert.strictEqual(await count('SELECT count(*)::int FROM rent_board_items WHERE subject = $1', B), 1, 'someone else\'s board stays');
// The report B left on A's listing cascades away with the listing it named.
assert.strictEqual(await t.ctx.s.db.value("SELECT count(*)::int FROM rent_reports WHERE listing_id = $1", [a.id]), 0);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
-- assert.deepStrictEqual(confirmation.body.erased, { rent_listings: 1, rent_reports: 1, rent_saved_searches: 1 });
++ assert.deepStrictEqual(confirmation.body.erased, { rent_listings: 1, rent_reports: 1, rent_saved_searches: 1, rent_board_items: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
ddiff --git a/test/board.test.js b/test/board.test.js
nnew file mode 100644
iindex 0000000..af9d323
---- /dev/null
++++ b/test/board.test.js
@@@ -0,0 +1,137 @@
++'use strict';
++/**
++ * The board (server/board/board.js, /board, /api/v1/board): a person keeps listings from any site in one list with
++ * their notes and how far they got. Only a signed-in person, only their own items; the same link twice is one item;
++ * a bad link or status is refused; the page works without JavaScript, opens prefilled from the bookmarklet's
++ * ?url=&title=, never fetches the listing, and is not indexed.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME } = require('./helpers/rent');
++const board = require('../server/board/board');
++
++const CL = 'https://saltlakecity.craigslist.org/apa/d/salt-lake-city-sunny-2br/7790000001.html';
++const ZL = 'https://www.zillow.com/homedetails/123-Main-St-Salt-Lake-City-UT-84111/1_zpid/';
++
++(async () => {
++ await check('validate: an http(s) link is required; prices, periods, currencies and statuses are checked', () => {
++ assert.strictEqual(board.validate({}).ok, false);
++ assert.strictEqual(board.validate({ url: 'javascript:alert(1)' }).ok, false, 'no javascript: links');
++ assert.strictEqual(board.validate({ url: 'ftp://example.com/x' }).ok, false);
++ assert.strictEqual(board.validate({ url: 'https://user:[email protected]/x' }).ok, false, 'no credentials in a link');
++ const v = board.validate({ url: CL, price: '$1,450', period: 'month' });
++ assert.deepStrictEqual(v.item, { url: CL, site: 'saltlakecity.craigslist.org', title: 'A listing on Craigslist', price: 1450, period: 'month', currency: 'USD' });
++ assert.strictEqual(board.validate({ url: CL, price: '-3' }).ok, false);
++ assert.strictEqual(board.validate({ url: CL, period: 'fortnight' }).ok, false);
++ assert.strictEqual(board.validate({ url: CL, currency: 'XYZ' }).ok, false);
++ assert.strictEqual(board.validate({ status: 'moved-in' }, { partial: true }).ok, false);
++ assert.deepStrictEqual(board.validate({ status: 'viewing' }, { partial: true }).item, { status: 'viewing' });
++ assert.strictEqual(board.siteName('zillow.com'), 'Zillow');
++ assert.strictEqual(board.siteName('airbnb.com'), 'Airbnb');
++ assert.strictEqual(board.siteName('example.org'), 'example.org');
++ assert.strictEqual(board.validate({ url: ZL, title: '9 Elm St | Zillow' }).item.title, '9 Elm St', 'a page title loses the site\'s own name');
++ assert.strictEqual(board.validate({ url: ZL, title: 'Bed - Bath' }).item.title, 'Bed - Bath', 'only the site\'s name is taken off');
++ });
++
++ const t = await boot();
++ try {
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++
++ await check('the API: a person keeps, lists, moves and forgets their own listings; nobody else sees them', async () => {
++ assert.strictEqual((await t.get('/api/v1/board')).status, 401);
++ const made = await t.get('/api/v1/board', { as: kim, json: { url: CL, title: 'Sunny 2BR', price: 1450, period: 'month', place: 'Downtown', notes: 'Ask about parking' }, headers: SAME });
++ assert.strictEqual(made.status, 201, made.text);
++ const item = made.json();
++ assert.ok(board.isItemId(item.id));
++ assert.strictEqual(made.headers.get('location'), `/api/v1/board/${item.id}`);
++ assert.deepStrictEqual([item.site, item.site_name, item.status, item.price, item.currency], ['saltlakecity.craigslist.org', 'Craigslist', 'interested', 1450, 'USD']);
++
++ const again = await t.get('/api/v1/board', { as: kim, json: { url: CL, title: 'Sunny 2BR, the one with the yard' }, headers: SAME });
++ assert.strictEqual(again.status, 200, 'the same link again updates the item');
++ assert.strictEqual(again.json().id, item.id);
++ assert.strictEqual(again.json().notes, 'Ask about parking', 'fields left out stay');
++
++ const moved = await t.get(`/api/v1/board/${item.id}`, { as: kim, method: 'PATCH', json: { status: 'viewing' }, headers: SAME });
++ assert.strictEqual(moved.status, 200, moved.text);
++ assert.strictEqual(moved.json().status, 'viewing');
++ assert.strictEqual((await t.get(`/api/v1/board/${item.id}`, { as: kim, method: 'PATCH', json: { status: 'moved-in' }, headers: SAME })).status, 422);
++
++ assert.strictEqual((await t.get(`/api/v1/board/${item.id}`, { as: lee, method: 'PATCH', json: { status: 'rented' }, headers: SAME })).status, 404, 'not someone else\'s');
++ assert.strictEqual((await t.get(`/api/v1/board/${item.id}`, { as: lee, method: 'DELETE', headers: SAME })).status, 404);
++ assert.deepStrictEqual((await t.get('/api/v1/board', { as: lee })).json().items, []);
++
++ const mine = (await t.get('/api/v1/board', { as: kim })).json();
++ assert.deepStrictEqual(mine.items.map((x) => x.id), [item.id]);
++ assert.deepStrictEqual(mine.statuses, board.STATUSES);
++
++ const other = await t.get('/api/v1/board', { as: kim, json: { url: ZL }, headers: SAME });
++ assert.strictEqual((await t.get(`/api/v1/board/${other.json().id}`, { as: kim, method: 'PATCH', json: { url: CL }, headers: SAME })).status, 409, 'two items cannot share a link');
++ assert.strictEqual((await t.get(`/api/v1/board/${other.json().id}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);
++ assert.strictEqual((await t.get('/api/v1/board', { as: kim, json: { url: 'javascript:alert(1)' }, headers: SAME })).json().code, 'board.invalid');
++ });
++
++ await check('/board: sign-in first, then the listings grouped by status with forms that work without JavaScript', async () => {
++ const anon = await t.get('/board?url=' + encodeURIComponent(ZL) + '&title=A+house');
++ assert.strictEqual(anon.status, 401);
++ assert.ok(anon.text.includes('/auth/login?next=' + encodeURIComponent('/board?url=' + encodeURIComponent(ZL) + '&title=A+house')), 'sign-in comes back to the same listing');
++
++ const r = await t.get('/board', { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('noindex'), 'a person\'s board is not indexed');
++ assert.ok(r.text.includes('Viewing booked'), 'grouped by status');
++ assert.ok(r.text.includes(`href="${CL}" target="_blank" rel="noopener nofollow ugc"`), 'the listing opens on its own site');
++ assert.ok(r.text.includes('Ask about parking'));
++ assert.ok(r.text.includes('<textarea id="bd-bookmarklet"') && r.text.includes('javascript:(()=&gt;{open('), 'the bookmarklet is text to copy');
++ assert.ok(!/href="javascript:/i.test(r.text), 'never a javascript: link');
++
++ const prefilled = await t.get('/board?url=' + encodeURIComponent(ZL) + '&title=A+house+with+a+yard', { as: kim });
++ assert.ok(prefilled.text.includes('Keep this listing') && prefilled.text.includes('<b>Zillow</b>'));
++ assert.ok(prefilled.text.includes(`value="${ZL}"`) && prefilled.text.includes('value="A house with a yard"'));
++ });
++
++ await check('the page forms: keep, move, edit and remove — from this site only', async () => {
++ const cross = await t.get('/board', { as: lee, form: { url: ZL }, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403, 'a cross-site form cannot act as the person');
++
++ const kept = await t.get('/board', { as: lee, form: { url: ZL, title: 'A house', price: '2,100', period: 'month', place: '', notes: '' }, headers: SAME });
++ assert.strictEqual(kept.status, 303, kept.text.slice(0, 200));
++ const id = kept.headers.get('location').split('#')[1];
++ assert.ok(board.isItemId(id));
++
++ assert.strictEqual((await t.get(`/board/${id}`, { as: lee, form: { status: 'applied' }, headers: SAME })).status, 303);
++ assert.strictEqual((await t.get(`/board/${id}`, { as: lee, form: { url: ZL, title: 'A house, applied Tuesday', price: '', period: 'month', place: 'Sugar House', notes: 'Waiting to hear' }, headers: SAME })).status, 303);
++ const row = await board.get(t.ctx.s, id);
++ assert.deepStrictEqual([row.status, row.title, row.price, row.place, row.notes], ['applied', 'A house, applied Tuesday', null, 'Sugar House', 'Waiting to hear']);
++
++ const bad = await t.get('/board', { as: lee, form: { url: 'not a link' }, headers: SAME });
++ assert.strictEqual(bad.status, 422);
++ assert.ok(bad.text.includes('an http or https address'));
++
++ assert.strictEqual((await t.get(`/board/${id}/delete`, { as: kim, method: 'POST', form: { go: '1' }, headers: SAME })).status, 303);
++ assert.ok(await board.get(t.ctx.s, id), 'someone else cannot remove it');
++ assert.strictEqual((await t.get(`/board/${id}/delete`, { as: lee, method: 'POST', form: { go: '1' }, headers: SAME })).status, 303);
++ assert.strictEqual(await board.get(t.ctx.s, id), null);
++ });
++
++ await check('a board holds MAX_ITEMS listings, then says so', async () => {
++ const s = t.ctx.s;
++ const nia = t.network.addUser('nia');
++ const subject = `user:${nia.subject}`;
++ const values = [];
++ for (let i = 0; i < board.MAX_ITEMS; i++) values.push(`('${s.newId('rbi')}', '${subject}', 'https://example.com/${i}', 'example.com', 'x', '2026-10-15T00:00:00.000Z', '2026-10-15T00:00:00.000Z')`);
++ await s.db.exec(`INSERT INTO rent_board_items (id, subject, url, site, title, created_at, updated_at) VALUES ${values.join(', ')}`);
++ const full = await t.get('/api/v1/board', { as: nia, json: { url: ZL }, headers: SAME });
++ assert.strictEqual(full.status, 409, full.text);
++ assert.strictEqual(full.json().code, 'board.full');
++ });
++
++ await check('search everywhere points at the board; robots.txt keeps it out', async () => {
++ const robots = await t.get('/robots.txt');
++ assert.ok(robots.text.includes('Disallow: /board'));
++ const home = await t.get('/');
++ assert.ok(home.text.includes('href="/board"'), 'the navbar has the board');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex cc87640..848a717 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.housing.search', 'rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);
++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.board.write', '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`);
}