openvibers/openvibe.reviews README.md

README.md

main 29.7 KB · 395 lines · markdown

1# OpenVibe.Reviews
2
3> Review signals gathered across sources with provenance, entity resolution and honest aggregates.
4
5**Live site:** https://openvibe.reviews · **API docs:** https://openvibe.services · **For AI:** https://openvibe.reviews/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha (roadmap Wave 17, Reviews half). **Public at `openvibe.reviews` since 2026-10-09**, after an independent pre-launch security review whose fixes shipped first. On 2026-10-18 it holds about 5,700 entities:
9- 19 Steam games with review signals from Sources;
10- 550 companies with CFPB complaint counts;
11- 1,155 NYC restaurants with their health inspections;
12- 4,000 NYC apartment buildings with HPD's open violations.
13
14Members write their own reviews and save the ratings other sites show.
15**Domain:** `openvibe.reviews` · **Port:** 4830 · **Service id:** `reviews`
16**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap Wave 17, §15.13, §29, §32.
17**License:** AGPL-3.0 (same as every OpenVibe service).
18
19## Purpose
20
21Entity resolution plus the interpretation and publication layer for review signals. Reviews reads
22what sources stated (through OpenVibe.Sources), attributes each observation to the thing it is
23about, shows every number with where it came from and when it was read, and computes an aggregate
24only from the signals that exist. It **never fabricates a rating and never presents AI output as
25user reviews**: summaries are text written or approved by a person, every point cites the signals
26it rests on, and nothing a summary says can become a number.
27
28## Every review site, one search
29
30The owner's April ask was review aggregation across Glassdoor, Yelp, BBB, Reddit and the rest, "subject to legal and
31technical feasibility". Their terms forbid copying their reviews, so `/everywhere`
32([server/reviews/everywhere.js](server/reviews/everywhere.js)) does what they allow: one name, its kind (a local
33business, an employer, a landlord, a company or online shop, a hosting provider, a product, software) and, for local
34places, where it is, open each site's own search in new tabs: Google Maps, Yelp, Tripadvisor, BBB, Trustpilot,
35Glassdoor, Indeed, Blind, LowEndTalk, RTINGS, YouTube reviews, Amazon, G2, Capterra, Product Hunt and Reddit. The URL
36forms were checked in a real browser on 2026-10-15 (WebHostingTalk, ApartmentRatings and Rate My Landlord have no
37search address that works). Reviews fetches and stores nothing from them; a result page is `noindex`.
38
39## Members' reviews
40
41Any signed-in OpenVibe member reviews anything of the kinds Reviews lists, adding it first when it is not there yet. The kinds are employers, landlords, restaurants, local businesses, hosting providers, products, apps, games, vehicles, communities, platforms and more. A local kind needs where it is.
42
43A review has 1 to 5 stars, a title, 30 to 5,000 characters, and how the member knows it (a current or former employee or tenant, a customer…). There is one review per member per entity, which they can rewrite. A member can add 20 things and write 30 reviews a day.
44
45The entity page shows members' reviews as theirs: "Reviews from OpenVibe members", with their own average. They are never mixed into the sources' aggregate. The member or an editor removes a review (`review_member_reviews`, migrations/0004).
46
47## What other sites show, brought by members
48
49A member can save the rating another site shows for something: Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google
50Maps or Tripadvisor. They do it at `/save` (the page's address, the rating, its scale, how many ratings) or with the
51OpenVibe Companion extension's "Save its rating to OpenVibe Reviews" button (0.5.0). That button reads the page's own
52structured data or star labels, only when pressed. The entity page shows the latest look per site, with the date and how
53many members saved it, beside that site's link (`review_seen_ratings`, migrations/0006). These are members' notes of
54what a page said, never signals and never in the aggregate. Capped at 60 a day per person; the person or an editor
55removes one.
56
57## Official records
58
59What a public body published about an entity, read through OpenVibe.Sources (`review_records`, migrations/0007, 0008).
60Each register has its own section on the entity page, never as signals or in the aggregate:
61
62| Source | Record | Entity |
63|---|---|---|
64| `cfpb-complaints-by-company` (weekly, CC0) | the CFPB complaint database's count for a company | an organization; added at 500 complaints or more |
65| `nyc-restaurant-inspections` (every 6 h) | NYC Health's inspection results: grade, score, violations (critical marked) | a restaurant per CAMIS id, named with its address |
66| `nyc-hpd-open-violations` (weekly) | NYC HPD's open housing-code violations by class (C immediately hazardous, B hazardous, A non-hazardous) | a landlord page per building, named by its address; added at 10 or more open class C |
67
68A record finds its entity by its external alias (`cfpb:`, `nyc-camis:`, `nyc-hpd:`). Otherwise it uses a same-named
69entity of that kind and place that holds no record of the same register, otherwise it adds one. So two restaurants of a
70chain, or two buildings, are never one page. Search gets one line per register.
71
72## Owns
73
74The nine authority tables of §15.13, in Reviews' own PostgreSQL database (`ov_reviews` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
75
76| Table | What it holds |
77|---|---|
78| `review_entities` | the things reviewed (kind, slug, state `active`/`merged`/`deleted`, `merged_into`) |
79| `review_entity_aliases` | names, URLs, GTIN/SKU/MPN, source bindings and external ids used for resolution (strong identifiers are unique) |
80| `review_sources` | the OpenVibe.Sources sources Reviews has seen: name, homepage, licence and terms notes, health |
81| `review_source_items` | Sources items Reviews read — signal fields only, **never review text** — with provenance and how each was resolved |
82| `review_signals` | typed observations (`recommendation`, `recommendation_tally`, `rating`, `rating_aggregate`) from one item revision, with provenance; values immutable (database triggers), status `active`/`superseded`/`withdrawn` |
83| `review_summaries` | one editorial summary per entity, its publication state and review flag |
84| `review_summary_revisions` | immutable summary revisions (openvibe-publishing/revisions) with authorship |
85| `review_trust_metadata` | editor trust decisions per source / signal / entity (exclude from the aggregate with a visible reason, limitation and verification notes), history kept |
86| `review_entity_links` | typed links between entities or to another service's EntityRef; a merge is a `merged_into` link (who, when, why), a split ends it |
87
88Helper tables: `review_aggregates` (every aggregate revision, immutable), `review_member_reviews`, `review_seen_ratings`,
89`review_records` (official records), `review_summary_citations`,
90`review_corrections`, `review_audit` (append-only editorial log), `review_import_queue`,
91`review_sync_state`, plus the Publishing packages' `review_summary_reviews`, `review_summary_drafts`,
92`review_entity_redirects`, `review_discussion_refs`, `review_index_revisions` and the SDK's
93`review_event_outbox` / `review_event_inbox`.
94
95## Does not own
96
97- **discussion** — OpenVibe.Community holds the threads and comments; Reviews stores the thread id and shows the comments as Community serves them
98- **source adapter policy and ingestion** — OpenVibe.Sources decides what is fetched, under which terms, and keeps the items
99- **AI generation** — OpenVibe.AI runs `reviews.summarize_entity`; Reviews only accepts its output as a draft
100- **search** — OpenVibe.Search indexes the documents Reviews sends
101
102## What works
103
104**Signals from Sources.** Two paths feed one `applyItem()`: a pull of `GET /api/v1/items?category=reviews&include_removed=1`
105in change order (cursor stored per page, a restart resumes) and the `sources.item.*` events through a
106signed webhook + inbox (created/updated items are queued and fetched by id; removals apply at once).
107An item becomes at most one signal: Steam `appreviews` items (`review_signal`, the Sources seed) give a
108`recommendation` from `voted_up`, with the reviewer facts Steam states (bought on Steam, received for
109free, playtime) as trust metadata; `total_positive/total_reviews` give a `recommendation_tally`;
110JSON-LD products give a `rating_aggregate` only when the page states a count; JSON-LD reviews give a
111`rating`. A rating whose source did not state the scale is recorded but left out of the aggregate
112with the reason shown. Each signal keeps the Sources item id and revision, the retrieval time, the
113source's publication time, the canonical URL and the licence note; a later revision of the item
114supersedes it, a removal withdraws it. A Sources outage changes nothing and invents nothing.
115
116**Resolution.** Deterministic rules, strongest first: a source binding (every item of that source is
117about one entity), the item's URL (normalised), GTIN (normalised to 14 digits), SKU, MPN. One entity →
118resolved. Identifiers naming different entities → ambiguous. A name alone never resolves: it yields
119candidates for an editor. Nothing → unmatched, listed on the editor desk. Editors confirm (optionally
120remembering the identifier, which settles other waiting items), re-attribute or ignore; every decision
121is in the audit log.
122
123**Merge and split.** A merge rewrites nothing: the merged entity keeps its aliases and its signals keep
124their attribution; it becomes `merged` with an active `merged_into` link and its page answers 301 to
125the target, whose aggregate spans both. A split ends the link and restores the pre-merge attribution
126exactly (tested as a round trip), recomputes both aggregates and flags the former target's summary if
127it cited signals that went back. Both are audited (link row, audit log, `reviews.entity.merged|split`).
128
129**Aggregates** (method `reviews-aggregate@1`, a pure function in `server/reviews/aggregate.js`):
130recommendation share = positives ÷ total (a source's own tally replaces its single recommendations);
131rating = count-weighted mean of value ÷ best, as a percentage and on the shared scale when there is
132one. Editors can exclude a source or signal, with a reason shown on the page. The aggregate lists its
133inputs, exclusions and computation. **With no qualifying signal it is absent — `null`, not zero, not
134a placeholder** — and every change of inputs records a new aggregate revision (including the one where
135nothing is left).
136
137**Summaries.** Editors write overview + pros + cons; every pro and con cites signals of the entity
138(uncited points, foreign or inactive signals, and any field that looks like a rating are refused).
139OpenVibe.AI's `reviews.summarize_entity` is a seam: `POST …/summary/proposals` stores a draft
140revision with ai authorship (workflow and run id, stub flag); it is never published by that call, stays
141off the page, feeds and Search, cannot be published until a person approves it, and AI text that states
142a rating ("4.5/5", "9 out of 10", star glyphs) is refused. When a cited signal is withdrawn or replaced,
143the summary is flagged on the page, the gate marks it `unsupported_claims` (noindex), and a pending
144revision (citations moved to replacements, unsupported points dropped) waits for an editor's approval.
145
146**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optional
147evidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the
148desk. What a request says and who sent it are never published; one person sends at most 20 a day and
149the same open request once (plus a per-address limit). A correction of a published summary is
150a new immutable summary revision carrying a public correction note (who: the editor who wrote it; when:
151its time), approved by that editor and published at once: accepting a request about an entity whose
152summary is published needs that note (the text is carried forward unless the editor changes it, from
153the desk or the edit page), and an editor can correct on their own with `correction_note` on a new
154revision. The entity page shows the corrected text, a "Corrected" notice and the summary's revision
155history with every correction note; earlier revisions stay readable at `/e/:slug/summary/:n`. Rejecting
156creates nothing. AI-drafted text a person corrected becomes AI-assisted (hybrid), never "written by a
157person". JSON-LD (`dateModified`), the feeds (a new entry that starts with the note) and the Search
158document follow the corrected revision.
159
160**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputs
161and computation, summary with citations, signal table with provenance and timestamps, sources and
162limitations, discussion), `/e/:slug.json`, `/e/:slug/history` (aggregate revisions, summary revisions,
163merges and splits, every signal including withdrawn ones, editorial log), `/e/:slug/summary/:n`,
164`/e/:slug/correct`, and the editor desk (`/editor`, new entity, edit, summary form with signal pickers,
165merge/split, item resolution, trust decisions, corrections) — all plain form posts behind Network SSO.
166Old slugs 301, merged entities 301, deleted ones 410.
167
168**Structured data and discovery.** JSON-LD `Product` / `VideoGame` / `SoftwareApplication` / … with
169`AggregateRating`, and a `Review` for a summary, are emitted **only when the aggregate exists** (i.e.
170real signals back it); without signals the page carries breadcrumbs only (tested). A recommendation
171share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real count; a summary's
172`Review` never has a `reviewRating`. The openvibe-publishing v1.7.0 gate decides robots per entity
173(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →
174`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feeds
175list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
176`reviews.index_document.upserted|deleted` with Sources items as provenance. With `INDEXNOW_KEY` set,
177publishing, editing, unpublishing or deleting an indexable entity page tells IndexNow (the key file is
178served at `/<key>.txt`; unset, nothing is sent, and drafts and noindex pages never ping).
179
180**Operations.** `/api/health`, `/api/ready` (DB required; Network key, Sources, Events relay, webhook
181secret, Community and editors reported), `/release.json`, `/metrics` (outbox and backlog gauges).
182
183**Telemetry.** Each HTTP request is aggregated by route template, method and status class, never by raw
184URL; `/shared` files, static assets, health, readiness and `/metrics` are not counted. Once a minute one
185`http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is
186written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the
187`platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `start()` in
188`server/index.js` builds the collector and, with the workers on (production), flushes it every minute
189and runs the 14-day retention; `server/app.js` mounts its middleware; the table is created by migration
190`migrations/0003_telemetry.sql`.
191
192## Routes
193
194| Path | What |
195|---|---|
196| `GET /`, `/about`, `/search?q=` | home (entities with source data), method, search |
197| `GET /e/:slug`, `/e/:slug.json`, `/e/:slug/history`, `/e/:slug/summary/:n` | entity page, JSON, history, one summary revision |
198| `GET|POST /e/:slug/correct`, `POST /e/:slug/discuss` | correction (signed in), comment via Community |
199| `/editor`, `/editor/entities/new`, `/e/:slug/edit`, `/editor/items/:id`, … | editor desk (editors only) |
200| `/robots.txt`, `/sitemap.xml`, `/sitemaps/*.xml`, `/feed.atom`, `/feed.json`, `/llms.txt` | discovery |
201| `POST /internal/events` | OpenVibe.Events deliveries (`sources.item.*`), HMAC-signed; not proxied publicly |
202| `/auth/*` | Network SSO (client `reviews`) |
203
204Sitemap entries carry lastmod from entity updates, the newest entity on the home page, or the release commit for `/about`.
205
206## API `/api/v1`
207
208People use their Network JWT; services use a client-credentials token for audience
209`openvibe.reviews` and act for the person in `X-OV-Subject`. One capability per route; editorial
210writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are RFC 9457 problem+json.
211A write carried by the `ov_token` cookie that another site started is 403 `request.cross_site`
212(Bearer tokens are not affected).
213
214| Capability | Routes |
215|---|---|
216| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |
217| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` `{ status, note?, correction_note?, summary? }` (accepting with a correction also needs `reviews.summary.publish`) |
218| `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` |
219| `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` |
220| `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |
221| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions` (`correction_note`, `correction_id`: a correction, published at once), `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
222| `reviews.correction.submit` | `POST /entities/:ref/corrections` |
223
224The ids and the service manifest are released in openvibe-contracts (pinned at v0.107.0; the source
225proposals stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
226[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)); `server/auth/capabilities.js`
227decides them with the contracts grant rule. `reviews.entity.manage` and
228`reviews.summary.propose` are additions to the §15.13 minimum list.
229
230### Per-actor limits
231
232`/api/v1` and the forms also limit who calls them, once `req.actor` is resolved and a route's
233capability guard passed, before any work (for a form, before its body is read):
234`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. Counted: a person as
235`user:usr_…` (their own token or cookie, named by a first-party service in `X-OV-Subject`, or an app's
236`on_behalf_of`); a service or app acting as itself (OpenVibe.AI's proposals, an import service) by its
237principal; a signed-out caller by address, on writes only. Signed-out reads keep only the per-address
238limit (many readers share a carrier or campus address), and a first-party service reading for itself
239is not counted on reads. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one
240`[Limits]` log line and `reviews_rate_limited_total{limit,window}`. A form and the API route that do
241the same thing share one budget; a form counts only a signed-in person.
242
243| Routes (API and form) | Per caller, a minute / an hour |
244|---|---|
245| Reads, and `POST /resolve` (a lookup) | `REVIEWS_LIMITS_MINUTE` / `REVIEWS_LIMITS_HOUR` (120 / 3000) |
246| Entity create, edit, delete, aliases, links, trust (`/editor/entities/new`, `/e/:slug/edit`) | 30 / 300 |
247| Merge and split | 10 / 100 |
248| Item resolution (`POST /items/:id/resolution`, `/editor/items/:id`) | 60 / 1200 |
249| `POST /signals/import` | 60 / 1200 |
250| Sync now (`POST /sources/sync`, `/editor/sync`) | 2 / 20 |
251| Summary write, review, publish, unpublish | 30 / 300 |
252| Summary proposals (OpenVibe.AI) | 60 / 1200 |
253| Correction submit (`POST …/corrections`, `/e/:slug/correct`) | 5 / 20 |
254| Correction resolve (`PATCH /corrections/:id`, `/editor/corrections/:id`) | 30 / 300 |
255| Discussion comment (`/e/:slug/discuss`, sent to Community) | 20 / 300 |
256
257The per-address limits (API 300 a minute, corrections 20 an hour, forms, sign-in) stay. Never limited
258per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the pages people read,
259and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`; the other tests
260boot with `rateLimits: false`, which turns off both kinds.
261
262## Events
263
264Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
265`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,
266reason, `replaced_by`), `reviews.summary.published|updated|unpublished` (a correction is
267`reviews.summary.updated` with `correction: { note, corrects }`; a dedicated `reviews.summary.corrected`
268would first need registering in OpenVibe.Contracts), and the Search index events
269`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`
270(subscription created with `scripts/subscribe.js`).
271
272## Running it
273
274```bash
275fnm exec --using=22.22.1 npm install
276cp .env.example .env
277fnm exec --using=22.22.1 npm run dev # http://127.0.0.1:4830
278fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases with stub upstreams
279```
280
281Production: `/opt/openvibe.reviews`, env `/etc/openvibe/reviews.env`, unit
282[deploy/systemd/openvibe-reviews.service](deploy/systemd/openvibe-reviews.service), database `ov_reviews`
283on PostgreSQL (ADR-035), nginx [deploy/nginx/openvibe.reviews.conf](deploy/nginx/openvibe.reviews.conf).
284There is **no seed**: a fresh database holds no entity, signal, summary or rating. Entities are created
285by editors; signals arrive only from Sources.
286
287## Depends on
288
289- OpenVibe.Network — SSO (OAuth client `reviews`), signing key, service principal `svc:reviews`
290- OpenVibe.Sources — review items (`sources.item.read`, `sources.source.read`); its reviews seed (Steam appreviews for Portal 2) is disabled until a person verifies its terms, so until then Reviews has nothing to show
291- OpenVibe.Events — outbound events (`events.event.publish`) and the `sources.item.*` subscription
292- OpenVibe.Community — discussion threads (`community.comment.write`)
293- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
294- OpenVibe.Search — consumes the index events
295- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional)
296- packages: openvibe-publishing v1.7.0, openvibe-contracts v0.107.0, openvibe-shared v4.179.0, openvibe-sdk v0.161.0
297
298## Capabilities
299
300Implemented here (the service manifest's `capabilities`, audience `openvibe.reviews`, one per route;
301routes under [API](#api-apiv1)): `reviews.entity.resolve`, `reviews.entity.manage`,
302`reviews.entity.merge`, `reviews.entity.split`, `reviews.signal.import`, `reviews.summary.propose`,
303`reviews.summary.publish` and `reviews.correction.submit`.
304
305Called elsewhere, as the service principal `svc:reviews` (client credentials, one token per audience):
306
307| Service | Grant | Why |
308|---|---|---|
309| OpenVibe.Sources | `sources.item.read`, `sources.source.read` | review items in change order, source names and terms |
310| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`scripts/subscribe.js`) | outbound events; the `sources.item.*` subscription |
311| OpenVibe.Community | `community.comment.write` | discussion threads on entity pages |
312
313Other services call Reviews with `reviews.summary.propose` (OpenVibe.AI) or `reviews.signal.import`.
314
315## Acceptance (tested in `test/`)
316
317- **no aggregate without signals**: `null`, no aggregate row, and it disappears again (as a recorded revision) when the last signal is withdrawn (`signals.test.js`, `aggregate.test.js`)
318- **AggregateRating JSON-LD absent without signals**, present with the real value and count while signals back it, gone after removal (`nojs.test.js`)
319- **merge → split round trip exact**: attribution, aliases and aggregates restored; merge/split audited and evented (`merge.test.js`)
320- **source removal changes the next aggregate** (webhook and pull paths); an item update supersedes its signal (`signals.test.js`)
321- **AI text never yields a rating**: rating fields and rating text refused; an AI draft is never published, shown, fed or indexed before a person approves it (`summaries.test.js`)
322- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)
323- **no-JS**: every public page and every editor workflow works with plain HTML and form posts; Community comments are shown, never copied (`nojs.test.js`)
324- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)
325- **a correction yields a public revision**: accepting a request (or an editor correcting on their own) creates a new immutable revision with the correction note, the page shows the corrected text and the revision history with the note without JavaScript, the old revision stays readable, Search/JSON-LD/feeds/events follow it, the request's text and sender never reach a public surface, a rejection creates nothing, and only an editor who is a person decides (`corrections.test.js`)
326- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)
327- the threat review's fixes: cross-site writes refused on pages and on the cookie-authenticated API, the correction queue's per-person allowance, bounded audit text, editor ids hidden from readers, ratings in words refused in AI text, out-of-scale source ratings not counted (`threat.test.js`; see [docs/threat-review.md](docs/threat-review.md))
328- every event validates as `events.event-envelope@1` and every index document as `search.index-document@1`; proposals validate against the contracts schemas; nothing seeded (`signals.test.js`, `proposals.test.js`)
329
330Demonstrated on production (2026-10-15): the deployed Sources runs 19 enabled review sources, and their items reach
331Reviews through the deployed Events `sources.item.*` subscription (1,187 signals stored).
332
333Not yet demonstrated: a real OpenVibe.AI `reviews.summarize_entity` run, because entities wait on an editor and no
334summary exists yet.
335
336## Security
337
338Reporting a vulnerability: [SECURITY.md](SECURITY.md). The authors' threat review, with file:line
339references and the gaps that remain: [docs/threat-review.md](docs/threat-review.md).
340
341- **Auth.** Network JWTs (RS256, verified offline) for people; client-credentials service tokens for
342 audience `openvibe.reviews`, one capability per route, acting for `X-OV-Subject`. Editorial writes
343 need an editor who is a person (staff or `REVIEWS_EDITORS`). A cookie-authenticated write started by
344 another site is 403 `request.cross_site`.
345- **Private data.** Review text is never stored; a correction's text and sender never reach a public
346 surface; editor ids are hidden from readers.
347- **Honesty.** No aggregate without signals; AI text never yields a rating and is never shown before a
348 person approves it.
349- **Egress.** Reviews calls only its configured Network, Sources, Events and Community hosts; it never
350 fetches a URL a user chose.
351- **Secrets.** `OV_OAUTH_CLIENT_SECRET` and `REVIEWS_EVENTS_SECRET` (Events delivery signatures) live
352 in `/etc/openvibe/reviews.env` (0600). nginx answers `/metrics` and `/internal/` with 404.
353
354## Deploy
355
356Production deploys with `sudo ovhost deploy reviews` on the host (strategy `git-checkout`: fetch,
357fast-forward `/opt/openvibe.reviews`, install on a lockfile change, restart, wait for `/api/ready`).
358The unit is `openvibe-reviews.service` on `127.0.0.1:4830`, the env file `/etc/openvibe/reviews.env`. The database is
359`ov_reviews` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh reviews` writes its settings); the
360release migrates it at boot (schema in [migrations/](migrations/)). The nginx reference is
361[deploy/nginx/openvibe.reviews.conf](deploy/nginx/openvibe.reviews.conf) (the domain has served this
362service since 2026-10-09).
363Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
364restart; afterwards `sudo ovhost rollback reviews --to <sha>`. Migrations only add tables and columns.
365
366## Launch rule
367
368This repository never made the product real on its own: `openvibe.reviews` kept its placeholder page
369on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
370following held (plan §12.12):
371
3721. an owning runtime with health/readiness endpoints and observability — **built** (`/api/health`, `/api/ready`, `/metrics`);
3732. canonical identity/auth integration (Network subjects, a scoped service principal) — **built** and provisioned in production;
3743. server-rendered public routes useful without JavaScript — **built**;
3754. real persistence and end-to-end workflows — **built** and deployed on the host (empty database); no Sources review source is enabled yet;
3765. capability and event registration against OpenVibe.Contracts — **done** (openvibe-contracts v0.107.0);
3776. a migration/seed strategy (schema migrations in [migrations/](migrations/); nothing seeded), a security/threat review, sitemap/robots/feed behaviour — discovery **built**; threat review **written**: [docs/threat-review.md](docs/threat-review.md) (the service authors' own, from the code: controls with file:line references, the gaps fixed in that pass and the ones that remain); an independent pre-launch security review followed, its fixes shipped first;
3787. acceptance tests proving the advertised functionality — **built** (`npm test`).
379
380The launch release removed `openvibe.reviews` from `OpenVibe.Sites/sites.json`, switched routing to
381this service and registered its maturity in the ecosystem registry in the same release it went live:
382the domain has served this service since 2026-10-09, when plan T11 deleted OpenVibe.Sites. A
383placeholder is never counted as an implemented service.
384
385---
386
387Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
388
389<!-- versions:start -->
390- openvibe-contracts: v0.419.0
391- openvibe-sdk: v0.161.0
392- openvibe-shared: v4.241.0
393- openvibe-publishing: v1.7.0
394<!-- versions:end -->
395