openvibers/openvibe.rent Commits

OpenVibe.Rent v1: listings posted by people

OP OpenVibers committed 8 Oct 2026 · d5d3f0c706 · 28 files, +2700 −142

One place to find and offer things to rent: apartments, rooms, houses, commercial
space, parking and storage, and equipment. In this version every listing is posted by
a person on this site — no scraped listings and no partner feeds (they arrive only
when their terms are verified, and the site says so), no payments, no deposits and no
messaging.

- migrations/0002_rent.sql: rent_listings, rent_reports, rent_saved_searches.
- server/listings/: the field rules and the street-address refusal, the store, the
  posting caps, staff by Network role claim, and the 30-day expiry sweep.
- API: GET/GET one/POST/PATCH/DELETE listings, renew, reports, saved searches, and
  the staff queue and state change. A listing is posted and owned by a person; an
  app or service token is refused, and no route names a capability.
- Pages: home, /listings with facets and a cursor, one listing with the safety note
  and a contact link for signed-in viewers only, /post, /mine, /saved, /safety and
  the staff queue; discovery carries every active listing in the sitemap and keeps
  /post, /mine, /saved and /staff out of robots.
- Per-caller budgets for posting, reporting, renewing and saving a search, shared by
  the form and the API route.
- Tests for the posting rules, search and paging, contact hiding, report → hide at 3
  → staff restore, expiry, owner-only edits, same-origin writes, saved searches and
  escaped text. The home page's size budget moved with the page (24.9 → 28.4 KB raw)
  and was raised with the same ~10% headroom.

Changes

ddiff --git a/.env.example b/.env.example
iindex 69b131c..0995788 100644
---- a/.env.example
++++ b/.env.example
@@@ -10,11 +10,17 @@ TRUST_PROXY=2
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
DATABASE_URL=
DATABASE_DIRECT_URL=
++# development only: where the embedded PGlite database lives (default data/pglite)
++#RENT_PGLITE_DIR=
VALKEY_URL=
VALKEY_PREFIX=ov:rent:
# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.
#RENT_LIMITS_MINUTE=120
#RENT_LIMITS_HOUR=3000
++# Requests one address may make to the API and to the pages per minute (server/app.js). The product's own budgets
++# for posting, reporting, renewing and saving a search are in server/http/caller-limits.js, not here.
++#RENT_API_RATE_LIMIT_PER_MIN=240
++#RENT_RATE_LIMIT_PER_MIN=300
# OpenVibe.Network: SSO (OAuth client `rent`, PKCE S256) and its JWKS; app tokens for audience openvibe.rent.
OV_NETWORK_URL=https://openvibe.network
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f249640..b8eea68 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,36 @@
What changed in OpenVibe.Rent, newest first. Each site also publishes its patch notes at /updates.
++## 0.2.0 — 2026-10-08
++
++The product: people post listings, anyone searches them, and reports bring a listing to staff.
++
++- **Listings, posted by people.** A signed-in person posts apartment, room, house, commercial, parking, equipment or
++ other: a title, a plain-text description, a price with an ISO 4217 currency from a short list and a period, a city
++ and an ISO 3166-1 alpha-2 country, and optionally a region, a neighbourhood, bedrooms, an available-from date and
++ an https or mailto contact link. At most 10 active listings and 5 new ones a day; each lasts 30 days and the owner
++ can renew it for 30 more.
++- **Never a street address.** A house number with a street is refused in the title, the description, the city, the
++ region and the neighbourhood — in the form and the API alike, with a message that says what to write instead.
++- **Search.** `/listings` and `GET /api/v1/listings`: q, kind, city, country, min, max and period, newest first,
++ 30 a page with a cursor, and facets for kind and country. A filter we do not recognise is ignored, not an error.
++- **A listing page** with the safety note word for word, the contact link for signed-in viewers only (anyone else is
++ asked to sign in), and JSON-LD as an `Offer` only where the schema.org type is clear.
++- **Reports and staff.** One report per person per listing; three distinct reports hide it from search until staff
++ look. Staff (a Network role of `admin` or `global_mod`) read the queue at `/staff` and restore, hide or remove a
++ listing. A listing staff restored is not hidden again by the same reports.
++- **Saved searches.** Save the current filters and see, each time you open `/saved`, how many listings have appeared
++ since you last looked. Alerts that come to you arrive later through OpenVibe.Watch.
++- **No payments, no deposits, no messaging** in this version, and no scraped or partner sources: every listing is
++ text a person typed here, and the site says so. Sources arrive only when their terms are verified.
++- Pages added: `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`;
++ the home page is the product's own. Discovery carries every active listing in the sitemap, and robots.txt keeps
++ `/post`, `/mine`, `/saved` and `/staff` to people.
++- The per-caller budgets are declared (`rent.listing.create`, `rent.listing.report`, `rent.listing.renew`,
++ `rent.saved.create`), and the page forms take the same budget as the API so a form is not a way round a limit.
++- The home page's size budget moved from 24.9/6.2 KB to 28.4/6.5 KB (raw/Brotli) because it is a real home page now;
++ the budget was raised to 31.5/7.2 with the same ~10% headroom.
++
## 0.1.0 — 2026-10-08
- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
ddiff --git a/README.md b/README.md
iindex 8ef92c7..6c21a2b 100644
---- a/README.md
++++ b/README.md
@@@ -2,50 +2,92 @@
> Find a place, or rent yours out.
--**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.
++**Status:** v1 works end to end: people post listings, anyone searches them, three reports hide one until staff look.
**Domain:** `openvibe.rent` · **Port:** 5010 · **Service id:** `rent` · **Env prefix:** `RENT`
**License:** AGPL-3.0 (same as every OpenVibe service).
--## What is already here
++## What it is
++
++One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and
++equipment. In this first version the listings are **posted by people on this site** — there are no scraped listings
++and no partner feeds. Other sources come later, and only when their terms are verified; each listing will then carry
++its provenance (the source, a link back to it, and when it was read). Until then every listing here was written by
++the person who posted it, and the page says so.
++
++Three rules shape the product:
++
++- **No payments, no deposits, no messaging.** A listing is a description, a price with a currency and a period, and a
++ way to make contact. OpenVibe handles no money for listings, and there is no inbox here.
++- **Never a street address.** A listing names a city, and a neighbourhood if the poster gave one. A house number
++ together with a street is refused when the listing is posted or edited, in the form and in the API alike, with a
++ message that says what to write instead.
++- **The safety note is on every listing page**, word for word: *Never pay or send a deposit before you have seen the
++ place and signed an agreement. OpenVibe does not handle payments for listings.*
++
++## What works
| Piece | Where | What it does |
|---|---|---|
--| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
--| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
--| 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 |
--| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |
--| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |
--| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |
--| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |
--| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |
--| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |
--| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
--| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
--| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |
--| Deploy | [deploy/nginx/openvibe.rent.conf](deploy/nginx/openvibe.rent.conf), [deploy/systemd/openvibe-rent.service](deploy/systemd/openvibe-rent.service) | nginx vhost and systemd unit (port 5010, `/opt/openvibe.rent`, `/etc/openvibe/rent.env`) |
--| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |
++| 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. |
++| 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. |
++| 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. |
++| 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. |
++| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
++| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
++| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. Alerts by notification come later through OpenVibe.Watch. |
++| Pages | [server/http/pages.js](server/http/pages.js) | Home, `/listings`, `/listings/:id`, `/listings/:id/edit`, `/post`, `/mine`, `/saved`, `/safety`, `/staff`, `/updates` — all server-rendered, all complete without JavaScript, forms are plain POSTs. |
++| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt` (disallows `/auth/`, `/api/`, `/post`, `/mine`, `/saved`, `/staff`), `sitemap.xml` (every active `/listings/:id`, up to 1000), `llms.txt`, `llms-full.txt`. |
++| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `rent_at` / `rent_rt` cookies; `/auth/me` for the shared navbar. |
++| Limits | [server/http/caller-limits.js](server/http/caller-limits.js) | Per-caller budgets for posting, reporting, renewing and saving a search. The page form and the API route take the same budget, so the form is not a way round a limit. |
++| 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
++Everything under `/api/v1`. A **person** is a Network token as a Bearer, or this site's session cookie; a write made
++with the cookie must come from `openvibe.rent` itself (same-origin). An app, agent or service token cannot post,
++edit, report or save anything here — a listing is somebody's own — so no route names a capability
++(`CAPABILITIES` in [server/http/principal.js](server/http/principal.js) is empty on purpose). Errors are RFC 9457
++`application/problem+json` with a stable `code`.
++
| Route | Who | |
|---|---|---|
--| `GET /api/v1/ping` | anyone | `{ ok: true, service: "rent" }` |
--
--Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming
--each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in
--[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).
--
--**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service
--with a Network token for audience `openvibe.rent` that holds the route's capability. A write made with the session cookie
--must come from `openvibe.rent` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.
++| `GET /ping` | anyone | liveness |
++| `GET /listings` | anyone | search: `q`, `kind`, `city`, `country`, `min`, `max`, `period`, `limit`, `before`; answers `listings`, `next`, `facets` |
++| `GET /listings/:id` | anyone | one listing; `contact_url` only when the caller is a signed-in person |
++| `POST /listings` | person | post one (201 + `Location`); 422 on a bad field, 429 on a cap |
++| `PATCH /listings/:id` | owner | edit it, or `{ "state": "hidden" \| "active" }` |
++| `DELETE /listings/:id` | owner | delete it (204) |
++| `POST /listings/:id/renew` | owner | 30 more days from now |
++| `POST /listings/:id/reports` | person | report it (`reason`, `note`); 409 if you already have |
++| `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 /staff/reports` | staff | the queue, `?state=hidden\|active\|removed\|all` |
++| `POST /listings/:id/state` | staff | `{ "state": "active" \| "hidden" \| "removed" }` |
++
++## Data sources and their terms
++
++**This version has none.** Every listing is text a person typed here, under the rules above; nothing is scraped,
++imported or fetched from anywhere. There is no outbound fetch for a listing at all — a contact URL is stored as
++text and shown as a link, and this service never opens it (a URL a caller sent must never decide what this service
++requests).
++
++The only external service this process talks to is **OpenVibe.Network** (its own OAuth token endpoint and its JWKS),
++with its terms as the network's own service. When listing sources are added they will be named here with their
++terms, and each listing will carry its provenance (source name, a link back, and `fetched_at`) both in the API and
++on the page.
## Configuration
See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `rent` OAuth client on the
--Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
--Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
++Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check;
++the Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on
++`/api/ready`).
++
++The product adds no environment variables of its own: its numbers (10 active listings, 5 a day, 30 days, 3 reports,
++30 a page, the per-caller budgets) are constants next to the rules they belong to, not deployment settings.
--## Development
++## Development and tests
```bash
npm install
@@@ -53,8 +95,11 @@ fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite datab
fnm exec --using=22 npm run dev # http://localhost:5010
```
--Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
--test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
++The suite covers the posting rules (sign-in, the caps, the street-address refusal, currency and country validation),
++search and paging and facets, the contact link being hidden from signed-out viewers (page and API), a report
++hiding a listing at 3 and staff restoring it, expiry, owner-only edits, same-origin writes, saved searches with
++their new counts, and hostile text staying text everywhere it is shown. `npm run test:pg` runs the same suite
++through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
## Deploy (for the lead)
@@@ -73,7 +118,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
- Request bodies are never logged.
- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard
-- decides what may be fetched.
++ decides what may be fetched. A listing's contact URL is never fetched here at all.
++- A listing is public text: it is escaped by the `html` template everywhere it is rendered, and a street address is
++ refused at the door.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 00bbc22..f2b2981 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,8 +1,8 @@
{
-- "stage": "skeleton",
++ "stage": "product",
"code": true,
"wave": "W20",
-- "plan": "OpenVibe.Rent starts from the OpenVibe service skeleton; the product's own plan is written here when it is",
++ "plan": "v1: listings posted by people — post, search, report, staff review, saved searches. No scraped or partner sources, no payments, no messaging until their terms are verified and agreed.",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5010",
"domain": "openvibe.rent",
@@@ -14,29 +14,41 @@
"openvibe-sdk v0.35.2",
"openvibe-shared v2.14.0"
],
-- "summary": "OpenVibe.Rent — Find a place, or rent yours out. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",
++ "summary": "OpenVibe.Rent — Find a place, or rent yours out. One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version every listing is posted by a person on this site: no scraped listings and no partner feeds, no payments, no deposits and no messaging. Search with filters and facets, a listing page with a plain safety note, reports that hide a listing at 3 until staff look, saved searches that count what is new, and a 30-day expiry an owner can renew.",
"works": [
-- "Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
-- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",
-- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",
-- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",
-- "Capability verification for app, agent and service tokens (audience openvibe.rent) and for a person's own token",
-- "Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
-- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",
-- "Deploy files: deploy/nginx/openvibe.rent.conf and deploy/systemd/openvibe-rent.service",
-- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"
++ "Posting: a signed-in person only (an app, agent or service token is refused), at most 10 active listings and 5 new ones a day, 30 days until expiry",
++ "The field rules: kind, title ≤120, 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, https or mailto contact link",
++ "A house number with a street is refused in the title, description, city, region and neighbourhood, in the form and the API alike, with a message saying what to write instead",
++ "Search: /listings and GET /api/v1/listings with q, kind, city, country, min, max and period, newest first, 30 a page with a cursor, facets for kind and country",
++ "A listing page with the safety note word for word, contact link for signed-in viewers only, and JSON-LD as an Offer only where the schema.org type is clear",
++ "Reports: one per person per listing, 3 distinct reports hide it from search until staff restore or remove it; a listing staff restored is not hidden again by the same reports",
++ "Staff judged by the Network role claim (admin, global_mod), as at OpenVibe.Help; the queue at /staff and GET /api/v1/staff/reports",
++ "The owner can edit, renew, hide, show and delete their own listing; nobody else can",
++ "Expiry: a timer (server/listings/expiry.js, started by the process entry) marks the 30-day ones expired; an expired listing leaves search and can be renewed",
++ "Saved searches (signed in): save the current filters, see what is new since you last looked; alerts by notification come later through OpenVibe.Watch",
++ "Pages: /, /listings, /listings/:id, /listings/:id/edit, /post, /mine, /saved, /safety, /staff, /updates — server-rendered, complete without JavaScript, forms are plain POSTs",
++ "Same-origin writes for the session cookie, and per-caller budgets shared by the form and the API route",
++ "Discovery: robots.txt (disallows /auth/, /api/, /post, /mine, /saved, /staff), sitemap.xml with every active listing (≤1000), llms.txt, llms-full.txt",
++ "Tests: posting rules, search and paging and facets, contact hidden from signed-out viewers, report → hide at 3 → staff restore, expiry, owner-only edits, same-origin writes, saved searches, hostile text escaped"
],
"notYet": [
-- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",
-- "Its service and capability manifests in openvibe-contracts (contracts-service is rent)",
-- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"
++ "Listing sources beyond people posting here: scraped and partner feeds arrive only when their terms are verified, and then each listing will carry its provenance (source name, link back, fetched_at)",
++ "Alerts that come to you from a saved search (a notification, an email) — they come later through OpenVibe.Watch",
++ "Payments, deposits and messaging: not in this version, and not part of the plan until the safety and legal side is settled",
++ "Its service and capability manifests in openvibe-contracts (contracts-service is rent) and registration with OpenVibe.Services",
++ "A deployment: the service is not yet deployed by OpenVibe.Host"
],
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"deployed": false,
"placeholderInSites": false,
"features": [
-- "Network sign-in",
++ "listing posting with rules",
++ "search with facets and paging",
++ "listing pages with a safety note",
++ "reports and a staff queue",
++ "saved searches",
++ "expiry and renewal",
"server-rendered pages",
"api mount",
"limits and readiness",
ddiff --git a/deploy/nginx/openvibe.rent.conf b/deploy/nginx/openvibe.rent.conf
iindex ba3a816..1f5b498 100644
---- a/deploy/nginx/openvibe.rent.conf
++++ b/deploy/nginx/openvibe.rent.conf
@@@ -89,8 +89,10 @@ server {
proxy_pass http://127.0.0.1:5010;
}
-- # ── Forms and other writes ──────────────────────────────
-- location ~ ^/forms {
++ # ── Form writes: post, edit, renew, hide/show, delete, report, staff state ──
++ # The real limits for these are the app's per-caller budgets (server/http/caller-limits.js), the same for the
++ # form and the API route; this is a coarse per-address ceiling in front of them.
++ location ~ "^(/post|/mine|/saved|/staff|/listings/[^/]+/(edit|renew|visibility|delete|reports|state))$" {
limit_req zone=rent_forms burst=10 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5010;
ddiff --git a/migrations/0002_rent.sql b/migrations/0002_rent.sql
nnew file mode 100644
iindex 0000000..21e45c5
---- /dev/null
++++ b/migrations/0002_rent.sql
@@@ -0,0 +1,62 @@
++-- phase: expand
++-- OpenVibe.Rent: listings people post themselves, the reports against them and the searches people save.
++-- Applied at boot by openvibe-sdk/db (one file per phase, NNNN_name.sql, in order). Nothing written here is ever a
++-- credential, a key or a token.
++--
++-- v1 is listing-posting only: no scraped or partner sources yet (those come when their terms are verified), no
++-- payments, no deposits and no messaging — a listing carries a contact URL and nothing more.
++
++CREATE TABLE rent_listings (
++ id text COLLATE "C" PRIMARY KEY, -- rnt_<ULID>
++ owner text COLLATE "C" NOT NULL, -- user:usr_… (a listing is posted by a person)
++ kind text COLLATE "C" NOT NULL, -- apartment | room | house | commercial | parking | equipment | other
++ title text NOT NULL,
++ description text NOT NULL,
++ price numeric(14, 2) NOT NULL,
++ currency text COLLATE "C" NOT NULL, -- ISO 4217, from a short list
++ period text COLLATE "C" NOT NULL, -- month | week | day | hour | once
++ city text NOT NULL,
++ region text,
++ country text COLLATE "C" NOT NULL, -- ISO 3166-1 alpha-2
++ neighbourhood text, -- never a street address (see the refused-address rule)
++ bedrooms integer,
++ available_from text COLLATE "C", -- YYYY-MM-DD
++ contact_url text, -- https:// or mailto:, shown only to signed-in viewers
++ state text COLLATE "C" NOT NULL, -- active | hidden | expired | removed
++ report_count integer NOT NULL DEFAULT 0,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C" NOT NULL -- 30 days after posting; the owner may renew
++);
++
++-- Search is "newest first" over active listings, paged by cursor (the ULID's own order).
++CREATE INDEX rent_listings_active ON rent_listings (id DESC) WHERE state = 'active';
++CREATE INDEX rent_listings_owner ON rent_listings (owner, id DESC);
++CREATE INDEX rent_listings_expiry ON rent_listings (expires_at) WHERE state = 'active';
++CREATE INDEX rent_listings_kind ON rent_listings (kind) WHERE state = 'active';
++CREATE INDEX rent_listings_country ON rent_listings (country) WHERE state = 'active';
++
++CREATE TABLE rent_reports (
++ id text COLLATE "C" PRIMARY KEY, -- rpt_<ULID>
++ listing_id text COLLATE "C" NOT NULL REFERENCES rent_listings (id) ON DELETE CASCADE,
++ reporter text COLLATE "C" NOT NULL, -- user:usr_…
++ reason text COLLATE "C" NOT NULL, -- scam | wrong | offensive | other
++ note text, -- ≤500 characters
++ created_at text COLLATE "C" NOT NULL
++);
++
++-- One report per reporter per listing: 3 distinct reports hide the listing until staff look at it.
++CREATE UNIQUE INDEX rent_reports_once ON rent_reports (listing_id, reporter);
++CREATE INDEX rent_reports_recent ON rent_reports (created_at DESC);
++
++CREATE TABLE rent_saved_searches (
++ id text COLLATE "C" PRIMARY KEY, -- ssv_<ULID>
++ subject text COLLATE "C" NOT NULL, -- user:usr_…
++ filters jsonb NOT NULL, -- q, kind, city, country, min, max, period
++ filters_key text COLLATE "C" NOT NULL, -- canonical filters, so the same search is saved once
++ created_at text COLLATE "C" NOT NULL,
++ last_seen_at text COLLATE "C" NOT NULL -- what "new since you last looked" is measured from
++);
++
++CREATE UNIQUE INDEX rent_saved_searches_once ON rent_saved_searches (subject, filters_key);
++CREATE INDEX rent_saved_searches_by_subject ON rent_saved_searches (subject, id DESC);
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 32685b7..4713561 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -37,6 +37,23 @@ form.inline { display: inline; }
.prose table { margin: .6rem 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
++/* Listings: one row per listing, plain enough to read without JavaScript. */
++.txt { white-space: pre-line; }
++.listing-list { list-style: none; margin: 0; padding: 0; }
++.listing { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); }
++.listing-title { flex: 1 1 22rem; font-weight: 600; overflow-wrap: anywhere; }
++.listing-meta { flex: 1 1 18rem; }
++.listing-price { white-space: nowrap; }
++.price { white-space: nowrap; }
++.listing-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; }
++.facets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
++.field-row { display: flex; flex-wrap: wrap; gap: 12px; }
++.field-row .field { flex: 1 1 12rem; }
++.search-big input[type="search"] { font-size: 1.05rem; min-height: 2.4rem; }
++.saved-list, .report-list { list-style: none; padding: 0; }
++.saved-list > li, .staff-item { padding: 8px 0; }
++.staff-item { border-bottom: 1px solid var(--border, #1f2d47); }
++
/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
ddiff --git a/server/app.js b/server/app.js
iindex 1c080ee..78170bf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,14 +4,14 @@
* OpenVibe.Rent — Express app factory; server/index.js listens, tests build their own instance with a temp database
* and a mock Network.
*
-- * /, /updates the pages (http/pages.js)
++ * /, /listings, /listings/:id, /post, /mine, /saved, /safety, /staff, /updates the pages (http/pages.js)
* /api/v1/* the API (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
*
-- * The product fills this in: its routes in http/api.js, its pages in http/pages.js, its capabilities in
-- * http/principal.js and its budgets in http/caller-limits.js. The plumbing here (helmet, SSO, legal pages,
-- * static assets, the API mount, the 404 and the error handler) stays.
++ * The product's routes are in http/api.js and its pages in http/pages.js; its budgets are in
++ * http/caller-limits.js and its listing rules in listings/. The plumbing here (helmet, SSO, legal pages, static
++ * assets, the API mount, the 404 and the error handler) stays.
*/
const path = require('path');
const express = require('express');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 9dd965a..f6bdd7f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -3,27 +3,282 @@
/**
* /api/v1 — OpenVibe.Rent's API.
*
-- * GET /ping public liveness: { ok: true, service }
++ * GET /listings public search active listings: q, kind, city, country, min, max, period
++ * GET /listings/:id public one listing (contact_url only for a signed-in person)
++ * POST /listings person post a listing (never an app or a service)
++ * PATCH /listings/:id owner edit it, or hide and show it again
++ * DELETE /listings/:id owner delete it
++ * POST /listings/:id/renew owner 30 more days
++ * POST /listings/:id/reports person report it (3 distinct reports hide it until staff look)
++ * 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 /staff/reports staff the report queue
++ * POST /listings/:id/state staff restore (active), hide or remove
*
-- * The product adds its own routes here. Every route that reads or changes a person's data names the capability it
-- * needs (`principal.requireCapability('<rent>.thing.read')`, listed in CAPABILITIES in ./principal.js) and takes
-- * its per-caller numbers (`limits.reads('<rent>.thing.read')` or `limits.budget('<name>')`, ./caller-limits.js).
-- * A refusal is an RFC 9457 problem+json with a stable code.
++ * A listing belongs to the person who posted it: only they may edit, renew, hide or delete it. Everyone may read
++ * an active one, and its contact_url is included only for a signed-in person — that is the whole point of the
++ * field. A hidden, expired or removed listing is 404 for everyone but its owner and staff. An app, agent or
++ * service token cannot post, edit, report or save anything here: a listing is somebody's own, and no route names
++ * a capability (see CAPABILITIES in ./principal.js). Writes made with the session cookie must come from
++ * openvibe.rent itself (same-origin), so another site cannot make a signed-in person act.
++ *
++ * Nothing is fetched: a contact URL is stored as text and shown, never opened.
++ *
++ * Posting over a cap answers 429 with `listing.limit_active` or `listing.limit_daily` — a quota refusal, told apart
++ * from the rate limiter's `rate_limited` by its code, and answered before the row is written.
*/
const express = require('express');
++const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const listings = require('../listings/listings');
++const store = require('../listings/store');
++const service = require('../listings/service');
++const { isStaff } = require('../listings/staff');
++
++/** The listing columns a caller may set, as the validate() input names. */
++const INPUT_KEYS = ['kind', 'title', 'description', 'price', 'currency', 'period', 'city', 'region', 'country', 'neighbourhood', 'bedrooms'];
function createApi(ctx) {
-- const { config, principal, limits } = ctx;
++ const { config, s, principal, limits } = ctx;
const r = asyncRouter();
++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
-- // The one route the skeleton ships. It is public and counted per caller with the default read numbers.
++ const personOf = (req) => (req.principal.kind === 'user' ? req.principal.requester : null);
++
++ // Liveness: public, and counted per caller with the default read numbers.
r.get('/ping', limits.reads('rent.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
++ // ── Who may act ─────────────────────────────────────────
++ /**
++ * A person, signed in. An app, agent or service token is refused: a listing is posted and answered by a person,
++ * and no app acts for one here. A session write must come from this site.
++ */
++ function requirePerson(req, res) {
++ const p = req.principal;
++ if (p.kind === 'anonymous') {
++ problem(req, res, 401, 'token.required', 'Sign in at openvibe.rent, or send a person\'s Network token as a Bearer.');
++ return false;
++ }
++ if (p.kind !== 'user') {
++ problem(req, res, 403, 'rent.person_required', 'A listing is posted, edited and reported by a person; an app, agent or service token cannot do it.');
++ return false;
++ }
++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
++ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.rent itself.');
++ return false;
++ }
++ return true;
++ }
++ function requireStaff(req, res) {
++ if (!requirePerson(req, res)) return false;
++ if (!isStaff(req.principal)) {
++ problem(req, res, 403, 'staff.forbidden', 'The report queue is for OpenVibe staff (a Network role of admin or global_mod).');
++ return false;
++ }
++ return true;
++ }
++ const person = (req, res, next) => (requirePerson(req, res) ? next() : undefined);
++ const staff = (req, res, next) => (requireStaff(req, res) ? next() : undefined);
++
++ // ── Reading listings ────────────────────────────────────
++ const mine = (req, row) => Boolean(row) && row.owner === personOf(req);
++ /** Active, or the caller's own, or staff: what a hidden, expired or removed listing answers to. */
++ const visible = (req, row) => Boolean(row) && (row.state === 'active' || mine(req, row) || isStaff(req.principal));
++
++ /** Load one listing for a route. Answers the row, or writes the problem and answers null. */
++ async function load(req, res, { needVisible = true } = {}) {
++ const id = String(req.params.id || '');
++ const row = listings.isListingId(id) ? await store.get(s, id) : null;
++ if (!row || (needVisible && !visible(req, row))) {
++ problem(req, res, 404, 'listing.not_found', 'No such listing.');
++ return null;
++ }
++ return row;
++ }
++
++ /** Load one listing the person must own — otherwise 403, because a listing is not private, it is somebody's. */
++ async function loadOwn(req, res) {
++ const row = await load(req, res);
++ if (!row) return null;
++ if (!mine(req, row)) {
++ problem(req, res, 403, 'listing.forbidden', 'Only the person who posted this listing can change it.');
++ return null;
++ }
++ return row;
++ }
++
++ const rowToInput = (row) => ({
++ kind: row.kind, title: row.title, description: row.description, price: Number(row.price),
++ currency: row.currency, period: row.period, city: row.city, region: row.region || '',
++ country: row.country, neighbourhood: row.neighbourhood || '', bedrooms: row.bedrooms == null ? '' : String(row.bedrooms),
++ availableFrom: row.available_from || '', contactUrl: row.contact_url || '',
++ });
++
++ /** PATCH: the listing as it stands, with whatever the caller sent laid over it. */
++ function overlay(base, body) {
++ const out = { ...base };
++ for (const k of INPUT_KEYS) if (body[k] !== undefined) out[k] = body[k];
++ for (const [from, to] of [['available_from', 'availableFrom'], ['availableFrom', 'availableFrom'], ['contact_url', 'contactUrl'], ['contactUrl', 'contactUrl']]) {
++ if (body[from] !== undefined) out[to] = body[from];
++ }
++ return out;
++ }
++
++ const pageOpts = (query) => ({
++ limit: Math.min(60, Math.max(1, Number.parseInt(query.limit, 10) || listings.PAGE_SIZE)),
++ before: listings.isListingId(query.before) ? String(query.before) : null,
++ });
++
++ r.get('/listings', limits.reads('rent.listing.read'), async (req, res) => {
++ const filters = listings.normalizeFilters(req.query);
++ const opts = pageOpts(req.query);
++ const out = await store.search(s, filters, opts);
++ const contact = Boolean(personOf(req));
++ return res.json({
++ filters,
++ count: out.rows.length,
++ listings: out.rows.map((row) => listings.toWire(row, { contact })),
++ next: out.next,
++ facets: await store.facets(s, filters),
++ note: 'Active listings only, newest first. Facets count the same filters, each without its own dimension.',
++ });
++ });
++
++ r.get('/listings/:id', limits.reads('rent.listing.read'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return undefined;
++ return res.json(listings.toWire(row, { contact: Boolean(personOf(req)) }));
++ });
++
++ // ── Posting and owning ──────────────────────────────────
++ r.post('/listings', person, limits.budget('rent.listing.create'), async (req, res) => {
++ const check = listings.validate(req.body || {});
++ if (!check.ok) return problem(req, res, 422, check.code, check.detail);
++ const made = await service.create(s, req.principal.requester, check.fields);
++ if (!made.ok) return problem(req, res, 429, made.code, made.detail);
++ res.set('Location', `/api/v1/listings/${made.row.id}`);
++ return res.status(201).json(listings.toWire(made.row, { contact: true }));
++ });
++
++ r.patch('/listings/:id', person, async (req, res) => {
++ const row = await loadOwn(req, res);
++ if (!row) return undefined;
++ const body = req.body || {};
++ if (row.state === 'removed') return problem(req, res, 409, 'listing.removed', 'Staff removed this listing; it can no longer be edited.');
++ const check = listings.validate(overlay(rowToInput(row), body));
++ if (!check.ok) return problem(req, res, 422, check.code, check.detail);
++ // An owner may hide the listing or show it again, but never set it expired or removed.
++ let state = null;
++ if (body.state !== undefined) {
++ if (!['active', 'hidden'].includes(String(body.state))) return problem(req, res, 422, 'listing.invalid', 'state: active or hidden (only staff remove a listing)');
++ state = String(body.state);
++ }
++ const f = check.fields;
++ // Editing an expired listing does not renew it: that is what POST /listings/:id/renew is for.
++ const nextState = row.state === 'expired' ? 'expired' : (state || row.state);
++ const after = await store.update(s, row.id, {
++ kind: f.kind, title: f.title, description: f.description, price: f.price, currency: f.currency,
++ period: f.period, city: f.city, region: f.region, country: f.country, neighbourhood: f.neighbourhood,
++ bedrooms: f.bedrooms, available_from: f.availableFrom, contact_url: f.contactUrl, state: nextState,
++ });
++ return res.json(listings.toWire(after, { contact: true }));
++ });
++
++ r.delete('/listings/:id', person, async (req, res) => {
++ const row = await loadOwn(req, res);
++ if (!row) return undefined;
++ await store.remove(s, row.id);
++ return res.status(204).end();
++ });
++
++ r.post('/listings/:id/renew', person, limits.budget('rent.listing.renew'), async (req, res) => {
++ const row = await loadOwn(req, res);
++ if (!row) return undefined;
++ if (row.state === 'removed') return problem(req, res, 409, 'listing.removed', 'Staff removed this listing; it cannot be renewed.');
++ const after = await store.renew(s, row.id);
++ return res.json(listings.toWire(after, { contact: true }));
++ });
++
++ // ── Reports ─────────────────────────────────────────────
++ r.post('/listings/:id/reports', person, limits.budget('rent.listing.report'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return undefined;
++ if (row.state !== 'active') return problem(req, res, 404, 'listing.not_found', 'No such listing.');
++ if (mine(req, row)) return problem(req, res, 403, 'listing.own', 'You cannot report your own listing.');
++ const check = listings.validateReport(req.body || {});
++ if (!check.ok) return problem(req, res, 422, check.code, check.detail);
++ const out = await store.addReport(s, row.id, {
++ id: s.newId('rpt'), reporter: req.principal.requester,
++ reason: check.fields.reason, note: check.fields.note, createdAt: s.iso(),
++ });
++ if (out.error === 'report.duplicate') return problem(req, res, 409, 'report.duplicate', 'You have already reported this listing.');
++ if (out.error) return problem(req, res, 404, 'listing.not_found', 'No such listing.');
++ res.set('Location', `/api/v1/listings/${row.id}`);
++ return res.status(201).json({
++ report: listings.toWireReport(out.report),
++ listing: listings.toWire(out.row),
++ hidden: out.hidden,
++ note: out.hidden ? `This listing now has ${listings.HIDE_AT_REPORTS} distinct reports and is hidden until staff look at it.` : undefined,
++ });
++ });
++
++ // ── Saved searches ──────────────────────────────────────
++ r.get('/saved-searches', person, limits.reads('rent.saved.read'), async (req, res) => {
++ const rows = await store.listSaved(s, req.principal.requester);
++ const out = [];
++ for (const row of rows) {
++ const filters = typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters;
++ out.push(listings.toWireSaved(row, { newCount: await store.countMatching(s, listings.normalizeFilters(filters), { sinceISO: row.last_seen_at }) }));
++ }
++ return res.json({
++ saved_searches: out,
++ note: 'new_count is the active listings that appeared since last_seen_at. Alerts by notification come later through OpenVibe.Watch.',
++ });
++ });
++
++ r.post('/saved-searches', person, limits.budget('rent.saved.create'), async (req, res) => {
++ const filters = listings.normalizeFilters(req.body || {});
++ const row = await store.saveSearch(s, {
++ id: s.newId('ssv'), subject: req.principal.requester, filters,
++ key: listings.filtersKey(filters), createdAt: s.iso(),
++ });
++ res.set('Location', `/api/v1/saved-searches/${row.id}`);
++ return res.status(201).json(listings.toWireSaved(row));
++ });
++
++ r.delete('/saved-searches/:id', person, async (req, res) => {
++ const id = String(req.params.id || '');
++ const row = listings.isSavedId(id) ? await store.getSaved(s, id) : null;
++ if (!row || row.subject !== req.principal.requester) return problem(req, res, 404, 'saved.not_found', 'No such saved search.');
++ await store.deleteSaved(s, 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';
++ const out = await store.staffQueue(s, { state, ...pageOpts(req.query) });
++ const queue = [];
++ for (const row of out.rows) {
++ queue.push({ listing: listings.toWire(row), reports: (await store.reportsFor(s, row.id)).map(listings.toWireReport) });
++ }
++ return res.json({ filter: { state }, counts: await store.staffCounts(s), queue, next: out.next });
++ });
++
++ r.post('/listings/:id/state', staff, async (req, res) => {
++ const row = await load(req, res, { needVisible: false });
++ if (!row) return undefined;
++ const state = String((req.body || {}).state || '');
++ if (!['active', 'hidden', 'removed'].includes(state)) return problem(req, res, 422, 'listing.invalid', 'state: active, hidden or removed');
++ return res.json(listings.toWire(await store.setState(s, row.id, state)));
++ });
++
return r;
}
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex bb6f2d1..ae29f09 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -28,11 +28,18 @@ function caller(req) {
/**
* The product's expensive routes, each with its numbers per caller (a minute, an hour). A route takes its entry
* with `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was
-- * not declared here. The empty object is the skeleton's starting point:
++ * not declared here. The form on the page and the API route take the same entry: the form is the same action
++ * through another door, so it cannot be a way round the limit (see http/pages.js budgeted()).
*
-- * 'rent.thing.create': { minute: 6, hour: 60 },
++ * These are the per-caller rates; the product's own caps (10 active listings, 5 a day, one report per person per
++ * listing) are enforced in server/listings/service.js and the store — both, because they mean different things.
*/
--const BUDGETS = {};
++const BUDGETS = {
++ 'rent.listing.create': { minute: 6, hour: 40 },
++ 'rent.listing.report': { minute: 10, hour: 60 },
++ 'rent.listing.renew': { minute: 10, hour: 60 },
++ 'rent.saved.create': { minute: 20, hour: 100 },
++};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 2beb917..8e960ce 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -2,25 +2,27 @@
/**
* Crawl artifacts for openvibe.rent, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
-- * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
-- * the API are not.
++ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in, the
++ * API, posting, a person's own listings, their saved searches and the staff queue are not.
*
-- * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.
++ * The sitemap and llms-full.txt are two renderings of one list (publicPages), so a page can never be in one and
++ * missing from the other. Search results are not pages here: /listings canonicalises to itself (the layout drops the
++ * query), and the listings that matter to a crawler are the individual /listings/:id pages, up to LISTINGS_MAX of
++ * them, newest first.
*/
const fs = require('fs');
const path = require('path');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
++const store = require('../listings/store');
++const listings = require('../listings/listings');
const SITE_NAME = 'OpenVibe.Rent';
--const DESCRIPTION = 'OpenVibe.Rent — Find a place, or rent yours out.';
--const DISALLOW = ['/auth/', '/api/'];
--
--const PAGE_TEXT = {
-- '/': ['OpenVibe.Rent home', 'OpenVibe.Rent: Find a place, or rent yours out.'],
-- '/updates': ['What shipped on OpenVibe.Rent', 'This site\'s update log, from the network changelog feed.'],
--};
++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'];
++// 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;
function dayOf(ts) {
const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
@@@ -33,19 +35,34 @@ function siteUpdated() {
function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
-- seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION, searchUrl: `${site}/listings?q={q}` }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe rent, rental search, apartments for rent, rooms for rent, commercial rentals, parking, equipment rental' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
}
--const publicPages = () => [
-- { path: '/', changefreq: 'weekly', priority: 1.0 },
-- { path: '/updates', changefreq: 'daily', priority: 0.5 },
--];
++/** One line of text for a listing, in the words of the person who posted it (no contact details, ever). */
++const listingText = (row) => `${listings.KIND_TEXT[row.kind] || row.kind} in ${[row.city, row.country].filter(Boolean).join(', ')} — ${listings.priceText(row)}. ${row.description}`;
++
++/** Every public page: the site's own pages, then the active listings a crawler should read. */
++async function publicPages(s) {
++ const static_ = [
++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Rent home', text: 'One place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. Listings are posted by people; OpenVibe handles no payments and no deposits.' },
++ { path: '/listings', changefreq: 'hourly', priority: 0.9, title: 'Listings for rent', text: 'Search every active listing by kind, city, country, price and period, newest first.' },
++ { path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Renting safely', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
++ // /post is a form behind sign-in and robots.txt disallows it, so it is deliberately not a sitemap page.
++ { path: '/updates', changefreq: 'daily', priority: 0.5, title: `What shipped on ${SITE_NAME}`, text: 'This site\'s update log, from the network changelog feed.' },
++ ];
++ if (!s) return static_;
++ const out = await store.search(s, listings.normalizeFilters({}), { limit: LISTINGS_MAX });
++ return [...static_, ...out.rows.map((row) => ({
++ path: `/listings/${row.id}`, changefreq: 'daily', priority: 0.7,
++ title: row.title, text: listingText(row),
++ }))];
++}
function createDiscoveryRoutes(ctx) {
-- const { config } = ctx;
++ const { config, s } = ctx;
const r = asyncRouter();
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
@@@ -53,47 +70,53 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
-- '# openvibe.rent: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
++ '# openvibe.rent: the public pages are for search and AI crawlers; sign-in, the API, posting, your listings, your saved searches and the staff queue are not.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
-- summary: 'OpenVibe.Rent: Find a place, or rent yours out.',
-- details: 'Every page is server-rendered and readable without JavaScript.',
++ summary: DESCRIPTION,
++ details: 'Every page is server-rendered and readable without JavaScript. In this first version the listings are posted by people on this site: there are no scraped listings and no partner feeds — other sources arrive only when their terms are verified. There are no payments, no deposits and no messaging; a listing carries a way to make contact and nothing more. A listing names a city and a neighbourhood, never a street address. OpenVibe does not handle payments for listings, and the safety rule is on every listing page: never pay or send a deposit before you have seen the place and signed an agreement.',
sections: [
{ title: 'Start here', links: [
-- { title: 'OpenVibe.Rent', url: abs('/'), note: 'Find a place, or rent yours out.' },
-- { title: 'What shipped on OpenVibe.Rent', url: abs('/updates') },
++ { title: 'OpenVibe.Rent', url: abs('/'), note: 'find something to rent, or offer your own' },
++ { title: 'Listings', url: abs('/listings'), note: 'search by kind, city, country, price and period' },
++ { title: 'Renting safely', url: abs('/safety'), note: 'the rules, including the one about deposits' },
++ { title: `What shipped on ${SITE_NAME}`, url: abs('/updates') },
] },
{ title: 'Machine-readable', links: [
++ { title: 'Listings (JSON)', url: abs('/api/v1/listings'), note: 'active listings, newest first, with facets' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
] },
{ title: 'Elsewhere', links: [
{ title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
++ { title: 'OpenVibe.Watch', url: 'https://openvibe.watch', note: 'where alerts for a saved search will come from later' },
{ title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },
] },
],
}));
});
-- r.get('/llms-full.txt', (_req, res) => {
-- const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
++ r.get('/llms-full.txt', async (_req, res) => {
++ const pages = await publicPages(s);
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
-- summary: 'Every public page of OpenVibe.Rent, one line each.',
++ summary: 'Every public page of OpenVibe.Rent, one line each: the site\'s own pages and the active listings.',
base: site,
-- maxBytes: 64 * 1024,
-- sections: [{ title: 'Pages', pages }],
++ // Big enough for every entry the sitemap can carry, so the two lists never disagree.
++ maxBytes: 512 * 1024,
++ sections: [{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
}));
});
-- r.get('/sitemap.xml', (_req, res) => {
++ r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
-- const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
++ const pages = await publicPages(s);
++ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex feea784..f40d5bd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,73 +1,671 @@
'use strict';
/**
-- * Public pages: the home page and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt,
-- * llms-full.txt, JSON-LD) are http/discovery.js.
++ * Public pages: home, search (/listings), one listing, /post, /mine, /saved, /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).
-- * The product replaces the home page's sections and adds its own routes here.
++ * Every page works without JavaScript and is server-rendered through openvibe-shared/shell (render/layout.js):
++ * search is a GET form, posting and reporting are plain POSTs. Nothing here is hand-written copy about a listing —
++ * a listing is what its owner typed, escaped by the `html` template and nothing else.
++ *
++ * A signed-in write must come from openvibe.rent itself (same-origin), exactly as in the API, and the form and the
++ * API take the same per-caller budget, so the form is not a way round a limit.
*/
++const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
--const { html, raw } = require('../render/html');
++const { sameOrigin } = require('./principal');
++const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
++const listings = require('../listings/listings');
++const store = require('../listings/store');
++const service = require('../listings/service');
++const { isStaff } = require('../listings/staff');
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Find a place, or rent yours out.';
++const HOME_LATEST = 6;
++
++const STATE_KIND = { active: 'ok', hidden: 'warn', expired: '', removed: 'bad' };
++const STATE_TEXT = { active: 'active', hidden: 'hidden — waiting for staff', expired: 'expired', removed: 'removed' };
++// Only from a clear schema.org type do we publish JSON-LD for a listing; "other" is left without one on purpose.
++const SCHEMA_TYPE = { apartment: 'Apartment', room: 'Room', house: 'House', commercial: 'Place', parking: 'ParkingFacility', equipment: 'Product' };
++
++/** Plain text exactly as it was typed: escaped by the template, line breaks kept by the stylesheet. */
++const textBlock = (t) => html`<p class="txt">${t}</p>`;
++const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));
function createPageRoutes(ctx) {
-- const { config } = ctx;
++ const { config, s } = ctx;
const r = asyncRouter();
-- const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ // The pages' forms are plain POSTs (no JavaScript): urlencoded only, and small — a listing is text.
++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 120 });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
-- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const staffViewer = (req) => isStaff(req.viewer);
++ const requesterOf = (req) => `user:${req.viewer.subject}`;
++ const base = () => String(config.baseUrl).replace(/\/+$/, '');
++
++ // ── Small pieces ─────────────────────────────────────────
++ const stateBadge = (st) => badge(STATE_TEXT[st] || st, STATE_KIND[st] || '');
++ const priceOf = (row) => html`<b class="price">${listings.priceText(row)}</b>`;
++ const whereOf = (row) => [row.neighbourhood, row.city, row.country].filter(Boolean).join(', ');
++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
++
++ /** One listing in a list: the title, the price, where it is and how old it is. */
++ const listingItem = (row) => html`<li class="listing">
++<a class="listing-title" href="/listings/${row.id}">${row.title}</a>
++<span class="listing-meta small muted">${listings.KIND_TEXT[row.kind] || row.kind} · ${whereOf(row)}${row.bedrooms == null ? '' : ` · ${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'}`} · posted ${time(row.created_at)}</span>
++<span class="listing-price">${priceOf(row)}</span>
++</li>`;
++
++ /** `empty` may be a plain string (escaped) or an `html` value (a link, say). */
++ const listingList = (rows, { empty = 'Nothing here yet.' } = {}) => (rows.length
++ ? html`<ul class="listing-list">${rows.map(listingItem)}</ul>`
++ : html`<p class="muted">${empty}</p>`);
++
++ /** The search box: one GET form, the same fields /listings and a saved search use. */
++ function searchForm(f = {}, { big = false } = {}) {
++ const opt = (value, text, selected) => html`<option value="${value}"${value === (selected || '') ? raw(' selected') : ''}>${text}</option>`;
++ return html`<form class="search-form${big ? ' search-big' : ''}" method="get" action="/listings" role="search">
++<div class="field"><label for="q">What are you looking for?</label>
++<input id="q" name="q" type="search" value="${f.q || ''}" maxlength="${listings.LIMITS.q}" placeholder="apartment in Lisbon, parking near the centre, a camera for a week"></div>
++<div class="field-row">
++<div class="field"><label for="kind">Kind</label><select id="kind" name="kind">${opt('', 'any kind', f.kind)}${listings.KINDS.map((k) => opt(k, listings.KIND_TEXT[k], f.kind))}</select></div>
++<div class="field"><label for="city">City</label><input id="city" name="city" type="text" value="${f.city || ''}" maxlength="${listings.LIMITS.city}"></div>
++<div class="field"><label for="country">Country</label><select id="country" name="country">${opt('', 'any country', f.country)}${listings.COUNTRIES.map((c) => opt(c, c, f.country))}</select></div>
++</div>
++<div class="field-row">
++<div class="field"><label for="min">Price from</label><input id="min" name="min" type="number" min="0" step="any" value="${f.min == null ? '' : f.min}"></div>
++<div class="field"><label for="max">Price up to</label><input id="max" name="max" type="number" min="0" step="any" value="${f.max == null ? '' : f.max}"></div>
++<div class="field"><label for="period">Per</label><select id="period" name="period">${opt('', 'any period', f.period)}${listings.PERIODS.map((p) => opt(p, listings.PERIOD_TEXT[p], f.period))}</select></div>
++</div>
++<p><button class="sc-btn sc-primary" type="submit">Search</button> <a class="sc-btn" href="/listings">Clear</a></p>
++</form>`;
++ }
++
++ /** A page write must come from this site, exactly as in the API: a cross-site form must not act as the person. */
++ function sameSite(req, res, back) {
++ if (sameOrigin(req, config.baseUrl)) return true;
++ page(req, res, {
++ title: 'Not from this site',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Not from this site' }],
++ body: html`<h1>That came from somewhere else</h1><p class="muted">A signed-in page that changes something has to come from openvibe.rent itself. <a href="${back}">Go back</a> and try again.</p>`,
++ }, 403);
++ return false;
++ }
++
++ /** The signed-in gate, with a page of its own rather than a bare 401. */
++ const mustSignIn = (back) => (req, res, next) => (signedIn(req) ? next()
++ : page(req, res, { title: 'Sign in', crumbs: [{ label: 'Home', href: '/' }, { label: 'Sign in' }], body: html`<h1>Sign in first</h1>${signInPrompt('A listing belongs to your OpenVibe account, so you can edit, renew or delete it later.', back)}` }, 401));
++
++ /**
++ * The same per-caller budget the API applies, given an HTML face: the limiter writes its refusal to a shim and
++ * the page renders its own 429 — a person using the form should not be handed problem+json. The counters, the
++ * windows and the metric are the API's.
++ */
++ const budgeted = (name) => {
++ const limit = ctx.limits.budget(name);
++ return (req, res, next) => {
++ let settled = false;
++ const refusal = (body) => {
++ let p = null;
++ try { p = JSON.parse(String(body)); } catch { p = null; }
++ if (p && p.retry_after_seconds) res.set('Retry-After', String(p.retry_after_seconds));
++ return page(req, res, {
++ title: 'Too many requests',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Too many requests' }],
++ body: html`<h1>Too many requests just now</h1>
++${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
++<p class="muted">Wait a moment and send it again — nothing was lost. <a href="/mine">Your listings</a> are still there.</p>`,
++ }, 429);
++ };
++ limit(req, {
++ statusCode: 0,
++ setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},
++ end: (body) => { if (!settled) { settled = true; refusal(body); } },
++ }, (err) => {
++ if (settled) return undefined;
++ settled = true;
++ return err ? next(err) : next();
++ });
++ return undefined;
++ };
++ };
++
++ const canSee = (req, row) => Boolean(row)
++ && (row.state === 'active' || (signedIn(req) && row.owner === requesterOf(req)) || staffViewer(req));
// ── Home ─────────────────────────────────────────────────
-- r.get('/', (req, res) => {
++ r.get('/', async (req, res) => {
++ const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
-- title: SITE_NAME,
-- accent: TAGLINE,
-- lede: `${SITE_NAME} is a new OpenVibe service. This is its skeleton: the platform plumbing below is in place, and the product is what goes on top of it.`,
++ title: 'Somewhere to live, work or park',
++ accent: 'Every listing here was posted by a person.',
++ lede: `${SITE_NAME} is one place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version people post the listings here themselves — nothing is scraped from another site, and nothing is paid for here.`,
actions: signedIn(req)
-- ? [{ label: 'Your account', href: '/auth/me' }, { label: 'What shipped', href: '/updates' }]
-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'What shipped', href: '/updates' }],
-- note: 'Open source (AGPL-3.0). Sign-in is OpenVibe.Network; a person\'s token stays in an httpOnly cookie.',
++ ? [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Your listings', href: '/mine' }]
++ : [{ label: 'Browse listings', href: '/listings', primary: true }, { label: 'Sign in to post', href: '/auth/login?next=%2Fpost' }],
++ note: 'No payments, no deposits, no messaging: a listing carries a way to make contact and nothing more.',
});
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
++<section class="sc-sec" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
++<p class="sc-lede">Straight to it, or <a href="/listings">browse everything</a>.</p>
++${searchForm({}, { big: true })}</section>
++
++<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest listings</h2>
++<p class="sc-lede">The newest active listings, posted by people on ${SITE_NAME}.</p>
++${listingList(latest.rows, { empty: 'No listings yet — yours could be the first.' })}
++<p><a class="sc-btn" href="/listings">Every listing</a> <a class="sc-btn" href="/post">Post a listing</a></p></section>
++
${raw(showcase.features({
-- title: 'What is already here',
-- lede: 'The parts every OpenVibe service needs, so a new product starts from them instead of rebuilding them.',
-- items: [
-- { icon: 'ov:account', title: 'Sign-in with OpenVibe.Network', text: 'OAuth 2 with PKCE (S256), server-side code exchange, httpOnly session cookies, and offline token verification against the Network signing key.' },
-- { icon: 'ov:page', title: 'Server-rendered pages', text: 'The OpenVibe Frame (navbar and footer), the shared theme, one stylesheet, crawl artifacts and JSON-LD. Every page is complete without JavaScript.' },
-- { icon: 'ov:tools', title: 'An API mount and capabilities', text: 'GET /api/v1/ping today; the router, the problem+json errors and the app/person capability guard are wired and waiting for the product\'s routes.' },
-- { icon: 'ov:gauge', title: 'Limits and readiness', text: 'Per-address and per-caller rate limits, /api/health, a truthful /api/ready and Prometheus metrics on loopback.' },
-- { icon: 'ov:db', title: 'PostgreSQL and migrations', text: 'openvibe-sdk/db, migrations/NNNN_*.sql applied at boot, PGlite in development, and a store with an injectable clock.' },
-- { icon: 'ov:deploy', title: 'Deploy files and tests', text: 'nginx and systemd units, an environment template, and a test suite on PGlite with a mock Network, all through npm test.' },
-- ],
-- }))}
--${raw(showcase.steps({
-- title: 'How a new service starts',
++ title: 'How it works',
++ lede: 'A listing here is somebody offering something, and the rules are the same for everyone.',
items: [
-- { title: 'Generate it', text: 'node scripts/new-service.js replaces the skeleton\'s tokens (service id, name, domain, port, tagline, env prefix) in every file and file name.' },
-- { title: 'Install and run', text: 'npm install, then npm run dev: the service listens on its port with an embedded PGlite database, if no DATABASE_URL is set.' },
-- { title: 'Make it a product', text: 'Add the product\'s tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js.' },
-- { title: 'Grant and deploy', text: 'Register the service and its capabilities with OpenVibe.Contracts and OpenVibe.Services, then install the unit and the vhost.' },
++ { icon: 'ov:account', title: 'Posted by people', text: 'Every listing is posted by a signed-in person, who can edit, renew, hide or delete it. Nothing here is scraped from another site.' },
++ { icon: 'ov:gauge', title: 'Safety first', text: 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.' },
++ { icon: 'ov:page', title: 'Reported listings are checked', text: 'Three distinct reports hide a listing until OpenVibe staff look at it and restore or remove it.' },
++ { icon: 'ov:db', title: 'Searches you can keep', text: 'Save your filters and see how many listings are new since you last looked. Alerts come later through OpenVibe.Watch.' },
++ { icon: 'ov:deploy', title: 'No addresses', text: 'A listing names a city and a neighbourhood, never a street address: a house number with a street is refused.' },
],
}))}
--${raw(showcase.cta({ title: 'Read the API', text: 'GET /api/v1/ping is the whole API today. The rest is what the product adds.', actions: [{ label: 'The API', href: '/api/v1/ping' }, { label: 'What shipped', href: '/updates' }] }))}`,
++
++<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
++<p class="sc-prose">Today: people, posting here themselves. There are no scraped listings and no partner feeds — other sources arrive only when their terms are verified, and each listing will then say where it came from and link back to it. <a href="/safety">The safety rules</a> are on every listing page.</p></section>
++
++${raw(showcase.cta({ title: 'Have something to rent out?', text: 'Post it in a minute: a description, a price, a period and where it is. You can edit, renew or hide it at any time.', actions: [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Safety rules', href: '/safety' }] }))}`,
});
});
++ // ── Search ───────────────────────────────────────────────
++ r.get('/listings', async (req, res) => {
++ const filters = listings.normalizeFilters(req.query);
++ const before = listings.isListingId(req.query.before) ? String(req.query.before) : null;
++ const out = await store.search(s, filters, { before });
++ const facets = await store.facets(s, filters);
++ const qs = listings.filtersToQuery(filters);
++ const more = (extra) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), ...extra }).toString()}`;
++ const facetLink = (dim, value) => `/listings?${new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(qs)), [dim]: value }).toString()}`;
++ page(req, res, {
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ title: 'Listings',
++ description: 'Search apartments, rooms, houses, commercial space, parking and storage, and equipment for rent — posted by people on OpenVibe.Rent.',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings' }],
++ body: html`<h1>Listings</h1>
++<p class="sc-lede">Active listings, newest first. ${out.rows.length} on this page.</p>
++${searchForm(filters)}
++
++<section class="sc-sec" aria-labelledby="h-results"><h2 id="h-results">Results</h2>
++${facetList('Kind', 'kind', facets.kind, filters.kind, facetLink)}
++${facetList('Country', 'country', facets.country, filters.country, facetLink)}
++${qs ? html`<div class="small"><a href="/listings">Clear all filters</a>${signedIn(req) ? html` · <form class="inline" method="post" action="/saved">
++<input type="hidden" name="q" value="${filters.q || ''}"><input type="hidden" name="kind" value="${filters.kind || ''}"><input type="hidden" name="city" value="${filters.city || ''}"><input type="hidden" name="country" value="${filters.country || ''}"><input type="hidden" name="period" value="${filters.period || ''}"><input type="hidden" name="min" value="${filters.min == null ? '' : filters.min}"><input type="hidden" name="max" value="${filters.max == null ? '' : filters.max}">
++<button class="sc-btn" type="submit">Save this search</button></form>` : ''}</div>` : ''}
++${listingList(out.rows, { empty: html`No listing matches those filters. <a href="/listings">Clear them</a> and try again.` })}
++${out.next ? html`<p><a href="${more({ before: out.next })}">Older listings</a>${before ? html` · <a href="/listings${qs ? `?${qs}` : ''}">Newest first</a>` : ''}</p>` : ''}
++</section>`,
++ });
++ });
++
++ function facetList(title, dim, values, selected, link) {
++ if (!values.length) return '';
++ return html`<p class="facets small"><span class="muted">${title}:</span>
++${selected ? html`<a href="${link(dim, '')}" class="badge info">any ${title.toLowerCase()}</a> ` : ''}
++${values.slice(0, 24).map((v) => html`<a href="${link(dim, v.value)}" class="badge${v.value === selected ? ' info' : ''}">${dim === 'kind' ? (listings.KIND_TEXT[v.value] || v.value) : v.value} (${v.count})</a> `)}</p>`;
++ }
++
++ // ── One listing ──────────────────────────────────────────
++ /** The schema.org Offer we publish, and only when the kind has a clear type: "other" gets no JSON-LD. */
++ function listingJsonLd(row) {
++ const type = SCHEMA_TYPE[row.kind];
++ if (!type || row.state !== 'active') return null;
++ return {
++ '@context': 'https://schema.org', '@type': 'Offer',
++ url: `${base()}/listings/${row.id}`,
++ name: row.title,
++ description: truncate(row.description, 300),
++ price: Number(row.price),
++ priceCurrency: row.currency,
++ priceValidUntil: String(row.expires_at).slice(0, 10),
++ availability: 'https://schema.org/InStock',
++ businessFunction: row.period === 'once' ? 'https://schema.org/Sell' : 'https://schema.org/LeaseOut',
++ areaServed: `${row.city}, ${row.country}`,
++ itemOffered: { '@type': type, name: row.title },
++ };
++ }
++
++ const reportForm = (row) => html`<form class="card report-form" method="post" action="/listings/${row.id}/reports">
++<fieldset><legend>Report this listing</legend>
++<div class="field"><label for="reason">Why?</label><select id="reason" name="reason">
++${listings.REASONS.map((x) => html`<option value="${x}">${x === 'scam' ? 'It looks like a scam' : x === 'wrong' ? 'The details are wrong' : x === 'offensive' ? 'The content is offensive' : 'Something else'}</option>`)}
++</select></div>
++<div class="field"><label for="note">Anything else? (optional)</label><textarea id="note" name="note" rows="3" maxlength="${listings.LIMITS.note}"></textarea></div>
++<button class="sc-btn" type="submit">Report it</button></fieldset>
++<p class="small muted">Three different people reporting a listing hide it from search until OpenVibe staff look at it.</p>
++</form>`;
++
++ r.get('/listings/:id', async (req, res) => {
++ const row = listings.isListingId(req.params.id) ? await store.get(s, req.params.id) : null;
++ if (!canSee(req, row)) {
++ return page(req, res, {
++ title: 'No such listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings', href: '/listings' }, { label: 'Not found' }],
++ body: html`<h1>No such listing</h1><p class="muted">There is no listing here. It may have expired or been removed. <a href="/listings">See what is available</a>.</p>`,
++ }, 404);
++ }
++ const isMine = row.owner === requesterOf(req);
++ const contact = row.contact_url || null;
++ return page(req, res, {
++ index: row.state === 'active', cache: (row.state === 'active' && !signedIn(req)) ? PUBLIC_CACHE : null,
++ title: truncate(row.title, 70),
++ description: truncate(`${listings.KIND_TEXT[row.kind] || row.kind} in ${whereOf(row)} — ${row.description}`, 160),
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings', href: '/listings' }, { label: truncate(row.title, 60) }],
++ jsonLd: [listingJsonLd(row), seo.jsonLd.breadcrumbs([{ name: SITE_NAME, url: `${base()}/` }, { name: 'Listings', url: `${base()}/listings` }, { name: row.title, url: `${base()}/listings/${row.id}` }])],
++ body: html`<article class="listing-page">
++<h1>${row.title}</h1>
++<p class="listing-facts">${stateBadge(row.state)} <span class="badge">${listings.KIND_TEXT[row.kind] || row.kind}</span> ${priceOf(row)}</p>
++<p class="where"><b>${whereOf(row)}</b>${row.region ? html` <span class="muted small">(${row.region})</span>` : ''}${row.neighbourhood ? '' : html` <span class="muted small">— the neighbourhood is not given</span>`}</p>
++<p class="facts small muted">${row.bedrooms == null ? '' : html`${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'} · `}${row.available_from ? html`available from ${row.available_from} · ` : ''}posted ${time(row.created_at)} · ${row.state === 'expired' ? 'expired' : 'expires'} ${time(row.expires_at)}</p>
++
++${notice(listings.SAFETY, 'warn')}
++
++<section aria-labelledby="h-about"><h2 id="h-about">About this listing</h2>${textBlock(row.description)}</section>
++
++<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">How to make contact</h2>
++${contact
++ ? (signedIn(req) ? html`<p><a class="sc-btn sc-primary" href="${contact}" rel="nofollow noopener">${contact}</a></p>
++<p class="small muted">This link was given by the person who posted the listing. OpenVibe has not checked it and does not take part in the arrangement.</p>`
++ : html`<p>${signInPrompt('Sign in to see how to contact the person who posted this listing.', `/listings/${row.id}`)}</p>`)
++ : html`<p class="muted">The person who posted this listing did not leave a way to make contact.</p>`}
++${!signedIn(req) && contact ? html`<p class="small muted">Contact details are shown to signed-in people only, so they cannot be harvested from a public page.</p>` : ''}
++</section>
++
++${isMine ? html`<section class="sc-sec" aria-labelledby="h-own"><h2 id="h-own">Your listing</h2>
++<p><a class="sc-btn" href="/listings/${row.id}/edit">Edit it</a></p>
++<form class="inline" method="post" action="/listings/${row.id}/renew"><button class="sc-btn" type="submit">Renew for ${listings.EXPIRY_DAYS} more days</button></form>
++<form class="inline" method="post" action="/listings/${row.id}/visibility"><input type="hidden" name="state" value="${row.state === 'hidden' ? 'active' : 'hidden'}"><button class="sc-btn" type="submit">${row.state === 'hidden' ? 'Show it again' : 'Hide it'}</button></form>
++<form class="inline" method="post" action="/listings/${row.id}/delete"><button class="sc-btn" type="submit">Delete it</button></form>
++<p class="small muted">Deleting removes the listing and its reports for good. Hiding leaves it in <a href="/mine">your listings</a> but takes it out of search.</p>
++</section>` : ''}
++
++${staffViewer(req) ? html`<section class="sc-sec" aria-labelledby="h-staff"><h2 id="h-staff">Staff</h2>
++<p class="small muted">${row.report_count} report${Number(row.report_count) === 1 ? '' : 's'}. <a href="/staff">The report queue</a>.</p></section>` : ''}
++
++${signedIn(req) && !isMine && row.state === 'active' ? reportForm(row) : ''}
++${!signedIn(req) ? html`<div class="notice">Anyone signed in can <a href="/auth/login?next=${encodeURIComponent(`/listings/${row.id}`)}">report this listing</a>. Three different reports hide it until OpenVibe staff look at it.</div>` : ''}
++</article>`,
++ });
++ });
++
++ // ── Posting ──────────────────────────────────────────────
++ /** The listing form. Every dynamic value is escaped; the field rules are the ones the API enforces. */
++ function listingForm({ values = {}, problem = null, action, submit = 'Post the listing' }) {
++ const v = values;
++ const opt = (value, text, selected) => html`<option value="${value}"${String(value) === String(selected == null ? '' : selected) ? raw(' selected') : ''}>${text}</option>`;
++ return html`<form class="card listing-form" method="post" action="${action}">
++${problem ? notice(problem, 'warn') : ''}
++<div class="field"><label for="kind">What is it?</label><select id="kind" name="kind" required>
++${listings.KINDS.map((k) => opt(k, listings.KIND_TEXT[k], v.kind))}
++</select></div>
++<div class="field"><label for="title">Title</label><input id="title" name="title" type="text" maxlength="${listings.LIMITS.title}" required value="${v.title || ''}" placeholder="Sunny two-bedroom flat near the park">
++<p class="small muted">Up to ${listings.LIMITS.title} characters. No street address, please — the area only.</p></div>
++<div class="field"><label for="description">Description</label><textarea id="description" name="description" rows="8" maxlength="${listings.LIMITS.description}" required placeholder="What it is like, what is included, who it suits.">${v.description || ''}</textarea>
++<p class="small muted">Plain text, up to ${listings.LIMITS.description} characters. Never a street address.</p></div>
++<div class="field-row">
++<div class="field"><label for="price">Price</label><input id="price" name="price" type="number" min="0" step="any" required value="${v.price == null ? '' : v.price}"></div>
++<div class="field"><label for="currency">Currency</label><select id="currency" name="currency" required>${listings.CURRENCIES.map((c) => opt(c, c, v.currency || 'USD'))}</select></div>
++<div class="field"><label for="period">Per</label><select id="period" name="period" required>${listings.PERIODS.map((p) => opt(p, listings.PERIOD_TEXT[p], v.period))}</select></div>
++</div>
++<div class="field-row">
++<div class="field"><label for="city">City or town</label><input id="city" name="city" type="text" maxlength="${listings.LIMITS.city}" required value="${v.city || ''}"></div>
++<div class="field"><label for="country">Country</label><select id="country" name="country" required>${listings.COUNTRIES.map((c) => opt(c, c, v.country))}</select></div>
++</div>
++<div class="field-row">
++<div class="field"><label for="region">Region or state (optional)</label><input id="region" name="region" type="text" maxlength="${listings.LIMITS.region}" value="${v.region || ''}"></div>
++<div class="field"><label for="neighbourhood">Neighbourhood (optional)</label><input id="neighbourhood" name="neighbourhood" type="text" maxlength="${listings.LIMITS.neighbourhood}" value="${v.neighbourhood || ''}">
++<p class="small muted">The area, never a street address.</p></div>
++</div>
++<div class="field-row">
++<div class="field"><label for="bedrooms">Bedrooms (optional)</label><input id="bedrooms" name="bedrooms" type="number" min="0" max="50" step="1" value="${v.bedrooms == null ? '' : v.bedrooms}"></div>
++<div class="field"><label for="available_from">Available from (optional)</label><input id="available_from" name="available_from" type="date" value="${v.availableFrom || ''}"></div>
++</div>
++<div class="field"><label for="contact_url">How to make contact (optional)</label><input id="contact_url" name="contact_url" type="text" maxlength="${listings.LIMITS.contactUrl}" value="${v.contactUrl || ''}" placeholder="https://example.com/your-listing or mailto:[email protected]">
++<p class="small muted">An https:// link or a mailto: address, shown only to signed-in people — never on a public page.</p></div>
++<p><button class="sc-btn sc-primary" type="submit">${submit}</button> <a class="sc-btn" href="/mine">Your listings</a></p>
++</form>`;
++ }
++
++ /** The form values, from the body as typed (so a refusal gives the text back). */
++ const formValues = (b) => ({
++ kind: b.kind, title: b.title, description: b.description, price: b.price, currency: b.currency, period: b.period,
++ city: b.city, region: b.region, country: b.country, neighbourhood: b.neighbourhood, bedrooms: b.bedrooms,
++ availableFrom: b.available_from, contactUrl: b.contact_url,
++ });
++
++ r.get('/post', (req, res) => {
++ if (!signedIn(req)) return page(req, res, {
++ title: 'Post a listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Post a listing' }],
++ body: html`<h1>Post a listing</h1>${signInPrompt('A listing belongs to your OpenVibe account, so you can edit, renew or delete it later.', '/post')}
++<p class="muted">Posting is for people: an app or a service cannot post here.</p>`,
++ }, 401);
++ return page(req, res, {
++ title: 'Post a listing',
++ description: 'Post a place, a space or a piece of equipment for rent on OpenVibe.Rent. No payments are handled here.',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Post a listing' }],
++ body: html`<h1>Post a listing</h1>
++<p class="sc-lede">A description, a price and where it is. You can have ${listings.MAX_ACTIVE} active listings at a time and post ${listings.MAX_PER_DAY} a day; each one lasts ${listings.EXPIRY_DAYS} days and you can renew it.</p>
++${notice(listings.SAFETY, 'warn')}
++${listingForm({ action: '/post' })}`,
++ });
++ });
++
++ r.post('/post', mustSignIn('/post'), budgeted('rent.listing.create'), async (req, res) => {
++ if (!sameSite(req, res, '/post')) return;
++ const b = req.body || {};
++ const check = listings.validate(formValues(b));
++ if (!check.ok) return page(req, res, {
++ title: 'Post a listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Post a listing' }],
++ body: html`<h1>Post a listing</h1>${listingForm({ values: formValues(b), problem: check.detail, action: '/post' })}`,
++ }, 422);
++ const made = await service.create(s, requesterOf(req), check.fields);
++ if (!made.ok) return page(req, res, {
++ title: 'Post a listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Post a listing' }],
++ body: html`<h1>Post a listing</h1>${listingForm({ values: formValues(b), problem: made.detail, action: '/post' })}`,
++ }, 429);
++ return res.redirect(303, `/listings/${made.row.id}`);
++ });
++
++ // ── Editing your own ────────────────────────────────────
++ async function ownListing(req, res) {
++ const row = listings.isListingId(req.params.id) ? await store.get(s, req.params.id) : null;
++ if (!signedIn(req) || !row || row.owner !== requesterOf(req)) {
++ page(req, res, {
++ title: 'Not yours to change',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings', href: '/mine' }, { label: 'Not yours' }],
++ body: html`<h1>Not yours to change</h1><p class="muted">Only the person who posted a listing can change it. <a href="/mine">Your listings</a>.</p>`,
++ }, signedIn(req) ? 403 : 401);
++ return null;
++ }
++ return row;
++ }
++
++ const rowValues = (row) => ({
++ kind: row.kind, title: row.title, description: row.description, price: Number(row.price), currency: row.currency,
++ period: row.period, city: row.city, region: row.region || '', country: row.country, neighbourhood: row.neighbourhood || '',
++ bedrooms: row.bedrooms == null ? '' : String(row.bedrooms), availableFrom: row.available_from || '', contactUrl: row.contact_url || '',
++ });
++
++ r.get('/listings/:id/edit', mustSignIn('/mine'), async (req, res) => {
++ const row = await ownListing(req, res);
++ if (!row) return undefined;
++ if (row.state === 'removed') return page(req, res, {
++ title: 'Removed by staff',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings', href: '/mine' }, { label: 'Removed' }],
++ body: html`<h1>Removed by staff</h1><p class="muted">OpenVibe staff removed this listing, so it cannot be edited. <a href="/mine">Your listings</a>.</p>`,
++ }, 409);
++ return page(req, res, {
++ title: `Edit: ${truncate(row.title, 50)}`,
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings', href: '/mine' }, { label: truncate(row.title, 40) }],
++ body: html`<h1>Edit your listing</h1>
++<p class="small muted">${stateBadge(row.state)} · expires ${time(row.expires_at)}</p>
++${notice(listings.SAFETY, 'warn')}
++${listingForm({ values: rowValues(row), action: `/listings/${row.id}/edit`, submit: 'Save the changes' })}`,
++ });
++ });
++
++ r.post('/listings/:id/edit', mustSignIn('/mine'), async (req, res) => {
++ if (!sameSite(req, res, '/mine')) return;
++ const row = await ownListing(req, res);
++ if (!row) return undefined;
++ const b = req.body || {};
++ const check = listings.validate(formValues(b));
++ if (!check.ok) return page(req, res, {
++ title: 'Edit your listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings', href: '/mine' }, { label: 'Edit' }],
++ body: html`<h1>Edit your listing</h1>${listingForm({ values: formValues(b), problem: check.detail, action: `/listings/${row.id}/edit`, submit: 'Save the changes' })}`,
++ }, 422);
++ const f = check.fields;
++ await store.update(s, row.id, {
++ kind: f.kind, title: f.title, description: f.description, price: f.price, currency: f.currency,
++ period: f.period, city: f.city, region: f.region, country: f.country, neighbourhood: f.neighbourhood,
++ bedrooms: f.bedrooms, available_from: f.availableFrom, contact_url: f.contactUrl,
++ state: row.state === 'expired' ? 'expired' : row.state,
++ });
++ return res.redirect(303, `/listings/${row.id}`);
++ });
++
++ r.post('/listings/:id/renew', mustSignIn('/mine'), budgeted('rent.listing.renew'), async (req, res) => {
++ if (!sameSite(req, res, '/mine')) return;
++ const row = await ownListing(req, res);
++ if (!row) return undefined;
++ if (row.state !== 'removed') await store.renew(s, row.id);
++ return res.redirect(303, `/listings/${row.id}`);
++ });
++
++ r.post('/listings/:id/visibility', mustSignIn('/mine'), async (req, res) => {
++ if (!sameSite(req, res, '/mine')) return;
++ const row = await ownListing(req, res);
++ if (!row) return undefined;
++ const state = String((req.body || {}).state || '');
++ if (['active', 'hidden'].includes(state) && row.state !== 'removed' && row.state !== 'expired') await store.setState(s, row.id, state);
++ return res.redirect(303, '/mine');
++ });
++
++ r.post('/listings/:id/delete', mustSignIn('/mine'), async (req, res) => {
++ if (!sameSite(req, res, '/mine')) return;
++ const row = await ownListing(req, res);
++ if (!row) return undefined;
++ await store.remove(s, row.id);
++ return res.redirect(303, '/mine');
++ });
++
++ // ── Your listings ────────────────────────────────────────
++ r.get('/mine', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, {
++ title: 'Your listings',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings' }],
++ body: html`<h1>Your listings</h1>${signInPrompt('Your listings belong to your OpenVibe account.', '/mine')}`,
++ }, 401);
++ const rows = await store.listByOwner(s, requesterOf(req));
++ return page(req, res, {
++ title: 'Your listings',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings' }],
++ body: html`<h1>Your listings</h1>
++<p class="sc-lede">Everything you posted, newest first. ${rows.filter((x) => x.state === 'active').length} of your ${listings.MAX_ACTIVE} allowed active listings.</p>
++<p><a class="sc-btn sc-primary" href="/post">Post a listing</a> <a class="sc-btn" href="/listings">Browse listings</a></p>
++${table(['Listing', 'State', 'Price', 'Reports', 'Expires', ''], rows.map((row) => [
++ html`<a href="/listings/${row.id}">${truncate(row.title, 60)}</a>`,
++ stateBadge(row.state),
++ priceOf(row),
++ String(row.report_count),
++ time(row.expires_at),
++ html`<a href="/listings/${row.id}/edit">Edit</a>
++<form class="inline" method="post" action="/listings/${row.id}/renew"><button class="sc-btn" type="submit">Renew</button></form>
++${row.state === 'removed' ? '' : html`<form class="inline" method="post" action="/listings/${row.id}/visibility"><input type="hidden" name="state" value="${row.state === 'hidden' ? 'active' : 'hidden'}"><button class="sc-btn" type="submit">${row.state === 'hidden' ? 'Show' : 'Hide'}</button></form>`}
++<form class="inline" method="post" action="/listings/${row.id}/delete"><button class="sc-btn" type="submit">Delete</button></form>`,
++ ]), { empty: 'You have not posted anything yet.' })}`,
++ });
++ });
++
++ // ── Saved searches ───────────────────────────────────────
++ r.get('/saved', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, {
++ title: 'Saved searches',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Saved searches' }],
++ body: html`<h1>Saved searches</h1>${signInPrompt('Saved searches belong to your OpenVibe account.', '/saved')}`,
++ }, 401);
++ const rows = await store.listSaved(s, requesterOf(req));
++ const items = [];
++ for (const row of rows) {
++ const filters = typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters;
++ items.push({
++ row, filters,
++ newCount: await store.countMatching(s, listings.normalizeFilters(filters), { sinceISO: row.last_seen_at }),
++ });
++ }
++ // Looking at the page is looking at the searches: what we counted is new since this moment next time.
++ const now = s.iso();
++ for (const it of items) await store.markSavedSeen(s, it.row.id, now);
++ return page(req, res, {
++ title: 'Saved searches',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Saved searches' }],
++ body: html`<h1>Saved searches</h1>
++<p class="sc-lede">Each one is a set of filters with the count of listings that have appeared since you last looked. Save one from <a href="/listings">the listings page</a>.</p>
++${items.length
++ ? html`<ul class="saved-list">${items.map((it) => html`<li>
++<a href="/listings?${listings.filtersToQuery(it.filters)}"><b>${savedTitle(it.filters)}</b></a>
++${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html` <span class="badge">nothing new</span>`}
++<span class="small muted"> · saved ${time(it.row.created_at)} · last looked ${time(it.row.last_seen_at)}</span>
++<form class="inline" method="post" action="/saved/${it.row.id}/delete"><button class="sc-btn" type="submit">Forget it</button></form>
++</li>`)}</ul>`
++ : html`<p class="muted">No saved searches yet. Search the listings, then save the filters to see what is new each time you come back.</p>`}
++<div class="notice">Alerts that come to you — a notification, an email — arrive later through OpenVibe.Watch. For now a saved search is a page you open.</div>`,
++ });
++ });
++
++ /** A saved search as a sentence: the filters, not a JSON blob. */
++ function savedTitle(f) {
++ const bits = [];
++ if (f.q) bits.push(`“${truncate(f.q, 40)}”`);
++ if (f.kind) bits.push(listings.KIND_TEXT[f.kind] || f.kind);
++ if (f.city) bits.push(`in ${f.city}`);
++ if (f.country) bits.push(`in ${f.country}`);
++ if (f.min != null || f.max != null) bits.push(`price ${f.min == null ? 'up to' : f.max == null ? 'from' : ''} ${f.min != null ? f.min : f.max}`);
++ if (f.period) bits.push(`per ${f.period}`);
++ return bits.length ? bits.join(' ') : 'Everything for rent';
++ }
++
++ r.post('/saved', mustSignIn('/listings'), budgeted('rent.saved.create'), async (req, res) => {
++ if (!sameSite(req, res, '/listings')) return;
++ const filters = listings.normalizeFilters(req.body || {});
++ await store.saveSearch(s, { id: s.newId('ssv'), subject: requesterOf(req), filters, key: listings.filtersKey(filters), createdAt: s.iso() });
++ return res.redirect(303, '/saved');
++ });
++
++ r.post('/saved/:id/delete', mustSignIn('/saved'), async (req, res) => {
++ if (!sameSite(req, res, '/saved')) return;
++ const id = String(req.params.id || '');
++ if (listings.isSavedId(id)) await store.deleteSaved(s, id, requesterOf(req));
++ return res.redirect(303, '/saved');
++ });
++
++ // ── Reporting ────────────────────────────────────────────
++ r.post('/listings/:id/reports', mustSignIn('/listings'), budgeted('rent.listing.report'), async (req, res) => {
++ if (!sameSite(req, res, '/listings')) return;
++ const row = listings.isListingId(req.params.id) ? await store.get(s, req.params.id) : null;
++ if (!canSee(req, row) || row.state !== 'active') return page(req, res, {
++ title: 'No such listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Listings', href: '/listings' }, { label: 'Not found' }],
++ body: html`<h1>No such listing</h1><p class="muted"><a href="/listings">See what is available</a>.</p>`,
++ }, 404);
++ if (row.owner === requesterOf(req)) return page(req, res, {
++ title: 'That is your listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Your listings', href: '/mine' }],
++ body: html`<h1>That is your own listing</h1><p class="muted">You cannot report your own listing. <a href="/listings/${row.id}">Back to it</a>.</p>`,
++ }, 403);
++ const check = listings.validateReport(req.body || {});
++ if (!check.ok) return res.redirect(303, `/listings/${row.id}`);
++ const out = await store.addReport(s, row.id, { id: s.newId('rpt'), reporter: requesterOf(req), reason: check.fields.reason, note: check.fields.note, createdAt: s.iso() });
++ return res.redirect(303, `/listings/${row.id}${out.hidden ? '?reported=hidden' : '?reported=1'}`);
++ });
++
++ // ── The report queue (staff) ─────────────────────────────
++ r.get('/staff', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, {
++ title: 'Report queue',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
++ body: html`<h1>The report queue</h1>${signInPrompt('The queue is for OpenVibe staff.', '/staff')}`,
++ }, 401);
++ if (!staffViewer(req)) return page(req, res, {
++ title: 'Staff only',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
++ body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/mine">Your listings</a>.</p>`,
++ }, 403);
++ const state = ['hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
++ const out = await store.staffQueue(s, { state, limit: 50 });
++ const counts = await store.staffCounts(s);
++ const items = [];
++ for (const row of out.rows) items.push({ row, reports: await store.reportsFor(s, row.id) });
++ return page(req, res, {
++ title: 'Report queue',
++ description: 'Listings people reported, for OpenVibe staff to restore or remove.',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
++ body: html`<h1>The report queue</h1>
++<p class="sc-lede">${counts.hidden} hidden by reports, ${counts.active} reported but still active, ${counts.removed} removed. A listing is hidden automatically at ${listings.HIDE_AT_REPORTS} distinct reports.</p>
++<form class="card filters" method="get" action="/staff">
++<div class="field"><label for="state">Show</label><select id="state" name="state">
++${['hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x}</option>`)}
++</select></div>
++<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>
++</form>
++${items.length ? items.map((it) => html`<section class="card staff-item">
++<h2><a href="/listings/${it.row.id}">${truncate(it.row.title, 80)}</a> ${stateBadge(it.row.state)}</h2>
++<p class="small muted">Posted by <code>${it.row.owner}</code> · ${whereOf(it.row)} · ${priceOf(it.row)} · ${it.row.report_count} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>
++${textBlock(truncate(it.row.description, 400))}
++<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>
++<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="active"><button class="sc-btn" type="submit">Restore it</button></form>
++<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="hidden"><button class="sc-btn" type="submit">Hide it</button></form>
++<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="removed"><button class="sc-btn" type="submit">Remove it</button></form>
++</section>`) : html`<p class="muted">Nothing in the queue with that filter.</p>`}`,
++ });
++ });
++
++ r.post('/listings/:id/state', async (req, res) => {
++ if (!signedIn(req) || !staffViewer(req)) return page(req, res, {
++ title: 'Staff only',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
++ body: html`<h1>Staff only</h1><p class="muted">Restoring or removing a listing is for OpenVibe staff.</p>`,
++ }, signedIn(req) ? 403 : 401);
++ if (!sameSite(req, res, '/staff')) return;
++ const row = listings.isListingId(req.params.id) ? await store.get(s, req.params.id) : null;
++ if (!row) return page(req, res, {
++ title: 'No such listing',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue', href: '/staff' }],
++ body: html`<h1>No such listing</h1><p class="muted"><a href="/staff">The report queue</a>.</p>`,
++ }, 404);
++ const state = String((req.body || {}).state || '');
++ if (['active', 'hidden', 'removed'].includes(state)) await store.setState(s, row.id, state);
++ return res.redirect(303, '/staff');
++ });
++
++ // ── Safety ───────────────────────────────────────────────
++ r.get('/safety', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'Safety rules',
++ description: 'How to rent safely: never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.',
++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Safety' }],
++ styles: [showcase.STYLESHEET],
++ body: html`<h1>Renting safely</h1>
++${notice(listings.SAFETY, 'warn')}
++<section class="sc-sec" aria-labelledby="h-rules"><h2 id="h-rules">The rules</h2>
++<ul class="prose">
++<li><b>See it first.</b> Never pay, and never send a deposit, before you have seen the place and signed an agreement.</li>
++<li><b>OpenVibe does not handle payments for listings.</b> There is no way to pay through this site and no deposit to leave here. Anyone who asks you to pay through OpenVibe.Rent is not telling the truth.</li>
++<li><b>There is no messaging here.</b> Contact happens where the listing says it does — usually an https link or an email address. Keep a copy of everything you agree.</li>
++<li><b>Meet in person, and take someone with you</b> for a first viewing, in daylight if you can.</li>
++<li><b>Treat a price that is far below the area as a warning</b>, and never send money to hold a place you have not seen.</li>
++<li><b>Report anything that looks wrong.</b> Anyone signed in can report a listing; ${listings.HIDE_AT_REPORTS} different reports hide it from search until OpenVibe staff look at it.</li>
++</ul></section>
++<section class="sc-sec" aria-labelledby="h-addresses"><h2 id="h-addresses">No street addresses</h2>
++<p class="sc-prose">A listing here names a city, and a neighbourhood if the poster gave one — never a street address. A house number together with a street is refused when the listing is posted or edited, in the form and in the API alike. Ask for the exact address once you have made contact with the person, and see the place before you commit to anything.</p></section>
++<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where listings come from</h2>
++<p class="sc-prose">Every listing in this version was posted by a person on this site. There are no scraped listings and no partner feeds: other sources arrive only when their terms are verified, and each listing will then say where it came from and link back to it. OpenVibe has not visited or checked any listing, and takes no part in the arrangement between the person renting and the person offering.</p></section>`,
++ }));
++
// ── The update log ───────────────────────────────────────
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 33a53a7..f1ac3ce 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -3,8 +3,12 @@
/**
* Who is calling /api/v1 — req.principal:
*
-- * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
-- * or this site's session cookie
++ * { kind: 'user', requester: 'user:usr_…', project: null, role, viaSession }
++ * a person: their Network token as a
++ * Bearer, or this site's session cookie.
++ * `role` is the Network role claim
++ * (user | global_mod | admin), which is
++ * what decides staff here
* { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
* a Network app, agent or service token for
* audience openvibe.rent; each route names
@@@ -20,11 +24,23 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--// The product fills this in: one entry per capability its routes name, in openvibe-contracts
--// manifests/capabilities/<rent>.*. requireCapability('<name>') refuses a name that is not listed here, so a
--// route can never be guarded by a capability the service does not declare.
++// One entry per capability the API's routes name, from openvibe-contracts manifests/capabilities/rent.*.
++// Empty on purpose: openvibe-contracts declares no `rent.*` capability yet, and every route here is a person acting
++// for themself (posting, editing or deleting their own listing, saving their own searches) or staff judged by their
++// role claim — neither needs one. A listing is never posted, edited or deleted by an app or a service: that is a
++// rule of the product (/listings/service.js), not something to grant. The moment a route exists for an app, its
++// capability is added here and in openvibe-contracts together. requireCapability('<name>') refuses a name that is
++// not listed here, so a route can never be guarded by a capability the service does not declare.
const CAPABILITIES = [];
++/** The person's Network role claim, as a plain string (an unlisted or missing one is the lowest role). */
++function roleClaim(claims) {
++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
++}
++function usernameClaim(claims) {
++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
++}
++
function decodePayload(token) {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
@@@ -63,11 +79,11 @@ function createPrincipal({ config, keys }) {
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
-- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
++ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), viaSession: false } };
}
const viewer = req.viewer;
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
-- return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };
++ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, viaSession: true } };
}
return { principal: { kind: 'anonymous' } };
}
ddiff --git a/server/http/router.js b/server/http/router.js
iindex 088ba89..244af0c 100644
---- a/server/http/router.js
++++ b/server/http/router.js
@@@ -1,14 +1,14 @@
'use strict';
/**
-- * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
-- * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ * express.Router() whose handlers may be async: a rejected promise goes to next(err) (the app's error handler,
++ * which logs without request bodies) instead of becoming an unhandled rejection.
*/
const express = require('express');
function asyncRouter() {
const r = express.Router();
-- for (const method of ['get', 'post']) {
++ for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const orig = r[method].bind(r);
r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
try {
ddiff --git a/server/index.js b/server/index.js
iindex 9ba0794..89910fe 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -3,9 +3,13 @@
/**
* OpenVibe.Rent — process entry. `node server/index.js`
* Listens on PORT (5010) behind nginx (deploy/).
++ *
++ * The expiry sweep (server/listings/expiry.js) is started here and nowhere else: a listing's 30 days are marked
++ * expired by a timer, not by a page render, so a request never does that work and a test never races it.
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startExpiryTimer } = require('./listings/expiry');
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops and the store closes.
@@@ -28,8 +32,12 @@ async function start() {
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
-- createLifecycle({ server, ctx });
-- return { server, ctx };
++ // One sweep at boot (a restart must not leave a stale listing up a moment longer than it should), then a timer.
++ const expiry = startExpiryTimer(ctx.s);
++ await expiry.tick();
++
++ createLifecycle({ server, ctx, timers: [expiry.timer] });
++ return { server, ctx, expiry };
}
if (require.main === module) {
ddiff --git a/server/listings/expiry.js b/server/listings/expiry.js
nnew file mode 100644
iindex 0000000..7160e2e
---- /dev/null
++++ b/server/listings/expiry.js
@@@ -0,0 +1,37 @@
++'use strict';
++
++/**
++ * The expiry sweep. A listing lasts 30 days; a timer (started by server/index.js, the process entry) marks the ones
++ * whose time is up expired, and an expired listing leaves search.
++ *
++ * The timer is deliberately not part of createApp: a test would then race it, and every test that cares about
++ * expiry calls store.expireListings(s) itself, on the store's injected clock.
++ */
++const store = require('./store');
++
++const DEFAULT_EVERY_MS = 5 * 60_000;
++
++/** Returns { timer, tick }: `timer` is cleared by the process's graceful stop, `tick` is one sweep. */
++function startExpiryTimer(s, { log = console, everyMs = DEFAULT_EVERY_MS } = {}) {
++ let running = false;
++ const tick = async () => {
++ if (running) return 0;
++ running = true;
++ try {
++ const n = await store.expireListings(s);
++ if (n) log.log(`[OpenVibe.Rent] ${n} listing(s) expired`);
++ return n;
++ } catch (err) {
++ log.error('[OpenVibe.Rent] expiry sweep failed:', err && err.message ? err.message : err);
++ return 0;
++ } finally {
++ running = false;
++ }
++ };
++ const timer = setInterval(tick, everyMs);
++ // Never hold the process open on the sweep alone.
++ if (timer && typeof timer.unref === 'function') timer.unref();
++ return { timer, tick };
++}
++
++module.exports = { startExpiryTimer, DEFAULT_EVERY_MS };
ddiff --git a/server/listings/listings.js b/server/listings/listings.js
nnew file mode 100644
iindex 0000000..6fea73f
---- /dev/null
++++ b/server/listings/listings.js
@@@ -0,0 +1,320 @@
++'use strict';
++
++/**
++ * What a listing is, on both sides of the wire — the rules the pages (http/pages.js) and the API (http/api.js)
++ * share, so a form and a POST can never disagree:
++ *
++ * id rnt_<ULID> (rpt_<ULID> for a report, ssv_<ULID> for a saved search)
++ * kind apartment | room | house | commercial | parking | equipment | other
++ * price a positive number with an ISO 4217 currency and a period (month, week, day, hour, once)
++ * place city + ISO 3166-1 alpha-2 country, an optional region and neighbourhood
++ * state active | hidden | expired | removed
++ *
++ * A listing is posted by a person, never by an app or a service, and a person may have at most 10 active listings
++ * and post 5 new ones a day. It expires 30 days after posting unless the owner renews it. Three distinct reports
++ * hide it until staff look.
++ *
++ * One rule has no exception: a neighbourhood, a city, a title or a description is NEVER a street address. A house
++ * number together with a street is refused with a message that says what to write instead — the same check for the
++ * form and the API, so nothing can slip through one of them.
++ *
++ * Nothing here fetches anything: a contact URL is text somebody typed, checked for shape, and shown only to a
++ * signed-in person.
++ */
++
++const KINDS = ['apartment', 'room', 'house', 'commercial', 'parking', 'equipment', 'other'];
++const PERIODS = ['month', 'week', 'day', 'hour', 'once'];
++const STATES = ['active', 'hidden', 'expired', 'removed'];
++const REASONS = ['scam', 'wrong', 'offensive', 'other'];
++// A short list on purpose: the currencies OpenVibe.Rent shows a price in, never anything a caller invents.
++const CURRENCIES = [
++ 'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF', 'RON',
++ 'BRL', 'MXN', 'INR', 'SGD', 'HKD', 'CNY', 'KRW', 'ZAR', 'TRY', 'AED', 'ILS', 'THB', 'IDR', 'PHP', 'MYR',
++];
++// ISO 3166-1 alpha-2, the countries a listing may name (a short list, the same for the form and the API).
++const COUNTRIES = [
++ 'AR', 'AT', 'AU', 'BE', 'BG', 'BR', 'CA', 'CH', 'CL', 'CN', 'CO', 'CZ', 'DE', 'DK', 'EE', 'EG', 'ES', 'FI',
++ 'FR', 'GB', 'GR', 'HK', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IS', 'IT', 'JP', 'KE', 'KR', 'LT', 'LU', 'LV',
++ 'MA', 'MX', 'MY', 'NG', 'NL', 'NO', 'NZ', 'PE', 'PH', 'PL', 'PT', 'RO', 'RS', 'SE', 'SG', 'SI', 'SK', 'TH',
++ 'TR', 'TW', 'UA', 'US', 'VN', 'ZA',
++];
++
++const LIMITS = { title: 120, description: 4000, city: 80, region: 80, neighbourhood: 80, contactUrl: 500, note: 500, q: 200 };
++const EXPIRY_DAYS = 30;
++const MAX_ACTIVE = 10;
++const MAX_PER_DAY = 5;
++const HIDE_AT_REPORTS = 3;
++const PAGE_SIZE = 30;
++const MAX_PRICE = 1e9;
++
++/** The safety note every listing page carries, word for word. */
++const SAFETY = 'Never pay or send a deposit before you have seen the place and signed an agreement. OpenVibe does not handle payments for listings.';
++
++const ID_RE = {
++ listing: /^rnt_[0-9A-HJKMNP-TV-Z]{26}$/,
++ report: /^rpt_[0-9A-HJKMNP-TV-Z]{26}$/,
++ saved: /^ssv_[0-9A-HJKMNP-TV-Z]{26}$/,
++};
++
++const isListingId = (id) => ID_RE.listing.test(String(id || ''));
++const isReportId = (id) => ID_RE.report.test(String(id || ''));
++const isSavedId = (id) => ID_RE.saved.test(String(id || ''));
++
++/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */
++function cleanText(value, max) {
++ const s = String(value == null ? '' : value)
++ .replace(/\r\n?/g, '\n')
++ // eslint-disable-next-line no-control-regex
++ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
++ .trim();
++ return max != null ? s.slice(0, max) : s;
++}
++
++// A street address is a number and a street. These words name a street in the languages that matter here; every
++// rule below still needs a digit beside one of them, so "Shoreditch" and "Greenwich Village" are never refused.
++const STREET_WORDS = [
++ 'street', 'st', 'road', 'rd', 'avenue', 'ave', 'lane', 'ln', 'drive', 'dr', 'boulevard', 'blvd', 'way',
++ 'court', 'ct', 'place', 'pl', 'terrace', 'crescent', 'close', 'grove', 'square', 'sq', 'highway', 'hwy',
++ 'route', 'rue', 'straße', 'strasse', 'str', 'weg', 'calle', 'via', 'avenida', 'rua', 'ulica', 'piazza',
++ 'platz', 'gasse', 'quai', 'allée', 'allee', 'chemin', 'impasse',
++].join('|');
++const STREET = new RegExp(`\\b(?:${STREET_WORDS})\\b`, 'i');
++// German-style compounds append the street word to the name ("Hauptstraße"), so those suffixes are matched
++// without a word boundary in front of them; every other rule still needs a digit beside the street word.
++const COMPOUND_SUFFIX = '(?:straße|strasse|str|weg|gasse|platz|allee|chaussee)';
++const REFUSALS = [
++ /\b(?:flat|unit|apt|apartment|suite|building|block|room|no|number)\s*\.?\s*#?\s*\d/i, // "Flat 3, 12 High Street"
++ /#\s*\d/, // "#4 Cherry Lane"
++ new RegExp(`\\d[\\w-]*(?:\\s+[\\w'’.,-]+){0,4}\\s+(?:${STREET_WORDS})\\b`, 'i'), // "221B Baker Street"
++ new RegExp(`\\b(?:${STREET_WORDS})\\b\\.?(?:\\s+[\\w'’.-]+){0,3}\\s+\\d+\\s*$`, 'i'), // "Rue de la Paix 12"
++ new RegExp(`${COMPOUND_SUFFIX}\\.?\\s*\\d{1,4}[a-z]?\\s*$`, 'i'), // "Hauptstraße 12"
++];
++
++/**
++ * Does this text look like a house number and a street? ("12 Rue de la Paix", "Flat 3, 12 High St", "Hauptstraße 12").
++ * Used on every free-text place field: a listing must never publish where somebody lives.
++ */
++function looksLikeStreetAddress(text) {
++ const t = String(text == null ? '' : text);
++ if (!t) return false;
++ if (REFUSALS.some((re) => re.test(t))) return true;
++ // A number at the end of a text that names a street ("Rue de la Paix 12").
++ return STREET.test(t) && /\d+\s*[a-z]?\s*$/i.test(t);
++}
++
++/** The one message a person gets, whether they used the form or the API. */
++const STREET_REFUSAL = (field) => `${field}: use the area, not a street address — OpenVibe.Rent never publishes a house number with a street. Try "Shoreditch", "Le Marais" or "Kreuzberg".`;
++
++/** How text that got past the shape checks is refused: the same wording everywhere. */
++const refuse = (field, detail) => ({ ok: false, code: 'listing.invalid', detail: `${field}: ${detail}` });
++
++/** An ISO 4217 currency from the short list, and nothing else. */
++const isCurrency = (c) => CURRENCIES.includes(String(c || '').toUpperCase());
++const isCountry = (c) => COUNTRIES.includes(String(c || '').toUpperCase());
++
++/** An optional https URL, or a mailto: — never http, never anything else a browser would not follow. */
++function checkContactUrl(value) {
++ const raw = cleanText(value, LIMITS.contactUrl + 1);
++ if (raw.length > LIMITS.contactUrl) return { error: `at most ${LIMITS.contactUrl} characters` };
++ if (!raw) return { value: null };
++ if (/^mailto:/i.test(raw)) {
++ const address = raw.slice(7).split('?')[0];
++ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address) ? { value: `mailto:${address}` } : { error: 'mailto: needs one email address, like mailto:[email protected]' };
++ }
++ let url = null;
++ try { url = new URL(raw); } catch { return { error: 'an https:// link or a mailto: address' }; }
++ if (url.protocol !== 'https:') return { error: 'an https:// link (http is not accepted) or a mailto: address' };
++ return { value: url.href };
++}
++
++/** A date-only string (YYYY-MM-DD) that is a real date, or null. */
++function checkDate(value, field) {
++ const s = cleanText(value, 11);
++ if (!s) return { value: null };
++ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return { error: `${field}: a date as YYYY-MM-DD` };
++ const d = new Date(`${s}T00:00:00Z`);
++ if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s) return { error: `${field}: a real date as YYYY-MM-DD` };
++ return { value: s };
++}
++
++/**
++ * Check a listing as it is posted or edited. Answers { ok, fields } or { ok: false, code, detail } — one shape for
++ * the form (which re-renders what was typed) and the API (which sends it as problem+json).
++ */
++function validate(input = {}) {
++ const kind = cleanText(input.kind, 20).toLowerCase();
++ if (!KINDS.includes(kind)) return { ok: false, code: 'listing.invalid', detail: `kind: one of ${KINDS.join(', ')}` };
++
++ const title = cleanText(input.title, LIMITS.title + 1);
++ if (!title) return refuse('title', 'say what you are renting (1 to 120 characters)');
++ if (title.length > LIMITS.title) return refuse('title', `at most ${LIMITS.title} characters`);
++ if (looksLikeStreetAddress(title)) return { ok: false, code: 'listing.address_refused', detail: STREET_REFUSAL('title') };
++
++ const description = cleanText(input.description, LIMITS.description + 1);
++ if (!description) return refuse('description', 'describe it (1 to 4000 characters)');
++ if (description.length > LIMITS.description) return refuse('description', `at most ${LIMITS.description} characters`);
++ if (looksLikeStreetAddress(description)) return { ok: false, code: 'listing.address_refused', detail: STREET_REFUSAL('description') };
++
++ const priceRaw = typeof input.price === 'number' ? input.price : Number(cleanText(input.price, 24).replace(/,/g, ''));
++ if (!Number.isFinite(priceRaw) || priceRaw <= 0) return refuse('price', 'a number greater than zero');
++ if (priceRaw > MAX_PRICE) return refuse('price', 'a number no greater than 1,000,000,000');
++ const price = Math.round(priceRaw * 100) / 100;
++
++ const currency = cleanText(input.currency, 8).toUpperCase();
++ if (!isCurrency(currency)) return refuse('currency', `one of ${CURRENCIES.slice(0, 8).join(', ')}… (an ISO 4217 code we support)`);
++
++ const period = cleanText(input.period, 10).toLowerCase();
++ if (!PERIODS.includes(period)) return refuse('period', `one of ${PERIODS.join(', ')}`);
++
++ const city = cleanText(input.city, LIMITS.city + 1);
++ if (!city) return refuse('city', 'the city or town (1 to 80 characters)');
++ if (city.length > LIMITS.city) return refuse('city', `at most ${LIMITS.city} characters`);
++ if (looksLikeStreetAddress(city)) return { ok: false, code: 'listing.address_refused', detail: STREET_REFUSAL('city') };
++
++ const region = cleanText(input.region, LIMITS.region + 1);
++ if (region.length > LIMITS.region) return refuse('region', `at most ${LIMITS.region} characters`);
++ if (looksLikeStreetAddress(region)) return { ok: false, code: 'listing.address_refused', detail: STREET_REFUSAL('region') };
++
++ const country = cleanText(input.country, 8).toUpperCase();
++ if (!isCountry(country)) return refuse('country', 'a two-letter ISO 3166-1 country code we support (US, GB, DE, FR…)');
++
++ const neighbourhood = cleanText(input.neighbourhood, LIMITS.neighbourhood + 1);
++ if (neighbourhood.length > LIMITS.neighbourhood) return refuse('neighbourhood', `at most ${LIMITS.neighbourhood} characters`);
++ if (looksLikeStreetAddress(neighbourhood)) return { ok: false, code: 'listing.address_refused', detail: STREET_REFUSAL('neighbourhood') };
++
++ let bedrooms = null;
++ const bedroomsRaw = cleanText(input.bedrooms, 6);
++ if (bedroomsRaw) {
++ if (!/^\d{1,2}$/.test(bedroomsRaw)) return refuse('bedrooms', 'a whole number (0 to 50)');
++ bedrooms = Number(bedroomsRaw);
++ if (bedrooms > 50) return refuse('bedrooms', 'a whole number (0 to 50)');
++ }
++
++ const from = checkDate(input.availableFrom != null ? input.availableFrom : input.available_from, 'available_from');
++ if (from.error) return refuse('available_from', from.error.replace(/^available_from: /, ''));
++
++ const contact = checkContactUrl(input.contactUrl != null ? input.contactUrl : input.contact_url);
++ if (contact.error) return refuse('contact_url', contact.error);
++
++ return {
++ ok: true,
++ fields: {
++ kind, title, description, price, currency, period, city,
++ region: region || null, country, neighbourhood: neighbourhood || null,
++ bedrooms, availableFrom: from.value, contactUrl: contact.value,
++ },
++ };
++}
++
++/** A report's own fields: a reason from the list, an optional note. */
++function validateReport({ reason, note } = {}) {
++ const r = cleanText(reason, 20).toLowerCase();
++ if (!REASONS.includes(r)) return { ok: false, code: 'report.invalid', detail: `reason: one of ${REASONS.join(', ')}` };
++ const text = cleanText(note, LIMITS.note + 1);
++ if (text.length > LIMITS.note) return { ok: false, code: 'report.invalid', detail: `note: at most ${LIMITS.note} characters` };
++ return { ok: true, fields: { reason: r, note: text || null } };
++}
++
++/**
++ * The search filters, normalised: what /listings, GET /api/v1/listings, a saved search and its "new since you last
++ * looked" count all share. A filter that is not one we know is ignored, never an error — a stale bookmark still
++ * shows listings.
++ */
++function normalizeFilters(input = {}) {
++ const q = cleanText(input.q, LIMITS.q + 1).slice(0, LIMITS.q);
++ const kind = KINDS.includes(String(input.kind || '').toLowerCase()) ? String(input.kind).toLowerCase() : null;
++ const city = cleanText(input.city, LIMITS.city + 1).slice(0, LIMITS.city) || null;
++ const country = isCountry(input.country) ? String(input.country).toUpperCase() : null;
++ const period = PERIODS.includes(String(input.period || '').toLowerCase()) ? String(input.period).toLowerCase() : null;
++ const num = (v) => {
++ const text = cleanText(v, 24).replace(/,/g, '').trim();
++ if (!text) return null; // an empty box is no filter, not a price of zero
++ const n = Number(text);
++ return Number.isFinite(n) && n >= 0 ? n : null;
++ };
++ let min = num(input.min);
++ let max = num(input.max);
++ if (min != null && max != null && min > max) { const t = min; min = max; max = t; }
++ return { q: q || null, kind, city, country, period, min, max };
++}
++
++/** The canonical string of a filter set: one saved search per distinct set, and the count query's key. */
++const filtersKey = (f) => JSON.stringify([f.q || null, f.kind || null, f.city || null, f.country || null, f.period || null, f.min == null ? null : Number(f.min), f.max == null ? null : Number(f.max)]);
++
++/** The query string a filter set round-trips through (links, the form, a saved search). */
++function filtersToQuery(f) {
++ const p = new URLSearchParams();
++ for (const [k, v] of [['q', f.q], ['kind', f.kind], ['city', f.city], ['country', f.country], ['period', f.period], ['min', f.min], ['max', f.max]]) {
++ if (v != null && v !== '') p.set(k, String(v));
++ }
++ return p.toString();
++}
++
++const isState = (s) => STATES.includes(String(s || ''));
++const isReason = (r) => REASONS.includes(String(r || ''));
++
++const KIND_TEXT = { apartment: 'Apartment', room: 'Room', house: 'House', commercial: 'Commercial space', parking: 'Parking or storage', equipment: 'Equipment', other: 'Other' };
++const PERIOD_TEXT = { month: 'a month', week: 'a week', day: 'a day', hour: 'an hour', once: 'once' };
++
++/** "€1,200 a month" — the price as a person reads it. The currency is a code, not a symbol: we never guess one. */
++function priceText(row) {
++ const n = Number(row.price);
++ const amount = Number.isFinite(n) ? n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 }) : String(row.price);
++ return `${amount} ${row.currency} ${PERIOD_TEXT[row.period] || row.period}`;
++}
++
++/**
++ * A listing row → the JSON the API answers with. contact_url is included only when the caller is a signed-in
++ * person: the whole point of the field is that a stranger cannot harvest it from a public page.
++ */
++function toWire(row, { contact = false } = {}) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ kind: row.kind,
++ title: row.title,
++ description: row.description,
++ price: Number(row.price),
++ currency: row.currency,
++ period: row.period,
++ city: row.city,
++ region: row.region || null,
++ country: row.country,
++ neighbourhood: row.neighbourhood || null,
++ bedrooms: row.bedrooms == null ? null : Number(row.bedrooms),
++ available_from: row.available_from || null,
++ contact_url: contact ? (row.contact_url || null) : undefined,
++ state: row.state,
++ report_count: Number(row.report_count || 0),
++ created_at: row.created_at,
++ updated_at: row.updated_at,
++ expires_at: row.expires_at,
++ };
++}
++
++const toWireReport = (row) => ({
++ id: row.id,
++ listing_id: row.listing_id,
++ reporter: { type: 'user', id: String(row.reporter).replace(/^user:/, '') },
++ reason: row.reason,
++ note: row.note || null,
++ created_at: row.created_at,
++});
++
++const toWireSaved = (row, { newCount = null } = {}) => ({
++ id: row.id,
++ filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters,
++ url: `/listings?${filtersToQuery(typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters)}`,
++ created_at: row.created_at,
++ last_seen_at: row.last_seen_at,
++ ...(newCount == null ? {} : { new_count: newCount }),
++});
++
++module.exports = {
++ KINDS, PERIODS, STATES, REASONS, CURRENCIES, COUNTRIES, LIMITS,
++ EXPIRY_DAYS, MAX_ACTIVE, MAX_PER_DAY, HIDE_AT_REPORTS, PAGE_SIZE, MAX_PRICE, SAFETY,
++ KIND_TEXT, PERIOD_TEXT,
++ isListingId, isReportId, isSavedId, isState, isReason, isCurrency, isCountry,
++ cleanText, looksLikeStreetAddress, STREET_REFUSAL, validate, validateReport,
++ normalizeFilters, filtersKey, filtersToQuery, priceText, toWire, toWireReport, toWireSaved,
++};
ddiff --git a/server/listings/service.js b/server/listings/service.js
nnew file mode 100644
iindex 0000000..5b31ca3
---- /dev/null
++++ b/server/listings/service.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * Posting rules that need the database, shared by the API (http/api.js) and the form (http/pages.js) so the two
++ * doors enforce the same numbers:
++ *
++ * a person, never an app or a service (the guard lives in http/api.js and http/pages.js)
++ * at most 10 active listings per person
++ * at most 5 new listings a day (UTC)
++ * 30 days until expiry, renewable by the owner
++ *
++ * The refusal is { ok: false, code, detail } — an API problem+json, or the sentence the form re-renders with.
++ */
++const listings = require('./listings');
++const store = require('./store');
++
++/** Post a listing for a person, or say why not. */
++async function create(s, owner, fields) {
++ const active = await store.countActiveByOwner(s, owner);
++ if (active >= listings.MAX_ACTIVE) {
++ return { ok: false, code: 'listing.limit_active', detail: `You already have ${listings.MAX_ACTIVE} active listings (the most one person can have). Hide or delete one first — /mine lists them.` };
++ }
++ const today = await store.countCreatedToday(s, owner);
++ if (today >= listings.MAX_PER_DAY) {
++ return { ok: false, code: 'listing.limit_daily', detail: `You have posted ${listings.MAX_PER_DAY} listings today (the most one person can post in a day). Try again tomorrow.` };
++ }
++ const now = s.now();
++ const row = await store.insert(s, {
++ id: s.newId('rnt'),
++ owner,
++ kind: fields.kind, title: fields.title, description: fields.description,
++ price: fields.price, currency: fields.currency, period: fields.period,
++ city: fields.city, region: fields.region, country: fields.country, neighbourhood: fields.neighbourhood,
++ bedrooms: fields.bedrooms, available_from: fields.availableFrom, contact_url: fields.contactUrl,
++ created_at: new Date(now).toISOString(),
++ expires_at: new Date(now + listings.EXPIRY_DAYS * store.DAY_MS).toISOString(),
++ });
++ return { ok: true, row };
++}
++
++/** Who may change a listing: the person who posted it. Staff act through POST /listings/:id/state, not as its owner. */
++const isOwner = (requester, row) => Boolean(row) && row.owner === requester;
++
++module.exports = { create, isOwner };
ddiff --git a/server/listings/staff.js b/server/listings/staff.js
nnew file mode 100644
iindex 0000000..a0e56fc
---- /dev/null
++++ b/server/listings/staff.js
@@@ -0,0 +1,21 @@
++'use strict';
++
++/**
++ * Who is staff at OpenVibe.Rent, exactly as at OpenVibe.Help: the Network role claim on the person's session
++ * (server/auth/sso.js puts `role` on req.viewer; http/principal.js puts it on req.principal). The roles are
++ * OpenVibe.Network's (manifests/policy/staff-roles.json): `global_mod` and `admin` may read the report queue and
++ * restore or remove a listing.
++ *
++ * One place decides staff, so the pages and the API cannot drift apart. Anonymous, an app token and an unlisted
++ * role are not staff.
++ */
++const STAFF_ROLES = ['admin', 'global_mod'];
++
++const roleOf = (who) => (typeof who === 'string' ? who : (who && typeof who.role === 'string' ? who.role : null));
++
++function isStaff(who) {
++ if (who && typeof who === 'object' && who.kind && who.kind !== 'user') return false;
++ return STAFF_ROLES.includes(roleOf(who));
++}
++
++module.exports = { isStaff, STAFF_ROLES };
ddiff --git a/server/listings/store.js b/server/listings/store.js
nnew file mode 100644
iindex 0000000..26a9504
---- /dev/null
++++ b/server/listings/store.js
@@@ -0,0 +1,195 @@
++'use strict';
++
++/**
++ * Listings, reports and saved searches in OpenVibe.Rent's PostgreSQL database (migrations/0002_rent.sql). Every
++ * function takes the store (server/db.js createStore) first; the clock is the store's own, so a test can move time
++ * and watch a listing expire.
++ *
++ * Search is one shape for /listings, GET /api/v1/listings, a saved search and its "new since you last looked"
++ * count: a filter set (server/listings/listings.js normalizeFilters) compiled to one WHERE clause, so the three can
++ * never mean different things. Only active listings are ever listed; a hidden, expired or removed one stays
++ * reachable by its owner and by staff, and by nobody else.
++ */
++
++const listings = require('./listings');
++
++const DAY_MS = 86_400_000;
++
++/** The WHERE clause for a filter set. `exclude` leaves one dimension out — how the kind and country facets count. */
++function buildWhere(f, { exclude = [], sinceISO = null } = {}) {
++ const parts = ["state = 'active'"];
++ const values = [];
++ const p = (v) => { values.push(v); return `$${values.length}`; };
++ if (f.q && !exclude.includes('q')) {
++ const ph = p(f.q.toLowerCase());
++ parts.push(`(strpos(lower(title), ${ph}) > 0 OR strpos(lower(description), ${ph}) > 0 OR strpos(lower(city), ${ph}) > 0 OR strpos(lower(coalesce(region, '')), ${ph}) > 0 OR strpos(lower(coalesce(neighbourhood, '')), ${ph}) > 0)`);
++ }
++ if (f.kind && !exclude.includes('kind')) parts.push(`kind = ${p(f.kind)}`);
++ if (f.city && !exclude.includes('city')) parts.push(`lower(city) = lower(${p(f.city)})`);
++ if (f.country && !exclude.includes('country')) parts.push(`country = ${p(f.country)}`);
++ if (f.period && !exclude.includes('period')) parts.push(`period = ${p(f.period)}`);
++ if (f.min != null && !exclude.includes('price')) parts.push(`price >= ${p(f.min)}`);
++ if (f.max != null && !exclude.includes('price')) parts.push(`price <= ${p(f.max)}`);
++ if (sinceISO) parts.push(`created_at > ${p(sinceISO)}`);
++ return { sql: parts.join(' AND '), values };
++}
++
++async function insert(s, row) {
++ await s.db.query(
++ `INSERT INTO rent_listings (id, owner, kind, title, description, price, currency, period, city, region, country,
++ neighbourhood, bedrooms, available_from, contact_url, state, report_count, created_at, updated_at, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'active', 0, $16, $16, $17)`,
++ [row.id, row.owner, row.kind, row.title, row.description, row.price, row.currency, row.period, row.city,
++ row.region, row.country, row.neighbourhood, row.bedrooms, row.available_from, row.contact_url,
++ row.created_at, row.expires_at]);
++ return get(s, row.id);
++}
++
++const get = (s, id) => s.db.maybe('SELECT * FROM rent_listings WHERE id = $1', [id]);
++
++/** Newest first, a page at a time, by cursor: the ULID's own order is the listing's order. */
++async function search(s, filters, { limit = listings.PAGE_SIZE, before = null } = {}) {
++ const w = buildWhere(filters);
++ const values = [...w.values];
++ let sql = `SELECT * FROM rent_listings WHERE ${w.sql}`;
++ if (before) { values.push(before); sql += ` AND id < $${values.length}`; }
++ values.push(limit + 1);
++ sql += ` ORDER BY id DESC LIMIT $${values.length}`;
++ const rows = await s.db.many(sql, values);
++ const page = rows.slice(0, limit);
++ return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
++}
++
++const countMatching = async (s, filters, { sinceISO = null } = {}) => {
++ const w = buildWhere(filters, { sinceISO });
++ return Number(await s.db.value(`SELECT count(*) FROM rent_listings WHERE ${w.sql}`, w.values));
++};
++
++/** How many listings each kind and each country has, under the filters that are not that dimension. */
++async function facets(s, filters) {
++ const group = async (column) => {
++ const w = buildWhere(filters, { exclude: [column === 'kind' ? 'kind' : 'country'] });
++ const rows = await s.db.many(`SELECT ${column} AS value, count(*) AS n FROM rent_listings WHERE ${w.sql} GROUP BY ${column} ORDER BY n DESC, value ASC`, w.values);
++ return rows.map((r) => ({ value: r.value, count: Number(r.n) }));
++ };
++ return { kind: await group('kind'), country: await group('country') };
++}
++
++/** Patch the columns a person may change (only the ones named); updated_at always moves. */
++async function update(s, id, patch) {
++ const cols = Object.keys(patch);
++ const sets = cols.map((c, i) => `${c} = $${i + 3}`);
++ await s.db.query(`UPDATE rent_listings SET ${sets.join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
++ return get(s, id);
++}
++
++/** Every active listing whose 30 days are up becomes expired — the sweep a timer runs in production. */
++async function expireListings(s) {
++ const now = s.iso();
++ return Number(await s.db.exec("UPDATE rent_listings SET state = 'expired', updated_at = $1 WHERE state = 'active' AND expires_at <= $1", [now]));
++}
++
++/** The owner may renew: 30 days from now (not a top-up that could run far past it), and an expired listing returns. */
++async function renew(s, id) {
++ const now = s.now();
++ const expires = new Date(now + listings.EXPIRY_DAYS * DAY_MS).toISOString();
++ await s.db.query("UPDATE rent_listings SET expires_at = $2, updated_at = $3, state = CASE WHEN state = 'expired' THEN 'active' ELSE state END WHERE id = $1", [id, expires, s.iso()]);
++ return get(s, id);
++}
++
++/** Staff action: restore (active), hide or remove. */
++async function setState(s, id, state) {
++ await s.db.query('UPDATE rent_listings SET state = $2, updated_at = $3 WHERE id = $1', [id, state, s.iso()]);
++ return get(s, id);
++}
++
++const remove = (s, id) => s.db.exec('DELETE FROM rent_listings WHERE id = $1', [id]);
++
++const countActiveByOwner = (s, owner) => s.db.value("SELECT count(*) FROM rent_listings WHERE owner = $1 AND state = 'active'", [owner]).then(Number);
++
++/** Listings this person posted today (UTC) — the 5-a-day cap. */
++const countCreatedToday = (s, owner) => s.db.value('SELECT count(*) FROM rent_listings WHERE owner = $1 AND created_at >= $2', [owner, new Date(s.now()).toISOString().slice(0, 10)])
++ .then(Number);
++
++const listByOwner = (s, owner, { limit = 100 } = {}) => s.db.many('SELECT * FROM rent_listings WHERE owner = $1 ORDER BY id DESC LIMIT $2', [owner, limit]);
++
++// ── Reports ────────────────────────────────────────────────────────────────────────────────────────────────────
++
++const reportBy = (s, listingId, reporter) => s.db.maybe('SELECT * FROM rent_reports WHERE listing_id = $1 AND reporter = $2', [listingId, reporter]);
++const reportsFor = (s, listingId) => s.db.many('SELECT * FROM rent_reports WHERE listing_id = $1 ORDER BY created_at, id', [listingId]);
++
++/**
++ * Record a report. One per reporter per listing (the unique index is the truth, not a read-then-write). The third
++ * distinct report hides the listing until staff look — counted once, when the threshold is crossed, so a listing a
++ * member of staff has restored is not hidden again by the same reports.
++ */
++async function addReport(s, listingId, { id, reporter, reason, note, createdAt }) {
++ return s.tx(async () => {
++ const row = await s.db.maybe('SELECT * FROM rent_listings WHERE id = $1', [listingId]);
++ if (!row) return { error: 'listing.not_found' };
++ if (row.state === 'removed') return { error: 'listing.removed' };
++ if (await reportBy(s, listingId, reporter)) return { error: 'report.duplicate' };
++ let report = null;
++ try {
++ report = await s.db.maybe('INSERT INTO rent_reports (id, listing_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
++ [id, listingId, reporter, reason, note, createdAt]);
++ } catch (err) {
++ if (err && err.code === '23505') return { error: 'report.duplicate' };
++ throw err;
++ }
++ const count = Number(await s.db.value('SELECT count(*) FROM rent_reports WHERE listing_id = $1', [listingId]));
++ const hide = row.state === 'active' && Number(row.report_count) < listings.HIDE_AT_REPORTS && count >= listings.HIDE_AT_REPORTS;
++ const after = await s.db.maybe(
++ `UPDATE rent_listings SET report_count = $2, updated_at = $3, state = CASE WHEN $4 THEN 'hidden' ELSE state END WHERE id = $1 RETURNING *`,
++ [listingId, count, s.iso(), hide]);
++ return { row: after, report, hidden: hide };
++ });
++}
++
++/**
++ * The queue staff read: the listings somebody reported, newest posting first, cursor-paged like search. `state`
++ * filters to the ones hidden by reports (the default), the ones still active or the ones staff removed.
++ */
++async function staffQueue(s, { state = 'hidden', limit = 50, before = null } = {}) {
++ const values = [];
++ const parts = ["report_count > 0"];
++ if (state === 'hidden') parts.push("state = 'hidden'");
++ if (state === 'active') parts.push("state = 'active'");
++ if (state === 'removed') parts.push("state = 'removed'");
++ let sql = `SELECT * FROM rent_listings WHERE ${parts.join(' AND ')}`;
++ if (before) { values.push(before); sql += ` AND id < $${values.length}`; }
++ values.push(limit + 1);
++ sql += ` ORDER BY id DESC LIMIT $${values.length}`;
++ const rows = await s.db.many(sql, values);
++ const page = rows.slice(0, limit);
++ return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
++}
++
++const staffCounts = async (s) => ({
++ hidden: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'hidden'")),
++ active: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'active'")),
++ removed: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'removed'")),
++});
++
++// ── Saved searches ─────────────────────────────────────────────────────────────────────────────────────────────
++
++async function saveSearch(s, { id, subject, filters, key, createdAt }) {
++ await s.db.query(
++ `INSERT INTO rent_saved_searches (id, subject, filters, filters_key, created_at, last_seen_at) VALUES ($1, $2, $3::jsonb, $4, $5, $5)
++ ON CONFLICT (subject, filters_key) DO NOTHING`,
++ [id, subject, JSON.stringify(filters), key, createdAt]);
++ return s.db.maybe('SELECT * FROM rent_saved_searches WHERE subject = $1 AND filters_key = $2', [subject, key]);
++}
++
++const listSaved = (s, subject) => s.db.many('SELECT * FROM rent_saved_searches WHERE subject = $1 ORDER BY id DESC', [subject]);
++const getSaved = (s, id) => s.db.maybe('SELECT * FROM rent_saved_searches WHERE id = $1', [id]);
++const deleteSaved = (s, id, subject) => s.db.exec('DELETE FROM rent_saved_searches WHERE id = $1 AND subject = $2', [id, subject]);
++const markSavedSeen = (s, id, at) => s.db.exec('UPDATE rent_saved_searches SET last_seen_at = $2 WHERE id = $1', [id, at]);
++
++module.exports = {
++ insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,
++ countActiveByOwner, countCreatedToday, listByOwner,
++ reportsFor, addReport, staffQueue, staffCounts,
++ saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,
++ DAY_MS,
++};
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 7665da4..0b4ddc0 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -22,8 +22,13 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Rent';
const TAGLINE = 'Find a place, or rent yours out.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
--// The product's own navigation: one entry per public page it serves.
++// The product's own navigation: one entry per public page it serves. /mine, /saved and the staff queue /staff are
++// 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: 'Listings', href: '/listings' },
++ { label: 'Post a listing', href: '/post' },
++ { label: 'Safety', href: '/safety' },
{ label: 'What shipped', href: '/updates' },
];
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex e1c2a0f..a1c4272 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -20,8 +20,11 @@ const READ = 'rent.api.read';
const rosa = t.network.addUser('rosa');
const sam = t.network.addUser('sam');
-- await check('BUDGETS starts empty: the product declares its own expensive routes', () => {
-- assert.deepStrictEqual(BUDGETS, {});
++ await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['rent.listing.create', 'rent.listing.renew', 'rent.listing.report', 'rent.saved.create']);
++ for (const [name, own] of Object.entries(BUDGETS)) {
++ assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
++ }
});
await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
ddiff --git a/test/helpers/rent.js b/test/helpers/rent.js
nnew file mode 100644
iindex 0000000..7a8dad9
---- /dev/null
++++ b/test/helpers/rent.js
@@@ -0,0 +1,27 @@
++'use strict';
++/**
++ * What the listing tests share: the same-origin header every signed-in write needs (a session-cookie write is only
++ * accepted from openvibe.rent itself) and a valid listing body to override field by field.
++ */
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++const listing = (over = {}) => ({
++ kind: 'apartment',
++ title: 'Sunny two-bedroom flat near the park',
++ description: 'Quiet street, plenty of light, available from the end of the month.',
++ price: 1200,
++ currency: 'EUR',
++ period: 'month',
++ city: 'Lisbon',
++ region: '',
++ country: 'PT',
++ neighbourhood: 'Alfama',
++ bedrooms: 2,
++ contact_url: 'https://example.com/rooms/1',
++ ...over,
++});
++
++/** POST /api/v1/listings as somebody, same-origin by default. */
++const post = (t, as, over = {}, o = {}) => t.get('/api/v1/listings', { as, json: listing(over), headers: { ...SAME, ...(o.headers || {}) } });
++
++module.exports = { SAME, listing, post };
ddiff --git a/test/listings-pages.test.js b/test/listings-pages.test.js
nnew file mode 100644
iindex 0000000..ab80c4a
---- /dev/null
++++ b/test/listings-pages.test.js
@@@ -0,0 +1,242 @@
++'use strict';
++/**
++ * The pages, as a person without JavaScript uses them: home, /listings, one listing, /post, /mine, /saved and
++ * /safety. Every page is server-rendered and every write is a plain form POST that must come from this site. The
++ * safety note is on the listing page word for word, a saved search counts what is new since you last looked, and
++ * a refused form gives back what was typed, escaped.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, post } = require('./helpers/rent');
++const listings = require('../server/listings/listings');
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const nia = t.network.addUser('nia');
++
++ await check('the home page: a search box, the latest listings, how it works, and no payments said plainly', async () => {
++ const made = await post(t, kim, { title: 'A flat on the home page' });
++ assert.strictEqual(made.status, 201, made.text);
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /action="\/listings"/, 'the search box posts to /listings');
++ assert.ok(r.text.includes('A flat on the home page'), 'the latest listings are on the home page');
++ assert.match(r.text, /How it works/);
++ assert.ok(r.text.includes('OpenVibe does not handle payments for listings'), 'the no-payments rule');
++ assert.match(r.text, /no scraped listings and no partner feeds/i, 'the sources-to-come statement');
++ assert.match(r.text, /href="\/post"/);
++ assert.match(r.text, /href="\/safety"/);
++ });
++
++ await check('the safety note is on every listing page, word for word', async () => {
++ const made = await post(t, kim, { title: 'A flat with a safety note' });
++ const id = made.json().id;
++ for (const path of [`/listings/${id}`, '/safety']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, path);
++ assert.ok(r.text.includes(listings.SAFETY), `${path} is missing the safety note`);
++ }
++ });
++
++ await check('/listings renders the filters, the facets and the results, and never the contact link when signed out', async () => {
++ await post(t, kim, { kind: 'room', city: 'Lisbon', title: 'A room in Lisbon' });
++ const r = await t.get('/listings?kind=room&city=Lisbon', { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('A room in Lisbon'), 'the match is listed');
++ assert.ok(!r.text.includes('A flat on the home page'), 'a filtered listing is not');
++ assert.match(r.text, /Kind:/, 'a kind facet');
++ assert.match(r.text, /Country:/, 'a country facet');
++ assert.match(r.text, /Save this search/i, 'a signed-in-only save form is offered to a signed-in viewer');
++ assert.ok(!r.text.includes('https://example.com/rooms/1'), 'no contact link on a signed-out page');
++ const empty = await t.get('/listings?q=nothingmatchesthis', { as: kim });
++ assert.strictEqual(empty.status, 200);
++ assert.match(empty.text, /No listing matches those filters/);
++ const bad = await t.get('/listings?kind=nonsense&before=nope');
++ assert.strictEqual(bad.status, 200, 'a filter we do not know is not an error');
++ });
++
++ await check('/post: sign-in is asked for, and the form is the same rules as the API', async () => {
++ const out = await t.get('/post');
++ assert.strictEqual(out.status, 401);
++ assert.match(out.text, /\/auth\/login\?next=%2Fpost/);
++
++ const page = await t.get('/post', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /action="\/post"/);
++ assert.match(page.text, /name="currency"/);
++
++ const crossSite = await t.get('/post', { as: kim, form: { kind: 'apartment', title: 'x' } });
++ assert.strictEqual(crossSite.status, 403, 'a form from another site cannot post as the person');
++
++ const refused = await t.get('/post', {
++ as: kim, headers: SAME,
++ form: { kind: 'apartment', title: 'A flat at 12 Rue de la Paix', description: 'Nice.', price: '900', currency: 'EUR', period: 'month', city: 'Paris', country: 'FR' },
++ });
++ assert.strictEqual(refused.status, 422);
++ assert.match(refused.text, /street address/);
++ assert.ok(refused.text.includes('A flat at 12 Rue de la Paix'), 'the refused text comes back, escaped');
++
++ const made = await t.get('/post', {
++ as: kim, headers: SAME,
++ form: { kind: 'equipment', title: 'A camera for the weekend', description: 'Two lenses, a bag and a charger.', price: '35', currency: 'GBP', period: 'day', city: 'London', country: 'GB', neighbourhood: 'Shoreditch', contact_url: 'mailto:[email protected]' },
++ });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
++ const where = made.headers.get('location');
++ assert.match(where, /^\/listings\/rnt_/);
++ const shown = await t.get(where, { as: kim });
++ assert.ok(shown.text.includes('A camera for the weekend'));
++ assert.ok(shown.text.includes('mailto:[email protected]'));
++ const anon = await t.get(where);
++ assert.ok(!anon.text.includes('mailto:[email protected]'), 'the contact address is not on a signed-out page');
++ });
++
++ await check('/mine lists your own listings with their actions, and only yours', async () => {
++ assert.strictEqual((await t.get('/mine')).status, 401);
++ const mine = await t.get('/mine', { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.ok(mine.text.includes('A flat on the home page'));
++ assert.ok(!mine.text.includes('Nothing here yet'), 'the table is not the empty state');
++ const theirs = await t.get('/mine', { as: lee });
++ assert.ok(!theirs.text.includes('A flat on the home page'), "somebody else's listing is not on your page");
++ assert.match(theirs.text, /You have not posted anything yet/);
++ });
++
++ await check('the owner edits, renews, hides and deletes through the pages; a stranger cannot', async () => {
++ const made = await post(t, kim, { title: 'Mine to change' });
++ const id = made.json().id;
++
++ const form = await t.get(`/listings/${id}/edit`, { as: kim });
++ assert.strictEqual(form.status, 200);
++ assert.ok(form.text.includes('Mine to change'));
++ assert.strictEqual((await t.get(`/listings/${id}/edit`, { as: lee })).status, 403);
++ assert.strictEqual((await t.get(`/listings/${id}/edit`)).status, 401);
++
++ const edited = await t.get(`/listings/${id}/edit`, {
++ as: kim, headers: SAME,
++ form: { kind: 'apartment', title: 'Renamed through the form', description: 'Still nice.', price: '1300', currency: 'EUR', period: 'month', city: 'Lisbon', country: 'PT' },
++ });
++ assert.strictEqual(edited.status, 303);
++ assert.strictEqual((await t.get(`/api/v1/listings/${id}`, { as: kim })).json().title, 'Renamed through the form');
++
++ const renewed = await t.get(`/listings/${id}/renew`, { as: kim, method: 'POST', headers: SAME });
++ assert.strictEqual(renewed.status, 303);
++ assert.ok(Date.parse((await t.get(`/api/v1/listings/${id}`, { as: kim })).json().expires_at) > Date.now());
++
++ const hidden = await t.get(`/listings/${id}/visibility`, { as: kim, form: { state: 'hidden' }, headers: SAME });
++ assert.strictEqual(hidden.status, 303);
++ assert.strictEqual((await t.get(`/api/v1/listings/${id}`, { as: kim })).json().state, 'hidden');
++ assert.strictEqual((await t.get(`/api/v1/listings/${id}`)).status, 404, 'hidden leaves the public');
++
++ assert.strictEqual((await t.get(`/listings/${id}/visibility`, { as: lee, form: { state: 'active' }, headers: SAME })).status, 403);
++ assert.strictEqual((await t.get(`/listings/${id}/delete`, { as: lee, method: 'POST', headers: SAME })).status, 403);
++
++ const deleted = await t.get(`/listings/${id}/delete`, { as: kim, method: 'POST', headers: SAME });
++ assert.strictEqual(deleted.status, 303);
++ assert.strictEqual((await t.get(`/api/v1/listings/${id}`, { as: kim })).status, 404);
++ });
++
++ await check('saved searches: save the filters, see what is new since you last looked', async () => {
++ assert.strictEqual((await t.get('/saved')).status, 401);
++ assert.strictEqual((await t.get('/saved', { as: kim })).status, 200);
++
++ const saved = await t.get('/saved', { as: kim, form: { kind: 'room', city: 'Lisbon' }, headers: SAME });
++ assert.strictEqual(saved.status, 303);
++ const list = (await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches;
++ assert.strictEqual(list.length, 1);
++ assert.deepStrictEqual(list[0].filters, { q: null, kind: 'room', city: 'Lisbon', country: null, period: null, min: null, max: null });
++ // Nothing has appeared since it was saved: everything that matches was already there.
++ assert.strictEqual(list[0].new_count, 0);
++ assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches[0].new_count, 0);
++
++ // The same filters again: one saved search, not two.
++ await t.get('/saved', { as: kim, form: { kind: 'room', city: 'Lisbon' }, headers: SAME });
++ assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.length, 1);
++
++ // A matching listing appears, and a non-matching one does not count.
++ assert.strictEqual((await post(t, lee, { kind: 'room', city: 'Lisbon', title: 'A new room in Lisbon' })).status, 201);
++ assert.strictEqual((await post(t, lee, { kind: 'apartment', city: 'Porto', title: 'A flat in Porto' })).status, 201);
++ const after = (await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches[0];
++ assert.strictEqual(after.new_count, 1, 'only the listing that matches the filters is new');
++
++ const page = await t.get('/saved', { as: kim });
++ assert.match(page.text, /1 new/);
++ assert.match(page.text, /Room in Lisbon/i, 'the filters are shown as a sentence');
++ assert.match(page.text, /OpenVibe\.Watch/, 'the alert story names OpenVibe.Watch');
++ const again = await t.get('/saved', { as: kim });
++ assert.match(again.text, /nothing new/, 'looking at the page is looking at the searches');
++ assert.ok(!again.text.includes('1 new'));
++
++ const id = after.id;
++ const gone = await t.get(`/api/v1/saved-searches/${id}`, { method: 'DELETE', as: kim, headers: SAME });
++ assert.strictEqual(gone.status, 204);
++ assert.strictEqual((await t.get('/api/v1/saved-searches', { as: kim })).json().saved_searches.length, 0);
++ });
++
++ await check('a signed-out list is not a saved search: the API refuses an app and anonymous', async () => {
++ assert.strictEqual((await t.get('/api/v1/saved-searches')).status, 401);
++ assert.strictEqual((await t.get('/api/v1/saved-searches', { json: { kind: 'room' }, headers: SAME })).status, 401);
++ const { signJwt } = require('./helpers/mocks');
++ const now = Math.floor(Date.now() / 1000);
++ const app = signJwt({
++ iss: t.network.url, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.rent'],
++ cap: ['rent.saved.create'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production',
++ iat: now, exp: now + 300, jti: 'tok_test_app',
++ }, t.network.privatePem);
++ assert.strictEqual((await t.get('/api/v1/saved-searches', { bearer: app, json: { kind: 'room' }, headers: SAME })).status, 403);
++ assert.strictEqual((await t.get('/api/v1/saved-searches/something-else', { method: 'DELETE', as: kim, headers: SAME })).status, 404);
++ });
++
++ await check('a listing page carries an Offer with its price and currency, and none where no type is clear', async () => {
++ const flat = await post(t, lee, { kind: 'apartment', title: 'A flat with JSON-LD', price: 1450, currency: 'EUR' });
++ assert.strictEqual(flat.status, 201, flat.text);
++ const blocks = async (id) => {
++ const r = await t.get(`/listings/${id}`);
++ return [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ };
++ const ofFlat = await blocks(flat.json().id);
++ const offer = ofFlat.find((b) => b['@type'] === 'Offer');
++ assert.ok(offer, `no Offer node: ${ofFlat.map((b) => b['@type'])}`);
++ assert.strictEqual(offer.price, 1450);
++ assert.strictEqual(offer.priceCurrency, 'EUR');
++ assert.strictEqual(offer.itemOffered['@type'], 'Apartment');
++ assert.ok(ofFlat.some((b) => b['@type'] === 'BreadcrumbList'));
++
++ const other = await post(t, lee, { kind: 'other', title: 'Something else entirely' });
++ assert.strictEqual(other.status, 201, other.text);
++ const ofOther = await blocks(other.json().id);
++ assert.strictEqual(ofOther.some((b) => b['@type'] === 'Offer'), false, 'no Offer when the type is not clear');
++ assert.ok(ofOther.some((b) => b['@type'] === 'BreadcrumbList'), 'the breadcrumbs stay');
++ });
++
++ await check('discovery: robots keeps the private pages out, and the sitemap carries the active listings', async () => {
++ const robots = await t.get('/robots.txt');
++ for (const d of ['/post', '/mine', '/saved', '/staff']) assert.ok(robots.text.includes(`Disallow: ${d}`), `no Disallow: ${d}`);
++ assert.ok(!robots.text.includes('Disallow: /listings'), '/listings stays crawlable');
++
++ const hidden = await post(t, nia, { title: 'A listing nobody should crawl' });
++ assert.strictEqual(hidden.status, 201, hidden.text);
++ assert.strictEqual((await t.get(`/api/v1/listings/${hidden.json().id}`, { method: 'PATCH', as: nia, json: { state: 'hidden' }, headers: SAME })).status, 200);
++ const active = await post(t, nia, { title: 'A listing a crawler should read' });
++ assert.strictEqual(active.status, 201, active.text);
++
++ const sitemap = await t.get('/sitemap.xml');
++ assert.strictEqual(sitemap.status, 200);
++ assert.ok(sitemap.text.includes(`<loc>https://openvibe.rent/listings/${active.json().id}</loc>`), 'the active listing is in the sitemap');
++ assert.ok(!sitemap.text.includes(hidden.json().id), 'the hidden one is not');
++ assert.ok(sitemap.text.includes('<loc>https://openvibe.rent/listings</loc>'));
++
++ const llms = await t.get('/llms.txt');
++ assert.match(llms.text, /no scraped listings and no partner feeds/i);
++ assert.match(llms.text, /no payments, no deposits and no messaging/i);
++ assert.ok(llms.text.includes('https://openvibe.rent/sitemap.xml'));
++
++ const full = await t.get('/llms-full.txt');
++ assert.ok(full.text.includes(`https://openvibe.rent/listings/${active.json().id}`), 'llms-full lists the same pages as the sitemap');
++ assert.ok(!full.text.includes(hidden.json().id));
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/listings-reports.test.js b/test/listings-reports.test.js
nnew file mode 100644
iindex 0000000..c41ed00
---- /dev/null
++++ b/test/listings-reports.test.js
@@@ -0,0 +1,179 @@
++'use strict';
++/**
++ * Reporting a listing and what staff do about it: a signed-in person reports once per listing (never their own,
++ * never an app), three distinct reports hide it from search until a member of OpenVibe staff looks, staff read the
++ * queue and restore, hide or remove it, and a listing a member of staff restored is not hidden again by the same
++ * reports. Reasons and notes are text: they are escaped wherever the queue shows them.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, post } = require('./helpers/rent');
++const store = require('../server/listings/store');
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const jo = t.network.addUser('jo');
++ const ada = t.network.addUser('ada', { role: 'admin' });
++ const gil = t.network.addUser('gil', { role: 'global_mod' });
++ const pat = t.network.addUser('pat', { role: 'streamer' });
++ const nia = t.network.addUser('nia');
++
++ const made = await post(t, kim, { title: 'A flat that is not what it seems', contact_url: '' });
++ assert.strictEqual(made.status, 201, made.text);
++ const ID = made.json().id;
++
++ const report = (body, o = {}) => t.get(`/api/v1/listings/${ID}/reports`, { as: o.as || sam, json: body || { reason: 'scam' }, headers: { ...SAME, ...(o.headers || {}) } });
++ const inSearch = async (id = ID) => (await t.get('/api/v1/listings?limit=60')).json().listings.some((x) => x.id === id);
++
++ await check('a signed-in person reports a listing; the report carries the reason and the note', async () => {
++ const r = await report({ reason: 'scam', note: 'They asked for a deposit over the phone.' });
++ assert.strictEqual(r.status, 201, r.text);
++ const body = r.json();
++ assert.match(body.report.id, /^rpt_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(body.report.reason, 'scam');
++ assert.strictEqual(body.report.note, 'They asked for a deposit over the phone.');
++ assert.strictEqual(body.report.reporter.type, 'user');
++ assert.strictEqual(body.hidden, false, 'one report does not hide anything');
++ assert.strictEqual(body.listing.report_count, 1);
++ assert.ok(inSearch(), 'still listed');
++ });
++
++ await check('one report per reporter per listing; your own listing is not yours to report', async () => {
++ const again = await report({ reason: 'wrong' });
++ assert.strictEqual(again.status, 409, again.text);
++ assert.strictEqual(again.json().code, 'report.duplicate');
++ const mine = await report({ reason: 'wrong' }, { as: kim });
++ assert.strictEqual(mine.status, 403);
++ assert.strictEqual(mine.json().code, 'listing.own');
++ assert.strictEqual((await report({ reason: 'nonsense' }, { as: jo })).status, 422, 'the reason is from the list');
++ assert.strictEqual((await report({ reason: 'other', note: 'x'.repeat(501) }, { as: jo })).status, 422, 'the note is capped');
++ });
++
++ await check('anonymous is refused; an app token cannot report as a person', async () => {
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}/reports`, { json: { reason: 'scam' }, headers: SAME })).status, 401);
++ const { signJwt } = require('./helpers/mocks');
++ const now = Math.floor(Date.now() / 1000);
++ const app = signJwt({
++ iss: t.network.url, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.rent'],
++ cap: ['rent.listing.report'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production',
++ iat: now, exp: now + 300, jti: 'tok_test_app',
++ }, t.network.privatePem);
++ const byApp = await t.get(`/api/v1/listings/${ID}/reports`, { bearer: app, json: { reason: 'scam' }, headers: SAME });
++ assert.strictEqual(byApp.status, 403, byApp.text);
++ assert.strictEqual(byApp.json().code, 'rent.person_required');
++ });
++
++ await check('the third distinct report hides the listing until staff look', async () => {
++ assert.strictEqual((await report({ reason: 'wrong' }, { as: jo })).status, 201);
++ const third = await report({ reason: 'offensive', note: 'Says something about the neighbours.' }, { as: pat });
++ assert.strictEqual(third.status, 201, third.text);
++ assert.strictEqual(third.json().hidden, true);
++ assert.strictEqual(third.json().listing.state, 'hidden');
++ assert.strictEqual(third.json().listing.report_count, 3);
++ assert.strictEqual(await inSearch(), false, 'hidden leaves search');
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}`)).status, 404, '404 for a stranger');
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}`, { as: kim })).status, 200, 'the owner still reads it');
++ assert.strictEqual((await report({ reason: 'other' }, { as: nia })).status, 404, 'a hidden listing is not reportable');
++ assert.strictEqual((await report({ reason: 'other' })).status, 404, 'even by someone who has not reported it');
++ });
++
++ await check('the queue is for staff: a signed-in person who is not staff gets 403, anonymous 401', async () => {
++ assert.strictEqual((await t.get('/api/v1/staff/reports')).status, 401);
++ assert.strictEqual((await t.get('/api/v1/staff/reports', { as: kim })).status, 403);
++ assert.strictEqual((await t.get('/api/v1/staff/reports', { as: kim })).json().code, 'staff.forbidden');
++ for (const staff of [ada, gil]) {
++ const q = await t.get('/api/v1/staff/reports', { as: staff });
++ assert.strictEqual(q.status, 200, q.text);
++ const body = q.json();
++ assert.strictEqual(body.filter.state, 'hidden');
++ assert.strictEqual(body.counts.hidden, 1);
++ const item = body.queue.find((x) => x.listing.id === ID);
++ assert.ok(item, 'the listing is in the queue');
++ assert.strictEqual(item.reports.length, 3);
++ assert.deepStrictEqual([...new Set(item.reports.map((x) => x.reason))].sort(), ['offensive', 'scam', 'wrong']);
++ assert.ok(item.reports.some((x) => x.note === 'They asked for a deposit over the phone.'));
++ }
++ assert.strictEqual((await t.get('/api/v1/listings/' + ID + '/state', { as: pat, json: { state: 'active' }, headers: SAME })).status, 403, 'a non-staff role is not staff');
++ });
++
++ await check('staff restore it: it is back in search, and the same reports do not hide it again', async () => {
++ const restored = await t.get(`/api/v1/listings/${ID}/state`, { as: gil, json: { state: 'active' }, headers: SAME });
++ assert.strictEqual(restored.status, 200, restored.text);
++ assert.strictEqual(restored.json().state, 'active');
++ assert.strictEqual(await inSearch(), true, 'back in search');
++ assert.strictEqual((await t.get('/api/v1/staff/reports', { as: gil })).json().counts.hidden, 0);
++
++ const fourth = await report({ reason: 'scam' }, { as: nia });
++ assert.strictEqual(fourth.status, 201, 'a listing a member of staff restored can be reported again');
++ assert.strictEqual(fourth.json().hidden, false, 'the threshold is crossed once, not on every report past it');
++ assert.strictEqual(fourth.json().listing.report_count, 4, 'it is recorded, and staff still see it');
++ assert.strictEqual(await inSearch(), true);
++ const active = await t.get('/api/v1/staff/reports?state=active', { as: gil });
++ assert.strictEqual(active.json().queue.some((x) => x.listing.id === ID), true, 'and it stays in the queue for staff');
++ });
++
++ await check('staff remove it: gone from search, not renewable and not editable', async () => {
++ const removed = await t.get(`/api/v1/listings/${ID}/state`, { as: ada, json: { state: 'removed' }, headers: SAME });
++ assert.strictEqual(removed.status, 200);
++ assert.strictEqual(removed.json().state, 'removed');
++ assert.strictEqual(await inSearch(), false);
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}`, { as: kim })).status, 200, 'the owner can still see why');
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}/renew`, { as: kim, json: {}, headers: SAME })).status, 409);
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}`, { method: 'PATCH', as: kim, json: { title: 'new' }, headers: SAME })).status, 409);
++ assert.strictEqual((await t.get(`/api/v1/listings/${ID}/state`, { as: ada, json: { state: 'sold' }, headers: SAME })).status, 422, 'only the three states');
++ });
++
++ await check('the staff page shows the queue, with what people wrote escaped', async () => {
++ const other = await post(t, kim, { title: 'Another one <b>bold</b>', contact_url: '' });
++ assert.strictEqual(other.status, 201, other.text);
++ const oid = other.json().id;
++ const r = await t.get(`/api/v1/listings/${oid}/reports`, { as: sam, json: { reason: 'wrong', note: '<script>alert(1)</script> and "quotes"' }, headers: SAME });
++ assert.strictEqual(r.status, 201, r.text);
++
++ const page = await t.get('/staff?state=active', { as: gil });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert.ok(page.text.includes(`/listings/${oid}`), 'the reported listing is linked');
++ assert.ok(page.text.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'the note is escaped');
++ assert.ok(!page.text.includes('<script>alert(1)</script>'), 'the raw note is not on the page');
++
++ assert.strictEqual((await t.get('/staff', { as: kim })).status, 403);
++ assert.strictEqual((await t.get('/staff')).status, 401);
++ // And the same act through the page's form.
++ const viaForm = await t.get(`/listings/${oid}/state`, { as: ada, form: { state: 'removed' }, headers: SAME });
++ assert.strictEqual(viaForm.status, 303);
++ assert.strictEqual((await t.get(`/api/v1/listings/${oid}`, { as: ada })).json().state, 'removed');
++ });
++
++ await check('reporting is limited per caller, on the same budget as the form', async () => {
++ const limits = await boot({ callerLimits: true });
++ try {
++ const u = limits.network.addUser('heavy');
++ const rows = [];
++ for (let i = 0; i < 12; i++) {
++ rows.push(await store.insert(limits.ctx.s, {
++ id: limits.ctx.s.newId('rnt'), owner: 'user:usr_01JABCDEFGHJKMNPQRSTVWXYZ0', kind: 'room',
++ title: `Listing ${i}`, description: 'x', price: 10, currency: 'EUR', period: 'month',
++ city: 'Lisbon', region: null, country: 'PT', neighbourhood: null, bedrooms: null,
++ available_from: null, contact_url: null, state: 'active',
++ created_at: limits.ctx.s.iso(), expires_at: limits.ctx.s.iso(),
++ }));
++ }
++ let refused = null;
++ for (const row of rows) {
++ const r = await limits.get(`/api/v1/listings/${row.id}/reports`, { as: u, json: { reason: 'wrong' }, headers: SAME });
++ if (r.status === 429) { refused = r; break; }
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ assert.ok(refused, 'the 11th report in a minute is refused');
++ assert.strictEqual(refused.json().code, 'rate_limited');
++ assert.ok(Number(refused.headers.get('retry-after')) > 0);
++ } finally {
++ await limits.close();
++ }
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/listings-search.test.js b/test/listings-search.test.js
nnew file mode 100644
iindex 0000000..8bd11e6
---- /dev/null
++++ b/test/listings-search.test.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * Searching the listings: the filters (q, kind, city, country, min, max, period), newest first, 30 a page with a
++ * cursor, the kind and country facets, and what is never listed (hidden, expired, removed). The contact URL is the
++ * private part of a listing: it is in the API's answer and on the page for a signed-in person, and in neither for
++ * anyone else. A filter we do not know is ignored, never an error — a stale bookmark still shows listings.
++ *
++ * The rows here are inserted through the store (server/listings/store.js) rather than posted: what is under test is
++ * the search, and a person's 5-a-day cap would otherwise have to be worked around to reach 31 of them.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, listing, post } = require('./helpers/rent');
++const store = require('../server/listings/store');
++
++const DAY = 86_400_000;
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const KIM = `user:${kim.subject}`;
++
++ /** Insert a listing straight into the store, as a person who posted it earlier would have left it. */
++ let n = 0;
++ const make = (over = {}) => {
++ n += 1;
++ const now = Date.now() + n; // a later insert is a later listing, for "newest first"
++ return store.insert(t.ctx.s, {
++ id: t.ctx.s.newId('rnt'), owner: KIM, kind: 'apartment', title: `Listing ${n}`,
++ description: 'A place to live.', price: 1000, currency: 'EUR', period: 'month',
++ city: 'Lisbon', region: null, country: 'PT', neighbourhood: 'Alfama', bedrooms: null,
++ available_from: null, contact_url: 'https://example.com/contact', state: 'active',
++ created_at: new Date(now).toISOString(),
++ expires_at: new Date(now + 30 * DAY).toISOString(),
++ ...over,
++ });
++ };
++
++ const search = async (qs, o = {}) => (await t.get(`/api/v1/listings?${qs}`, o)).json();
++
++ await check('filters: q, kind, city, country, min, max, period', async () => {
++ const a = await make({ kind: 'apartment', city: 'Lisbon', country: 'PT', price: 1200, period: 'month', title: 'Sunny flat with a balcony', neighbourhood: 'Alfama' });
++ await make({ kind: 'room', city: 'Lisbon', country: 'PT', price: 450, period: 'month', title: 'Room in a shared house', neighbourhood: 'Graça' });
++ await make({ kind: 'house', city: 'Porto', country: 'PT', price: 2200, period: 'month', title: 'House with a garden', neighbourhood: 'Cedofeita' });
++ await make({ kind: 'equipment', city: 'Berlin', country: 'DE', price: 40, period: 'day', title: 'Camera and two lenses for hire', neighbourhood: null });
++ await make({ kind: 'parking', city: 'Paris', country: 'FR', price: 90, period: 'month', title: 'Garage space near the market', neighbourhood: 'Le Marais' });
++
++ const ids = async (qs) => (await search(qs)).listings.map((x) => x.id);
++ assert.deepStrictEqual((await ids('kind=room')).sort(), [(await search('kind=room')).listings[0].id]);
++ assert.strictEqual((await search('kind=room')).count, 1);
++ assert.strictEqual((await search('city=lisbon')).count, 2, 'the city match is case-insensitive');
++ assert.strictEqual((await search('country=DE')).count, 1);
++ assert.strictEqual((await search('period=day')).count, 1);
++ assert.strictEqual((await search('min=1000&max=1500')).count, 1);
++ assert.strictEqual((await search('min=1000&max=2500')).count, 2);
++ assert.strictEqual((await search('min=2000')).count, 1);
++ assert.strictEqual((await search('max=50')).count, 1);
++ assert.strictEqual((await search('q=garage')).count, 1, 'q searches the title');
++ assert.strictEqual((await search('q=balcony')).count, 1);
++ assert.strictEqual((await search('q=alfama')).count, 1, 'q searches the neighbourhood');
++ assert.strictEqual((await search('q=Lisbon')).count, 2, 'q searches the city');
++ assert.strictEqual((await search('kind=room&city=Berlin')).count, 0, 'the filters are ANDed');
++ assert.strictEqual((await search('min=500&max=100')).count, 1, 'a reversed range is swapped (100–500), and then matches');
++ assert.strictEqual((await search('kind=nonsense&country=ZZ&period=fortnight&min=abc')).count, 5, 'a filter we do not know is ignored');
++ assert.strictEqual((await search('')).count, 5);
++ assert.strictEqual((await search('')).listings[0].id, (await search('')).listings[0].id);
++ // Newest first.
++ const ordered = (await search('')).listings.map((x) => x.id);
++ assert.deepStrictEqual(ordered, [...ordered].sort().reverse(), 'ULID order, newest first');
++ assert.ok(ordered.includes(a.id));
++ });
++
++ await check('the facets count each dimension without its own filter', async () => {
++ const all = await search('');
++ assert.deepStrictEqual(all.facets.kind.map((x) => x.value).sort(), ['apartment', 'equipment', 'house', 'parking', 'room']);
++ assert.deepStrictEqual(all.facets.country.map((x) => [x.value, x.count]).sort(), [['DE', 1], ['FR', 1], ['PT', 3]]);
++ const pt = await search('country=PT');
++ assert.deepStrictEqual(pt.facets.kind.map((x) => x.value).sort(), ['apartment', 'house', 'room']);
++ assert.strictEqual(pt.facets.country.find((x) => x.value === 'PT').count, 3, 'the country facet counts the others');
++ const rooms = await search('kind=room');
++ assert.strictEqual(rooms.facets.kind.length, 5, 'the kind facet leaves its own dimension out');
++ assert.strictEqual(rooms.facets.country.find((x) => x.value === 'PT').count, 1);
++ });
++
++ await check('30 a page, newest first, and the cursor walks back through them', async () => {
++ for (let i = 0; i < 40; i++) await make({ title: `Bulk listing ${i}` });
++ const first = await search('');
++ assert.strictEqual(first.count, 30, 'a page is 30 listings');
++ assert.ok(first.next, 'there is a cursor');
++ assert.strictEqual(first.listings.length, 30);
++ const second = await search(`before=${first.next}`);
++ assert.strictEqual(second.count, 15, 'the rest');
++ const overlap = second.listings.filter((x) => first.listings.some((y) => y.id === x.id));
++ assert.deepStrictEqual(overlap, [], 'no listing is on two pages');
++ const ids = [...first.listings, ...second.listings].map((x) => x.id);
++ assert.deepStrictEqual(ids, [...ids].sort().reverse(), 'the whole walk is newest first');
++ assert.strictEqual(second.next, null, 'the last page has no cursor');
++ assert.strictEqual((await search('before=not-an-id')).count, 30, 'a cursor that is not a listing id is ignored');
++ });
++
++ await check('a hidden, expired or removed listing is not listed; its owner and staff still see it', async () => {
++ const row = await make({ title: 'To be hidden' });
++ assert.ok((await search('q=to be hidden')).count === 1);
++ await store.setState(t.ctx.s, row.id, 'hidden');
++ assert.strictEqual((await search('q=to be hidden')).count, 0, 'hidden leaves search');
++ assert.strictEqual((await t.get(`/api/v1/listings/${row.id}`)).status, 404, 'and is 404 for a stranger');
++ assert.strictEqual((await t.get(`/api/v1/listings/${row.id}`, { as: kim })).status, 200, 'the owner still reads it');
++
++ const ada = t.network.addUser('ada2', { role: 'admin' });
++ assert.strictEqual((await t.get(`/api/v1/listings/${row.id}`, { as: ada })).status, 200, 'staff read it');
++
++ await store.setState(t.ctx.s, row.id, 'active');
++ assert.strictEqual((await search('q=to be hidden')).count, 1);
++ await store.remove(t.ctx.s, row.id);
++ assert.strictEqual((await search('q=to be hidden')).count, 0, 'a deleted listing is gone');
++ assert.strictEqual((await t.get(`/api/v1/listings/${row.id}`, { as: kim })).status, 404);
++
++ // The sweep at 30 days: an expired listing leaves search, and the store still has it.
++ const old = await make({ title: 'Long expired' });
++ await t.ctx.s.db.query('UPDATE rent_listings SET expires_at = $2 WHERE id = $1', [old.id, '2020-01-01T00:00:00.000Z']);
++ assert.ok(await store.expireListings(t.ctx.s) >= 1);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT state FROM rent_listings WHERE id = $1', [old.id]), 'expired');
++ assert.strictEqual((await search('q=long expired')).count, 0);
++ });
++
++ await check('contact_url is for a signed-in person only — in the API, both on one listing and in the list', async () => {
++ const mine = (await search('', { as: kim })).listings.find((x) => x.contact_url);
++ assert.ok(mine, 'the signed-in person sees their own contact link in the list');
++ const anon = await search('');
++ assert.ok(anon.listings.every((x) => !('contact_url' in x)), 'a signed-out list carries no contact_url at all');
++ const anonOne = await t.get(`/api/v1/listings/${mine.id}`);
++ assert.strictEqual(anonOne.status, 200);
++ assert.ok(!('contact_url' in anonOne.json()), 'and neither does one listing');
++ const signedOne = await t.get(`/api/v1/listings/${mine.id}`, { as: kim });
++ assert.strictEqual(signedOne.json().contact_url, 'https://example.com/contact');
++ });
++
++ await check('the page shows the contact link only to a signed-in viewer, and asks anyone else to sign in', async () => {
++ const mine = (await search('', { as: kim })).listings.find((x) => x.contact_url);
++ const out = await t.get(`/listings/${mine.id}`);
++ assert.strictEqual(out.status, 200);
++ assert.ok(!out.text.includes('https://example.com/contact'), 'the signed-out page leaks the contact link');
++ assert.match(out.text, /Sign in to see how to contact/i);
++ const inPage = await t.get(`/listings/${mine.id}`, { as: kim });
++ assert.ok(inPage.text.includes('https://example.com/contact'), 'the signed-in page shows it');
++ assert.ok(!inPage.text.includes('Sign in to see how to contact'), 'and no prompt');
++ });
++
++ await check('an expired or hidden listing leaves the page too: 404 for a stranger, not for its owner', async () => {
++ const row = await post(t, lee, { title: 'Posted then hidden' });
++ assert.strictEqual(row.status, 201, row.text);
++ await t.get(`/api/v1/listings/${row.json().id}`, { method: 'PATCH', as: lee, json: { state: 'hidden' }, headers: SAME });
++ const anon = await t.get(`/listings/${row.json().id}`);
++ assert.strictEqual(anon.status, 404);
++ const owner = await t.get(`/listings/${row.json().id}`, { as: lee });
++ assert.strictEqual(owner.status, 200);
++ assert.match(owner.text, /hidden/);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/listings.test.js b/test/listings.test.js
nnew file mode 100644
iindex 0000000..19be7c3
---- /dev/null
++++ b/test/listings.test.js
@@@ -0,0 +1,235 @@
++'use strict';
++/**
++ * Posting a listing, through the API: it takes a signed-in person (never an app), a session write must come from
++ * openvibe.rent itself, the field rules are enforced (kind, price, currency, country, period, contact URL) and a
++ * house number with a street is refused everywhere it could hide — title, description, city, region, neighbourhood.
++ * A person may have 10 active listings and post 5 a day, each listing lasts 30 days, its owner may edit, renew,
++ * hide or delete it and nobody else may, and a hostile title stays text everywhere it is shown.
++ *
++ * The caps and the expiry are exercised against the store's own functions rather than the clock: a token is
++ * verified against the real clock, so a test that jumped the store's clock a day ahead would be testing an expired
++ * session, not a daily cap.
++ */
++const assert = require('assert');
++const { signJwt } = require('./helpers/mocks');
++const { boot, check, done } = require('./helpers/boot');
++const { SAME, listing, post } = require('./helpers/rent');
++
++const RNT = /^rnt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DAY = 86_400_000;
++const LONG_AGO = '2020-01-01T00:00:00.000Z';
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ // The caps get a person of their own, so how many listings another check left behind cannot change them.
++ const pat = t.network.addUser('pat');
++
++ /** Send every row this person posted back in time, so a new UTC day begins for them. */
++ const newDay = (user) => t.ctx.s.db.query('UPDATE rent_listings SET created_at = $2 WHERE owner = $1', [`user:${user.subject}`, LONG_AGO]);
++
++ await check('posting needs a signed-in person: anonymous is 401, an app token is 403', async () => {
++ const anon = await t.get('/api/v1/listings', { json: listing(), headers: SAME });
++ assert.strictEqual(anon.status, 401, anon.text);
++ assert.strictEqual(anon.json().code, 'token.required');
++
++ const now = Math.floor(Date.now() / 1000);
++ const app = signJwt({
++ iss: t.network.url, sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.rent'],
++ cap: ['rent.listing.create'], project_id: 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0', env: 'production',
++ iat: now, exp: now + 300, jti: 'tok_test_app',
++ }, t.network.privatePem);
++ const byApp = await t.get('/api/v1/listings', { bearer: app, json: listing(), headers: SAME });
++ assert.strictEqual(byApp.status, 403, byApp.text);
++ assert.strictEqual(byApp.json().code, 'rent.person_required');
++ });
++
++ await check('a session write must come from this site; a Bearer write need not', async () => {
++ const cross = await post(t, kim, {}, { headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403);
++ assert.strictEqual(cross.json().code, 'request.cross_site');
++ const bare = await t.get('/api/v1/listings', { as: kim, json: listing() });
++ assert.strictEqual(bare.status, 403, 'no fetch metadata and no Origin: refused');
++ const bearer = await t.get('/api/v1/listings', { bearer: t.network.userToken(kim), json: listing() });
++ assert.strictEqual(bearer.status, 201, bearer.text);
++ await t.get(`/api/v1/listings/${bearer.json().id}`, { method: 'DELETE', as: kim, headers: SAME });
++ });
++
++ let id = null;
++ await check('a listing is posted: rnt_ id, active, a 30-day expiry, and its own words back', async () => {
++ const r = await post(t, kim);
++ assert.strictEqual(r.status, 201, r.text);
++ const made = r.json();
++ assert.match(made.id, RNT);
++ id = made.id;
++ assert.strictEqual(r.headers.get('location'), `/api/v1/listings/${id}`);
++ assert.strictEqual(made.state, 'active');
++ assert.strictEqual(made.kind, 'apartment');
++ assert.strictEqual(made.price, 1200);
++ assert.strictEqual(made.currency, 'EUR');
++ assert.strictEqual(made.country, 'PT');
++ assert.strictEqual(made.neighbourhood, 'Alfama');
++ assert.strictEqual(made.contact_url, 'https://example.com/rooms/1', 'the owner sees their own contact link');
++ assert.strictEqual(Date.parse(made.expires_at) - Date.parse(made.created_at), 30 * DAY);
++ assert.strictEqual(made.report_count, 0);
++ });
++
++ await check('the fields are checked: kind, title, price, currency, country, period, contact URL', async () => {
++ const bad = async (over, expect) => {
++ const r = await post(t, kim, over);
++ assert.strictEqual(r.status, 422, `${JSON.stringify(over)} → ${r.status} ${r.text.slice(0, 140)}`);
++ assert.match(r.json().detail, expect, JSON.stringify(over));
++ };
++ await bad({ kind: 'castle' }, /kind/);
++ await bad({ title: ' ' }, /title/);
++ await bad({ title: 'x'.repeat(121) }, /title/);
++ await bad({ description: '' }, /description/);
++ await bad({ description: 'x'.repeat(4001) }, /description/);
++ await bad({ price: 0 }, /price/);
++ await bad({ price: -5 }, /price/);
++ await bad({ price: 'lots' }, /price/);
++ await bad({ price: 2e9 }, /price/);
++ await bad({ currency: 'USDX' }, /currency/);
++ await bad({ currency: 'BTC' }, /currency/);
++ await bad({ country: 'ZZ' }, /country/);
++ await bad({ country: 'Portugal' }, /country/);
++ await bad({ period: 'fortnight' }, /period/);
++ await bad({ contact_url: 'http://example.com/x' }, /contact_url/);
++ await bad({ contact_url: 'javascript:alert(1)' }, /contact_url/);
++ await bad({ contact_url: 'mailto:not-an-address' }, /contact_url/);
++ await bad({ available_from: '31-12-2026' }, /available_from/);
++ await bad({ bedrooms: 'four' }, /bedrooms/);
++ await bad({ bedrooms: '51' }, /bedrooms/);
++ });
++
++ await check('lower-case currency and country are accepted and normalised', async () => {
++ const r = await post(t, kim, { currency: 'usd', country: 'us', period: 'Month', kind: 'Room' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().currency, 'USD');
++ assert.strictEqual(r.json().country, 'US');
++ assert.strictEqual(r.json().period, 'month');
++ assert.strictEqual(r.json().kind, 'room');
++ assert.strictEqual((await t.get(`/api/v1/listings/${r.json().id}`, { method: 'DELETE', as: kim, headers: SAME })).status, 204);
++ });
++
++ await check('a street address is refused, in every place field, with a message that says what to write', async () => {
++ for (const [field, value] of [
++ ['neighbourhood', '12 Rue de la Paix'],
++ ['neighbourhood', '221B Baker Street'],
++ ['neighbourhood', 'Flat 3, 12 High Street'],
++ ['neighbourhood', 'Hauptstraße 12'],
++ ['neighbourhood', '#4 Cherry Lane'],
++ ['neighbourhood', 'Unit 5 / 22 Main Rd'],
++ ['city', '12 High Street'],
++ ['region', 'Rue de la Paix 12'],
++ ['title', 'Flat at 12 Rue de la Paix'],
++ ['description', 'Come to 221B Baker Street, second floor.'],
++ ]) {
++ const r = await post(t, kim, { [field]: value });
++ assert.strictEqual(r.status, 422, `${field}: ${value} → ${r.status} ${r.text.slice(0, 120)}`);
++ assert.strictEqual(r.json().code, 'listing.address_refused', `${field}: ${value}`);
++ assert.match(r.json().detail, /street address/);
++ assert.match(r.json().detail, new RegExp(`^${field}:`), `${field}: names the field`);
++ }
++ // And the areas a person really writes are accepted.
++ for (const area of ['Shoreditch', 'Le Marais', 'Kreuzberg', 'Greenwich Village', 'Northern Quarter', 'Old Town']) {
++ const r = await post(t, kim, { neighbourhood: area });
++ assert.strictEqual(r.status, 201, `${area} → ${r.status} ${r.text.slice(0, 120)}`);
++ await t.get(`/api/v1/listings/${r.json().id}`, { method: 'DELETE', as: kim, headers: SAME });
++ }
++ });
++
++ await check('the cap: 5 new listings a day, then 10 active ones, then no more', async () => {
++ const store_ = require('../server/listings/store');
++ const activeCount = (user) => store_.countActiveByOwner(t.ctx.s, `user:${user.subject}`);
++ for (let i = 1; i <= 5; i++) {
++ const r = await post(t, pat, { title: `Day one listing ${i}` });
++ assert.strictEqual(r.status, 201, `listing ${i}: ${r.text.slice(0, 140)}`);
++ }
++ const sixth = await post(t, pat, { title: 'One too many today' });
++ assert.strictEqual(sixth.status, 429, sixth.text);
++ assert.strictEqual(sixth.json().code, 'listing.limit_daily');
++ assert.match(sixth.json().detail, /5 listings today/);
++
++ await newDay(pat);
++ for (let i = 1; i <= 5; i++) {
++ const r = await post(t, pat, { title: `A later day, listing ${i}` });
++ assert.strictEqual(r.status, 201, `later listing ${i}: ${r.text.slice(0, 140)}`);
++ }
++ assert.strictEqual(await activeCount(pat), 10, 'ten active');
++ const eleventh = await post(t, pat, { title: 'The eleventh active listing' });
++ assert.strictEqual(eleventh.status, 429, eleventh.text);
++ assert.strictEqual(eleventh.json().code, 'listing.limit_active');
++ assert.match(eleventh.json().detail, /10 active listings/);
++
++ // Deleting one makes room at once, and the daily cap still applies first.
++ const doomed = await t.ctx.s.db.value("SELECT id FROM rent_listings WHERE owner = $1 AND state = 'active' ORDER BY id DESC LIMIT 1", [`user:${pat.subject}`]);
++ assert.strictEqual((await t.get(`/api/v1/listings/${doomed}`, { method: 'DELETE', as: pat, headers: SAME })).status, 204);
++ assert.strictEqual((await t.get(`/api/v1/listings/${doomed}`, { as: pat })).status, 404);
++ await newDay(pat);
++ assert.strictEqual((await post(t, pat, { title: 'Room again after a delete' })).status, 201);
++ });
++
++ await check('owner-only: another person cannot edit, renew, hide or delete it; the owner can', async () => {
++ const mine = await t.ctx.s.db.maybe('SELECT * FROM rent_listings WHERE owner = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`]);
++ const patch = (target, body, as) => t.get(`/api/v1/listings/${target.id}`, { method: 'PATCH', as, json: body, headers: SAME });
++ const taken = await patch(mine, { title: 'Taken over' }, lee);
++ assert.strictEqual(taken.status, 403, taken.text);
++ assert.strictEqual(taken.json().code, 'listing.forbidden');
++ assert.strictEqual((await t.get(`/api/v1/listings/${mine.id}`, { method: 'DELETE', as: lee, headers: SAME })).status, 403);
++ assert.strictEqual((await t.get(`/api/v1/listings/${mine.id}/renew`, { as: lee, json: {}, headers: SAME })).status, 403);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*) FROM rent_listings WHERE owner = $1', [`user:${lee.subject}`]).then(Number), 0, 'lee has none of their own');
++
++ const edited = await patch(mine, { title: 'Renamed by its owner', price: 950.5, period: 'week' }, kim);
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.strictEqual(edited.json().title, 'Renamed by its owner');
++ assert.strictEqual(edited.json().price, 950.5);
++ assert.strictEqual(edited.json().period, 'week');
++ assert.strictEqual(edited.json().kind, mine.kind, 'a patch leaves what it does not name alone');
++ assert.strictEqual(edited.json().city, mine.city);
++
++ const hidden = await patch(mine, { state: 'hidden' }, kim);
++ assert.strictEqual(hidden.json().state, 'hidden');
++ assert.strictEqual((await t.get('/api/v1/listings?limit=60')).json().listings.some((x) => x.id === mine.id), false, 'hidden leaves search');
++ assert.strictEqual((await patch(mine, { state: 'active' }, kim)).json().state, 'active');
++ assert.strictEqual((await patch(mine, { state: 'removed' }, kim)).status, 422, 'only staff remove a listing');
++ assert.strictEqual((await patch(mine, { title: '12 High Street' }, kim)).status, 422, 'the rules apply to an edit too');
++ });
++
++ await check('renewing adds 30 days, and brings an expired listing back', async () => {
++ const mine = await t.ctx.s.db.maybe('SELECT * FROM rent_listings WHERE owner = $1 ORDER BY id DESC LIMIT 1', [`user:${kim.subject}`]);
++ const before = Date.parse(mine.expires_at);
++ const renewed = await t.get(`/api/v1/listings/${mine.id}/renew`, { as: kim, json: {}, headers: SAME });
++ assert.strictEqual(renewed.status, 200, renewed.text);
++ // Renewing is 30 days from now, never a top-up that could run far past it, and never a shortening.
++ assert.ok(Math.abs(Date.parse(renewed.json().expires_at) - (Date.now() + 30 * DAY)) < 60_000, renewed.json().expires_at);
++ assert.ok(Date.parse(renewed.json().expires_at) >= before, 'a renew never takes time away');
++
++ // Its time is up: the sweep (what the timer calls in production) marks it, and search drops it.
++ await t.ctx.s.db.query('UPDATE rent_listings SET expires_at = $2 WHERE id = $1', [mine.id, LONG_AGO]);
++ const store = require('../server/listings/store');
++ assert.ok(await store.expireListings(t.ctx.s) >= 1, 'the sweep marked them expired');
++ assert.strictEqual((await t.get(`/api/v1/listings/${mine.id}`, { as: kim })).json().state, 'expired');
++ assert.strictEqual((await t.get('/api/v1/listings?limit=60')).json().listings.some((x) => x.id === mine.id), false, 'expired leaves search');
++ const back = await t.get(`/api/v1/listings/${mine.id}/renew`, { as: kim, json: {}, headers: SAME });
++ assert.strictEqual(back.json().state, 'active');
++ assert.ok(Date.parse(back.json().expires_at) > Date.now(), 'the new expiry is in the future');
++ assert.strictEqual((await t.get('/api/v1/listings?limit=60')).json().listings.some((x) => x.id === mine.id), true, 'and it is back in search');
++ });
++
++ await check('a hostile title and description stay text: nothing a page shows is live markup', async () => {
++ const nasty = '<script>alert(1)</script> & "quotes" \'apostrophes\'';
++ const r = await post(t, lee, { title: `Nasty ${nasty}`, description: `Description ${nasty}` });
++ assert.strictEqual(r.status, 201, r.text);
++ const made = r.json();
++ const html = (await t.get(`/listings/${made.id}`)).text;
++ assert.ok(!html.includes('<script>alert(1)</script>'), 'the raw tag is on the page');
++ assert.ok(html.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'the escaped text is');
++ assert.ok(html.includes('&amp;'), 'the ampersand is escaped');
++ assert.ok(html.includes('&#39;'), 'the apostrophe is escaped');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 5509fda..d866707 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -13,9 +13,13 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
++//
++// Raised on 2026-10-08 for the product's home page: it is no longer the skeleton's, so the measurement moved from
++// 24.9/6.2 KB to 28.4/6.5 KB (search box, latest listings, how it works, where the listings come from, cta). The
++// budget keeps the same ~10% headroom over the new measurement; the other lines are unchanged.
const BUDGETS = {
-- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
-- htmlBrotliKB: 6.9, // 6.2
++ htmlRawKB: 31.5, // measured 28.4 (the home page: hero, search, latest listings, features, sources, cta, JSON-LD)
++ htmlBrotliKB: 7.2, // 6.5
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5