openvibers/openvibe.reviews Commits

OpenVibe.Reviews service (Wave 17, Reviews half): entities with aliases and typed links, deterministic resolution of OpenVibe.Sources review items with editor confirmation, signals with provenance, merges that rewrite nothing and splits that restore attribution exactly (audited), aggregates computed only from signals present and absent without them, editor-reviewed summaries citing signals (AI drafts held, never a rating), corrections queue, Community discussion referenced; SSR pages useful without JavaScript, JSON-LD AggregateRating only when signals back it, sitemaps/feeds/robots/llms from the publishing gate, Search index events; capability and service manifest proposals, systemd unit, nginx vhost, CI on Node 22.22.1

OP OpenVibers committed 23 Sept 2026 · a0033be013 · 51 files, +7971 −55


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..c9c8752
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,62 @@
++# ═══════════════════════════════════════════════════════════════
++# OpenVibe.Reviews — Environment Configuration
++# Copy to .env and fill in values. In production this lives at /etc/openvibe/reviews.env (0600);
++# the systemd unit sets NODE_ENV, PORT and REVIEWS_DB_PATH itself. Names only here: never commit values.
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4830
++HOST=127.0.0.1
++NODE_ENV=development
++#BASE_URL=https://openvibe.reviews
++# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
++#TRUST_PROXY=2
++
++# Store (SQLite, WAL). Production: /var/lib/openvibe-reviews/reviews.db (systemd StateDirectory).
++REVIEWS_DB_PATH=./data/reviews.db
++
++# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
++# from <url>/api/.well-known/jwks (internal URL first) and retried every 30 s until it loads.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++#OV_NETWORK_ISSUER=https://openvibe.network
++# Pin the key instead of fetching it (PEM; \n escapes allowed).
++#OV_NETWORK_PUBLIC_KEY=
++# A browser's user JWT is accepted when its aud contains one of these.
++#REVIEWS_USER_AUDIENCES=openvibe.reviews,openvibe.network
++
++# Editors: Network staff (admin, global_mod) plus these usr_ subject ids (comma-separated).
++#REVIEWS_EDITORS=
++
++# OAuth client `reviews` (browser sign-in, redirect <BASE_URL>/auth/callback) and service principal
++# svc:reviews (client credentials for Sources, Events and Community) — one registration.
++OV_OAUTH_CLIENT_ID=reviews
++OV_OAUTH_CLIENT_SECRET=
++#OV_OAUTH_REDIRECT_URI=https://openvibe.reviews/auth/callback
++#COOKIE_SECURE=true
++
++# OpenVibe.Sources — review items (sources.item.read, sources.source.read). Unset: nothing is read,
++# so there are no signals and no aggregates (the pages say so).
++#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
++#REVIEWS_SOURCES_SYNC=true
++#REVIEWS_SOURCES_SYNC_INTERVAL_MS=300000
++#REVIEWS_SOURCES_PAGE_SIZE=200
++#REVIEWS_IMPORT_QUEUE_INTERVAL_MS=15000
++
++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL is set (events wait in the outbox until then).
++#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_RELAY_INTERVAL_MS=2000
++# Signing secret of the sources.item.* subscription (scripts/subscribe.js); comma-separated during rotation.
++#REVIEWS_EVENTS_SECRET=
++
++# OpenVibe.Community — discussion threads (community.comment.write).
++#OV_COMMUNITY_URL=https://openvibe.community
++#OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
++
++# Indexability gate policy for entity pages (openvibe-publishing/seo). Sources are always required.
++#REVIEWS_GATE_MIN_WORDS=60
++#REVIEWS_GATE_MIN_SIGNALS=1
++
++# Release reporting (openvibe-shared/release): the deployed commit, read from git when unset.
++#RELEASE_COMMIT=
++#MIN_CLIENT_RELEASE=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..9d281a0
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,20 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # The reviews.* capabilities and the reviews service manifest are proposals (docs/) until the
++ # lead releases them in OpenVibe.Contracts; until then this check is expected to fail.
++ - run: npx openvibe-contracts-check --service reviews --src server
++ continue-on-error: true
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..07af271
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/README.md b/README.md
iindex 62a76cf..ae32526 100644
---- a/README.md
++++ b/README.md
@@@ -2,74 +2,218 @@
> Review signals gathered across sources with provenance, entity resolution and honest aggregates.
--**Status:** placeholder — planning only, no runnable code yet.
--**Domain:** `openvibe.reviews`
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.8.
++**Status:** alpha (roadmap Wave 17, Reviews half). Runs and is tested against stub upstreams; **not deployed**, and `openvibe.reviews` still shows its placeholder on OpenVibe.Sites.
++**Domain:** `openvibe.reviews` · **Port:** 4830 · **Service id:** `reviews`
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap Wave 17, §15.13, §29, §32.
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
--Entity resolution plus the interpretation/publication layer for review signals. It never fabricates ratings or presents AI output as user reviews.
++Entity resolution plus the interpretation and publication layer for review signals. Reviews reads
++what sources stated (through OpenVibe.Sources), attributes each observation to the thing it is
++about, shows every number with where it came from and when it was read, and computes an aggregate
++only from the signals that exist. It **never fabricates a rating and never presents AI output as
++user reviews**: summaries are text written or approved by a person, every point cites the signals
++it rests on, and nothing a summary says can become a number.
## Owns
--- `review_entities`, `review_entity_aliases`, `review_sources`, `review_source_items`, `review_signals`, `review_summaries`, `review_summary_revisions`, `review_trust_metadata`, `review_entity_links`
++The nine authority tables of §15.13, in Reviews' own SQLite database:
++
++| Table | What it holds |
++|---|---|
++| `review_entities` | the things reviewed (kind, slug, state `active`/`merged`/`deleted`, `merged_into`) |
++| `review_entity_aliases` | names, URLs, GTIN/SKU/MPN, source bindings and external ids used for resolution (strong identifiers are unique) |
++| `review_sources` | the OpenVibe.Sources sources Reviews has seen: name, homepage, licence and terms notes, health |
++| `review_source_items` | Sources items Reviews read — signal fields only, **never review text** — with provenance and how each was resolved |
++| `review_signals` | typed observations (`recommendation`, `recommendation_tally`, `rating`, `rating_aggregate`) from one item revision, with provenance; values immutable (SQLite triggers), status `active`/`superseded`/`withdrawn` |
++| `review_summaries` | one editorial summary per entity, its publication state and review flag |
++| `review_summary_revisions` | immutable summary revisions (openvibe-publishing/revisions) with authorship |
++| `review_trust_metadata` | editor trust decisions per source / signal / entity (exclude from the aggregate with a visible reason, limitation and verification notes), history kept |
++| `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 |
++
++Helper tables: `review_aggregates` (every aggregate revision, immutable), `review_summary_citations`,
++`review_corrections`, `review_audit` (append-only editorial log), `review_import_queue`,
++`review_sync_state`, plus the Publishing packages' `review_summary_reviews`, `review_summary_drafts`,
++`review_entity_redirects`, `review_discussion_refs`, `review_index_revisions` and the SDK's
++`review_event_outbox` / `review_event_inbox`.
## Does not own
--- discussion (Community)
--- source adapter policy
--
--## Planned surfaces
--
--- resolve names/URLs/SKUs/places to entities with audited merge/split, provenance for every excerpt, pros/cons/themes with source references, coverage/trust limitations, correction/dispute workflow
--
--## Data (authority tables / families)
--
--- see above
--
--## Capabilities and events
--
--- `reviews.entity.resolve|merge|split`, `reviews.signal.import`, `reviews.summary.publish`, `reviews.correction.submit`
--
--Events: ``reviews.entity.merged|split``, ``reviews.signal.added|removed``, ``reviews.summary.published|updated``
++- **discussion** — OpenVibe.Community holds the threads and comments; Reviews stores the thread id and shows the comments as Community serves them
++- **source adapter policy and ingestion** — OpenVibe.Sources decides what is fetched, under which terms, and keeps the items
++- **AI generation** — OpenVibe.AI runs `reviews.summarize_entity`; Reviews only accepts its output as a draft
++- **search** — OpenVibe.Search indexes the documents Reviews sends
++
++## What works
++
++**Signals from Sources.** Two paths feed one `applyItem()`: a pull of `GET /api/v1/items?category=reviews&include_removed=1`
++in change order (cursor stored per page, a restart resumes) and the `sources.item.*` events through a
++signed webhook + inbox (created/updated items are queued and fetched by id; removals apply at once).
++An item becomes at most one signal: Steam `appreviews` items (`review_signal`, the Sources seed) give a
++`recommendation` from `voted_up`, with the reviewer facts Steam states (bought on Steam, received for
++free, playtime) as trust metadata; `total_positive/total_reviews` give a `recommendation_tally`;
++JSON-LD products give a `rating_aggregate` only when the page states a count; JSON-LD reviews give a
++`rating`. A rating whose source did not state the scale is recorded but left out of the aggregate
++with the reason shown. Each signal keeps the Sources item id and revision, the retrieval time, the
++source's publication time, the canonical URL and the licence note; a later revision of the item
++supersedes it, a removal withdraws it. A Sources outage changes nothing and invents nothing.
++
++**Resolution.** Deterministic rules, strongest first: a source binding (every item of that source is
++about one entity), the item's URL (normalised), GTIN (normalised to 14 digits), SKU, MPN. One entity →
++resolved. Identifiers naming different entities → ambiguous. A name alone never resolves: it yields
++candidates for an editor. Nothing → unmatched, listed on the editor desk. Editors confirm (optionally
++remembering the identifier, which settles other waiting items), re-attribute or ignore; every decision
++is in the audit log.
++
++**Merge and split.** A merge rewrites nothing: the merged entity keeps its aliases and its signals keep
++their attribution; it becomes `merged` with an active `merged_into` link and its page answers 301 to
++the target, whose aggregate spans both. A split ends the link and restores the pre-merge attribution
++exactly (tested as a round trip), recomputes both aggregates and flags the former target's summary if
++it cited signals that went back. Both are audited (link row, audit log, `reviews.entity.merged|split`).
++
++**Aggregates** (method `reviews-aggregate@1`, a pure function in `server/reviews/aggregate.js`):
++recommendation share = positives ÷ total (a source's own tally replaces its single recommendations);
++rating = count-weighted mean of value ÷ best, as a percentage and on the shared scale when there is
++one. Editors can exclude a source or signal, with a reason shown on the page. The aggregate lists its
++inputs, exclusions and computation. **With no qualifying signal it is absent — `null`, not zero, not
++a placeholder** — and every change of inputs records a new aggregate revision (including the one where
++nothing is left).
++
++**Summaries.** Editors write overview + pros + cons; every pro and con cites signals of the entity
++(uncited points, foreign or inactive signals, and any field that looks like a rating are refused).
++OpenVibe.AI's `reviews.summarize_entity` is a seam: `POST …/summary/proposals` stores a draft
++revision with ai authorship (workflow and run id, stub flag); it is never published by that call, stays
++off the page, feeds and Search, cannot be published until a person approves it, and AI text that states
++a rating ("4.5/5", "9 out of 10", star glyphs) is refused. When a cited signal is withdrawn or replaced,
++the summary is flagged on the page, the gate marks it `unsupported_claims` (noindex), and a pending
++revision (citations moved to replacements, unsupported points dropped) waits for an editor's approval.
++
++**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optional
++evidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the
++desk. Correction text is not published.
++
++**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputs
++and computation, summary with citations, signal table with provenance and timestamps, sources and
++limitations, discussion), `/e/:slug.json`, `/e/:slug/history` (aggregate revisions, summary revisions,
++merges and splits, every signal including withdrawn ones, editorial log), `/e/:slug/summary/:n`,
++`/e/:slug/correct`, and the editor desk (`/editor`, new entity, edit, summary form with signal pickers,
++merge/split, item resolution, trust decisions, corrections) — all plain form posts behind Network SSO.
++Old slugs 301, merged entities 301, deleted ones 410.
++
++**Structured data and discovery.** JSON-LD `Product` / `VideoGame` / `SoftwareApplication` / … with
++`AggregateRating`, and a `Review` for a summary, are emitted **only when the aggregate exists** (i.e.
++real signals back it); without signals the page carries breadcrumbs only (tested). A recommendation
++share is expressed as `ratingValue` 0–100 with `bestRating: 100` and the real count; a summary's
++`Review` never has a `reviewRating`. The openvibe-publishing v0.2.0 gate decides robots per entity
++(no live signal → `unsourced`, summary under the word minimum → `thin`, unsupported points →
++`unsupported_claims`, unreviewed AI → hidden); sitemaps list indexable entities only, Atom/JSON feeds
++list published summaries, `robots.txt` and `llms.txt` are served, and Search receives
++`reviews.index_document.upserted|deleted` with Sources items as provenance.
++
++**Operations.** `/api/health`, `/api/ready` (DB required; Network key, Sources, Events relay, webhook
++secret, Community and editors reported), `/release.json`, `/metrics` (outbox and backlog gauges).
++
++## Routes
++
++| Path | What |
++|---|---|
++| `GET /`, `/about`, `/search?q=` | home (entities with source data), method, search |
++| `GET /e/:slug`, `/e/:slug.json`, `/e/:slug/history`, `/e/:slug/summary/:n` | entity page, JSON, history, one summary revision |
++| `GET|POST /e/:slug/correct`, `POST /e/:slug/discuss` | correction (signed in), comment via Community |
++| `/editor`, `/editor/entities/new`, `/e/:slug/edit`, `/editor/items/:id`, … | editor desk (editors only) |
++| `/robots.txt`, `/sitemap.xml`, `/sitemaps/*.xml`, `/feed.atom`, `/feed.json`, `/llms.txt` | discovery |
++| `POST /internal/events` | OpenVibe.Events deliveries (`sources.item.*`), HMAC-signed; not proxied publicly |
++| `/auth/*` | Network SSO (client `reviews`) |
++
++## API `/api/v1`
++
++People use their Network JWT; services use a client-credentials token for audience
++`openvibe.reviews` and act for the person in `X-OV-Subject`. One capability per route; editorial
++writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are RFC 9457 problem+json.
++
++| Capability | Routes |
++|---|---|
++| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |
++| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` |
++| `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` |
++| `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` |
++| `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |
++| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions`, `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
++| `reviews.correction.submit` | `POST /entities/:ref/corrections` |
++
++The ids are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (with
++[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)); `server/auth/capabilities.js`
++decides them with the contracts grant rule until a release defines them. `reviews.entity.manage` and
++`reviews.summary.propose` are additions to the §15.13 minimum list.
++
++## Events
++
++Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
++`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,
++reason, `replaced_by`), `reviews.summary.published|updated|unpublished`, and the Search index events
++`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`
++(subscription created with `scripts/subscribe.js`).
++
++## Running it
++
++```bash
++fnm exec --using=22.22.1 npm install
++cp .env.example .env
++fnm exec --using=22.22.1 npm run dev # http://127.0.0.1:4830
++fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases with stub upstreams
++```
++
++Production: `/opt/openvibe.reviews`, env `/etc/openvibe/reviews.env`, unit
++[deploy/systemd/openvibe-reviews.service](deploy/systemd/openvibe-reviews.service), store
++`/var/lib/openvibe-reviews/reviews.db`, nginx [deploy/nginx/openvibe.reviews.conf](deploy/nginx/openvibe.reviews.conf).
++There is **no seed**: a fresh database holds no entity, signal, summary or rating. Entities are created
++by editors; signals arrive only from Sources.
## Depends on
--- source registry
--- OpenVibe.AI
--- Search
--- OpenVibe.Community
--- OpenVibe.Events
--
--## Acceptance (must be true before "done")
--
--- entity merges are reversible and audited
--- every signal retains source identity and timestamp
--- no generated star rating exists without supporting source data
--- source removal updates the next aggregate revision
--
--## Bootstrap / extraction source
--
--No current implementation; Wave 15.
++- OpenVibe.Network — SSO (OAuth client `reviews`), signing key, service principal `svc:reviews`
++- 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
++- OpenVibe.Events — outbound events (`events.event.publish`) and the `sources.item.*` subscription
++- OpenVibe.Community — discussion threads (`community.comment.write`)
++- OpenVibe.AI — optional, proposes summaries through `reviews.summary.propose`
++- OpenVibe.Search — consumes the index events
++- packages: openvibe-publishing v0.2.0, openvibe-contracts v0.19.0, openvibe-shared v1.3.0, openvibe-sdk v0.2.2
++
++## Acceptance (tested in `test/`)
++
++- **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`)
++- **AggregateRating JSON-LD absent without signals**, present with the real value and count while signals back it, gone after removal (`nojs.test.js`)
++- **merge → split round trip exact**: attribution, aliases and aggregates restored; merge/split audited and evented (`merge.test.js`)
++- **source removal changes the next aggregate** (webhook and pull paths); an item update supersedes its signal (`signals.test.js`)
++- **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`)
++- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)
++- **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`)
++- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)
++- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)
++- 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`)
++
++Not yet demonstrated: a run against the deployed Sources with a real, enabled review source (none is
++enabled), a real OpenVibe.AI `reviews.summarize_entity` run, and delivery through the deployed Events.
## Launch rule
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
++This repository does not make the product real on its own. `openvibe.reviews` keeps its placeholder
++page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
++following hold (plan §12.12):
++
++1. an owning runtime with health/readiness endpoints and observability — **built** (`/api/health`, `/api/ready`, `/metrics`);
++2. canonical identity/auth integration (Network subjects, a scoped service principal) — **built**, principal and grants not provisioned yet;
++3. server-rendered public routes useful without JavaScript — **built**;
++4. real persistence and end-to-end workflows — **built**, not deployed, and no Sources review source is enabled yet;
++5. capability and event registration against OpenVibe.Contracts — **proposed** in `docs/`, not released;
++6. a migration/seed strategy (none: nothing to migrate, nothing seeded), a security/threat review, sitemap/robots/feed behaviour — discovery **built**; the security review is the lead's;
++7. acceptance tests proving the advertised functionality — **built** (`npm test`).
++
++The launch release removes `openvibe.reviews` from `OpenVibe.Sites/sites.json`, switches routing to
++this service and registers its maturity in the ecosystem registry **in the same release it goes live**.
++A placeholder is never counted as an implemented service, and this README does not call the product
++live until that release has happened.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 73be2aa..6fe8c3b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,19 @@
{
"repository": "OpenVibers/OpenVibe.Reviews",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W17",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "runtime": "node 22, express, better-sqlite3",
++ "port": 4830,
++ "domain": "openvibe.reviews",
++ "contracts": "openvibe-contracts v0.19.0",
++ "packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
++ "capabilities": ["reviews.entity.resolve", "reviews.entity.manage", "reviews.entity.merge", "reviews.entity.split", "reviews.signal.import", "reviews.summary.propose", "reviews.summary.publish", "reviews.correction.submit"],
++ "capabilitiesRegistered": false,
++ "deployed": false,
++ "onOpenVibeSites": true,
++ "seed": "none: no entities, signals, summaries or ratings are seeded",
++ "dataSource": "OpenVibe.Sources category reviews; no review source is enabled there yet",
++ "updated": "2026-09-22"
}
ddiff --git a/deploy/nginx/openvibe.reviews.conf b/deploy/nginx/openvibe.reviews.conf
nnew file mode 100644
iindex 0000000..c238149
---- /dev/null
++++ b/deploy/nginx/openvibe.reviews.conf
@@@ -0,0 +1,102 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.reviews — Nginx Configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.reviews.conf — keep them in sync and reload
++## nginx after changing it. Not live: openvibe.reviews is served by OpenVibe.Sites (placeholder)
++## until the launch release removes it from OpenVibe.Sites/sites.json in the same release.
++##
++## The Node app (port 4830, systemd unit openvibe-reviews) renders every page. nginx terminates
++## TLS, redirects www → apex, rate-limits auth, API and form posts, and never caches anything
++## itself: Reviews' Cache-Control decides (public, max-age=60 only for anonymous views of public
++## pages; private, no-store for everything else).
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovreviews_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovreviews_auth:10m rate=10r/m;
++# Form posts only (reads are not limited here): an empty key is not counted.
++map $request_method $ovreviews_write_key { default ""; POST $binary_remote_addr; }
++limit_req_zone $ovreviews_write_key zone=ovreviews_write:10m rate=30r/m;
++
++# ── HTTP → HTTPS redirect ──────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.reviews www.openvibe.reviews;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.reviews$request_uri; }
++}
++
++# ── www → apex ─────────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.reviews;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.reviews/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.reviews/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.reviews$request_uri;
++}
++
++# ── HTTPS server ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.reviews;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.reviews/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.reviews/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.reviews.access.log;
++ error_log /var/log/nginx/openvibe.reviews.error.log;
++
++ # Summaries are at most a few KB; the API accepts 256 KB JSON bodies.
++ client_max_body_size 1m;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header X-Frame-Options SAMEORIGIN always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/feed+json application/atom+xml image/svg+xml text/plain application/xml text/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ # Never let a shared cache here hold a personalised or private answer.
++ proxy_no_cache 1;
++ proxy_cache_bypass 1;
++
++ # Prometheus metrics are for the host only (the app also refuses non-loopback callers).
++ location = /metrics { return 404; }
++ # OpenVibe.Events delivers sources.item.* to 127.0.0.1:4830/internal/events directly; never public.
++ location /internal/ { return 404; }
++
++ location /auth/ {
++ limit_req zone=ovreviews_auth burst=10 nodelay;
++ proxy_pass http://127.0.0.1:4830;
++ }
++
++ location /api/ {
++ limit_req zone=ovreviews_api burst=40 nodelay;
++ proxy_pass http://127.0.0.1:4830;
++ }
++
++ location / {
++ limit_req zone=ovreviews_write burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4830;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-reviews.service b/deploy/systemd/openvibe-reviews.service
nnew file mode 100644
iindex 0000000..2098320
---- /dev/null
++++ b/deploy/systemd/openvibe-reviews.service
@@@ -0,0 +1,33 @@
++[Unit]
++Description=OpenVibe.Reviews — review signals with provenance, entity resolution, honest aggregates (server-rendered)
++# Network (openvibe-network) signs the tokens this service verifies. Reviews starts without it and
++# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
++# Sources, Events and Community are optional: each degrades to an explicit failure state (without
++# Sources there are no signals, so no aggregates — the pages say so).
++After=network.target
++Wants=openvibe-network.service openvibe-sources.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.reviews
++EnvironmentFile=/etc/openvibe/reviews.env
++Environment=NODE_ENV=production
++Environment=PORT=4830
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.reviews/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# The code tree stays read-only; the database lives in /var/lib/openvibe-reviews.
++StateDirectory=openvibe-reviews
++Environment=REVIEWS_DB_PATH=/var/lib/openvibe-reviews/reviews.db
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/capabilities-proposal/reviews.correction.submit.json b/docs/capabilities-proposal/reviews.correction.submit.json
nnew file mode 100644
iindex 0000000..c5a4967
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.correction.submit.json
@@@ -0,0 +1,19 @@
++{
++ "id": "reviews.correction.submit",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Send a correction (about an entity, alias, signal, summary or aggregate, with an optional evidence URL) to the editors' queue on behalf of the person named in X-OV-Subject. Corrections are read by editors and not published.",
++ "permissions": [
++ "reviews:correction:submit"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-corrections",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/entities/:ref/corrections"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.entity.manage.json b/docs/capabilities-proposal/reviews.entity.manage.json
nnew file mode 100644
iindex 0000000..25e9122
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.entity.manage.json
@@@ -0,0 +1,31 @@
++{
++ "id": "reviews.entity.manage",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Editorial management of entities for the editor named in X-OV-Subject: create, edit, rename (old slugs redirect) and delete entities, add and remove aliases, typed links between entities or to another service's EntityRef, trust metadata (exclude a source or signal from aggregates with a visible reason, limitation and verification notes), and the correction queue.",
++ "permissions": [
++ "reviews:entity:manage"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [
++ "reviews.index_document.upserted",
++ "reviews.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/entities",
++ "PATCH /api/v1/entities/:ref",
++ "DELETE /api/v1/entities/:ref",
++ "POST /api/v1/entities/:ref/aliases",
++ "DELETE /api/v1/entities/:ref/aliases/:id",
++ "POST /api/v1/entities/:ref/links",
++ "DELETE /api/v1/entities/:ref/links/:id",
++ "POST /api/v1/trust",
++ "GET /api/v1/corrections",
++ "PATCH /api/v1/corrections/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.entity.merge.json b/docs/capabilities-proposal/reviews.entity.merge.json
nnew file mode 100644
iindex 0000000..18935fd
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.entity.merge.json
@@@ -0,0 +1,21 @@
++{
++ "id": "reviews.entity.merge",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Merge one entity into another, decided by the editor named in X-OV-Subject (never a service on its own). Nothing is rewritten: the merged entity keeps its aliases and its signals keep their attribution; an audited merged_into link records who, when and why, and the target's aggregate is recomputed over both. Reversible with reviews.entity.split.",
++ "permissions": [
++ "reviews:entity:merge"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [
++ "reviews.entity.merged"
++ ],
++ "implementedBy": [
++ "POST /api/v1/entities/:ref/merge"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.entity.resolve.json b/docs/capabilities-proposal/reviews.entity.resolve.json
nnew file mode 100644
iindex 0000000..fff1d3d
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.entity.resolve.json
@@@ -0,0 +1,30 @@
++{
++ "id": "reviews.entity.resolve",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Resolve a name, URL, GTIN/SKU/MPN, source binding or external id to an entity (deterministic rules; a name alone yields candidates, never a match), read entity pages as JSON (aggregate with inputs, signals with provenance, summary), their history and summary revisions, and — for an editor named in X-OV-Subject — read the resolution queue and confirm or ignore an ambiguous source item.",
++ "permissions": [
++ "reviews:entity:resolve"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-read",
++ "events": [
++ "reviews.signal.added",
++ "reviews.signal.removed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/resolve",
++ "GET /api/v1/entities",
++ "GET /api/v1/entities/:ref",
++ "GET /api/v1/entities/:ref/history",
++ "GET /api/v1/entities/:ref/aggregate",
++ "GET /api/v1/entities/:ref/summary/revisions/:n",
++ "GET /api/v1/items",
++ "GET /api/v1/items/:id",
++ "POST /api/v1/items/:id/resolution"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.entity.split.json b/docs/capabilities-proposal/reviews.entity.split.json
nnew file mode 100644
iindex 0000000..8231ae3
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.entity.split.json
@@@ -0,0 +1,21 @@
++{
++ "id": "reviews.entity.split",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Undo a merge, decided by the editor named in X-OV-Subject: ends the merged_into link (audited) and restores the pre-merge attribution exactly; both aggregates are recomputed and a summary that cited the split-off signals is flagged with a pending revision.",
++ "permissions": [
++ "reviews:entity:split"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [
++ "reviews.entity.split"
++ ],
++ "implementedBy": [
++ "POST /api/v1/entities/:ref/split"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.signal.import.json b/docs/capabilities-proposal/reviews.signal.import.json
nnew file mode 100644
iindex 0000000..ee06125
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.signal.import.json
@@@ -0,0 +1,23 @@
++{
++ "id": "reviews.signal.import",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Import review signals from OpenVibe.Sources: one item by id, or the next pages of category-reviews items in change order. Reviews fetches the item itself (sources.item.read), keeps its provenance (source, item revision, retrieval time, licence note) and only the signal fields, never review text. Removed or revised items withdraw or replace their signal; the next aggregate revision reflects it.",
++ "permissions": [
++ "reviews:signal:import"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [
++ "reviews.signal.added",
++ "reviews.signal.removed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/signals/import",
++ "POST /api/v1/sources/sync"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.summary.propose.json b/docs/capabilities-proposal/reviews.summary.propose.json
nnew file mode 100644
iindex 0000000..cd9c541
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.summary.propose.json
@@@ -0,0 +1,19 @@
++{
++ "id": "reviews.summary.propose",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "The OpenVibe.AI seam (workflow reviews.summarize_entity): propose a summary revision whose every pro and con cites signals of the entity. It is stored with ai authorship (workflow and run id, stub-provider flag), is never published by this call, stays out of pages, feeds and Search until a person approves it, and can never carry a rating (rating fields and rating text are refused).",
++ "permissions": [
++ "reviews:summary:propose"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/entities/:ref/summary/proposals"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/reviews.summary.publish.json b/docs/capabilities-proposal/reviews.summary.publish.json
nnew file mode 100644
iindex 0000000..d462c90
---- /dev/null
++++ b/docs/capabilities-proposal/reviews.summary.publish.json
@@@ -0,0 +1,26 @@
++{
++ "id": "reviews.summary.publish",
++ "version": "1.0",
++ "owner": "reviews",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Write, review (approve or reject), publish and unpublish summary revisions as the editor named in X-OV-Subject. Publishing refuses AI or system-prepared revisions without a person's approval and any revision with a point that cites no live signal of the entity.",
++ "permissions": [
++ "reviews:summary:publish"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "reviews-write",
++ "events": [
++ "reviews.summary.published",
++ "reviews.summary.updated",
++ "reviews.summary.unpublished"
++ ],
++ "implementedBy": [
++ "POST /api/v1/entities/:ref/summary/revisions",
++ "POST /api/v1/entities/:ref/summary/revisions/:n/review",
++ "POST /api/v1/entities/:ref/summary/publish",
++ "POST /api/v1/entities/:ref/summary/unpublish"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..4edeadb
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,46 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.19.0 <1.0.0"
++ },
++ "id": "reviews",
++ "name": "OpenVibe.Reviews",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Reviews",
++ "domains": [
++ "openvibe.reviews"
++ ],
++ "publicOrigin": "https://openvibe.reviews",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "reviews.entity.resolve",
++ "reviews.entity.manage",
++ "reviews.entity.merge",
++ "reviews.entity.split",
++ "reviews.signal.import",
++ "reviews.summary.propose",
++ "reviews.summary.publish",
++ "reviews.correction.submit"
++ ],
++ "eventsProduced": [
++ "reviews.entity.merged",
++ "reviews.entity.split",
++ "reviews.signal.added",
++ "reviews.signal.removed",
++ "reviews.summary.published",
++ "reviews.summary.updated",
++ "reviews.summary.unpublished",
++ "reviews.index_document.upserted",
++ "reviews.index_document.deleted"
++ ],
++ "eventsConsumed": [
++ "sources.item.created",
++ "sources.item.updated",
++ "sources.item.removed"
++ ],
++ "namespacesOwned": [
++ "reviews.*"
++ ],
++ "notes": "Entities with aliases and typed links; resolution of OpenVibe.Sources review items by deterministic rules (source binding, URL, GTIN/SKU/MPN) with editor confirmation for name-only and conflicting matches; merges that rewrite nothing and splits that restore attribution exactly, both audited; typed signals with provenance (source item, item revision, retrieval time, licence note) and trust metadata; aggregates computed only from signals present (method reviews-aggregate@1, inputs and exclusions exposed, absent without qualifying signals, a new revision on every change); editor-written or AI-drafted-and-approved summaries whose points cite signals and never carry a rating; source removal withdraws signals, flags citing summaries and prepares a pending revision; corrections queue; Community discussion referenced, not copied. Server-rendered pages useful without JavaScript; AggregateRating/Product/Review JSON-LD only when signals back it; sitemaps, feeds and Search index events from the openvibe-publishing v0.2.0 gate. Not deployed; openvibe.reviews stays on OpenVibe.Sites until launch."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..382915d
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1546 @@
++{
++ "name": "openvibe-reviews",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-reviews",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.19.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-publishing": {
++ "version": "0.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..3d7bd61
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,30 @@
++{
++ "name": "openvibe-reviews",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Reviews: review signals gathered from OpenVibe.Sources with provenance, entity resolution with reversible audited merges, honest aggregates that exist only when signals do, and editor-reviewed summaries, server-rendered and useful without JavaScript.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js"
++ },
++ "keywords": ["openvibe", "reviews", "publishing"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Reviews",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/public/css/reviews.css b/public/css/reviews.css
nnew file mode 100644
iindex 0000000..371cefa
---- /dev/null
++++ b/public/css/reviews.css
@@@ -0,0 +1,94 @@
++/* OpenVibe.Reviews — small, readable, works without JavaScript. Colours follow the network theme
++ variables (set by theme-loader.js) with dark defaults matching the critical canvas. */
++:root {
++ --rv-bg: var(--bg-primary, #0a0f1c);
++ --rv-panel: var(--bg-secondary, #111a2c);
++ --rv-text: var(--text-primary, #e6edf7);
++ --rv-muted: var(--text-secondary, #9fb0c8);
++ --rv-line: var(--border-color, rgba(255, 255, 255, .12));
++ --rv-accent: var(--accent, #60a5fa);
++ --rv-red: #f87171;
++ --rv-ok: #34d399;
++ --rv-warn: #fbbf24;
++}
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--rv-bg); color: var(--rv-text); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--rv-accent); }
++a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; }
++.rv-skip { position: absolute; left: -9999px; }
++.rv-skip:focus { left: 16px; top: 8px; background: var(--rv-panel); padding: 6px 10px; z-index: 10; }
++.rv-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++.rv-bar { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; max-width: 1100px; margin: 0 auto; padding: 12px 16px; border-bottom: 1px solid var(--rv-line); }
++.rv-brand { font-weight: 700; color: var(--rv-text); text-decoration: none; }
++.rv-search { display: flex; gap: 6px; flex: 1 1 240px; }
++.rv-search input { flex: 1; min-width: 0; }
++.rv-account { font-size: 14px; color: var(--rv-muted); }
++.rv-main { max-width: 1100px; margin: 0 auto; padding: 16px 16px 48px; overflow-wrap: anywhere; }
++h1 { font-size: 1.9rem; line-height: 1.25; margin: .4em 0 .3em; }
++h2 { font-size: 1.3rem; margin: 1.6em 0 .5em; border-bottom: 1px solid var(--rv-line); padding-bottom: .2em; }
++.rv-muted { color: var(--rv-muted); }
++.rv-tag { display: inline-block; font-size: 12px; padding: 0 6px; border: 1px solid var(--rv-line); border-radius: 10px; color: var(--rv-muted); }
++.rv-notice { padding: 8px 12px; border-left: 4px solid var(--rv-accent); background: var(--rv-panel); }
++.rv-notice.rv-warn { border-color: var(--rv-warn); }
++.rv-notice.rv-error { border-color: var(--rv-red); }
++.rv-notice.rv-ok { border-color: var(--rv-ok); }
++.rv-notice.rv-ai { border-color: #a78bfa; }
++.rv-button, button { display: inline-block; background: var(--rv-accent); color: #0a0f1c; border: 0; border-radius: 6px; padding: 6px 14px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }
++input, select, textarea { font: inherit; color: var(--rv-text); background: var(--rv-panel); border: 1px solid var(--rv-line); border-radius: 6px; padding: 6px 8px; max-width: 100%; }
++textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
++.rv-form label { display: block; margin: 10px 0; }
++.rv-form label > input[type=text], .rv-form label > input[type=url], .rv-form label > input[type=search], .rv-form label > select { display: block; width: 100%; margin-top: 4px; }
++.rv-form .rv-check { display: block; }
++.rv-inline { display: inline; }
++fieldset { border: 1px solid var(--rv-line); border-radius: 6px; margin: 12px 0; }
++.ov-breadcrumbs ol { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0 0 8px; font-size: 14px; }
++.ov-breadcrumbs li + li::before { content: "›"; margin-right: 4px; color: var(--rv-muted); }
++.rv-article header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; }
++.rv-actions { font-size: 14px; }
++.rv-infobox { float: right; clear: right; width: min(320px, 100%); margin: 0 0 16px 16px; background: var(--rv-panel); border: 1px solid var(--rv-line); border-radius: 6px; }
++.rv-infobox table { width: 100%; border-collapse: collapse; font-size: 14px; }
++.rv-infobox th, .rv-infobox td { text-align: left; vertical-align: top; padding: 6px 8px; border-top: 1px solid var(--rv-line); }
++.rv-infobox tr:first-child th, .rv-infobox tr:first-child td { border-top: 0; }
++.rv-content pre, .ov-diff { overflow-x: auto; background: var(--rv-panel); padding: 10px; border-radius: 6px; }
++.ov-diff { white-space: pre-wrap; }
++.ov-diff ins { background: rgba(52, 211, 153, .22); text-decoration: underline; }
++.ov-diff del { background: rgba(248, 113, 113, .22); }
++.ov-redlink { color: var(--rv-red); }
++.ov-media { margin: 12px 0; }
++.ov-media img { max-width: 100%; height: auto; }
++.ov-media-missing { padding: 24px; border: 1px dashed var(--rv-red); color: var(--rv-red); border-radius: 6px; }
++.rv-sources blockquote { margin: 4px 0 0 12px; color: var(--rv-muted); font-style: italic; }
++.rv-meta { clear: both; font-size: 14px; color: var(--rv-muted); border-top: 1px solid var(--rv-line); margin-top: 24px; }
++table { border-collapse: collapse; }
++.rv-history, .rv-fields { width: 100%; font-size: 14px; display: block; overflow-x: auto; }
++.rv-history th, .rv-history td, .rv-fields th, .rv-fields td { padding: 4px 8px; border-bottom: 1px solid var(--rv-line); text-align: left; vertical-align: top; }
++.rv-history tr.rv-live { background: var(--rv-panel); }
++.rv-tree { padding-left: 1.2em; }
++.rv-comments { list-style: none; padding: 0; }
++.rv-comments li { border-top: 1px solid var(--rv-line); padding: 8px 0; }
++@media (max-width: 640px) {
++ .rv-infobox { float: none; margin: 0 0 16px; width: 100%; }
++ h1 { font-size: 1.5rem; }
++}
++@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
++/* Reviews: aggregate, signals, summary */
++.rv-entities { list-style: none; padding: 0; }
++.rv-entities li { padding: 8px 0; border-top: 1px solid var(--rv-line); }
++.rv-agg-box { background: var(--rv-panel); border: 1px solid var(--rv-line); border-radius: 8px; padding: 10px 14px; }
++.rv-agg { font-size: 1.15rem; margin: 4px 0; }
++.rv-agg strong { font-size: 1.6rem; }
++.rv-scroll { overflow-x: auto; max-width: 100%; }
++.rv-signals { width: 100%; font-size: 14px; }
++.rv-signals th, .rv-signals td { padding: 6px 8px; border-bottom: 1px solid var(--rv-line); text-align: left; vertical-align: top; }
++.rv-signals tr:target { outline: 2px solid var(--rv-accent); }
++.rv-signal.rv-withdrawn, .rv-signal.rv-superseded { opacity: .7; }
++.rv-proscons { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 24px; }
++.rv-cite { font-size: 12px; text-decoration: none; white-space: nowrap; }
++.rv-cite-gone { color: var(--rv-warn); text-decoration: line-through; }
++.rv-unsupported { color: var(--rv-muted); }
++.rv-point { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 12px; }
++.rv-point select { width: 100%; }
++.rv-limits { color: var(--rv-muted); }
++.rv-audit { font-size: 14px; }
++details summary { cursor: pointer; }
++code { overflow-wrap: anywhere; }
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..72aaa34
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -0,0 +1,43 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Create Reviews' OpenVibe.Events subscription for OpenVibe.Sources item events.
++ *
++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4830/internal/events] [--pattern 'sources.item.*']
++ *
++ * Reads the environment (.env or /etc/openvibe/reviews.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,
++ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the reviews principal needs events.subscription.manage
++ * for audience openvibe.events) and REVIEWS_EVENTS_SECRET — the delivery signing secret, handed to
++ * Events here, so generate it first (`openssl rand -hex 32`). Nothing secret is printed. An existing
++ * identical subscription is reported, not duplicated. The pull sync (GET /api/v1/items in change
++ * order) works without this subscription; the subscription only makes removals arrive sooner.
++ */
++require('dotenv').config();
++const { serviceAuth } = require('openvibe-contracts');
++const { load } = require('../server/config');
++
++const args = process.argv.slice(2);
++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
++
++(async () => {
++ const config = load();
++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
++ const pattern = opt('pattern', 'sources.item.*');
++ const secret = config.eventsWebhookSecrets[0];
++ if (!config.eventsUrl) throw new Error('EVENTS_URL is not set');
++ if (!secret || secret.length < 32) throw new Error('REVIEWS_EVENTS_SECRET must be set (32+ characters) before subscribing');
++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.events', scope: 'events.subscription.manage',
++ });
++ const res = await fetch(`${config.eventsUrl}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); return; }
++ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());
++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}).`);
++})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..5c5a4cb
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,129 @@
++'use strict';
++/**
++ * OpenVibe.Reviews — Express app factory. server/index.js starts it; tests build their own.
++ *
++ * Pages (SSR, useful without JavaScript) server/http/pages.js
++ * /api/v1 (people and service principals) server/http/api.js
++ * POST /internal/events (signed webhook) server/http/consumer.js
++ * Discovery: robots, sitemaps, feeds, llms server/http/machine.js
++ * /auth/* (Network SSO) server/auth/session.js
++ * GET /api/health, /api/ready, /release.json, /metrics (loopback only)
++ */
++const path = require('path');
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const { createReadiness } = require('openvibe-shared/ready');
++const { http } = require('openvibe-contracts');
++const { createSessionRoutes } = require('./auth/session');
++const { createApi } = require('./http/api');
++const { createPages } = require('./http/pages');
++const { createMachine } = require('./http/machine');
++const { consumerRouter } = require('./http/consumer');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch }) {
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++
++ const release = require('openvibe-shared/release').createRelease({ service: 'reviews', root: path.join(__dirname, '..') });
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'reviews', release: release.release });
++ metrics.registry.gauge({
++ name: 'reviews_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
++ collect: () => [{ labels: { state: 'pending' }, value: platform.outbox.pending() }, { labels: { state: 'rejected' }, value: platform.outbox.rejected() }],
++ });
++ metrics.registry.gauge({
++ name: 'reviews_work', help: 'Editorial and import backlog', labelNames: ['kind'],
++ collect: () => {
++ const s = svc.stats();
++ return [
++ { labels: { kind: 'items_unresolved' }, value: s.items_unresolved },
++ { labels: { kind: 'corrections_open' }, value: s.corrections_open },
++ { labels: { kind: 'summaries_flagged' }, value: s.summaries_flagged },
++ { labels: { kind: 'import_queue' }, value: sync.pending() },
++ ];
++ },
++ });
++
++ app.use((req, res, next) => {
++ res.set('X-Content-Type-Options', 'nosniff');
++ res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
++ res.set('Content-Security-Policy', [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
++ "img-src 'self' data: https:",
++ "connect-src 'self' https://openvibe.network",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'self'",
++ "form-action 'self' https://openvibe.network",
++ "base-uri 'self'",
++ "object-src 'none'",
++ ].join('; '));
++ next();
++ });
++ app.use(cookieParser());
++
++ const limiter = (windowMs, max) => (rateLimits ? rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false }) : (_q, _s, n) => n());
++ app.use('/auth/', limiter(15 * 60000, 60));
++ app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'reviews', service: 'reviews', host: 'openvibe.reviews', name: 'OpenVibe.Reviews', profile: 'ugc' })); }
++
++ // Signed deliveries from OpenVibe.Events. Loopback/internal only in nginx (not proxied publicly).
++ const consumer = consumerRouter({ db, svc, sync, config, log });
++ app.use('/internal', http.middleware(), consumer.router);
++
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-reviews', version: VERSION }));
++ app.get('/release.json', release.handler);
++ const ready = createReadiness({
++ service: 'reviews', release: release.release,
++ checks: [
++ { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('review_entities','review_signals','review_summary_revisions')").get().n === 3 || 'review tables missing' },
++ { name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },
++ { name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: no review items are read, so no signals and no aggregates exist' },
++ { name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
++ { name: 'events_webhook', required: false, check: () => (config.eventsWebhookSecrets.length ? true : 'REVIEWS_EVENTS_SECRET is not set: sources.item.* deliveries are refused; the pull sync still runs') },
++ { name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
++ { name: 'editors', required: false, check: () => (svc.access.editorCount() ? true : 'REVIEWS_EDITORS is empty: only Network staff can edit') },
++ ],
++ details: () => ({
++ outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() },
++ sources: { cursor: sync.cursor(), import_queue: sync.pending(), last_pull_ok_at: sync.state('sources.last_pull_ok_at'), last_pull_error: sync.state('sources.last_pull_error') },
++ backlog: svc.stats(),
++ }),
++ });
++ app.get('/api/ready', ready.handler);
++
++ app.use('/api/', limiter(60000, 300));
++ app.use('/api/v1/entities/:ref/corrections', limiter(60 * 60000, 20));
++ app.use('/api/v1', createApi({ svc, viewers, platform, sync, config, log }));
++ app.use('/api', (req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
++
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res) { res.setHeader('Cache-Control', res.req && res.req.query && res.req.query.v ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'); },
++ }));
++ app.use(createMachine({ svc, config }));
++
++ app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
++ app.post('/e/:slug/correct', limiter(60 * 60000, 20));
++ const pages = createPages({ svc, platform, sync, viewers, config, log });
++ app.use(pages.router);
++
++ app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[Reviews]', err && err.stack ? err.stack : err);
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error' });
++ res.status(500).set('Cache-Control', 'private, no-store').type('text/plain').send('Something went wrong on our side. Please try again.');
++ });
++ app.locals.consumer = consumer;
++ return app;
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..97d0361
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * Capability checks for service tokens, including the reviews.* ids this service introduces before
++ * the contracts library knows them (proposed in docs/capabilities-proposal/).
++ *
++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
++ * manifests yet. Until a release defines them, a grant of a proposed id is decided locally with the
++ * library's own matching rule (the exact id, or a `prefix.*` grant covering it). An id the library
++ * knows always goes through the library, so the day the release lands nothing changes here.
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const CAPS = Object.freeze({
++ ENTITY_RESOLVE: 'reviews.entity.resolve',
++ ENTITY_MANAGE: 'reviews.entity.manage',
++ ENTITY_MERGE: 'reviews.entity.merge',
++ ENTITY_SPLIT: 'reviews.entity.split',
++ SIGNAL_IMPORT: 'reviews.signal.import',
++ SUMMARY_PROPOSE: 'reviews.summary.propose',
++ SUMMARY_PUBLISH: 'reviews.summary.publish',
++ CORRECTION_SUBMIT: 'reviews.correction.submit',
++});
++const PROPOSED = new Set(Object.values(CAPS));
++
++/** → { allowed, code, reason } like capabilities.check(). */
++function checkCapability(claims, capabilityId) {
++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
++ return capabilities.grants(claims && claims.cap, capabilityId)
++ ? { allowed: true, code: null, reason: null }
++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
++ }
++ return capabilities.check(claims, capabilityId);
++}
++
++module.exports = { checkCapability, CAPS, PROPOSED };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..9973667
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * The Network's RS256 public key, used to verify user JWTs (SSO) and service tokens offline.
++ * Pinned with OV_NETWORK_PUBLIC_KEY, or fetched from <network>/api/.well-known/jwks (internal URL
++ * first) and retried at most every 30 s until it loads. Without it nobody can sign in or call the
++ * API with a token; public pages keep working.
++ */
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console, publicKey = null } = {}) {
++ let key = publicKey || config.networkPublicKey || null;
++ let lastFetch = 0;
++ let inflight = null;
++
++ async function fetchKey() {
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) continue;
++ const jwks = await res.json();
++ if (jwks && typeof jwks.public_key === 'string' && jwks.public_key.includes('BEGIN')) {
++ key = jwks.public_key;
++ log.log(`[Reviews] Network public key loaded from ${base}`);
++ return key;
++ }
++ } catch (err) {
++ log.warn(`[Reviews] JWKS fetch failed from ${base}: ${err.message}`);
++ }
++ }
++ return null;
++ }
++
++ return {
++ get() { return key; },
++ loaded() { return !!key; },
++ async ensure() {
++ if (key) return key;
++ if (inflight) return inflight;
++ if (Date.now() - lastFetch < 30000) return null;
++ lastFetch = Date.now();
++ inflight = fetchKey().finally(() => { inflight = null; });
++ return inflight;
++ },
++ };
++}
++
++module.exports = { createKeyStore };
ddiff --git a/server/auth/session.js b/server/auth/session.js
nnew file mode 100644
iindex 0000000..58a303c
---- /dev/null
++++ b/server/auth/session.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * openvibe.reviews as an OAuth2 client of OpenVibe.Network (client `reviews`, redirect
++ * <BASE_URL>/auth/callback) — the same session layer as OpenVibe.Community's server/auth/routes.js.
++ *
++ * GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)
++ * GET /auth/callback → server-side code exchange, sets the cookies
++ * POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)
++ * GET /auth/logout → clears the cookies (best-effort refresh revoke)
++ * GET /auth/me → the signed-in profile (offline JWT verification)
++ * POST /auth/refresh → rotates tokens with the refresh token
++ *
++ * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,
++ * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).
++ */
++const crypto = require('crypto');
++const express = require('express');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++function sanitizeNext(next, config) {
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next;
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++function withParam(target, key, value) {
++ const at = target.indexOf('#');
++ const hash = at >= 0 ? target.slice(at) : '';
++ const base = at >= 0 ? target.slice(0, at) : target;
++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++function authorizeUrl(config, { silent = false } = {}) {
++ const state = crypto.randomBytes(16).toString('hex');
++ const u = new URL(`${config.networkUrl}/oauth/authorize`);
++ u.searchParams.set('client_id', config.oauth.clientId);
++ u.searchParams.set('redirect_uri', config.oauth.redirectUri);
++ u.searchParams.set('response_type', 'code');
++ u.searchParams.set('scope', config.oauth.scope);
++ u.searchParams.set('state', state);
++ if (silent) u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state };
++}
++
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return false;
++ let claims;
++ try { claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return false; }
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
++ const router = express.Router();
++ const secure = config.cookies.secure;
++ const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
++ const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
++ const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
++ const hint = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
++
++ function setSession(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, access());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refresh());
++ res.cookie(HINT_COOKIE, 'account', hint());
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refresh(), maxAge: undefined });
++ }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
++
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
++ signal: AbortSignal.timeout(10000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status, error: data.error || 'invalid_grant' });
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ router.get('/login', async (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ const next = sanitizeNext(req.query.next, config);
++ if (silent) {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
++ }
++ const { url, state } = authorizeUrl(config, { silent });
++ res.cookie(STATE_COOKIE, state, flow());
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(url);
++ });
++
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
++ if (error) {
++ clearFlow(res);
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
++ const expected = req.cookies && req.cookies[STATE_COOKIE];
++ clearFlow(res);
++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');
++ }
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ log.error(`[Reviews] code exchange failed: ${err.message}`);
++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });
++ try {
++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ ok: true, user: data.user || null });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(401).json({ error: 'invalid_grant' });
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ router.get('/logout', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (token) {
++ try {
++ await fetchImpl(`${config.networkInternalUrl || config.networkUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', hint());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ router.get('/me', async (req, res) => {
++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
++ if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
++ res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
++ });
++
++ router.post('/refresh', async (req, res) => {
++ const token = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!token) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ token: data.access_token });
++ } catch (err) {
++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..19efba9
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * Who is calling — resolved once per request into `req.actor` (see server/reviews/access.js):
++ *
++ * { kind: 'anonymous', subject: null }
++ * { kind: 'user', subject: 'usr_…'|null, staff, user }
++ * A browser or client with the Network user JWT (ov_token cookie or Bearer), verified offline
++ * (RS256, issuer, audience). role admin/global_mod makes the person staff.
++ * { kind: 'service', service: 'svc:x', claims, subject }
++ * A Network client-credentials token for audience openvibe.reviews. It names the person it acts
++ * for in X-OV-Subject (usr_…); every route checks one capability against the token.
++ *
++ * Identity never comes from a body or a query. A request that presents a service token is judged
++ * on that token alone: a bad one is refused, never downgraded to anonymous.
++ */
++const contracts = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++
++const { ids, serviceAuth } = contracts;
++const STAFF_ROLES = new Set(['admin', 'global_mod']);
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++const ACCESS_COOKIE = 'ov_token';
++
++class AuthError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++function bearer(req) {
++ const h = String(req.headers.authorization || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++function createViewerResolver({ keys, config }) {
++ async function fromServiceToken(req, token) {
++ const publicKey = await keys.ensure();
++ if (!publicKey) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) throw new AuthError(401, r.code, r.reason);
++ const header = req.get('x-ov-subject');
++ let subject = null;
++ if (header) {
++ if (!ids.isSubjectId('user', header)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
++ subject = header;
++ }
++ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, staff: false };
++ }
++
++ async function fromUserToken(token, { strict }) {
++ const publicKey = await keys.ensure();
++ if (!publicKey) {
++ if (strict) throw new AuthError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ return null;
++ }
++ let claims;
++ try {
++ claims = await verifyUserToken(token, { publicKey, issuer: config.networkIssuer, audience: config.userAudiences });
++ } catch (err) {
++ if (strict) throw new AuthError(401, err.code || 'token.invalid', err.message);
++ return null;
++ }
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ return {
++ kind: 'user', subject, staff: STAFF_ROLES.has(claims.role),
++ user: { username: claims.username || null, display_name: claims.display_name || claims.username || null, avatar_url: claims.avatar_url || null, role: claims.role || null, subject_id: subject },
++ };
++ }
++
++ /**
++ * opts.services=false (server-rendered pages): a service token is no identity at all.
++ * A Bearer user token is strict (a bad one is 401); the cookie is lenient (expired = signed out).
++ */
++ async function resolve(req, opts = {}) {
++ const token = bearer(req);
++ if (token) {
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ if (opts.services === false) return ANONYMOUS;
++ return fromServiceToken(req, token);
++ }
++ return (await fromUserToken(token, { strict: true })) || ANONYMOUS;
++ }
++ const cookie = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!cookie) return ANONYMOUS;
++ return (await fromUserToken(cookie, { strict: false })) || ANONYMOUS;
++ }
++
++ return { resolve };
++}
++
++module.exports = { createViewerResolver, AuthError, ANONYMOUS, ACCESS_COOKIE, STAFF_ROLES };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..58f0ce6
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * OpenVibe.Reviews configuration. load(env) is pure so tests build their own config; the process
++ * entry calls load() with process.env after dotenv.
++ */
++
++function list(value, fallback) {
++ return String(value == null || value === '' ? fallback : value).split(',').map((s) => s.trim()).filter(Boolean);
++}
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++const bool = (v, d) => (v == null || v === '' ? d : v === 'true' || v === '1');
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4830);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.reviews' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ return {
++ serviceId: 'reviews',
++ nodeEnv,
++ isProduction,
++ port,
++ host: env.HOST || '127.0.0.1',
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
++ dbPath: env.REVIEWS_DB_PATH || './data/reviews.db',
++
++ // Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? String(env.OV_NETWORK_PUBLIC_KEY).replace(/\\n/g, '\n') : null,
++ userAudiences: list(env.REVIEWS_USER_AUDIENCES, 'openvibe.reviews,openvibe.network'),
++ audience: 'openvibe.reviews',
++
++ // Editors: Network staff (admin, global_mod) plus these usr_ subjects.
++ editors: list(env.REVIEWS_EDITORS, ''),
++
++ // OAuth client `reviews` (browser sign-in) and service principal `svc:reviews` (same credentials).
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'reviews',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile theme',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // Other services (all optional: each integration degrades to an explicit failure state).
++ eventsUrl: trim(env.EVENTS_URL || ''),
++ eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // HMAC secrets of Reviews' OpenVibe.Events subscription (sources.item.*); comma-separated for rotation.
++ eventsWebhookSecrets: list(env.REVIEWS_EVENTS_SECRET, ''),
++ communityUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
++ communityInternalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || ''),
++ sourcesInternalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
++
++ // Pulling review items from OpenVibe.Sources (sources.item.read) in change order.
++ sync: {
++ enabled: bool(env.REVIEWS_SOURCES_SYNC, true),
++ intervalMs: int(env.REVIEWS_SOURCES_SYNC_INTERVAL_MS, 5 * 60 * 1000),
++ pageSize: Math.min(500, Math.max(1, int(env.REVIEWS_SOURCES_PAGE_SIZE, 200))),
++ queueIntervalMs: int(env.REVIEWS_IMPORT_QUEUE_INTERVAL_MS, 15000),
++ },
++
++ // The indexability gate policy for entity pages (openvibe-publishing/seo).
++ gate: {
++ minWords: int(env.REVIEWS_GATE_MIN_WORDS, 60),
++ requireSources: true,
++ minSources: Math.max(1, int(env.REVIEWS_GATE_MIN_SIGNALS, 1)),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..9fb9381
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,317 @@
++'use strict';
++/**
++ * Reviews' own SQLite database (one per service, WAL, created on boot, idempotent).
++ *
++ * The nine authority tables of roadmap §15.13:
++ * review_entities the things reviewed; merged entities point at their canonical entity
++ * review_entity_aliases names, URLs, SKUs/GTINs, source bindings used to resolve source items
++ * review_sources the OpenVibe.Sources sources Reviews has seen (key, notes, health)
++ * review_source_items Sources items Reviews read (signal fields only, never review text),
++ * their provenance and how they were resolved to an entity
++ * review_signals typed observations extracted from one item revision, attributed to
++ * one entity; values and provenance are immutable, only status moves
++ * review_summaries one editorial summary per entity and its publication state
++ * review_summary_revisions immutable summary revisions (openvibe-publishing/revisions, prefix review_summary)
++ * review_trust_metadata editor-set trust facts per source / signal / entity (include/exclude
++ * from aggregates, limitations, verification), history kept
++ * review_entity_links typed links between entities; a merge is a `merged_into` link, a split
++ * ends it (who, when, why: the audit of the merge itself)
++ *
++ * Helper tables (not authority): review_aggregates (every computed aggregate revision, immutable),
++ * review_summary_citations (which signal each summary point cites, immutable), review_corrections
++ * (the correction queue), review_audit (append-only log of editorial actions), review_import_queue
++ * and review_sync_state (Sources consumption). The Publishing packages add review_summary_drafts,
++ * review_summary_revision_purges, review_summary_reviews, review_entity_redirects,
++ * review_discussion_refs and review_index_revisions; the SDK adds review_event_outbox and the
++ * inbox table review_event_inbox.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { createRevisionStore } = require('openvibe-publishing/revisions');
++const { createDiscussionRefs } = require('openvibe-publishing/discussion');
++const { createReviewLog } = require('openvibe-publishing/authorship');
++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
++const seo = require('openvibe-publishing/seo');
++
++const ENTITY_KINDS = ['product', 'game', 'software', 'service', 'place', 'organization', 'media', 'other'];
++const ALIAS_TYPES = ['name', 'url', 'sku', 'gtin', 'mpn', 'source', 'external'];
++const SIGNAL_TYPES = ['recommendation', 'recommendation_tally', 'rating', 'rating_aggregate'];
++const LINK_TYPES = ['merged_into', 'related', 'edition_of', 'successor_of', 'part_of', 'external_ref'];
++const TRUST_KEYS = ['aggregate', 'limitation', 'verification'];
++
++const q = (list) => list.map((s) => `'${s}'`).join(',');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS review_entities (
++ id TEXT PRIMARY KEY,
++ slug TEXT NOT NULL UNIQUE,
++ name TEXT NOT NULL,
++ kind TEXT NOT NULL CHECK (kind IN (${q(ENTITY_KINDS)})),
++ description TEXT,
++ state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','deleted')),
++ merged_into TEXT REFERENCES review_entities(id),
++ noindex INTEGER NOT NULL DEFAULT 0,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ deleted_at INTEGER,
++ CHECK ((state = 'merged') = (merged_into IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS review_entities_merged ON review_entities (merged_into);
++CREATE INDEX IF NOT EXISTS review_entities_name ON review_entities (name COLLATE NOCASE);
++
++CREATE TABLE IF NOT EXISTS review_entity_aliases (
++ id TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL REFERENCES review_entities(id),
++ type TEXT NOT NULL CHECK (type IN (${q(ALIAS_TYPES)})),
++ value TEXT NOT NULL,
++ norm TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ removed_at INTEGER,
++ removed_by TEXT
++);
++-- A strong identifier (URL, SKU, GTIN, MPN, source binding, external id) names one entity at a time;
++-- names may be shared (two things called "Portal" is why names need an editor's confirmation).
++CREATE UNIQUE INDEX IF NOT EXISTS review_entity_aliases_strong ON review_entity_aliases (type, norm) WHERE removed_at IS NULL AND type != 'name';
++CREATE UNIQUE INDEX IF NOT EXISTS review_entity_aliases_name ON review_entity_aliases (norm, entity_id) WHERE removed_at IS NULL AND type = 'name';
++CREATE INDEX IF NOT EXISTS review_entity_aliases_entity ON review_entity_aliases (entity_id);
++
++CREATE TABLE IF NOT EXISTS review_sources (
++ key TEXT PRIMARY KEY,
++ name TEXT,
++ homepage_url TEXT,
++ category TEXT,
++ license_note TEXT,
++ terms_note TEXT,
++ status TEXT,
++ stale INTEGER,
++ last_success_at TEXT,
++ first_seen_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS review_source_items (
++ id TEXT PRIMARY KEY,
++ source_key TEXT NOT NULL REFERENCES review_sources(key),
++ kind TEXT NOT NULL,
++ identity TEXT NOT NULL,
++ canonical_url TEXT,
++ title TEXT,
++ item_revision INTEGER NOT NULL,
++ content_hash TEXT NOT NULL,
++ published_at TEXT,
++ retrieved_at TEXT NOT NULL,
++ parser_version TEXT,
++ license_note TEXT,
++ terms_note TEXT,
++ fields TEXT NOT NULL DEFAULT '{}',
++ state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','removed')),
++ removed_at TEXT,
++ removed_reason TEXT,
++ resolution TEXT NOT NULL DEFAULT 'unmatched' CHECK (resolution IN ('resolved','ambiguous','unmatched','ignored')),
++ entity_id TEXT REFERENCES review_entities(id),
++ resolution_rule TEXT,
++ candidates TEXT NOT NULL DEFAULT '[]',
++ resolved_by TEXT,
++ resolved_at INTEGER,
++ signal_note TEXT,
++ first_seen_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ CHECK ((resolution = 'resolved') = (entity_id IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS review_source_items_resolution ON review_source_items (resolution, updated_at);
++CREATE INDEX IF NOT EXISTS review_source_items_entity ON review_source_items (entity_id);
++CREATE INDEX IF NOT EXISTS review_source_items_source ON review_source_items (source_key);
++
++CREATE TABLE IF NOT EXISTS review_signals (
++ id TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL REFERENCES review_entities(id),
++ source_item_id TEXT NOT NULL REFERENCES review_source_items(id),
++ source_key TEXT NOT NULL REFERENCES review_sources(key),
++ item_revision INTEGER NOT NULL,
++ type TEXT NOT NULL CHECK (type IN (${q(SIGNAL_TYPES)})),
++ recommended INTEGER CHECK (recommended IN (0,1)),
++ positive_count INTEGER,
++ total_count INTEGER,
++ rating_value REAL,
++ rating_best REAL,
++ rating_worst REAL,
++ rating_count INTEGER,
++ observed_at TEXT NOT NULL,
++ source_published_at TEXT,
++ canonical_url TEXT,
++ license_note TEXT,
++ trust TEXT NOT NULL DEFAULT '{}',
++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','withdrawn')),
++ status_reason TEXT,
++ status_at INTEGER,
++ superseded_by TEXT,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ CHECK (type != 'recommendation' OR recommended IS NOT NULL),
++ CHECK (type != 'recommendation_tally' OR (positive_count IS NOT NULL AND total_count IS NOT NULL AND positive_count >= 0 AND total_count >= positive_count)),
++ CHECK (type NOT IN ('rating','rating_aggregate') OR rating_value IS NOT NULL)
++);
++-- At most one active signal per source item: an update supersedes, a removal withdraws.
++CREATE UNIQUE INDEX IF NOT EXISTS review_signals_active_item ON review_signals (source_item_id) WHERE status = 'active';
++CREATE INDEX IF NOT EXISTS review_signals_entity ON review_signals (entity_id, status);
++-- Provenance and values never change after the signal exists; only its status moves.
++CREATE TRIGGER IF NOT EXISTS review_signals_immutable BEFORE UPDATE OF id, entity_id, source_item_id, source_key, item_revision, type,
++ recommended, positive_count, total_count, rating_value, rating_best, rating_worst, rating_count, observed_at,
++ source_published_at, canonical_url, license_note, trust, created_by, created_at ON review_signals
++BEGIN SELECT RAISE(ABORT, 'review_signals values and provenance are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS review_signals_no_delete BEFORE DELETE ON review_signals
++BEGIN SELECT RAISE(ABORT, 'review_signals rows are withdrawn, never deleted'); END;
++
++CREATE TABLE IF NOT EXISTS review_summaries (
++ id TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL UNIQUE REFERENCES review_entities(id),
++ state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','published','unpublished')),
++ published_revision INTEGER,
++ published_at INTEGER,
++ revision_published_at INTEGER,
++ flagged INTEGER NOT NULL DEFAULT 0,
++ flag_reason TEXT,
++ flagged_at INTEGER,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS review_summary_citations (
++ summary_id TEXT NOT NULL REFERENCES review_summaries(id),
++ revision INTEGER NOT NULL,
++ point TEXT NOT NULL,
++ signal_id TEXT NOT NULL REFERENCES review_signals(id),
++ PRIMARY KEY (summary_id, revision, point, signal_id)
++);
++CREATE INDEX IF NOT EXISTS review_summary_citations_signal ON review_summary_citations (signal_id);
++CREATE TRIGGER IF NOT EXISTS review_summary_citations_no_update BEFORE UPDATE ON review_summary_citations
++BEGIN SELECT RAISE(ABORT, 'review_summary_citations rows are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS review_summary_citations_no_delete BEFORE DELETE ON review_summary_citations
++BEGIN SELECT RAISE(ABORT, 'review_summary_citations rows are never deleted'); END;
++
++CREATE TABLE IF NOT EXISTS review_trust_metadata (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope TEXT NOT NULL CHECK (scope IN ('source','signal','entity')),
++ scope_id TEXT NOT NULL,
++ key TEXT NOT NULL CHECK (key IN (${q(TRUST_KEYS)})),
++ value TEXT NOT NULL,
++ note TEXT,
++ set_by TEXT NOT NULL,
++ set_at INTEGER NOT NULL,
++ ended_at INTEGER,
++ ended_by TEXT
++);
++CREATE UNIQUE INDEX IF NOT EXISTS review_trust_current ON review_trust_metadata (scope, scope_id, key) WHERE ended_at IS NULL;
++
++CREATE TABLE IF NOT EXISTS review_entity_links (
++ id TEXT PRIMARY KEY,
++ from_entity TEXT NOT NULL REFERENCES review_entities(id),
++ to_entity TEXT REFERENCES review_entities(id),
++ type TEXT NOT NULL CHECK (type IN (${q(LINK_TYPES)})),
++ ref TEXT,
++ note TEXT,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ ended_at INTEGER,
++ ended_by TEXT,
++ end_note TEXT,
++ CHECK ((type = 'external_ref') = (ref IS NOT NULL AND to_entity IS NULL))
++);
++CREATE INDEX IF NOT EXISTS review_entity_links_from ON review_entity_links (from_entity, type);
++CREATE INDEX IF NOT EXISTS review_entity_links_to ON review_entity_links (to_entity, type);
++CREATE UNIQUE INDEX IF NOT EXISTS review_entity_links_one_merge ON review_entity_links (from_entity) WHERE type = 'merged_into' AND ended_at IS NULL;
++
++CREATE TABLE IF NOT EXISTS review_aggregates (
++ entity_id TEXT NOT NULL REFERENCES review_entities(id),
++ revision INTEGER NOT NULL,
++ computed_at INTEGER NOT NULL,
++ trigger TEXT NOT NULL,
++ inputs_hash TEXT NOT NULL,
++ result TEXT,
++ PRIMARY KEY (entity_id, revision)
++);
++CREATE TRIGGER IF NOT EXISTS review_aggregates_no_update BEFORE UPDATE ON review_aggregates
++BEGIN SELECT RAISE(ABORT, 'review_aggregates rows are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS review_aggregates_no_delete BEFORE DELETE ON review_aggregates
++BEGIN SELECT RAISE(ABORT, 'review_aggregates rows are never deleted'); END;
++
++CREATE TABLE IF NOT EXISTS review_corrections (
++ id TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL REFERENCES review_entities(id),
++ target_type TEXT NOT NULL CHECK (target_type IN ('entity','alias','signal','summary','aggregate')),
++ target_id TEXT,
++ body TEXT NOT NULL,
++ evidence_url TEXT,
++ submitted_by TEXT NOT NULL,
++ via TEXT,
++ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','accepted','rejected')),
++ resolved_by TEXT,
++ resolution_note TEXT,
++ resolved_at INTEGER,
++ created_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS review_corrections_status ON review_corrections (status, created_at);
++CREATE INDEX IF NOT EXISTS review_corrections_entity ON review_corrections (entity_id, status);
++
++CREATE TABLE IF NOT EXISTS review_audit (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ at INTEGER NOT NULL,
++ actor TEXT NOT NULL,
++ action TEXT NOT NULL,
++ entity_id TEXT,
++ target TEXT,
++ detail TEXT NOT NULL DEFAULT '{}'
++);
++CREATE INDEX IF NOT EXISTS review_audit_entity ON review_audit (entity_id, id);
++CREATE TRIGGER IF NOT EXISTS review_audit_no_update BEFORE UPDATE ON review_audit
++BEGIN SELECT RAISE(ABORT, 'review_audit rows are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS review_audit_no_delete BEFORE DELETE ON review_audit
++BEGIN SELECT RAISE(ABORT, 'review_audit rows are never deleted'); END;
++
++CREATE TABLE IF NOT EXISTS review_import_queue (
++ item_id TEXT PRIMARY KEY,
++ reason TEXT NOT NULL,
++ enqueued_at INTEGER NOT NULL,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ next_attempt_at INTEGER NOT NULL DEFAULT 0,
++ last_error TEXT
++);
++
++CREATE TABLE IF NOT EXISTS review_sync_state (
++ name TEXT PRIMARY KEY,
++ value TEXT,
++ updated_at INTEGER NOT NULL
++);
++`;
++
++function openDb(dbPath) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ return db;
++}
++
++/** Creates every table and returns the Publishing package stores bound to this database. */
++function createStores(db, { now = () => Date.now() } = {}) {
++ db.exec(SCHEMA);
++ const revisions = createRevisionStore(db, { prefix: 'review_summary', now });
++ return {
++ revisions,
++ reviews: createReviewLog(db, { prefix: 'review_summary', now }),
++ redirects: seo.createRedirectStore(db, { prefix: 'review_entity', now }),
++ discussions: createDiscussionRefs(db, { prefix: 'review', now }),
++ sequencer: createIndexSequencer(db, { prefix: 'review', now }),
++ };
++}
++
++const AUTHORITY_TABLES = Object.freeze([
++ 'review_entities', 'review_entity_aliases', 'review_sources', 'review_source_items', 'review_signals',
++ 'review_summaries', 'review_summary_revisions', 'review_trust_metadata', 'review_entity_links',
++]);
++
++module.exports = { openDb, createStores, SCHEMA, AUTHORITY_TABLES, ENTITY_KINDS, ALIAS_TYPES, SIGNAL_TYPES, LINK_TYPES, TRUST_KEYS };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..d493e54
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * /api/v1 — JSON API for people (Network user JWT as Bearer or the ov_token cookie) and services
++ * (Network client-credentials token for audience openvibe.reviews, one capability per route,
++ * acting for the person named in X-OV-Subject). Errors are RFC 9457 problem+json. Responses are
++ * Cache-Control: private, no-store. Editorial writes need an editor who is a person.
++ *
++ * GET /entities?q=&limit=&offset= reviews.entity.resolve list or search active entities
++ * POST /resolve reviews.entity.resolve { name?, url?, gtin?, sku?, mpn?, source?, external? }
++ * GET /entities/:ref reviews.entity.resolve entity page data: aggregate with inputs, signals with provenance, summary
++ * GET /entities/:ref/history reviews.entity.resolve aggregate revisions, summary revisions, merges, audit
++ * GET /entities/:ref/aggregate reviews.entity.resolve { aggregate: null | { revision, result } }
++ * POST /entities reviews.entity.manage { name, kind?, description?, slug?, aliases? }
++ * PATCH /entities/:ref reviews.entity.manage { name?, kind?, description?, slug?, noindex? }
++ * DELETE /entities/:ref reviews.entity.manage
++ * POST /entities/:ref/aliases reviews.entity.manage { type, value }
++ * DELETE /entities/:ref/aliases/:id reviews.entity.manage
++ * POST /entities/:ref/links reviews.entity.manage { type, to? | ref?, note? }
++ * DELETE /entities/:ref/links/:id reviews.entity.manage
++ * POST /entities/:ref/merge reviews.entity.merge { into, note? }
++ * POST /entities/:ref/split reviews.entity.split { note? }
++ * POST /trust reviews.entity.manage { scope, scope_id, key, value, note? }
++ * GET /items?resolution=queue reviews.entity.resolve items waiting for an editor
++ * GET /items/:id reviews.entity.resolve
++ * POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor
++ * POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it
++ * POST /sources/sync reviews.signal.import pull the next pages of review items from Sources
++ * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish? }
++ * POST /entities/:ref/summary/proposals reviews.summary.propose { overview?, pros?, cons?, workflow: { id, run_id }, stub_provider?, note? }
++ * GET /entities/:ref/summary/revisions/:n reviews.entity.resolve
++ * POST /entities/:ref/summary/revisions/:n/review reviews.summary.publish { decision: approved|rejected, note?, publish? }
++ * POST /entities/:ref/summary/publish reviews.summary.publish { revision? }
++ * POST /entities/:ref/summary/unpublish reviews.summary.publish
++ * POST /entities/:ref/corrections reviews.correction.submit { target_type, target_id?, body, evidence_url? } a person
++ * GET /corrections reviews.entity.manage open corrections (editors)
++ * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note? }
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { actorMiddleware, guard, run } = require('./common');
++
++const { http } = contracts;
++
++function createApi({ svc, viewers, platform, sync, config, log = console }) {
++ const router = express.Router();
++ router.use(http.middleware());
++ router.use(express.json({ limit: '256kb' }));
++ router.use((err, req, res, next) => (err ? http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
++ router.use(actorMiddleware(viewers));
++ const R = (fn, status) => run(fn, status, log);
++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
++
++ function live(ref) {
++ const e = svc.entity(ref);
++ if (e.state === 'deleted') throw new svc.ReviewsError(410, 'entity.deleted', 'This entity was deleted');
++ if (e.state === 'merged') throw new svc.ReviewsError(409, 'entity.merged', `Merged into ${svc.canonicalOf(e.id)}`, { canonical_id: svc.canonicalOf(e.id) });
++ return e;
++ }
++
++ router.get('/entities', guard('reviews.entity.resolve'), R((req) => {
++ const text = String(req.query.q || '').trim();
++ if (text) return { entities: svc.search(text, { limit: Number(req.query.limit) || 30 }).map(svc.entityView) };
++ const out = svc.listEntities({ limit: Number(req.query.limit) || 50, offset: Number(req.query.offset) || 0 });
++ return { entities: out.entities.map(svc.entityView), total: out.total };
++ }));
++ router.post('/resolve', guard('reviews.entity.resolve'), R((req) => svc.resolve(body(req))));
++ router.get('/entities/:ref', guard('reviews.entity.resolve'), R((req) => {
++ const e = svc.entity(req.params.ref);
++ if (e.state !== 'active') return { entity: svc.entityView(e), canonical: e.state === 'merged' ? svc.entityView(svc.entityById(svc.canonicalOf(e.id))) : null };
++ const p = svc.page(e, req.actor);
++ return { ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } };
++ }));
++ router.get('/entities/:ref/history', guard('reviews.entity.resolve'), R((req) => svc.history(svc.entity(req.params.ref), req.actor)));
++ router.get('/entities/:ref/aggregate', guard('reviews.entity.resolve'), R((req) => {
++ const e = live(req.params.ref);
++ const p = svc.page(e, req.actor);
++ return { entity_id: e.id, aggregate: p.aggregate, aggregate_revision: p.aggregate_revision };
++ }));
++
++ router.post('/entities', guard('reviews.entity.manage'), R((req) => {
++ const out = svc.createEntity(body(req), req.actor);
++ return { entity: svc.entityView(out.entity), settled_items: out.settled_items };
++ }, 201));
++ router.patch('/entities/:ref', guard('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.updateEntity(req.params.ref, body(req), req.actor)) })));
++ router.delete('/entities/:ref', guard('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.deleteEntity(req.params.ref, body(req), req.actor)) })));
++ router.post('/entities/:ref/aliases', guard('reviews.entity.manage'), R((req) => svc.addAlias(req.params.ref, body(req), req.actor), 201));
++ router.delete('/entities/:ref/aliases/:id', guard('reviews.entity.manage'), R((req) => ({ alias: svc.removeAlias(req.params.ref, req.params.id, req.actor) })));
++ router.post('/entities/:ref/links', guard('reviews.entity.manage'), R((req) => ({ link: svc.addLink(req.params.ref, body(req), req.actor) }), 201));
++ router.delete('/entities/:ref/links/:id', guard('reviews.entity.manage'), R((req) => ({ link: svc.endLink(req.params.ref, req.params.id, body(req), req.actor) })));
++ router.post('/entities/:ref/merge', guard('reviews.entity.merge'), R((req) => {
++ const out = svc.merge(req.params.ref, body(req), req.actor);
++ return { link: out.link, entity: svc.entityView(out.entity), into: svc.entityView(out.into), signals: out.signals };
++ }));
++ router.post('/entities/:ref/split', guard('reviews.entity.split'), R((req) => {
++ const out = svc.split(req.params.ref, body(req), req.actor);
++ return { link: out.link, entity: svc.entityView(out.entity), from: out.from ? svc.entityView(out.from) : null, signals: out.signals };
++ }));
++ router.post('/trust', guard('reviews.entity.manage'), R((req) => svc.setTrust(body(req), req.actor)));
++
++ router.get('/items', guard('reviews.entity.resolve'), R((req) => {
++ if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'The resolution queue is for Reviews editors');
++ return { items: svc.resolutionQueue(Number(req.query.limit) || 200).map(svc.itemView) };
++ }));
++ router.get('/items/:id', guard('reviews.entity.resolve'), R((req) => {
++ const row = svc.item(req.params.id);
++ if (!row) throw new svc.ReviewsError(404, 'item.not_found', 'Reviews has not read that item');
++ return { item: svc.itemView(row) };
++ }));
++ router.post('/items/:id/resolution', guard('reviews.entity.resolve'), R((req) => {
++ const b = body(req);
++ if (b.ignore === true) return { item: svc.itemView(svc.ignoreItem(req.params.id, b, req.actor)) };
++ const out = svc.confirmResolution(req.params.id, b, req.actor);
++ return { item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null, settled_items: out.settled_items };
++ }));
++
++ router.post('/signals/import', guard('reviews.signal.import'), R(async (req) => {
++ const a = req.actor;
++ if (a.kind === 'anonymous' || (a.kind === 'user' && !svc.access.isEditor(a))) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Importing is for Reviews editors and granted services');
++ const b = body(req);
++ const out = await svc.importItem(b.source_item_id || b.item_id, platform.sources, a);
++ return { outcome: out.outcome, item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null };
++ }));
++ router.post('/sources/sync', guard('reviews.signal.import'), R(async (req) => {
++ const a = req.actor;
++ if (a.kind === 'anonymous' || (a.kind === 'user' && !svc.access.isEditor(a))) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Syncing is for Reviews editors and granted services');
++ const pulled = await sync.pull();
++ const drained = await sync.drain();
++ return { pull: pulled, queue: drained };
++ }));
++
++ router.post('/entities/:ref/summary/revisions', guard('reviews.summary.publish'), R((req) => svc.writeSummary(req.params.ref, body(req), req.actor), 201));
++ router.post('/entities/:ref/summary/proposals', guard('reviews.summary.propose'), R((req) => svc.proposeSummary(req.params.ref, body(req), req.actor), 201));
++ router.get('/entities/:ref/summary/revisions/:n', guard('reviews.entity.resolve'), R((req) => ({ revision: svc.summaryRevision(svc.entity(req.params.ref), req.params.n, req.actor) })));
++ router.post('/entities/:ref/summary/revisions/:n/review', guard('reviews.summary.publish'), R((req) => svc.reviewSummary(req.params.ref, req.params.n, body(req), req.actor)));
++ router.post('/entities/:ref/summary/publish', guard('reviews.summary.publish'), R((req) => ({ summary: svc.publishSummary(req.params.ref, body(req), req.actor) })));
++ router.post('/entities/:ref/summary/unpublish', guard('reviews.summary.publish'), R((req) => ({ summary: svc.unpublishSummary(req.params.ref, req.actor) })));
++
++ router.post('/entities/:ref/corrections', guard('reviews.correction.submit'), R((req) => ({ correction: svc.submitCorrection(req.params.ref, body(req), req.actor) }), 201));
++ router.get('/corrections', guard('reviews.entity.manage'), R((req) => {
++ if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
++ return { corrections: svc.openCorrections() };
++ }));
++ router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => ({ correction: svc.resolveCorrection(req.params.id, body(req), req.actor) })));
++
++ return router;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/common.js b/server/http/common.js
nnew file mode 100644
iindex 0000000..e50bf80
---- /dev/null
++++ b/server/http/common.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Shared HTTP helpers: the actor middleware, capability guards and error mapping.
++ */
++const contracts = require('openvibe-contracts');
++const { checkCapability } = require('../auth/capabilities');
++const { AuthError } = require('../auth/viewer');
++
++const { http } = contracts;
++
++/** Resolves req.actor. Pages pass { services: false }: pages are for browsers. */
++function actorMiddleware(viewers, opts = {}) {
++ return async (req, res, next) => {
++ try {
++ req.actor = await viewers.resolve(req, opts);
++ next();
++ } catch (err) {
++ if (!(err instanceof AuthError)) return next(err);
++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ }
++ };
++}
++
++/**
++ * One capability per route for service tokens. People (user JWTs) and anonymous callers pass here
++ * and are judged by the editorial rules in the service.
++ */
++function guard(capabilityId) {
++ return function reviewsCapabilityGuard(req, res, next) {
++ const a = req.actor;
++ if (!a || a.kind !== 'service') return next();
++ const c = checkCapability(a.claims, capabilityId);
++ if (c.allowed) return next();
++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++}
++
++/** Service errors → problem+json. */
++function sendError(res, req, err, log = console) {
++ const status = err && Number.isInteger(err.status) ? err.status : 500;
++ if (status >= 500 && status !== 503) log.error('[Reviews]', err && err.stack ? err.stack : err);
++ const code = (err && err.code) || (status === 500 ? 'internal.error' : 'request.invalid');
++ const detail = status === 500 ? 'Internal error' : (err && err.message) || undefined;
++ return http.sendProblem(res, status, code, { detail, ctx: req.ov, extra: err && err.extra ? err.extra : undefined });
++}
++
++function run(fn, status = 200, log = console) {
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (res.headersSent) return;
++ res.status(typeof status === 'function' ? status(out) : status).set('Cache-Control', 'private, no-store').json(out);
++ } catch (err) {
++ sendError(res, req, err, log);
++ }
++ };
++}
++
++module.exports = { actorMiddleware, guard, sendError, run };
ddiff --git a/server/http/consumer.js b/server/http/consumer.js
nnew file mode 100644
iindex 0000000..882b1a1
---- /dev/null
++++ b/server/http/consumer.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * Sources → Reviews: POST /internal/events, the endpoint of Reviews' OpenVibe.Events subscription
++ * (topic pattern `sources.item.*`).
++ *
++ * sources.item.created|updated category reviews: the item id is queued and fetched from Sources
++ * (the event carries no fields), then applied
++ * sources.item.removed category reviews: the item's signal is withdrawn at once; the next
++ * aggregate revision no longer counts it
++ *
++ * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite transaction
++ * as the change. The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with
++ * REVIEWS_EVENTS_SECRET) is verified with openvibe-sdk's parseDelivery. Only events whose source
++ * is `sources` are applied.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { parseDelivery, createInbox } = require('openvibe-sdk/events');
++
++const CONSUMER = 'reviews-sources';
++
++function consumerRouter({ db, svc, sync, config, log = console }) {
++ const router = express.Router();
++ const inbox = createInbox(db, { table: 'review_event_inbox' });
++ inbox.ensureSchema();
++ const system = { kind: 'system', service: 'svc:reviews' };
++
++ function apply(event) {
++ return db.transaction(() => {
++ const r = inbox.once(CONSUMER, event.event_id, () => {
++ if (event.source !== 'sources') return 'ignored:source';
++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
++ if (p.category !== 'reviews') return 'ignored:category';
++ if (!/^itm_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(p.item_id || ''))) return 'ignored:no_item';
++ if (event.event_type === 'sources.item.created' || event.event_type === 'sources.item.updated') { sync.enqueue(p.item_id, event.event_type); return 'queued'; }
++ if (event.event_type === 'sources.item.removed') return svc.removeItem(p.item_id, p.reason || 'removed by its source', system).outcome;
++ return 'ignored:type';
++ });
++ return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
++ })();
++ }
++
++ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
++ const secrets = config.eventsWebhookSecrets;
++ if (!secrets.length) return http.sendProblem(res, 503, 'reviews.webhook_disabled', { detail: 'REVIEWS_EVENTS_SECRET is not set', ctx: req.ov });
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ let delivery = null;
++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
++ if (!delivery) return http.sendProblem(res, 401, 'reviews.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
++ const event = delivery.event;
++ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
++ return http.sendProblem(res, 400, 'reviews.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
++ }
++ let out;
++ try {
++ out = apply(event);
++ } catch (e) {
++ log.error(`[Reviews] event ${event.event_id} (${event.event_type}) failed:`, e.message);
++ return http.sendProblem(res, 500, 'reviews.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });
++ }
++ if (out.outcome === 'queued') sync.drain().catch(() => {});
++ res.status(200).json({ event_id: event.event_id, duplicate: out.duplicate, outcome: out.outcome });
++ });
++
++ return { router, apply };
++}
++
++module.exports = { consumerRouter, CONSUMER };
ddiff --git a/server/http/machine.js b/server/http/machine.js
nnew file mode 100644
iindex 0000000..a61ea1f
---- /dev/null
++++ b/server/http/machine.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * Discovery artifacts (roadmap §32.4). Every list here is built from the indexability gate's
++ * decision for each entity page: only indexable pages enter sitemaps; only listable ones enter feeds.
++ *
++ * /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
++ * /sitemap.xml sitemap index → /sitemaps/pages.xml, /sitemaps/entities-<n>.xml
++ * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1)
++ * /llms.txt orientation for language models
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const sharedSeo = require('openvibe-shared/seo');
++
++const PER_SITEMAP = 45000;
++
++function createMachine({ svc, config }) {
++ const router = express.Router();
++ const origin = config.baseUrl;
++ const cache = (res, s = 300) => res.set('Cache-Control', `public, max-age=${s}`);
++
++ router.get('/robots.txt', (_req, res) => {
++ cache(res, 3600).type('text/plain').send(seo.robotsTxt({
++ sitemaps: [`${origin}/sitemap.xml`],
++ disallow: ['/auth/', '/api/', '/internal/', '/editor', '/search', '/e/*/edit', '/e/*/correct'],
++ }));
++ });
++
++ function entityEntries() {
++ return svc.publicEntities().map(({ entity, decision }) => ({ loc: svc.entityUrl(entity), lastmod: entity.updated_at, decision }));
++ }
++
++ router.get('/sitemap.xml', (_req, res) => {
++ const pages = seo.sitemap(entityEntries(), { maxUrls: PER_SITEMAP });
++ const maps = [{ loc: `${origin}/sitemaps/pages.xml` }];
++ pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/entities-${i + 1}.xml` }));
++ cache(res).type('application/xml').send(seo.sitemapIndex(maps));
++ });
++ router.get('/sitemaps/pages.xml', (_req, res) => {
++ const policy = { minWords: 0, requireSources: false };
++ const entries = ['/', '/about'].map((p) => ({ loc: seo.canonicalUrl(origin, p), decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(origin, p), wordCount: 0 }, { policy, now: Date.now() }) }));
++ cache(res).type('application/xml').send(seo.sitemap(entries).files[0]);
++ });
++ router.get('/sitemaps/entities-:n.xml', (req, res) => {
++ const files = seo.sitemap(entityEntries(), { maxUrls: PER_SITEMAP }).files;
++ const n = Number(req.params.n);
++ if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
++ cache(res).type('application/xml').send(files[n - 1]);
++ });
++
++ function feedItems() {
++ return svc.recentSummaries(50).map(({ summary, entity, rev, decision }) => ({
++ id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
++ url: `${svc.entityUrl(entity)}#summary`,
++ title: `${entity.name}: summary`,
++ summary: [rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)].join('\n').slice(0, 1000),
++ published: summary.revision_published_at,
++ updated: summary.revision_published_at,
++ tags: [entity.kind],
++ decision,
++ }));
++ }
++
++ router.get('/feed.atom', (_req, res) => {
++ const items = feedItems();
++ if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No summary has been published yet.');
++ cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
++ });
++ router.get('/feed.json', (_req, res) => {
++ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Reviews: published summaries', link: `${origin}/`, feedUrl: `${origin}/feed.json`, description: 'Editor-reviewed summaries, by the time their current revision was published.' }, feedItems())));
++ });
++
++ router.get('/llms.txt', (_req, res) => {
++ cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
++ name: 'OpenVibe.Reviews',
++ summary: 'Review signals from named sources (via OpenVibe.Sources) resolved to entities, with provenance for every number, aggregates that exist only when signals do, and editor-reviewed summaries.',
++ details: 'Every entity page has a JSON representation at the same address plus ".json" (same content). Each signal names its source item, retrieval time and licence note; the aggregate lists its inputs, exclusions and computation (method reviews-aggregate@1). Summaries never carry a rating; AI-drafted summaries are labelled and published only after a person approves them. Review text is not republished.',
++ sections: [
++ { title: 'Start here', links: [{ title: 'Entities', url: `${origin}/` }, { title: 'How it works', url: `${origin}/about` }] },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap', url: `${origin}/sitemap.xml` },
++ { title: 'Published summaries (Atom)', url: `${origin}/feed.atom` },
++ { title: 'Published summaries (JSON Feed)', url: `${origin}/feed.json` },
++ ] },
++ ],
++ }));
++ });
++
++ return router;
++}
++
++module.exports = { createMachine };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..3f36dec
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,374 @@
++'use strict';
++/**
++ * Server-rendered pages. Everything a reader or an editor does works with plain links and HTML
++ * form posts; the shared navbar is progressive enhancement.
++ *
++ * GET / entities with source data
++ * GET /about how signals, the aggregate and summaries work
++ * GET /search?q=
++ * GET /e/:slug entity page: aggregate with inputs, summary, signals with provenance, discussion
++ * GET /e/:slug.json the same data as JSON
++ * GET /e/:slug/history aggregate revisions, summary revisions, merges and splits, every signal, editorial log
++ * GET /e/:slug/summary/:n one summary revision (editors: approve / reject)
++ * GET|POST /e/:slug/correct a signed-in person sends a correction to the editors
++ * POST /e/:slug/discuss comment through OpenVibe.Community
++ * Editors: GET /editor, POST /editor/sync, GET|POST /editor/entities/new, GET|POST /e/:slug/edit,
++ * POST /e/:slug/summary, POST /e/:slug/summary/:n/review, POST /e/:slug/merge,
++ * POST /e/:slug/split, GET|POST /editor/items/:id, POST /editor/corrections/:id
++ *
++ * Caching: an anonymous view of a public page is `public, max-age=60`; everything else is
++ * `private, no-store`. A merged entity answers 301 to the entity it was merged into; a deleted
++ * one 410; an old slug 301 to the current one.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const { renderPage } = require('../render/layout');
++const views = require('../render/views');
++const { ratingForStructuredData } = require('../reviews/aggregate');
++const { actorMiddleware } = require('./common');
++
++const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing' };
++
++/**
++ * JSON-LD for an entity page. Product / Thing with an AggregateRating, and a Review for the
++ * summary, exist ONLY when the aggregate exists — i.e. when real signals back it. Without
++ * signals the page carries breadcrumbs and nothing that could be read as a rating.
++ */
++function structuredData(p, config) {
++ const en = p.entity;
++ const origin = config.baseUrl;
++ const out = [seo.structuredData.breadcrumbs([{ name: 'OpenVibe.Reviews', url: `${origin}/` }, { name: en.name, url: en.url }])];
++ const rating = ratingForStructuredData(p.aggregate && p.aggregate.result);
++ if (!rating) return out;
++ const type = SCHEMA_TYPE[en.kind] || 'Thing';
++ out.push(type === 'Product'
++ ? seo.structuredData.product({ name: en.name, url: en.url, description: en.description, aggregateRating: rating })
++ : seo.structuredData.ratedThing({ type, name: en.name, url: en.url, description: en.description, aggregateRating: rating }));
++ const pub = p.summary && p.summary.published;
++ if (pub && pub.points.some((x) => x.supported)) {
++ const text = [pub.overview || '', ...pub.points.filter((x) => x.kind !== 'overview').map((x) => `${x.kind === 'pro' ? 'Pro' : 'Con'}: ${x.text}`)].join('\n').trim();
++ out.push(seo.structuredData.review({
++ url: `${en.url}#summary`, itemReviewed: { type, name: en.name, url: en.url },
++ author: { type: 'Organization', name: 'OpenVibe.Reviews editors', url: `${origin}/about` },
++ datePublished: p.summary.revision_published_at, body: text.slice(0, 5000),
++ publisher: { name: 'OpenVibe', url: 'https://openvibe.network' },
++ }));
++ }
++ return out.filter(Boolean);
++}
++
++function list(v) { return v == null ? [] : (Array.isArray(v) ? v : [v]).map(String).filter(Boolean); }
++
++/** The no-JS summary form → the summary input shape. */
++function summaryFromForm(b) {
++ const points = (kind) => {
++ const out = [];
++ for (let i = 0; i < 12; i++) {
++ const text = String(b[`${kind}_text_${i}`] || '').trim();
++ if (!text) continue;
++ out.push({ text, signals: list(b[`${kind}_signals_${i}`]) });
++ }
++ return out;
++ };
++ return {
++ overview: String(b.overview || ''), overview_signals: list(b.overview_signals),
++ pros: points('pro'), cons: points('con'),
++ expected_revision: b.expected_revision != null && b.expected_revision !== '' ? Number(b.expected_revision) : undefined,
++ message: b.message || null, publish: b.publish === '1',
++ };
++}
++
++function createPages({ svc, platform, sync, viewers, config, log = console }) {
++ const router = express.Router();
++ const form = express.urlencoded({ extended: false, limit: '200kb' });
++ router.use(actorMiddleware(viewers, { services: false }));
++
++ const origin = new URL(config.baseUrl).origin;
++ // Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
++ router.use((req, res, next) => {
++ if (req.method !== 'POST') return next();
++ const o = req.get('origin');
++ if (o && o !== origin && o !== 'null') return res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
++ next();
++ });
++
++ const send = (req, res, status, body, o = {}) => {
++ const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
++ res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')
++ .send(renderPage({ config, actor: req.actor, path: o.path || req.path, editor: svc.access.isEditor(req.actor), ...o, body }));
++ };
++ const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
++ const notFound = (req, res) => errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.');
++ const gone = (req, res) => errorPage(req, res, 410, 'Gone', 'This entity was deleted. Its history is kept, but it is no longer published.');
++ const needSignIn = (req, res, message) => send(req, res, 401, views.signInPage({ next: req.originalUrl, message }), { title: 'Sign in', robots: 'noindex, nofollow' });
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
++ if (err && err.status && err.status < 500) return errorPage(req, res, err.status, 'That did not work', err.message);
++ next(err);
++ });
++ const isEditor = (req) => svc.access.isEditor(req.actor);
++ const editorOnly = (req, res) => {
++ if (!req.actor.subject) { needSignIn(req, res, 'Sign in with your OpenVibe account. The editor desk is for Reviews editors.'); return false; }
++ if (!isEditor(req)) { errorPage(req, res, 403, 'Editors only', 'This page is for OpenVibe.Reviews editors.'); return false; }
++ return true;
++ };
++
++ /** :slug → an active entity, or the right redirect / 410 / 404. */
++ function locate(req, res, suffix = '') {
++ const slug = req.params.slug;
++ const e = svc.findEntity(slug);
++ const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
++ if (!e) {
++ const r = svc.resolveRedirect(`/e/${slug}`);
++ if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + query); return null; }
++ if (r && r.status === 410) { gone(req, res); return null; }
++ notFound(req, res);
++ return null;
++ }
++ if (e.state === 'deleted') { gone(req, res); return null; }
++ if (e.state === 'merged') {
++ const c = svc.entityById(svc.canonicalOf(e.id));
++ res.set('Cache-Control', 'public, max-age=60').redirect(301, `${svc.entityPath(c)}${suffix}${query}`);
++ return null;
++ }
++ return e;
++ }
++
++ // ── Public ───────────────────────────────────────────────
++ router.get('/', (req, res) => {
++ const all = svc.listEntities({ limit: 1 });
++ send(req, res, 200, views.home({ entities: svc.entitiesWithData(60), total: all.total, editor: isEditor(req) }), {
++ robots: 'index, follow', cache: 'public', active: 'home', path: '/',
++ jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),
++ });
++ });
++ router.get('/about', (req, res) => send(req, res, 200, views.aboutPage(), { title: 'How it works', robots: 'index, follow', cache: 'public', active: 'about' }));
++ router.get('/search', (req, res) => {
++ const query = String(req.query.q || '').slice(0, 200);
++ send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query });
++ });
++
++ router.get('/e/:slug.json', (req, res) => {
++ const e = locate(req, res, '.json');
++ if (!e) return;
++ const p = svc.page(e, req.actor);
++ res.status(200).set('Cache-Control', req.actor.kind === 'anonymous' ? 'public, max-age=60' : 'private, no-store').set('X-Robots-Tag', p.decision.robots)
++ .json({ ...p, decision: { indexable: p.decision.indexable, robots: p.decision.robots, reasons: p.decision.codes } });
++ });
++
++ async function discussionFor(e, actor) {
++ if (!platform.community.configured) return { state: 'unavailable', reason: 'OpenVibe.Community is not configured on this server' };
++ const threadId = svc.knownThread(e.id);
++ if (!threadId) return { state: 'none' };
++ try {
++ const data = await platform.community.getThread(threadId, { subject: actor.subject });
++ return { state: 'ok', threadId, thread: data.thread, comments: data.comments || [] };
++ } catch (err) {
++ return { state: 'unavailable', reason: err.code === 'thread.not_found' ? 'the thread was removed' : null };
++ }
++ }
++
++ router.get('/e/:slug', wrap(async (req, res) => {
++ const e = locate(req, res);
++ if (!e) return;
++ const p = svc.page(e, req.actor);
++ const discussion = await discussionFor(e, req.actor);
++ const head = seo.metaTags({
++ decision: p.decision, title: `${e.name} · OpenVibe.Reviews`, siteName: 'OpenVibe.Reviews', type: 'website',
++ description: e.description || `${e.name}: review signals from ${p.sources.length ? p.sources.map((s) => s.name || s.key).join(', ') : 'no source yet'}, with provenance.`,
++ canonical: p.entity.url, jsonLd: structuredData(p, config),
++ });
++ send(req, res, 200, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null }), {
++ head, title: e.name, cache: discussion.state === 'ok' ? null : 'public', path: svc.entityPath(e),
++ });
++ }));
++
++ router.get('/e/:slug/history', (req, res) => {
++ const e = locate(req, res, '/history');
++ if (!e) return;
++ send(req, res, 200, views.historyPage(svc.history(e, req.actor)), { title: `History of ${e.name}`, robots: 'noindex, follow', cache: 'public' });
++ });
++
++ router.get('/e/:slug/summary/:n', wrap(async (req, res) => {
++ const e = locate(req, res, `/summary/${req.params.n}`);
++ if (!e) return;
++ const r = svc.summaryRevision(e, req.params.n, req.actor);
++ send(req, res, 200, views.summaryRevisionPage(svc.entityView(e), r, { editor: isEditor(req) }), { title: `${e.name}: summary revision ${r.number}`, robots: 'noindex, follow' });
++ }));
++
++ router.get('/e/:slug/correct', (req, res) => {
++ const e = locate(req, res, '/correct');
++ if (!e) return;
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
++ send(req, res, 200, views.correctionPage(svc.entityView(e), { values: { target_type: req.query.target_type, target_id: req.query.target_id } }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ });
++ router.post('/e/:slug/correct', form, wrap(async (req, res) => {
++ const e = locate(req, res, '/correct');
++ if (!e) return;
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
++ const b = req.body || {};
++ try {
++ svc.submitCorrection(e.id, { target_type: b.target_type, target_id: b.target_id || null, body: b.body, evidence_url: b.evidence_url || null }, req.actor);
++ send(req, res, 201, views.correctionPage(svc.entityView(e), { done: true }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ send(req, res, err.status, views.correctionPage(svc.entityView(e), { values: b, error: err.message }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
++ }
++ }));
++
++ router.post('/e/:slug/discuss', form, wrap(async (req, res) => {
++ const e = locate(req, res, '/discuss');
++ if (!e) return;
++ if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
++ const message = String((req.body || {}).message || '').trim();
++ if (!message) return res.redirect(303, `${svc.entityPath(e)}#discussion`);
++ if (!platform.community.configured) return errorPage(req, res, 503, 'Discussion unavailable', 'OpenVibe.Community is not configured on this server. Nothing was posted.');
++ try {
++ const threadId = await svc.discussionThread(e, platform.community);
++ await platform.community.addComment(threadId, { subject: req.actor.subject, message });
++ } catch (err) {
++ log.warn(`[Reviews] comment on ${e.id} failed: ${err.message}`);
++ return errorPage(req, res, 503, 'Discussion unavailable', 'The discussion is held by OpenVibe.Community, which could not take the comment right now. Nothing was posted.');
++ }
++ res.redirect(303, `${svc.entityPath(e)}#discussion`);
++ }));
++
++ // ── Editors ──────────────────────────────────────────────
++ router.get('/editor', (req, res) => {
++ if (!editorOnly(req, res)) return;
++ send(req, res, 200, views.editorHome({
++ queue: svc.resolutionQueue(100), pending: svc.pendingSummaries(), flagged: svc.flaggedSummaries(),
++ corrections: svc.openCorrections(), stats: svc.stats(), itemView: svc.itemView,
++ }), { title: 'Editor desk', robots: 'noindex, nofollow' });
++ });
++
++ router.post('/editor/sync', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const pulled = await sync.pull();
++ await sync.drain();
++ if (!pulled.ok) return errorPage(req, res, 503, 'Sources unavailable', `Nothing changed: ${pulled.reason}`);
++ res.redirect(303, '/editor');
++ }));
++
++ router.get('/editor/entities/new', (req, res) => {
++ if (!editorOnly(req, res)) return;
++ send(req, res, 200, views.newEntityPage({ values: { name: req.query.name || '' } }), { title: 'New entity', robots: 'noindex, nofollow' });
++ });
++ router.post('/editor/entities/new', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const b = req.body || {};
++ const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
++ try {
++ const out = svc.createEntity({ name: b.name, kind: b.kind, description: b.description || null, aliases }, req.actor);
++ res.redirect(303, `${svc.entityPath(out.entity)}/edit`);
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ send(req, res, err.status, views.newEntityPage({ values: b, error: err.message }), { title: 'New entity', robots: 'noindex, nofollow' });
++ }
++ }));
++
++ function editPage(req, res, e, { status = 200, error = null, flash = null } = {}) {
++ send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
++ }
++
++ router.get('/e/:slug/edit', (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const e = locate(req, res, '/edit');
++ if (!e) return;
++ editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null });
++ });
++ router.post('/e/:slug/edit', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ let e = locate(req, res, '/edit');
++ if (!e) return;
++ const b = req.body || {};
++ try {
++ switch (b.action) {
++ case 'details': e = svc.updateEntity(e.id, { name: b.name, slug: b.slug, kind: b.kind, description: b.description || null, noindex: b.noindex === '1' }, req.actor); break;
++ case 'add_alias': svc.addAlias(e.id, { type: b.type, value: b.value }, req.actor); break;
++ case 'remove_alias': svc.removeAlias(e.id, b.alias_id, req.actor); break;
++ case 'add_link': svc.addLink(e.id, { type: b.type, to: b.to }, req.actor); break;
++ case 'unpublish_summary': svc.unpublishSummary(e.id, req.actor); break;
++ case 'trust': {
++ const [scope, ...rest] = String(b.scope_ref || '').split(':');
++ svc.setTrust({ scope, scope_id: rest.join(':'), key: b.key, value: b.value, note: b.note || null }, req.actor);
++ break;
++ }
++ default: return errorPage(req, res, 400, 'Unknown action', 'The form sent an action this page does not know.');
++ }
++ res.redirect(303, `${svc.entityPath(svc.entityById(e.id))}/edit?saved=1`);
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ editPage(req, res, svc.entityById(e.id), { status: err.status, error: err.message });
++ }
++ }));
++
++ router.post('/e/:slug/summary', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const e = locate(req, res, '/summary');
++ if (!e) return;
++ try {
++ svc.writeSummary(e.id, summaryFromForm(req.body || {}), req.actor);
++ res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
++ } catch (err) {
++ if (!err.status || err.status >= 500) throw err;
++ editPage(req, res, e, { status: err.status, error: err.message });
++ }
++ }));
++
++ router.post('/e/:slug/summary/:n/review', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const e = locate(req, res, `/summary/${req.params.n}/review`);
++ if (!e) return;
++ const b = req.body || {};
++ svc.reviewSummary(e.id, req.params.n, { decision: b.decision, note: b.note || null, publish: true }, req.actor);
++ res.redirect(303, `${svc.entityPath(e)}?saved=1`);
++ }));
++
++ router.post('/e/:slug/merge', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const e = locate(req, res, '/merge');
++ if (!e) return;
++ const b = req.body || {};
++ const out = svc.merge(e.id, { into: String(b.into || '').trim(), note: b.note || null }, req.actor);
++ res.redirect(303, `${svc.entityPath(out.into)}/history#merges`);
++ }));
++
++ router.post('/e/:slug/split', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const e = svc.findEntity(req.params.slug);
++ if (!e) return notFound(req, res);
++ const b = req.body || {};
++ const out = svc.split(e.id, { note: b.note || null }, req.actor);
++ res.redirect(303, `${svc.entityPath(out.entity)}/history#merges`);
++ }));
++
++ router.get('/editor/items/:id', (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const row = svc.item(req.params.id);
++ if (!row) return notFound(req, res);
++ const item = svc.itemView(row);
++ const candidates = item.candidates.map((c) => c.entity).filter(Boolean);
++ send(req, res, 200, views.itemPage({ item, candidates }), { title: `Item ${item.id}`, robots: 'noindex, nofollow' });
++ });
++ router.post('/editor/items/:id', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const b = req.body || {};
++ if (b.action === 'ignore') svc.ignoreItem(req.params.id, { note: b.note || null }, req.actor);
++ else {
++ const target = String(b.entity_other || '').trim() || String(b.entity || '').trim();
++ svc.confirmResolution(req.params.id, { entity: target, add_alias: b.add_alias || null }, req.actor);
++ }
++ res.redirect(303, '/editor#items');
++ }));
++
++ router.post('/editor/corrections/:id', form, wrap(async (req, res) => {
++ if (!editorOnly(req, res)) return;
++ const b = req.body || {};
++ svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null }, req.actor);
++ res.redirect(303, '/editor#corrections');
++ }));
++
++ return { router, errorPage };
++}
++
++module.exports = { createPages, structuredData, summaryFromForm };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..2a8e905
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * OpenVibe.Reviews entry point.
++ *
++ * node server/index.js (systemd: openvibe-reviews.service, port 4830)
++ *
++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
++ * clock/fetch/log/tokens/keys, and returns handles to every part. Workers (the Sources pull, the
++ * import queue, the Events outbox relay) run only when asked (`workers: true`).
++ */
++const { load } = require('./config');
++const { openDb, createStores } = require('./db');
++const { createPlatform } = require('./integrations/platform');
++const { createReviewsService } = require('./reviews/service');
++const { createSourcesSync } = require('./reviews/sync');
++const { createKeyStore } = require('./auth/keys');
++const { createViewerResolver } = require('./auth/viewer');
++const { createApp } = require('./app');
++
++async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true } = {}) {
++ config = config || load();
++ const db = openDb(config.dbPath);
++ const stores = createStores(db, { now });
++ const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
++ const svc = createReviewsService({ db, stores, outbox: platform.outbox, config, now, log });
++ const sync = createSourcesSync({ db, svc, platform, config, now, log });
++ const keys = createKeyStore({ config, fetchImpl, log, publicKey });
++ keys.ensure().catch(() => {});
++ const viewers = createViewerResolver({ keys, config });
++ const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl });
++
++ // Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).
++ try { svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
++
++ const timers = [];
++ if (workers) {
++ if (config.sync.enabled && platform.sources.configured) {
++ const pull = () => sync.pull().then((r) => { if (!r.ok) log.warn(`[Reviews] Sources pull: ${r.reason}`); }).catch((err) => log.warn(`[Reviews] Sources pull: ${err.message}`));
++ timers.push(setTimeout(pull, 5000));
++ timers.push(setInterval(pull, config.sync.intervalMs));
++ timers.push(setInterval(() => sync.drain().catch((err) => log.warn(`[Reviews] import queue: ${err.message}`)), config.sync.queueIntervalMs));
++ }
++ if (platform.eventsConfigured) platform.outbox.start();
++ for (const t of timers) if (t.unref) t.unref();
++ }
++
++ let server = null;
++ if (listen) {
++ await new Promise((resolve) => { server = app.listen(config.port, config.host, resolve); });
++ server.keepAliveTimeout = 65000;
++ log.log(`[Reviews] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
++ }
++
++ async function stop() {
++ for (const t of timers) { clearInterval(t); clearTimeout(t); }
++ await platform.outbox.stop();
++ if (server) await new Promise((resolve) => server.close(resolve));
++ db.close();
++ }
++ return { app, db, svc, sync, stores, platform, keys, viewers, server, config, stop };
++}
++
++module.exports = { start };
++
++if (require.main === module) {
++ require('dotenv').config();
++ start().then((h) => {
++ const shutdown = (signal) => {
++ console.log(`[Reviews] ${signal} — closing`);
++ h.stop().finally(() => process.exit(0));
++ setTimeout(() => process.exit(0), 10000).unref();
++ };
++ process.on('SIGTERM', () => shutdown('SIGTERM'));
++ process.on('SIGINT', () => shutdown('SIGINT'));
++ }).catch((err) => {
++ console.error('[Reviews] failed to start:', err);
++ process.exit(1);
++ });
++}
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
nnew file mode 100644
iindex 0000000..0152210
---- /dev/null
++++ b/server/integrations/platform.js
@@@ -0,0 +1,143 @@
++'use strict';
++/**
++ * Clients for the services Reviews composes (roadmap §29): Events (outbox relay), Sources (review
++ * items with provenance) and Community (discussion threads). Each call uses a Network
++ * client-credentials token for svc:reviews, minted per audience by the SDK token client.
++ *
++ * Every integration is optional. Missing configuration or an unreachable service is an explicit
++ * failure (an error with a stable code, or `configured: false`), never invented data.
++ */
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++const { createDiscussionClient } = require('openvibe-publishing/discussion');
++
++class IntegrationError extends Error {
++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
++}
++
++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ITEM_RE = /^itm_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SOURCE_KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
++
++function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {
++ const tokenClient = tokens || (config.oauth.clientSecret
++ ? createServiceTokenClient({ network: config.networkInternalUrl || config.networkUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const forAudience = (audience) => ({
++ authHeaders: () => {
++ if (!tokenClient) throw new IntegrationError(503, 'reviews.principal_unconfigured', 'OV_OAUTH_CLIENT_SECRET is not set: Reviews has no service token');
++ return tokenClient.authHeaders({ audience });
++ },
++ invalidate: () => tokenClient && tokenClient.invalidate({ audience }),
++ });
++
++ // ── Events: the outbox always records; the relay runs only with EVENTS_URL ──
++ const sdk = createClient({
++ fetch: fetchImpl,
++ network: config.networkUrl,
++ autoDiscover: false,
++ ...(tokenClient ? { tokenProvider: tokenClient } : {}),
++ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : {},
++ onWarning: (msg) => log.warn(`[Reviews] ${msg}`),
++ });
++ const events = createEventsClient(sdk, { source: 'reviews' });
++ const outbox = createOutbox(db, {
++ events, table: 'review_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
++ onError: (err) => log.warn(`[Reviews] event relay: ${err && err.message}`),
++ });
++ outbox.ensureSchema();
++
++ async function getJson(url, audience, { headers = {}, timeoutMs = 8000 } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await forAudience(audience).authHeaders()), ...headers }, signal: AbortSignal.timeout(timeoutMs) });
++ } catch (err) {
++ if (err instanceof IntegrationError) throw err;
++ throw new IntegrationError(503, 'upstream.unavailable', `${new URL(url).host} did not answer: ${err && err.name === 'TimeoutError' ? 'timeout' : err && err.message}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status === 401) forAudience(audience).invalidate();
++ return { status: res.status, body };
++ }
++
++ // ── Sources: review items (sources.item.read) and source records (sources.source.read) ──
++ const sources = {
++ configured: !!(tokenClient && config.sourcesInternalUrl),
++ requireConfigured() {
++ if (!sources.configured) throw new IntegrationError(503, 'sources.unavailable', 'OpenVibe.Sources is not configured (OV_SOURCES_INTERNAL_URL and the service principal)');
++ },
++ /** One item with its provenance. → { item, source } */
++ async getItem(id) {
++ if (!ITEM_RE.test(String(id || ''))) throw new IntegrationError(422, 'signal.invalid_source_item', 'A Sources item id looks like itm_<ULID>');
++ sources.requireConfigured();
++ const r = await getJson(`${config.sourcesInternalUrl}/api/v1/items/${encodeURIComponent(id)}`, 'openvibe.sources');
++ if (r.status === 404) throw new IntegrationError(404, 'signal.source_item_not_found', `Sources has no item ${id}`);
++ if (r.status !== 200 || !r.body || !r.body.item) throw new IntegrationError(503, 'sources.unavailable', `Sources answered ${r.status}`);
++ return { item: r.body.item, source: r.body.source || null };
++ },
++ /** Items of category `reviews` in change order (creations, revisions, removals). */
++ async listItems({ after = 0, limit = 200 } = {}) {
++ sources.requireConfigured();
++ const u = new URL(`${config.sourcesInternalUrl}/api/v1/items`);
++ u.searchParams.set('category', 'reviews');
++ u.searchParams.set('include_removed', '1');
++ u.searchParams.set('after', String(after));
++ u.searchParams.set('limit', String(limit));
++ const r = await getJson(u.toString(), 'openvibe.sources');
++ if (r.status !== 200 || !r.body || !Array.isArray(r.body.items)) throw new IntegrationError(503, 'sources.unavailable', `Sources answered ${r.status}`);
++ return r.body;
++ },
++ /** The source record (name, homepage, notes). null when Sources does not know it. */
++ async getSource(key) {
++ if (!SOURCE_KEY_RE.test(String(key || ''))) return null;
++ sources.requireConfigured();
++ const r = await getJson(`${config.sourcesInternalUrl}/api/v1/sources/${encodeURIComponent(key)}`, 'openvibe.sources');
++ if (r.status === 404) return null;
++ if (r.status !== 200 || !r.body) throw new IntegrationError(503, 'sources.unavailable', `Sources answered ${r.status}`);
++ return r.body.source || r.body;
++ },
++ };
++
++ // ── Community: comment threads (reference, never copy) ──
++ const communityBase = config.communityInternalUrl || config.communityUrl;
++ const discussionClient = createDiscussionClient({ communityUrl: communityBase, tokenClient: forAudience('openvibe.community'), fetchImpl });
++ const community = {
++ configured: !!(tokenClient && communityBase),
++ resolveThread: (ref, opts) => discussionClient.resolveThread(ref, opts),
++ async getThread(threadId, { subject } = {}) {
++ const headers = subject && SUBJECT_RE.test(subject) ? { 'X-OV-Subject': subject } : {};
++ const r = await getJson(`${communityBase}/api/v1/comments/threads/${encodeURIComponent(threadId)}?limit=50`, 'openvibe.community', { headers, timeoutMs: 5000 });
++ if (r.status === 404) throw new IntegrationError(404, 'thread.not_found', 'The discussion thread no longer exists');
++ if (r.status !== 200 || !r.body || !r.body.thread) throw new IntegrationError(503, 'discussion.unavailable', `Community answered ${r.status}`);
++ return r.body;
++ },
++ async addComment(threadId, { subject, message }) {
++ if (!SUBJECT_RE.test(String(subject || ''))) throw new IntegrationError(403, 'reviews.person_required', 'Only a signed-in person can comment');
++ let res;
++ try {
++ res = await fetchImpl(`${communityBase}/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-OV-Subject': subject, ...(await forAudience('openvibe.community').authHeaders()) },
++ body: JSON.stringify({ message: String(message || '').slice(0, 5000) }),
++ signal: AbortSignal.timeout(5000),
++ });
++ } catch (err) {
++ if (err instanceof IntegrationError) throw err;
++ throw new IntegrationError(503, 'discussion.unavailable', 'Community did not answer');
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status !== 201 && res.status !== 200) {
++ throw new IntegrationError(res.status >= 500 ? 503 : res.status, (body && body.code) || 'discussion.unavailable', (body && (body.detail || body.error)) || `Community answered ${res.status}`);
++ }
++ return body;
++ },
++ };
++
++ return {
++ tokenClient, sdk, events, outbox, sources, community,
++ eventsConfigured: !!(tokenClient && config.eventsUrl),
++ };
++}
++
++module.exports = { createPlatform, IntegrationError };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..edab08c
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * Page shell: every page is server-rendered through this. The <head> SEO block comes from
++ * openvibe-shared/seo (robots always explicit: from the indexability gate for entity pages,
++ * noindex for editing surfaces), the shared chrome from openvibe-shared (app icon, SSR footer, a
++ * <noscript> navigation) plus the Network's navbar.js/footer.js as progressive enhancement.
++ * Nothing on the page needs JavaScript to be read, navigated, corrected or edited.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const sharedSeo = require('openvibe-shared/seo');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++const { escapeHtml: esc } = require('openvibe-publishing/ssr');
++
++const SITE_NAME = 'OpenVibe.Reviews';
++const NETWORK_URL = 'https://openvibe.network';
++const DEFAULT_DESCRIPTION = 'OpenVibe.Reviews: review signals gathered from named sources, with provenance for every number and no rating without source data. Part of the OpenVibe network.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++
++const hashes = new Map();
++function asset(rel) {
++ if (!hashes.has(rel)) {
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ }
++ return `/${rel}?v=${hashes.get(rel)}`;
++}
++
++const LINKS = [
++ { label: 'Entities', href: '/', key: 'home' },
++ { label: 'How it works', href: '/about', key: 'about' },
++];
++
++function navConfig(o, config) {
++ return {
++ service: 'reviews',
++ apiBase: NETWORK_URL,
++ links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
++ };
++}
++
++/**
++ * o: title, description, path (canonical path), robots (required unless head), head (extra head
++ * HTML built from the gate), jsonLd, body, active, actor, config, editor
++ */
++function renderPage(o) {
++ const { config } = o;
++ if (!o.robots && !o.head) throw new TypeError('renderPage needs explicit robots (or a head block built from the gate)');
++ const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
++ const head = o.head || sharedSeo.headTags({
++ title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.baseUrl}${o.path || '/'}`,
++ robots: o.robots, siteName: SITE_NAME, type: o.ogType || 'website', jsonLd: o.jsonLd || null,
++ });
++ const feeds = [
++ { type: 'application/atom+xml', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },
++ { type: 'application/feed+json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },
++ ];
++ const actor = o.actor || { kind: 'anonymous' };
++ const who = actor.kind === 'user'
++ ? `<span class="rv-who">Signed in as ${esc((actor.user && (actor.user.display_name || actor.user.username)) || 'you')}</span>${o.editor ? ' · <a href="/editor">Editor desk</a>' : ''} · <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
++ : `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++${head}
++${appIcon.headTags({ site: 'network' })}
++${feeds.map((f) => `<link rel="alternate" type="${f.type}" title="${esc(f.title)}" href="${f.href}">`).join('\n')}
++<link rel="stylesheet" href="${asset('css/reviews.css')}">
++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
++<script src="${NETWORK_URL}/shared/footer.js" defer></script>
++</head>
++<body>
++<a class="rv-skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map((l) => ({ label: l.label, href: l.href })) })}
++<header class="rv-bar"><a class="rv-brand" href="/">${SITE_NAME}</a><form class="rv-search" action="/search" method="get" role="search"><label for="rv-q" class="rv-sr">Search entities</label><input id="rv-q" name="q" type="search" placeholder="Search entities" value="${esc(o.query || '')}"><button type="submit">Search</button></form><span class="rv-account">${who}</span></header>
++<main id="main" class="rv-main">
++${o.body || ''}
++</main>
++${chrome.footer({ service: 'reviews', variant: 'full' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'reviews', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME } }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* chrome is optional */ }
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
ddiff --git a/server/render/views.js b/server/render/views.js
nnew file mode 100644
iindex 0000000..285c27d
---- /dev/null
++++ b/server/render/views.js
@@@ -0,0 +1,402 @@
++'use strict';
++/**
++ * Page bodies. Every interpolated value goes through openvibe-publishing/ssr's html`` (escaped
++ * unless raw()). Forms are plain HTML posts; nothing here needs JavaScript.
++ *
++ * Numbers shown here come from review_signals (with their provenance) or from the aggregate
++ * computed over them. There is no place in these templates that prints a rating from anywhere
++ * else, and no star glyphs: values are printed as the source stated them.
++ */
++const ssr = require('openvibe-publishing/ssr');
++
++const { html, raw } = ssr;
++const t = (v) => raw(ssr.timeTag(v));
++const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '' }));
++const e = encodeURIComponent;
++const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
++
++const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other' };
++const TYPE_LABEL = { recommendation: 'Recommendation', recommendation_tally: 'Recommendation count', rating: 'Rating', rating_aggregate: 'Aggregate rating' };
++const REASON_LABEL = {
++ scale_not_stated: 'the source did not state the rating scale',
++ covered_by_source_tally: 'already counted in the same source\'s own tally',
++ older_tally_same_source: 'a newer tally from the same source is used',
++ source_excluded_by_editor: 'source excluded by an editor',
++ signal_excluded_by_editor: 'excluded by an editor',
++ no_count: 'the source stated no count',
++};
++
++function epath(entity) { return `/e/${e(entity.slug)}`; }
++/** Only http(s) URLs become links; anything else is shown as nothing. */
++function safeUrl(u) { try { const x = new URL(String(u || '')); return x.protocol === 'http:' || x.protocol === 'https:' ? x.toString() : null; } catch { return null; } }
++function notice(kind, text) { return html`<p class="rv-notice rv-${kind}" role="status">${text}</p>`; }
++function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }
++
++function signalValue(s) {
++ switch (s.type) {
++ case 'recommendation': return s.recommended ? 'Recommends' : 'Does not recommend';
++ case 'recommendation_tally': return `${n(s.positive_count)} of ${n(s.total_count)} recommend`;
++ case 'rating': return s.rating_best != null ? `${s.rating_value} / ${s.rating_best}` : `${s.rating_value} (scale not stated)`;
++ case 'rating_aggregate': return `${s.rating_best != null ? `${s.rating_value} / ${s.rating_best}` : `${s.rating_value} (scale not stated)`} from ${n(s.rating_count)} ratings`;
++ default: return '';
++ }
++}
++
++function trustFacts(trust) {
++ const bits = [];
++ if (trust.steam_purchase === true) bits.push('bought on Steam');
++ if (trust.steam_purchase === false) bits.push('not bought on Steam');
++ if (trust.received_for_free === true) bits.push('received for free');
++ if (trust.written_during_early_access === true) bits.push('written during early access');
++ if (trust.verified_purchase === true) bits.push('verified purchase');
++ if (trust.playtime_at_review_min != null) bits.push(`${n(Math.round(trust.playtime_at_review_min / 60))} h played at review`);
++ if (trust.language) bits.push(`language: ${trust.language}`);
++ return bits.join(' · ');
++}
++
++function errorBody({ status, title, message }) {
++ return html`<section class="rv-error"><h1>${title}</h1><p>${message}</p><p class="rv-muted">HTTP ${String(status)}</p><p><a href="/">Back to OpenVibe.Reviews</a></p></section>`;
++}
++
++function signInPage({ next, message }) {
++ return html`<section><h1>Sign in</h1><p>${message}</p><p><a class="rv-button" href="/auth/login?next=${e(next || '/')}">Sign in with OpenVibe</a></p></section>`;
++}
++
++function aggregateLine(agg) {
++ if (!agg || !agg.result) return html`<span class="rv-muted">No aggregate: no qualifying signals</span>`;
++ const c = agg.result.components;
++ const parts = [];
++ if (c.rating) parts.push(c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best} from ${n(c.rating.count)} ratings` : `${c.rating.percent}% from ${n(c.rating.count)} ratings`);
++ if (c.recommendation) parts.push(`${c.recommendation.percent}% recommend (${n(c.recommendation.positive)} of ${n(c.recommendation.total)})`);
++ return html`${parts.join(' · ')}`;
++}
++
++function home({ entities, total, editor }) {
++ return html`<section class="rv-intro"><h1>OpenVibe.Reviews</h1>
++<p>Review signals gathered from named sources. Every number on an entity page shows where it came from and when it was read, the aggregate lists its inputs and how it was computed, and there is no aggregate at all when no source has stated anything. Summaries are written or checked by a person; AI drafts are never published on their own and never produce a rating.</p>
++<p><a href="/about">How it works</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>
++<section><h2>Entities with source data</h2>
++${entities.length ? html`<ul class="rv-entities">${entities.map((x) => html`<li><a href="${epath(x.entity)}"><strong>${x.entity.name}</strong></a> <span class="rv-tag">${KIND_LABEL[x.entity.kind]}</span><br><span class="rv-muted">${aggregateLine(x.aggregate)}</span></li>`)}</ul>`
++ : html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
++<p class="rv-muted">${n(total)} entit${total === 1 ? 'y' : 'ies'} in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>`;
++}
++
++function aboutPage() {
++ return html`<section><h1>How OpenVibe.Reviews works</h1>
++<h2 id="signals">Signals</h2>
++<p>A signal is one observation a source made about one thing: a Steam reviewer recommending a game, a shop page stating "4.2 out of 5 from 1,234 ratings". Signals come only from items that <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibe.Sources</a> retrieved, and each keeps its source, the item and its revision, when it was retrieved, when the source published it, and the licence note the source was ingested under. Reviews keeps the numbers, never the review text: the words belong to the people who wrote them.</p>
++<p>When a source updates an item the old signal is marked replaced and a new one takes its place; when a source removes an item its signal is withdrawn. Both stay visible in the entity's history.</p>
++<h2 id="method">The aggregate (method reviews-aggregate@1)</h2>
++<ul>
++<li><strong>Recommendation share</strong>: positives ÷ total over individual recommendations (1 of 1 or 0 of 1) and sources' own recommendation counts. When a source states its own count, its individual recommendations are not counted a second time.</li>
++<li><strong>Rating</strong>: the count-weighted mean of value ÷ best over single ratings (count 1) and aggregate ratings (count = the number of ratings the source states), shown as a percentage, and on the source's scale when every input shares one. A rating whose source did not state the scale is left out, and the page says so.</li>
++<li>Editors can exclude a source or a single signal, always with a reason shown on the page.</li>
++<li>With no qualifying signal there is no aggregate. Not zero, not a placeholder: none.</li>
++</ul>
++<p>Each change to the inputs produces a new aggregate revision; the entity history lists them all.</p>
++<h2 id="summaries">Summaries</h2>
++<p>A summary is text written by an editor (or drafted by OpenVibe.AI and approved by an editor) whose pros and cons each cite the signals they rest on. A summary never carries a rating. If a cited signal is withdrawn or replaced, the summary is flagged on the page and a revised version waits for an editor.</p>
++<h2 id="entities">Entities, merges and splits</h2>
++<p>Items are matched to entities by deterministic rules: a source bound to one entity, the item's URL, a GTIN, SKU or MPN. A name alone is never enough: an editor confirms it. A merge never rewrites anything, so a split restores the earlier attribution exactly; both are recorded with who, when and why.</p>
++<h2 id="corrections">Corrections and discussion</h2>
++<p>Anyone signed in can send a correction to the editors from an entity page. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>
++</section>`;
++}
++
++function searchPage({ query, results }) {
++ return html`<section><h1>Search</h1>
++<form action="/search" method="get" class="rv-form"><label>Name <input type="search" name="q" value="${query}"></label><button type="submit">Search</button></form>
++${query ? (results.length ? html`<ul>${results.map((x) => html`<li><a href="${epath(x)}">${x.name}</a> <span class="rv-tag">${KIND_LABEL[x.kind]}</span></li>`)}</ul>` : html`<p class="rv-muted">No entity matches.</p>`) : ''}</section>`;
++}
++
++function aggregateSection(p) {
++ const head = html`<h2 id="aggregate">Aggregate</h2>`;
++ if (!p.aggregate) {
++ return html`<section class="rv-aggregate">${head}<p><strong>No aggregate.</strong> No source has stated a qualifying signal for this entity${p.aggregate_revision ? ' any more (the last aggregate revision recorded that nothing was left)' : ' yet'}. There is no rating here because there is no data behind one.</p></section>`;
++ }
++ const r = p.aggregate.result;
++ const c = r.components;
++ const byId = new Map([...p.signals, ...p.inactive_signals].map((s) => [s.signal_id, s]));
++ return html`<section class="rv-aggregate">${head}
++<div class="rv-agg-box">
++${c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${c.rating.on_scale ? html` <span class="rv-muted">(${c.rating.percent}% of the best possible rating)</span>` : ''}</p>` : ''}
++${c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend: ${n(c.recommendation.positive)} of ${n(c.recommendation.total)}</p>` : ''}
++<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${tl(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${t(r.observed.earliest)} to ${t(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>
++</div>
++<details><summary>Computation</summary><ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>
++<p>Inputs: ${r.inputs.map((i, k) => html`${k ? ', ' : ''}<a href="#${i.signal_id}">${i.signal_id}</a>`)}</p>
++${r.excluded.length ? html`<p>Left out:</p><ul>${r.excluded.map((x) => html`<li><a href="#${x.signal_id}">${x.signal_id}</a>${byId.get(x.signal_id) ? html` (${byId.get(x.signal_id).source_key})` : ''}: ${REASON_LABEL[x.reason] || x.reason}${x.note ? html` — ${x.note}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">Nothing was left out.</p>`}
++</details></section>`;
++}
++
++function citeLinks(cites) {
++ return html`${cites.map((c, i) => html`${i ? ' ' : ''}<a href="#${c.signal_id}" class="${c.ok ? 'rv-cite' : 'rv-cite rv-cite-gone'}" title="${c.ok ? 'cited signal' : `cited signal is ${c.status}`}">[${c.signal_id.slice(-6)}${c.ok ? '' : ` ${c.status}`}]</a>`)}`;
++}
++
++function summarySection(p) {
++ const s = p.summary;
++ const head = html`<h2 id="summary">Summary</h2>`;
++ if (!s || !s.published) {
++ const pending = s ? (Array.isArray(s.pending) ? s.pending.length : s.pending) : 0;
++ return html`<section class="rv-summary">${head}<p class="rv-muted">No published summary.${pending ? ` ${pending} revision(s) wait for an editor.` : ''}</p></section>`;
++ }
++ const r = s.published;
++ const pros = r.points.filter((x) => x.kind === 'pro');
++ const cons = r.points.filter((x) => x.kind === 'con');
++ const ov = r.points.find((x) => x.kind === 'overview');
++ return html`<section class="rv-summary">${head}
++${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by OpenVibe.Reviews editors. Revision ${String(r.number)}, published ${t(s.revision_published_at)}.</p>`}
++${s.flagged ? notice('warn', `Under review: ${s.flag_reason || 'a cited signal changed'} (${s.flagged_at ? s.flagged_at.slice(0, 10) : ''}). Points whose citations are marked below may no longer be supported.`) : ''}
++${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 2 }))}${ov ? html`<p class="rv-muted">Cites ${citeLinks(ov.citations)}</p>` : ''}</div>` : ''}
++<div class="rv-proscons">
++<div><h3>Pros</h3>${pros.length ? html`<ul>${pros.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
++<div><h3>Cons</h3>${cons.length ? html`<ul>${cons.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
++</div>
++<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p></section>`;
++}
++
++function signalRow(s) {
++ const pv = s.provenance;
++ const link = safeUrl(s.canonical_url) || safeUrl(pv.source_homepage);
++ const trust = trustFacts(s.trust);
++ const ex = s.editor_trust && s.editor_trust.aggregate;
++ return html`<tr id="${s.signal_id}" class="rv-signal rv-${s.status}">
++<td>${TYPE_LABEL[s.type]}<br><strong>${signalValue(s)}</strong>${s.status !== 'active' ? html`<br><span class="rv-tag">${s.status}</span> <span class="rv-muted">${s.status_reason || ''}</span>` : ''}${ex ? html`<br><span class="rv-tag">excluded</span> <span class="rv-muted">${ex.note || ''}</span>` : ''}</td>
++<td>${pv.source_name || s.source_key}${pv.source_name ? html`<br><span class="rv-muted">${s.source_key}</span>` : ''}</td>
++<td>${link ? html`<a href="${link}" rel="nofollow noopener">${s.canonical_url ? 'item' : 'source'}</a> ` : ''}<span class="rv-muted">${s.source_item_id} r${String(s.item_revision)}</span>${trust ? html`<br><span class="rv-muted">${trust}</span>` : ''}</td>
++<td>Retrieved ${tl(pv.retrieved_at)}${pv.last_seen_at && pv.last_seen_at !== pv.retrieved_at ? html`<br><span class="rv-muted">last seen ${tl(pv.last_seen_at)}</span>` : ''}${s.source_published_at ? html`<br><span class="rv-muted">source dated ${t(s.source_published_at)}</span>` : ''}</td>
++<td class="rv-muted">${pv.license_note || 'No licence note recorded'}</td>
++</tr>`;
++}
++
++function signalTable(signals, { caption }) {
++ return html`<div class="rv-scroll"><table class="rv-signals"><caption class="rv-sr">${caption}</caption>
++<thead><tr><th scope="col">Signal</th><th scope="col">Source</th><th scope="col">Item</th><th scope="col">When</th><th scope="col">Licence</th></tr></thead>
++<tbody>${signals.map(signalRow)}</tbody></table></div>`;
++}
++
++function discussionHtml(d, { entity, actor }) {
++ const head = html`<h2 id="discussion">Discussion</h2>`;
++ if (!d || d.state === 'unavailable') return html`<section class="rv-discussion">${head}<p class="rv-notice rv-warn">The discussion (held by OpenVibe.Community) could not be loaded right now${d && d.reason ? html`: ${d.reason}` : ''}.</p></section>`;
++ if (d.state === 'none') {
++ return html`<section class="rv-discussion">${head}<p class="rv-muted">No discussion has been started for this entity.</p>
++${actor && actor.subject ? html`<form method="post" action="${epath(entity)}/discuss" class="rv-form"><label for="rv-comment">Start it (posted to OpenVibe.Community as you)</label><textarea id="rv-comment" name="message" rows="3" maxlength="5000" required></textarea><button type="submit">Comment</button></form>` : html`<p><a href="/auth/login?next=${e(epath(entity))}">Sign in</a> to start the discussion.</p>`}</section>`;
++ }
++ const comments = (d.comments || []).filter((c) => !c.deleted);
++ const form = actor && actor.subject
++ ? html`<form method="post" action="${epath(entity)}/discuss" class="rv-form"><label for="rv-comment">Add a comment (posted to OpenVibe.Community as you)</label><textarea id="rv-comment" name="message" rows="3" maxlength="5000" required></textarea><button type="submit">Comment</button></form>`
++ : html`<p><a href="/auth/login?next=${e(epath(entity))}">Sign in</a> to comment.</p>`;
++ return html`<section class="rv-discussion">${head}<p class="rv-muted">Comments are held by OpenVibe.Community and shown here as they are there.</p>
++${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li><strong>${c.display_name || 'Someone'}</strong> <span class="rv-muted">${t(c.created_at)}${c.origin === 'ai' ? ' · AI' : ''}</span><p>${c.message}</p></li>`)}</ul>` : html`<p class="rv-muted">No comments yet.</p>`}
++${form}</section>`;
++}
++
++function entityPage(p, { discussion, actor, flash }) {
++ const en = p.entity;
++ const limits = [];
++ for (const s of p.sources) {
++ if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);
++ if (s.trust.limitation) limits.push(html`${s.name || s.key}: ${s.trust.limitation.value}`);
++ if (s.trust.verification) limits.push(html`${s.name || s.key} verification: ${s.trust.verification.value}`);
++ }
++ if (p.entity_trust.limitation) limits.push(html`${p.entity_trust.limitation.value}`);
++ const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
++<article class="rv-entity">
++<header><h1>${en.name}</h1><p class="rv-muted"><span class="rv-tag">${KIND_LABEL[en.kind]}</span> · <a href="${epath(en)}/history">History</a> · <a href="${epath(en)}.json">JSON</a> · <a href="${epath(en)}/correct">Suggest a correction</a>${p.editor ? html` · <a href="${epath(en)}/edit">Edit</a>` : ''}</p></header>
++${flash ? notice('ok', flash) : ''}
++${en.description ? html`<p>${en.description}</p>` : ''}
++${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
++${aggregateSection(p)}
++${summarySection(p)}
++<section><h2 id="signals">Signals (${String(p.signals.length)})</h2>
++${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
++${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
++</section>
++<section><h2 id="limits">Sources and limitations</h2>
++${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}
++${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}
++<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
++</section>
++${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
++${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
++${p.links.length ? html`<ul>${p.links.map((l) => html`<li>${l.type.replace(/_/g, ' ')}${l.direction === 'in' ? ' (from)' : ''}: ${l.other ? html`<a href="${epath(l.other)}">${l.other.name}</a>` : html`${l.ref ? `${l.ref.service} ${l.ref.type} ${l.ref.label || l.ref.id}` : ''}`}</li>`)}</ul>` : ''}</section>` : ''}
++${raw(discussionHtml(discussion, { entity: en, actor }))}
++<p class="rv-meta">Indexing: ${p.decision.indexable ? 'indexable' : `not indexed (${p.decision.codes.join(', ')})`}.</p>
++</article>`;
++}
++
++function historyPage(h) {
++ const en = h.entity;
++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History' }])}
++<section><h1>History of ${en.name}</h1>
++<h2 id="aggregates">Aggregate revisions</h2>
++${h.aggregates.length ? html`<div class="rv-scroll"><table class="rv-history"><thead><tr><th>Revision</th><th>Computed</th><th>Why</th><th>Result</th></tr></thead><tbody>${h.aggregates.map((a) => html`<tr><td>${String(a.revision)}</td><td>${tl(a.computed_at)}</td><td>${a.trigger.replace(/_/g, ' ')}</td><td>${aggregateLine(a)}${a.result ? html` <span class="rv-muted">(${String(a.result.inputs.length)} inputs)</span>` : ''}</td></tr>`)}</tbody></table></div>` : html`<p class="rv-muted">No aggregate has ever been computed: no qualifying signal yet.</p>`}
++<h2 id="summary">Summary revisions</h2>
++${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.message ? html` <span class="rv-muted">${r.message}</span>` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}
++<h2 id="merges">Merges and splits</h2>
++${h.merges.length ? html`<ul>${h.merges.map((m) => html`<li><a href="${epath(m.from)}">${m.from.name}</a> merged into <a href="${epath(m.to)}">${m.to.name}</a> ${tl(m.merged_at)}${m.note ? html` — ${m.note}` : ''}${m.split_at ? html`; split again ${tl(m.split_at)}${m.split_note ? html` — ${m.split_note}` : ''}` : html` <span class="rv-tag">active</span>`}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
++<h2 id="signals">Every signal, including replaced and withdrawn ones</h2>
++${h.signals.length ? signalTable(h.signals, { caption: 'All signals' }) : html`<p class="rv-muted">None.</p>`}
++<h2 id="audit">Editorial log</h2>
++${h.audit.length ? html`<ul class="rv-audit">${h.audit.map((a) => html`<li>${tl(a.at)} · ${a.action} · <span class="rv-muted">${a.actor}</span></li>`)}</ul>` : html`<p class="rv-muted">Nothing recorded.</p>`}
++</section>`;
++}
++
++function summaryRevisionPage(en, r, { editor }) {
++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History', url: `${epath(en)}/history` }, { name: `Summary revision ${r.number}` }])}
++<section><h1>Summary revision ${String(r.number)} <span class="rv-tag">${r.status}</span></h1>
++${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by an editor ${t(r.created_at)}.</p>`}
++${r.system ? notice('warn', `Prepared automatically after: ${r.system.reason}. ${r.system.dropped_points && r.system.dropped_points.length ? `${r.system.dropped_points.length} point(s) were dropped because every signal they cited is gone.` : ''}`) : ''}
++${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 1 }))}</div>` : ''}
++<ul>${r.points.filter((p) => p.kind !== 'overview').map((p) => html`<li><strong>${p.kind === 'pro' ? 'Pro' : 'Con'}:</strong> ${p.text} ${citeLinks(p.citations)}</li>`)}</ul>
++${editor && (r.status === 'pending_ai' || r.status === 'pending_system' || r.status === 'draft') ? html`<form method="post" action="${epath(en)}/summary/${String(r.number)}/review" class="rv-form"><fieldset><legend>Editor review</legend>
++<label>Note <input type="text" name="note" maxlength="500"></label>
++<button type="submit" name="decision" value="approved">Approve and publish</button> <button type="submit" name="decision" value="rejected">Reject</button></fieldset></form>` : ''}
++</section>`;
++}
++
++function correctionPage(en, { values = {}, error = null, done = false } = {}) {
++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'Suggest a correction' }])}
++<section><h1>Suggest a correction for ${en.name}</h1>
++${done ? notice('ok', 'Thank you. Your correction is in the editors\' queue.') : ''}
++${error ? notice('error', error) : ''}
++<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. Corrections are read by editors; they are not published.</p>
++<form method="post" action="${epath(en)}/correct" class="rv-form">
++<label>About <select name="target_type">${['entity', 'signal', 'summary', 'aggregate', 'alias'].map((x) => html`<option value="${x}"${values.target_type === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>
++<label>Which one (optional: a signal id such as sig_…) <input type="text" name="target_id" maxlength="100" value="${values.target_id || ''}"></label>
++<label>What is wrong <textarea name="body" rows="6" minlength="10" maxlength="4000" required>${values.body || ''}</textarea></label>
++<label>Evidence URL (optional) <input type="url" name="evidence_url" value="${values.evidence_url || ''}"></label>
++<button type="submit">Send to the editors</button></form></section>`;
++}
++
++// ── Editor surfaces ──────────────────────────────────────────
++function editorHome({ queue, pending, flagged, corrections, stats, itemView }) {
++ return html`<section><h1>Editor desk</h1>
++<p class="rv-muted">${n(stats.entities)} entities · ${n(stats.signals)} live signals · ${n(stats.items_unresolved)} items to resolve · ${n(stats.corrections_open)} open corrections · ${n(stats.summaries_flagged)} flagged summaries</p>
++<p><a class="rv-button" href="/editor/entities/new">New entity</a></p>
++<form method="post" action="/editor/sync" class="rv-inline"><button type="submit">Pull review items from OpenVibe.Sources now</button></form>
++<h2 id="items">Items waiting for resolution</h2>
++${queue.length ? html`<ul>${queue.map((row) => { const it = itemView(row); return html`<li><a href="/editor/items/${e(it.id)}">${it.title || it.identity}</a> <span class="rv-tag">${it.resolution}</span> <span class="rv-muted">${it.source_key} · ${it.kind}${it.candidates.length ? ` · ${it.candidates.length} candidate(s)` : ''}</span></li>`; })}</ul>` : html`<p class="rv-muted">Nothing waits.</p>`}
++<h2 id="summaries">Summary revisions waiting for review</h2>
++${pending.length ? html`<ul>${pending.map((x) => html`<li><a href="${epath(x.entity)}">${x.entity.name}</a>: ${x.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(x.entity)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</li>`)}</ul>` : html`<p class="rv-muted">Nothing waits.</p>`}
++<h2 id="flagged">Flagged summaries</h2>
++${flagged.length ? html`<ul>${flagged.map((s) => html`<li><a href="${epath(s.entity)}">${s.entity.name}</a>: ${s.flag_reason}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
++<h2 id="corrections">Open corrections</h2>
++${corrections.length ? html`<ul>${corrections.map((c) => html`<li><a href="${epath(c.entity)}">${c.entity.name}</a> · ${c.target_type}${c.target_id ? ` ${c.target_id}` : ''} · ${t(c.created_at)}<blockquote>${c.body}</blockquote>${safeUrl(c.evidence_url) ? html`<a href="${safeUrl(c.evidence_url)}" rel="nofollow noopener">evidence</a>` : ''}
++<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-inline"><input type="text" name="note" placeholder="Note" maxlength="500"> <button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
++</section>`;
++}
++
++function kindOptions(selected) {
++ return html`${Object.keys(KIND_LABEL).map((k) => html`<option value="${k}"${selected === k ? raw(' selected') : ''}>${KIND_LABEL[k]}</option>`)}`;
++}
++
++function newEntityPage({ values = {}, error = null } = {}) {
++ return html`<section><h1>New entity</h1>${error ? notice('error', error) : ''}
++<form method="post" action="/editor/entities/new" class="rv-form">
++<label>Name <input type="text" name="name" required maxlength="200" value="${values.name || ''}"></label>
++<label>Kind <select name="kind">${kindOptions(values.kind || 'other')}</select></label>
++<label>Description (optional) <input type="text" name="description" maxlength="2000" value="${values.description || ''}"></label>
++<fieldset><legend>Identifiers (optional, used to resolve source items)</legend>
++<label>Source binding: every item of this OpenVibe.Sources source is about this entity <input type="text" name="alias_source" value="${values.alias_source || ''}" placeholder="steam-reviews-portal-2"></label>
++<label>URL <input type="url" name="alias_url" value="${values.alias_url || ''}"></label>
++<label>GTIN <input type="text" name="alias_gtin" value="${values.alias_gtin || ''}"></label>
++<label>SKU <input type="text" name="alias_sku" value="${values.alias_sku || ''}"></label>
++<label>External id (namespace:id) <input type="text" name="alias_external" value="${values.alias_external || ''}" placeholder="steam_app:620"></label>
++</fieldset>
++<button type="submit">Create</button></form></section>`;
++}
++
++function editEntityPage({ page: p, error = null, flash = null, entities = [] }) {
++ const en = p.entity;
++ const allSignals = p.signals;
++ const sel = (name, chosen = []) => html`<select name="${name}" multiple size="4">${allSignals.map((s) => html`<option value="${s.signal_id}"${chosen.includes(s.signal_id) ? raw(' selected') : ''}>${s.signal_id.slice(-8)} · ${s.source_key} · ${signalValue(s)}</option>`)}</select>`;
++ const cur = p.summary && p.summary.published;
++ const pros = cur ? cur.points.filter((x) => x.kind === 'pro') : [];
++ const cons = cur ? cur.points.filter((x) => x.kind === 'con') : [];
++ const ov = cur ? cur.points.find((x) => x.kind === 'overview') : null;
++ const rows = (kind, list) => html`${[0, 1, 2, 3, 4, 5].map((i) => html`<div class="rv-point"><label>${kind === 'pro' ? 'Pro' : 'Con'} ${String(i + 1)} <input type="text" name="${kind}_text_${String(i)}" maxlength="600" value="${list[i] ? list[i].text : ''}"></label><label>cites ${sel(`${kind}_signals_${String(i)}`, list[i] ? list[i].citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label></div>`)}`;
++ return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'Edit' }])}
++<section><h1>Edit ${en.name}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
++<h2>Details</h2>
++<form method="post" action="${epath(en)}/edit" class="rv-form"><input type="hidden" name="action" value="details">
++<label>Name <input type="text" name="name" maxlength="200" value="${en.name}"></label>
++<label>Slug <input type="text" name="slug" maxlength="90" value="${en.slug}"></label>
++<label>Kind <select name="kind">${kindOptions(en.kind)}</select></label>
++<label>Description <input type="text" name="description" maxlength="2000" value="${en.description || ''}"></label>
++<label class="rv-check"><input type="checkbox" name="noindex" value="1"${en.noindex ? raw(' checked') : ''}> Ask search engines not to index this page</label>
++<button type="submit">Save</button></form>
++
++<h2>Identifiers</h2>
++<ul>${p.aliases.map((a) => html`<li>${a.type}: ${a.value} <form method="post" action="${epath(en)}/edit" class="rv-inline"><input type="hidden" name="action" value="remove_alias"><input type="hidden" name="alias_id" value="${a.id}"><button type="submit">Remove</button></form></li>`)}</ul>
++<form method="post" action="${epath(en)}/edit" class="rv-form"><input type="hidden" name="action" value="add_alias">
++<label>Type <select name="type">${['name', 'url', 'gtin', 'sku', 'mpn', 'source', 'external'].map((x) => html`<option value="${x}">${x}</option>`)}</select></label>
++<label>Value <input type="text" name="value" required maxlength="1000"></label><button type="submit">Add identifier</button></form>
++
++<h2>Summary</h2>
++<p class="rv-muted">Every pro and con cites the signals it rests on. A summary carries no rating. Saving creates a new revision; "Save and publish" publishes it.</p>
++<form method="post" action="${epath(en)}/summary" class="rv-form">
++<label>Overview (Markdown) <textarea name="overview" rows="6" maxlength="6000">${cur ? cur.overview : ''}</textarea></label>
++<label>The overview cites ${sel('overview_signals', ov ? ov.citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label>
++<fieldset><legend>Pros</legend>${rows('pro', pros)}</fieldset>
++<fieldset><legend>Cons</legend>${rows('con', cons)}</fieldset>
++<label>Revision note <input type="text" name="message" maxlength="500"></label>
++<input type="hidden" name="expected_revision" value="${String(p.summary ? p.summary.head_revision : 0)}">
++<button type="submit" name="publish" value="0">Save</button> <button type="submit" name="publish" value="1">Save and publish</button></form>
++${p.summary && p.summary.pending && p.summary.pending.length ? html`<p>Waiting for review: ${p.summary.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(en)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</p>` : ''}
++${p.summary && p.summary.state === 'published' ? html`<form method="post" action="${epath(en)}/edit" class="rv-inline"><input type="hidden" name="action" value="unpublish_summary"><button type="submit">Unpublish the summary</button></form>` : ''}
++
++<h2>Trust</h2>
++<p class="rv-muted">Exclude a source or a signal from the aggregate (the reason is shown on the page), or record a limitation readers should know.</p>
++<form method="post" action="${epath(en)}/edit" class="rv-form"><input type="hidden" name="action" value="trust">
++<label>Applies to <select name="scope_ref">${p.sources.map((s) => html`<option value="source:${s.key}">source ${s.name || s.key}</option>`)}${allSignals.map((s) => html`<option value="signal:${s.signal_id}">signal ${s.signal_id.slice(-8)} (${s.source_key})</option>`)}<option value="entity:${en.id}">this entity</option></select></label>
++<label>What <select name="key"><option value="aggregate">aggregate: include / exclude</option><option value="limitation">limitation note</option><option value="verification">verification note</option></select></label>
++<label>Value (include / exclude, or the note) <input type="text" name="value" maxlength="1000"></label>
++<label>Reason (required to exclude) <input type="text" name="note" maxlength="1000"></label>
++<button type="submit">Record</button></form>
++
++<h2>Links</h2>
++<form method="post" action="${epath(en)}/edit" class="rv-form"><input type="hidden" name="action" value="add_link">
++<label>Type <select name="type">${['related', 'edition_of', 'successor_of', 'part_of'].map((x) => html`<option value="${x}">${x.replace(/_/g, ' ')}</option>`)}</select></label>
++<label>Other entity (slug) <input type="text" name="to" required></label><button type="submit">Link</button></form>
++
++<h2>Merge</h2>
++<p class="rv-muted">Merging keeps everything as it is (aliases, signal attribution) and shows this entity's signals under the target. It can be split again at any time, exactly.</p>
++<form method="post" action="${epath(en)}/merge" class="rv-form">
++<label>Merge ${en.name} into (slug) <input type="text" name="into" required list="rv-entities"></label>
++<datalist id="rv-entities">${entities.filter((x) => x.id !== en.id).map((x) => html`<option value="${x.slug}">${x.name}</option>`)}</datalist>
++<label>Why <input type="text" name="note" maxlength="500" required></label>
++<button type="submit">Merge</button></form>
++${p.merged_from.length ? html`<h3>Split</h3><ul>${p.merged_from.filter((m) => m.merged_into === en.id).map((m) => html`<li>${m.name} <form method="post" action="${epath(m)}/split" class="rv-inline"><input type="text" name="note" placeholder="Why" maxlength="500" required> <button type="submit">Split</button></form></li>`)}</ul>` : ''}
++</section>`;
++}
++
++function itemPage({ item, candidates, error = null }) {
++ return html`${crumbs([{ name: 'Editor desk', url: '/editor' }, { name: `Item ${item.id}` }])}
++<section><h1>Source item ${item.id}</h1>${error ? notice('error', error) : ''}
++<table class="rv-fields"><tbody>
++<tr><th>Source</th><td>${item.source_key}</td></tr>
++<tr><th>Kind</th><td>${item.kind}</td></tr>
++<tr><th>Title</th><td>${item.title || ''}</td></tr>
++<tr><th>URL</th><td>${safeUrl(item.canonical_url) ? html`<a href="${safeUrl(item.canonical_url)}" rel="nofollow noopener">${item.canonical_url}</a>` : html`${item.canonical_url || ''}`}</td></tr>
++<tr><th>Identity</th><td>${item.identity}</td></tr>
++<tr><th>Retrieved</th><td>${tl(item.retrieved_at)} (revision ${String(item.item_revision)})</td></tr>
++<tr><th>Fields kept</th><td><code>${JSON.stringify(item.fields)}</code></td></tr>
++<tr><th>Signal</th><td>${item.signal_note || (item.signal ? signalValue(item.signal) : '')}</td></tr>
++<tr><th>Resolution</th><td>${item.resolution}${item.resolution_rule ? ` (${item.resolution_rule})` : ''}</td></tr>
++</tbody></table>
++<h2>Resolve</h2>
++<form method="post" action="/editor/items/${e(item.id)}" class="rv-form"><input type="hidden" name="action" value="resolve">
++<label>Entity <select name="entity">${candidates.map((c) => html`<option value="${c.slug}">${c.name} (${c.slug})</option>`)}</select></label>
++<label>Or another entity (slug) <input type="text" name="entity_other"></label>
++<label>Remember an identifier of this item for next time <select name="add_alias"><option value="">no</option><option value="source">its source (every item of ${item.source_key} is this entity)</option>${item.canonical_url ? html`<option value="url">its URL</option>` : ''}${item.fields.gtin ? html`<option value="gtin">its GTIN</option>` : ''}${item.fields.sku ? html`<option value="sku">its SKU</option>` : ''}${item.title ? html`<option value="name">its name</option>` : ''}</select></label>
++<button type="submit">Attribute to this entity</button></form>
++<form method="post" action="/editor/items/${e(item.id)}" class="rv-form"><input type="hidden" name="action" value="ignore"><label>Not about anything Reviews covers: <input type="text" name="note" placeholder="Note"></label><button type="submit">Ignore</button></form>
++</section>`;
++}
++
++module.exports = {
++ home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
++ editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
++};
ddiff --git a/server/reviews/access.js b/server/reviews/access.js
nnew file mode 100644
iindex 0000000..6e62be6
---- /dev/null
++++ b/server/reviews/access.js
@@@ -0,0 +1,39 @@
++'use strict';
++/**
++ * Who may do what. One place, used by the pages, the API and the service.
++ *
++ * Actors (server/auth/viewer.js):
++ * { kind: 'anonymous' } reads public pages
++ * { kind: 'user', subject: 'usr_…'|null, staff } Network SSO (browser or Bearer)
++ * { kind: 'service', service: 'svc:x', claims, subject } service token; acts for X-OV-Subject if given
++ * { kind: 'system', service: 'svc:reviews' } this service's own workers (Sources sync)
++ *
++ * Editors are Network staff (admin, global_mod) and the usr_ subjects listed in REVIEWS_EDITORS.
++ * A service acting for a person gets that person's rights (and must hold the route's capability);
++ * staff status travels only in a person's own token, so a service acting for staff is an editor
++ * only when the subject is listed in REVIEWS_EDITORS. Editorial decisions (merge, split, confirm a
++ * resolution, publish a summary, exclude a signal) are always taken by a person, never a service
++ * on its own and never the AI.
++ */
++const PERSON_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createAccess(config) {
++ const editors = new Set((config.editors || []).filter((s) => PERSON_RE.test(s)));
++
++ const api = {
++ isPerson(actor) { return !!(actor && actor.subject && PERSON_RE.test(actor.subject)); },
++ isEditor(actor) {
++ if (!actor) return false;
++ if (actor.kind === 'system') return true;
++ if (!api.isPerson(actor)) return false;
++ if (actor.kind === 'user' && actor.staff) return true;
++ return editors.has(actor.subject);
++ },
++ /** An editor who is a person (not the system): the one who answers for an editorial decision. */
++ isEditorPerson(actor) { return api.isPerson(actor) && api.isEditor(actor); },
++ editorCount() { return editors.size; },
++ };
++ return api;
++}
++
++module.exports = { createAccess, PERSON_RE };
ddiff --git a/server/reviews/aggregate.js b/server/reviews/aggregate.js
nnew file mode 100644
iindex 0000000..3f02a27
---- /dev/null
++++ b/server/reviews/aggregate.js
@@@ -0,0 +1,150 @@
++'use strict';
++/**
++ * The aggregate of an entity: a pure function of the signals present and the editors' trust
++ * decisions. It exposes every input, every exclusion with its reason, and the computation.
++ *
++ * There is no default. With no qualifying signal the result is null — not zero, not "no rating
++ * yet" dressed up as a number. Summaries, AI output and editors' text never enter here: the only
++ * inputs are rows of review_signals, which come only from OpenVibe.Sources items.
++ *
++ * Method reviews-aggregate@1:
++ * recommendation positives / total over `recommendation` (one reviewer, 1 of 1 or 0 of 1) and
++ * `recommendation_tally` (a source's own counts) signals. A source that states a
++ * tally has its individual recommendations left out (they are inside the tally);
++ * of several tallies from one source only the most recently observed counts.
++ * rating count-weighted mean of value / best over `rating` (count 1) and
++ * `rating_aggregate` (count = the source's stated number of ratings) signals,
++ * reported as a percentage; when every input shares one scale, also on that scale.
++ * A rating whose source did not state the scale (bestRating) is left out.
++ */
++const crypto = require('crypto');
++
++const METHOD = 'reviews-aggregate@1';
++
++const round = (x, d) => { const f = 10 ** d; return Math.round(x * f) / f; };
++
++function stable(value) {
++ if (value === null || typeof value !== 'object') return JSON.stringify(value === undefined ? null : value);
++ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
++ return `{${Object.keys(value).filter((k) => value[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
++}
++
++function hashOf(result) {
++ return crypto.createHash('sha256').update(stable(result)).digest('hex');
++}
++
++function inputView(s) {
++ const v = { signal_id: s.id, type: s.type, source_key: s.source_key, source_item_id: s.source_item_id, entity_id: s.entity_id, observed_at: s.observed_at };
++ if (s.type === 'recommendation') { v.recommended = s.recommended === 1; v.weight = 1; }
++ if (s.type === 'recommendation_tally') { v.positive_count = s.positive_count; v.total_count = s.total_count; v.weight = s.total_count; }
++ if (s.type === 'rating' || s.type === 'rating_aggregate') {
++ v.rating_value = s.rating_value; v.rating_best = s.rating_best; v.rating_worst = s.rating_worst; v.rating_count = s.rating_count; v.weight = s.rating_count;
++ }
++ return v;
++}
++
++/**
++ * signals: active review_signals rows of the entity (and of the entities merged into it).
++ * exclusions: { sources: Map<key, note>, signals: Map<id, note> } from review_trust_metadata.
++ * → null | { method, components: { recommendation?, rating? }, inputs, excluded, sources, observed, computation }
++ */
++function computeAggregate(signals, { exclusions = { sources: new Map(), signals: new Map() } } = {}) {
++ const excluded = [];
++ const candidates = [];
++ const sorted = [...signals].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
++ for (const s of sorted) {
++ if (s.status !== 'active') continue;
++ if (exclusions.sources.has(s.source_key)) { excluded.push({ signal_id: s.id, reason: 'source_excluded_by_editor', note: exclusions.sources.get(s.source_key) || null }); continue; }
++ if (exclusions.signals.has(s.id)) { excluded.push({ signal_id: s.id, reason: 'signal_excluded_by_editor', note: exclusions.signals.get(s.id) || null }); continue; }
++ if ((s.type === 'rating' || s.type === 'rating_aggregate') && !(s.rating_best > 0)) { excluded.push({ signal_id: s.id, reason: 'scale_not_stated', note: 'The source did not state the best possible rating, so the value cannot be read on a scale.' }); continue; }
++ if ((s.type === 'rating_aggregate' || s.type === 'recommendation_tally') && !((s.rating_count || s.total_count) > 0)) { excluded.push({ signal_id: s.id, reason: 'no_count', note: null }); continue; }
++ candidates.push(s);
++ }
++
++ // One tally per source (the latest observation); a source's single recommendations are inside its tally.
++ const latestTally = new Map();
++ for (const s of candidates) {
++ if (s.type !== 'recommendation_tally') continue;
++ const cur = latestTally.get(s.source_key);
++ if (!cur || s.observed_at > cur.observed_at || (s.observed_at === cur.observed_at && s.id > cur.id)) latestTally.set(s.source_key, s);
++ }
++ const used = [];
++ for (const s of candidates) {
++ if (s.type === 'recommendation_tally' && latestTally.get(s.source_key) !== s) { excluded.push({ signal_id: s.id, reason: 'older_tally_same_source', note: null }); continue; }
++ if (s.type === 'recommendation' && latestTally.has(s.source_key)) { excluded.push({ signal_id: s.id, reason: 'covered_by_source_tally', note: null }); continue; }
++ used.push(s);
++ }
++
++ const recs = used.filter((s) => s.type === 'recommendation' || s.type === 'recommendation_tally');
++ const rates = used.filter((s) => s.type === 'rating' || s.type === 'rating_aggregate');
++ const components = {};
++ const computation = [];
++
++ if (recs.length) {
++ let positive = 0;
++ let total = 0;
++ for (const s of recs) {
++ if (s.type === 'recommendation') { positive += s.recommended === 1 ? 1 : 0; total += 1; } else { positive += s.positive_count; total += s.total_count; }
++ }
++ if (total > 0) {
++ components.recommendation = { positive, total, percent: round((positive / total) * 100, 1), inputs: recs.map((s) => s.id) };
++ computation.push(`recommendation: ${positive} of ${total} recommend (${components.recommendation.percent}%) = sum of positives / sum of totals over ${recs.length} signal${recs.length === 1 ? '' : 's'}`);
++ }
++ }
++ if (rates.length) {
++ let weight = 0;
++ let normSum = 0;
++ let valueSum = 0;
++ const bests = new Set();
++ const worsts = new Set();
++ for (const s of rates) {
++ const n = s.rating_count;
++ weight += n;
++ normSum += (s.rating_value / s.rating_best) * n;
++ valueSum += s.rating_value * n;
++ bests.add(s.rating_best);
++ worsts.add(s.rating_worst == null ? 'unstated' : s.rating_worst);
++ }
++ if (weight > 0) {
++ const r = { count: weight, percent: round((normSum / weight) * 100, 1), inputs: rates.map((s) => s.id) };
++ if (bests.size === 1) {
++ r.on_scale = { value: round(valueSum / weight, 2), best: [...bests][0] };
++ if (worsts.size === 1 && !worsts.has('unstated')) r.on_scale.worst = [...worsts][0];
++ }
++ components.rating = r;
++ computation.push(`rating: ${r.percent}% = Σ(value ÷ best × count) ÷ Σ count over ${rates.length} signal${rates.length === 1 ? '' : 's'} and ${weight} rating${weight === 1 ? '' : 's'}`
++ + (r.on_scale ? `; on the shared ${r.on_scale.best}-point scale: ${r.on_scale.value} = Σ(value × count) ÷ Σ count` : ''));
++ }
++ }
++
++ if (!components.recommendation && !components.rating) return null;
++ const inputs = used.map(inputView);
++ const observed = inputs.map((i) => i.observed_at).sort();
++ return {
++ method: METHOD,
++ components,
++ inputs,
++ excluded,
++ sources: [...new Set(inputs.map((i) => i.source_key))].sort(),
++ observed: { earliest: observed[0], latest: observed[observed.length - 1] },
++ computation,
++ };
++}
++
++/**
++ * schema.org AggregateRating fields for an aggregate, or null. The rating component wins; a
++ * recommendation share is expressed as a 0–100 value with its real count.
++ */
++function ratingForStructuredData(result) {
++ if (!result) return null;
++ const r = result.components.rating;
++ if (r && r.count > 0) {
++ if (r.on_scale) return { value: r.on_scale.value, best: r.on_scale.best, worst: r.on_scale.worst, count: r.count };
++ return { value: r.percent, best: 100, worst: 0, count: r.count };
++ }
++ const c = result.components.recommendation;
++ if (c && c.total > 0) return { value: c.percent, best: 100, worst: 0, count: c.total };
++ return null;
++}
++
++module.exports = { computeAggregate, ratingForStructuredData, hashOf, stable, METHOD };
ddiff --git a/server/reviews/extract.js b/server/reviews/extract.js
nnew file mode 100644
iindex 0000000..e7860af
---- /dev/null
++++ b/server/reviews/extract.js
@@@ -0,0 +1,130 @@
++'use strict';
++/**
++ * From one OpenVibe.Sources item (sources.item@1) to what Reviews keeps of it: the signal fields
++ * (never review text — the text belongs to its author and Reviews does not republish it), the
++ * identifiers used to resolve it to an entity, and at most one typed signal.
++ *
++ * Signals are observations exactly as the source stated them. Nothing is computed, defaulted or
++ * guessed here: a rating without a stated scale is recorded with rating_best = null (and the
++ * aggregate leaves it out, saying why); an aggregate rating without a count is not a signal.
++ *
++ * review_signal (API adapter, e.g. Steam appreviews) voted_up → recommendation
++ * total_positive/total_reviews or
++ * recommend_count/total_count → recommendation_tally
++ * product (JSON-LD) aggregate_rating { value, best, worst, count|review_count } → rating_aggregate
++ * review (JSON-LD) rating { value, best, worst } → rating (one rating)
++ */
++
++function num(v) {
++ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
++ if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return Number(v.trim());
++ return null;
++}
++
++function count(v) {
++ const n = num(v);
++ return n != null && Number.isInteger(n) && n >= 0 ? n : null;
++}
++
++function boolOf(v) {
++ if (v === true || v === 1 || v === '1' || v === 'true') return true;
++ if (v === false || v === 0 || v === '0' || v === 'false') return false;
++ return null;
++}
++
++// Per-review facts a source states about the reviewer (kept as trust metadata of the signal).
++const TRUST_FIELDS = ['steam_purchase', 'received_for_free', 'written_during_early_access', 'verified_purchase', 'playtime_at_review_min', 'votes_up', 'votes_funny', 'weighted_vote_score', 'language'];
++// Identifiers used for resolution.
++const ID_FIELDS = ['sku', 'gtin', 'mpn', 'brand', 'schema_type', 'item_reviewed', 'item_reviewed_type'];
++const TALLY = [['total_positive', 'total_reviews'], ['recommend_count', 'total_count']];
++
++function scalar(v) {
++ if (v == null) return null;
++ if (typeof v === 'boolean') return v;
++ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
++ if (typeof v === 'string') return v.slice(0, 200);
++ return null;
++}
++
++function ratingOf(r) {
++ if (!r || typeof r !== 'object') return null;
++ const out = { value: num(r.value), best: num(r.best), worst: num(r.worst), count: count(r.count), review_count: count(r.review_count) };
++ return out.value == null && out.count == null && out.review_count == null ? null : out;
++}
++
++/** The fields Reviews stores for an item: signal values, reviewer facts, identifiers. No text. */
++function keptFields(item) {
++ const f = item && item.fields && typeof item.fields === 'object' ? item.fields : {};
++ const out = {};
++ if (f.voted_up !== undefined) out.voted_up = boolOf(f.voted_up);
++ for (const [p, t] of TALLY) {
++ if (f[p] !== undefined) out[p] = count(f[p]);
++ if (f[t] !== undefined) out[t] = count(f[t]);
++ }
++ if (f.aggregate_rating !== undefined) out.aggregate_rating = ratingOf(f.aggregate_rating);
++ if (f.rating !== undefined) out.rating = ratingOf(f.rating);
++ for (const k of [...TRUST_FIELDS, ...ID_FIELDS]) if (f[k] !== undefined && scalar(f[k]) !== null) out[k] = scalar(f[k]);
++ return out;
++}
++
++/** Titles are kept only where they name the thing (a product page), never a reviewer's headline. */
++function keptTitle(item) {
++ return item && item.kind === 'product' && item.title ? String(item.title).slice(0, 300) : null;
++}
++
++function trustOf(fields) {
++ const out = {};
++ for (const k of TRUST_FIELDS) if (fields[k] !== undefined && fields[k] !== null) out[k] = fields[k];
++ return out;
++}
++
++/**
++ * → { signal: { type, recommended?, positive_count?, total_count?, rating_value?, rating_best?,
++ * rating_worst?, rating_count?, trust }, note: null }
++ * | { signal: null, note: 'why there is no signal' }
++ */
++function extractSignal(item) {
++ const f = keptFields(item);
++ const trust = trustOf(f);
++ for (const [p, t] of TALLY) {
++ if (f[p] != null && f[t] != null) {
++ if (f[p] > f[t]) return { signal: null, note: `${p} (${f[p]}) exceeds ${t} (${f[t]})` };
++ return { signal: { type: 'recommendation_tally', positive_count: f[p], total_count: f[t], trust }, note: null };
++ }
++ }
++ if (item.kind === 'review_signal' || f.voted_up !== undefined) {
++ if (f.voted_up === true || f.voted_up === false) return { signal: { type: 'recommendation', recommended: f.voted_up ? 1 : 0, trust }, note: null };
++ return { signal: null, note: 'the item states no recommendation (voted_up)' };
++ }
++ if (item.kind === 'product') {
++ const r = f.aggregate_rating;
++ if (!r || r.value == null) return { signal: null, note: 'the product states no aggregate rating' };
++ const n = r.count != null && r.count > 0 ? r.count : (r.review_count != null && r.review_count > 0 ? r.review_count : null);
++ if (n == null) return { signal: null, note: 'the aggregate rating states no number of ratings' };
++ if (r.best != null && (r.best <= 0 || r.value > r.best)) return { signal: null, note: `rating ${r.value} is outside the stated scale (best ${r.best})` };
++ return { signal: { type: 'rating_aggregate', rating_value: r.value, rating_best: r.best, rating_worst: r.worst, rating_count: n, trust }, note: null };
++ }
++ if (item.kind === 'review') {
++ const r = f.rating;
++ if (!r || r.value == null) return { signal: null, note: 'the review states no rating' };
++ if (r.best != null && (r.best <= 0 || r.value > r.best)) return { signal: null, note: `rating ${r.value} is outside the stated scale (best ${r.best})` };
++ return { signal: { type: 'rating', rating_value: r.value, rating_best: r.best, rating_worst: r.worst, rating_count: 1, trust }, note: null };
++ }
++ return { signal: null, note: `items of kind "${item.kind}" carry no review signal` };
++}
++
++/** Identifiers of an item for resolution: [{ type, value }] strongest first, then name candidates. */
++function identifiers(item) {
++ const f = keptFields(item);
++ const strong = [{ type: 'source', value: item.source_key }];
++ if (item.canonical_url) strong.push({ type: 'url', value: item.canonical_url });
++ if (f.gtin) strong.push({ type: 'gtin', value: f.gtin });
++ if (f.sku) strong.push({ type: 'sku', value: f.sku });
++ if (f.mpn) strong.push({ type: 'mpn', value: f.mpn });
++ const names = [];
++ if (item.kind === 'product' && item.title) names.push(item.title);
++ if (f.item_reviewed) names.push(f.item_reviewed);
++ return { strong, names };
++}
++
++module.exports = { extractSignal, keptFields, keptTitle, identifiers, TRUST_FIELDS };
ddiff --git a/server/reviews/normalize.js b/server/reviews/normalize.js
nnew file mode 100644
iindex 0000000..c331830
---- /dev/null
++++ b/server/reviews/normalize.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * Normalisation of the identifiers entities are resolved by. Pure functions: the same input always
++ * gives the same key, so resolution is deterministic.
++ */
++
++class NormalizeError extends Error {
++ constructor(message) { super(message); this.status = 422; this.code = 'alias.invalid'; }
++}
++
++function name(value) {
++ const s = String(value == null ? '' : value).normalize('NFKD').replace(/[̀-ͯ]/g, '')
++ .toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').trim();
++ if (!s) throw new NormalizeError('A name needs at least one letter or digit');
++ return s.slice(0, 200);
++}
++
++/** host (without www.) + path (+ query minus tracking parameters); http and https are the same thing. */
++function url(value) {
++ let u;
++ try { u = new URL(String(value || '').trim()); } catch { throw new NormalizeError('A URL alias must be an absolute http(s) URL'); }
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new NormalizeError('A URL alias must be http(s)');
++ const host = u.hostname.toLowerCase().replace(/^www\./, '');
++ const keep = [...u.searchParams.entries()].filter(([k]) => !/^(utm_|fbclid$|gclid$|ref$|mc_)/i.test(k)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
++ let pathname = u.pathname.replace(/\/{2,}/g, '/');
++ if (pathname.length > 1) pathname = pathname.replace(/\/+$/, '');
++ const query = keep.length ? `?${new URLSearchParams(keep).toString()}` : '';
++ return `${host}${pathname === '/' ? '' : pathname}${query}`.slice(0, 1000);
++}
++
++function code(value, what) {
++ const s = String(value == null ? '' : value).trim().toUpperCase().replace(/\s+/g, '');
++ if (!s || s.length > 100) throw new NormalizeError(`A ${what} is 1–100 characters`);
++ return s;
++}
++
++/** GTIN-8/12/13/14 → 14 digits, so the same product under two lengths is one key. */
++function gtin(value) {
++ const d = String(value == null ? '' : value).replace(/[\s-]/g, '');
++ if (!/^\d+$/.test(d) || ![8, 12, 13, 14].includes(d.length)) throw new NormalizeError('A GTIN has 8, 12, 13 or 14 digits');
++ return d.padStart(14, '0');
++}
++
++function sourceKey(value) {
++ const s = String(value == null ? '' : value).trim().toLowerCase();
++ if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(s)) throw new NormalizeError('A source binding is an OpenVibe.Sources source key');
++ return s;
++}
++
++/** namespace:id, e.g. steam_app:620 */
++function external(value) {
++ const m = String(value == null ? '' : value).trim().match(/^([A-Za-z][A-Za-z0-9_.-]{0,39}):(.{1,200})$/);
++ if (!m) throw new NormalizeError('An external id is namespace:id (e.g. steam_app:620)');
++ return `${m[1].toLowerCase()}:${m[2].trim()}`;
++}
++
++const BY_TYPE = { name, url, sku: (v) => code(v, 'SKU'), mpn: (v) => code(v, 'MPN'), gtin, source: sourceKey, external };
++
++function alias(type, value) {
++ const fn = BY_TYPE[type];
++ if (!fn) throw new NormalizeError(`alias type must be one of ${Object.keys(BY_TYPE).join(', ')}`);
++ return fn(value);
++}
++
++/** Try to normalise; null when the value is not a valid identifier of that type. */
++function tryAlias(type, value) {
++ if (value == null || value === '') return null;
++ try { return alias(type, value); } catch { return null; }
++}
++
++function slug(value) {
++ const s = String(value == null ? '' : value).normalize('NFKD').replace(/[̀-ͯ]/g, '')
++ .toLowerCase().replace(/&/g, '-and-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/, '');
++ return s || null;
++}
++
++module.exports = { alias, tryAlias, slug, NormalizeError, TYPES: Object.keys(BY_TYPE) };
ddiff --git a/server/reviews/service.js b/server/reviews/service.js
nnew file mode 100644
iindex 0000000..6136429
---- /dev/null
++++ b/server/reviews/service.js
@@@ -0,0 +1,1473 @@
++'use strict';
++/**
++ * The reviews domain: entities and aliases, resolution of Sources items to entities, signals with
++ * provenance, aggregates, reversible merges, summaries, corrections. Reviews owns the
++ * interpretation and publication layer; OpenVibe.Sources owns the items, Community the discussion,
++ * OpenVibe.AI only proposes drafts.
++ *
++ * Every write runs in one SQLite transaction together with the events it causes (transactional
++ * outbox): reviews.entity.merged|split, reviews.signal.added|removed,
++ * reviews.summary.published|updated|unpublished, and the Search index events
++ * reviews.index_document.upserted|deleted.
++ *
++ * Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
++ */
++const seo = require('openvibe-publishing/seo');
++const hooks = require('openvibe-publishing/index-hooks');
++const authorship = require('openvibe-publishing/authorship');
++const ssr = require('openvibe-publishing/ssr');
++const { ulid } = require('openvibe-contracts').ids;
++const norm = require('./normalize');
++const extract = require('./extract');
++const { computeAggregate, hashOf } = require('./aggregate');
++const { createAccess } = require('./access');
++const { ENTITY_KINDS, ALIAS_TYPES, LINK_TYPES, TRUST_KEYS } = require('../db');
++
++class ReviewsError extends Error {
++ constructor(status, code, message, extra) { super(message); this.status = status; this.code = code; if (extra) this.extra = extra; }
++}
++
++const ITEM_RE = /^itm_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ENTITY_ID_RE = /^ent_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SIGNAL_ID_RE = /^sig_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUMMARY_WORKFLOW = 'reviews.summarize_entity';
++const MAX_POINTS = 12;
++const MAX_POINT = 600;
++const MAX_OVERVIEW = 6000;
++// A summary is text with citations. Nothing in it can carry a score.
++const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note']);
++const RATING_KEY_RE = /(rating|stars?|score|grade|verdict_value)/i;
++// Numeric ratings in AI text ("4.5/5", "8 out of 10", "★★★★"): AI output never states a rating.
++const RATING_TEXT_RE = /(\b\d+(?:[.,]\d+)?\s*(?:\/|out of)\s*(?:5|10|100)\b)|[★☆⭐]|\b\d+(?:[.,]\d+)?\s*stars?\b/i;
++
++const toIso = (ms) => (ms == null ? null : new Date(ms).toISOString());
++
++function createReviewsService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
++ const { revisions, reviews, redirects, discussions, sequencer } = stores;
++ const access = createAccess(config);
++ const origin = config.baseUrl;
++ const policy = config.gate;
++
++ const q = {
++ entity: db.prepare('SELECT * FROM review_entities WHERE id = ?'),
++ entityBySlug: db.prepare('SELECT * FROM review_entities WHERE slug = ?'),
++ insertEntity: db.prepare(`INSERT INTO review_entities (id, slug, name, kind, description, created_by, created_at, updated_at)
++ VALUES (@id, @slug, @name, @kind, @description, @created_by, @now, @now)`),
++ mergedInto: db.prepare("SELECT id FROM review_entities WHERE merged_into = ? AND state = 'merged'"),
++ listActive: db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' ORDER BY e.name COLLATE NOCASE LIMIT ? OFFSET ?`),
++ countActive: db.prepare("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active'"),
++ searchEntities: db.prepare(`SELECT DISTINCT e.* FROM review_entities e LEFT JOIN review_entity_aliases a ON a.entity_id = e.id AND a.removed_at IS NULL AND a.type = 'name'
++ WHERE e.state = 'active' AND (e.name LIKE @like ESCAPE '\\' OR a.norm LIKE @nlike ESCAPE '\\')
++ ORDER BY e.name COLLATE NOCASE LIMIT @limit`),
++ alias: db.prepare('SELECT * FROM review_entity_aliases WHERE id = ?'),
++ aliasesOf: db.prepare('SELECT * FROM review_entity_aliases WHERE entity_id = ? AND removed_at IS NULL ORDER BY type, norm'),
++ strongAlias: db.prepare("SELECT * FROM review_entity_aliases WHERE type = ? AND norm = ? AND removed_at IS NULL AND type != 'name'"),
++ nameAliases: db.prepare("SELECT * FROM review_entity_aliases WHERE type = 'name' AND norm = ? AND removed_at IS NULL"),
++ insertAlias: db.prepare(`INSERT INTO review_entity_aliases (id, entity_id, type, value, norm, created_by, created_at) VALUES (@id, @entity_id, @type, @value, @norm, @by, @now)`),
++ removeAlias: db.prepare('UPDATE review_entity_aliases SET removed_at = ?, removed_by = ? WHERE id = ? AND removed_at IS NULL'),
++ link: db.prepare('SELECT * FROM review_entity_links WHERE id = ?'),
++ linksFrom: db.prepare("SELECT * FROM review_entity_links WHERE from_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
++ linksTo: db.prepare("SELECT * FROM review_entity_links WHERE to_entity = ? AND ended_at IS NULL AND type != 'merged_into' ORDER BY created_at"),
++ activeMerge: db.prepare("SELECT * FROM review_entity_links WHERE from_entity = ? AND type = 'merged_into' AND ended_at IS NULL"),
++ mergeLinksOf: db.prepare("SELECT * FROM review_entity_links WHERE type = 'merged_into' AND (from_entity = ? OR to_entity = ?) ORDER BY created_at"),
++ insertLink: db.prepare(`INSERT INTO review_entity_links (id, from_entity, to_entity, type, ref, note, created_by, created_at) VALUES (@id, @from, @to, @type, @ref, @note, @by, @now)`),
++ endLink: db.prepare('UPDATE review_entity_links SET ended_at = ?, ended_by = ?, end_note = ? WHERE id = ? AND ended_at IS NULL'),
++ source: db.prepare('SELECT * FROM review_sources WHERE key = ?'),
++ insertSource: db.prepare(`INSERT INTO review_sources (key, name, homepage_url, category, license_note, terms_note, status, stale, last_success_at, first_seen_at, updated_at)
++ VALUES (@key, @name, @homepage_url, @category, @license_note, @terms_note, @status, @stale, @last_success_at, @now, @now)`),
++ updateSource: db.prepare(`UPDATE review_sources SET name = COALESCE(@name, name), homepage_url = COALESCE(@homepage_url, homepage_url), category = COALESCE(@category, category),
++ license_note = @license_note, terms_note = @terms_note, status = COALESCE(@status, status), stale = COALESCE(@stale, stale),
++ last_success_at = COALESCE(@last_success_at, last_success_at), updated_at = @now WHERE key = @key`),
++ item: db.prepare('SELECT * FROM review_source_items WHERE id = ?'),
++ insertItem: db.prepare(`INSERT INTO review_source_items (id, source_key, kind, identity, canonical_url, title, item_revision, content_hash, published_at, retrieved_at,
++ parser_version, license_note, terms_note, fields, state, removed_at, removed_reason, resolution, entity_id, resolution_rule, candidates, resolved_by, resolved_at, signal_note, first_seen_at, updated_at)
++ VALUES (@id, @source_key, @kind, @identity, @canonical_url, @title, @item_revision, @content_hash, @published_at, @retrieved_at,
++ @parser_version, @license_note, @terms_note, @fields, @state, @removed_at, @removed_reason, @resolution, @entity_id, @resolution_rule, @candidates, @resolved_by, @resolved_at, @signal_note, @now, @now)`),
++ updateItemContent: db.prepare(`UPDATE review_source_items SET kind = @kind, identity = @identity, canonical_url = @canonical_url, title = @title, item_revision = @item_revision,
++ content_hash = @content_hash, published_at = @published_at, retrieved_at = @retrieved_at, parser_version = @parser_version,
++ license_note = @license_note, terms_note = @terms_note, fields = @fields, signal_note = @signal_note, updated_at = @now WHERE id = @id`),
++ touchItem: db.prepare('UPDATE review_source_items SET retrieved_at = ?, updated_at = ? WHERE id = ? AND retrieved_at < ?'),
++ setResolution: db.prepare(`UPDATE review_source_items SET resolution = @resolution, entity_id = @entity_id, resolution_rule = @rule, candidates = @candidates,
++ resolved_by = @by, resolved_at = @at, updated_at = @now WHERE id = @id`),
++ removeItem: db.prepare("UPDATE review_source_items SET state = 'removed', removed_at = ?, removed_reason = ?, updated_at = ? WHERE id = ?"),
++ queueItems: db.prepare(`SELECT * FROM review_source_items WHERE resolution IN ('ambiguous','unmatched') AND state = 'active' ORDER BY updated_at DESC LIMIT ?`),
++ unresolvedItems: db.prepare("SELECT * FROM review_source_items WHERE resolution IN ('ambiguous','unmatched') AND state = 'active' ORDER BY first_seen_at"),
++ itemsOfEntity: db.prepare('SELECT * FROM review_source_items WHERE entity_id = ?'),
++ signal: db.prepare('SELECT * FROM review_signals WHERE id = ?'),
++ activeSignalOfItem: db.prepare("SELECT * FROM review_signals WHERE source_item_id = ? AND status = 'active'"),
++ signalsOfEntity: db.prepare('SELECT * FROM review_signals WHERE entity_id = ? ORDER BY observed_at DESC, id DESC'),
++ activeSignalsOfEntity: db.prepare("SELECT * FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY observed_at DESC, id DESC"),
++ entitiesWithSourceSignals: db.prepare("SELECT DISTINCT entity_id FROM review_signals WHERE source_key = ? AND status = 'active'"),
++ insertSignal: db.prepare(`INSERT INTO review_signals (id, entity_id, source_item_id, source_key, item_revision, type, recommended, positive_count, total_count,
++ rating_value, rating_best, rating_worst, rating_count, observed_at, source_published_at, canonical_url, license_note, trust, created_by, created_at)
++ VALUES (@id, @entity_id, @source_item_id, @source_key, @item_revision, @type, @recommended, @positive_count, @total_count,
++ @rating_value, @rating_best, @rating_worst, @rating_count, @observed_at, @source_published_at, @canonical_url, @license_note, @trust, @created_by, @now)`),
++ setSignalStatus: db.prepare("UPDATE review_signals SET status = @status, status_reason = @reason, status_at = @at, superseded_by = @superseded_by WHERE id = @id AND status = 'active'"),
++ trustCurrent: db.prepare('SELECT * FROM review_trust_metadata WHERE scope = ? AND scope_id = ? AND ended_at IS NULL ORDER BY key'),
++ trustOne: db.prepare('SELECT * FROM review_trust_metadata WHERE scope = ? AND scope_id = ? AND key = ? AND ended_at IS NULL'),
++ trustEnd: db.prepare('UPDATE review_trust_metadata SET ended_at = ?, ended_by = ? WHERE id = ?'),
++ trustInsert: db.prepare('INSERT INTO review_trust_metadata (scope, scope_id, key, value, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?, ?)'),
++ trustHistory: db.prepare('SELECT * FROM review_trust_metadata WHERE (scope = ? AND scope_id = ?) ORDER BY id'),
++ excludedSources: db.prepare("SELECT scope_id, note FROM review_trust_metadata WHERE scope = 'source' AND key = 'aggregate' AND value = 'exclude' AND ended_at IS NULL"),
++ excludedSignals: db.prepare("SELECT scope_id, note FROM review_trust_metadata WHERE scope = 'signal' AND key = 'aggregate' AND value = 'exclude' AND ended_at IS NULL"),
++ lastAggregate: db.prepare('SELECT * FROM review_aggregates WHERE entity_id = ? ORDER BY revision DESC LIMIT 1'),
++ aggregates: db.prepare('SELECT * FROM review_aggregates WHERE entity_id = ? ORDER BY revision DESC LIMIT ?'),
++ insertAggregate: db.prepare('INSERT INTO review_aggregates (entity_id, revision, computed_at, trigger, inputs_hash, result) VALUES (?, ?, ?, ?, ?, ?)'),
++ summary: db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?'),
++ summaryById: db.prepare('SELECT * FROM review_summaries WHERE id = ?'),
++ insertSummary: db.prepare('INSERT INTO review_summaries (id, entity_id, created_at, updated_at) VALUES (?, ?, ?, ?)'),
++ publishSummary: db.prepare(`UPDATE review_summaries SET state = 'published', published_revision = @n, published_at = COALESCE(published_at, @now),
++ revision_published_at = @now, flagged = 0, flag_reason = NULL, flagged_at = NULL, updated_at = @now WHERE id = @id`),
++ unpublishSummary: db.prepare("UPDATE review_summaries SET state = 'unpublished', updated_at = ? WHERE id = ?"),
++ flagSummary: db.prepare('UPDATE review_summaries SET flagged = 1, flag_reason = ?, flagged_at = ?, updated_at = ? WHERE id = ?'),
++ insertCitation: db.prepare('INSERT OR IGNORE INTO review_summary_citations (summary_id, revision, point, signal_id) VALUES (?, ?, ?, ?)'),
++ citationsOf: db.prepare('SELECT * FROM review_summary_citations WHERE summary_id = ? AND revision = ? ORDER BY point, signal_id'),
++ summariesCiting: db.prepare('SELECT DISTINCT summary_id FROM review_summary_citations WHERE signal_id = ?'),
++ flaggedSummaries: db.prepare('SELECT * FROM review_summaries WHERE flagged = 1 ORDER BY flagged_at DESC LIMIT 200'),
++ allSummaries: db.prepare('SELECT * FROM review_summaries ORDER BY updated_at DESC LIMIT 500'),
++ publishedSummaries: db.prepare("SELECT * FROM review_summaries WHERE state = 'published' ORDER BY revision_published_at DESC LIMIT ?"),
++ correction: db.prepare('SELECT * FROM review_corrections WHERE id = ?'),
++ insertCorrection: db.prepare(`INSERT INTO review_corrections (id, entity_id, target_type, target_id, body, evidence_url, submitted_by, via, created_at)
++ VALUES (@id, @entity_id, @target_type, @target_id, @body, @evidence_url, @submitted_by, @via, @now)`),
++ resolveCorrection: db.prepare("UPDATE review_corrections SET status = ?, resolved_by = ?, resolution_note = ?, resolved_at = ? WHERE id = ? AND status = 'open'"),
++ openCorrections: db.prepare("SELECT * FROM review_corrections WHERE status = 'open' ORDER BY created_at LIMIT 200"),
++ openCorrectionsOf: db.prepare("SELECT COUNT(*) AS n FROM review_corrections WHERE entity_id = ? AND status = 'open'"),
++ audit: db.prepare('INSERT INTO review_audit (at, actor, action, entity_id, target, detail) VALUES (?, ?, ?, ?, ?, ?)'),
++ auditOf: db.prepare('SELECT * FROM review_audit WHERE entity_id = ? OR target = ? ORDER BY id DESC LIMIT 300'),
++ allEntityIds: db.prepare('SELECT id FROM review_entities ORDER BY id'),
++ };
++
++ // ── Small helpers ────────────────────────────────────────
++ const fail = (status, code, message, extra) => { throw new ReviewsError(status, code, message, extra); };
++ const tx = (fn) => db.transaction(fn)();
++ const entityPath = (e) => `/e/${encodeURIComponent(e.slug)}`;
++ const entityUrl = (e) => seo.canonicalUrl(origin, entityPath(e));
++ const parse = (s, d) => { try { return s == null ? d : JSON.parse(s); } catch { return d; } };
++
++ function actorId(actor) {
++ if (!actor) return 'svc:reviews';
++ if (actor.subject) return actor.subject;
++ if (actor.service) return actor.service;
++ return 'svc:reviews';
++ }
++ function requireEditorPerson(actor, what) {
++ if (!access.isPerson(actor)) fail(403, 'reviews.person_required', `${what} is decided by a signed-in editor (a service must name the person in X-OV-Subject)`);
++ if (!access.isEditor(actor)) fail(403, 'reviews.editor_required', `${what} is for Reviews editors`);
++ return actor.subject;
++ }
++ function requireEditor(actor, what) {
++ if (!access.isEditor(actor)) fail(403, 'reviews.editor_required', `${what} is for Reviews editors`);
++ return actorId(actor);
++ }
++ function audit(actor, action, entityId, target, detail = {}) {
++ q.audit.run(now(), typeof actor === 'string' ? actor : actorId(actor), action, entityId || null, target || null, JSON.stringify(detail));
++ }
++ function emit(envelope) { return outbox.enqueue(envelope); }
++
++ function entityOrFail(ref) {
++ const s = String(ref == null ? '' : ref);
++ const e = ENTITY_ID_RE.test(s) ? q.entity.get(s) : q.entityBySlug.get(s);
++ if (!e) fail(404, 'entity.not_found', 'No such entity');
++ return e;
++ }
++
++ /** The entity a merged entity now lives under (follows merged_into). */
++ function canonicalOf(entityId) {
++ let e = q.entity.get(entityId);
++ const seen = new Set();
++ while (e && e.state === 'merged' && e.merged_into && !seen.has(e.id)) { seen.add(e.id); e = q.entity.get(e.merged_into); }
++ return e ? e.id : null;
++ }
++
++ /** The entity and every entity merged into it, transitively. */
++ function closure(entityId) {
++ const out = new Set([entityId]);
++ const stack = [entityId];
++ while (stack.length) {
++ const id = stack.pop();
++ for (const r of q.mergedInto.all(id)) if (!out.has(r.id)) { out.add(r.id); stack.push(r.id); }
++ }
++ return out;
++ }
++
++ function activeSignalsIn(ids) {
++ const out = [];
++ for (const id of ids) out.push(...q.activeSignalsOfEntity.all(id));
++ return out.sort((a, b) => (a.observed_at < b.observed_at ? 1 : a.observed_at > b.observed_at ? -1 : (a.id < b.id ? 1 : -1)));
++ }
++ function allSignalsIn(ids) {
++ const out = [];
++ for (const id of ids) out.push(...q.signalsOfEntity.all(id));
++ return out.sort((a, b) => (a.observed_at < b.observed_at ? 1 : a.observed_at > b.observed_at ? -1 : (a.id < b.id ? 1 : -1)));
++ }
++
++ // ── Aggregates ───────────────────────────────────────────
++ function exclusions() {
++ return {
++ sources: new Map(q.excludedSources.all().map((r) => [r.scope_id, r.note])),
++ signals: new Map(q.excludedSignals.all().map((r) => [r.scope_id, r.note])),
++ };
++ }
++
++ function computeFor(canonicalId) {
++ return computeAggregate(activeSignalsIn(closure(canonicalId)), { exclusions: exclusions() });
++ }
++
++ /**
++ * Records the next aggregate revision of the canonical entity when the result changed. A change
++ * to nothing (every signal gone) is recorded as a revision whose result is null; an entity
++ * that never had a qualifying signal has no aggregate row at all.
++ */
++ function refreshAggregate(entityId, trigger) {
++ const canon = canonicalOf(entityId);
++ const e = canon && q.entity.get(canon);
++ if (!e || e.state !== 'active') return null;
++ const result = computeFor(canon);
++ const hash = hashOf(result);
++ const last = q.lastAggregate.get(canon);
++ if (last && last.inputs_hash === hash) return last;
++ if (!last && result === null) return null;
++ const revision = last ? last.revision + 1 : 1;
++ q.insertAggregate.run(canon, revision, now(), String(trigger).slice(0, 60), hash, result ? JSON.stringify(result) : null);
++ return q.lastAggregate.get(canon);
++ }
++
++ function aggregateView(row) {
++ if (!row) return null;
++ return { revision: row.revision, computed_at: toIso(row.computed_at), trigger: row.trigger, result: row.result ? JSON.parse(row.result) : null };
++ }
++
++ // ── Summaries: shape, citations, validation ──────────────
++ function summaryPoints(rev) {
++ const f = rev ? rev.fields || {} : {};
++ const out = [];
++ if (Array.isArray(f.overview_signals) && f.overview_signals.length) out.push({ key: 'overview', kind: 'overview', text: rev.content, signals: f.overview_signals });
++ (f.pros || []).forEach((p, i) => out.push({ key: `pro:${i}`, kind: 'pro', text: p.text, signals: p.signals || [] }));
++ (f.cons || []).forEach((p, i) => out.push({ key: `con:${i}`, kind: 'con', text: p.text, signals: p.signals || [] }));
++ return out;
++ }
++
++ function summaryText(rev) {
++ if (!rev) return '';
++ const f = rev.fields || {};
++ return [rev.content || '', ...(f.pros || []).map((p) => p.text), ...(f.cons || []).map((p) => p.text)].join('\n');
++ }
++
++ /** For each point of a revision: which cited signals are still active and belong to the entity. */
++ function citationState(canonicalId, rev) {
++ const members = closure(canonicalId);
++ return summaryPoints(rev).map((p) => {
++ const cites = p.signals.map((id) => {
++ const s = q.signal.get(id);
++ const ok = !!(s && s.status === 'active' && members.has(s.entity_id));
++ return { signal_id: id, ok, status: s ? s.status : 'missing', in_entity: !!(s && members.has(s.entity_id)), superseded_by: s ? s.superseded_by : null };
++ });
++ return { ...p, cites, supported: cites.some((c) => c.ok) };
++ });
++ }
++
++ function unsupportedCount(canonicalId, rev) {
++ return citationState(canonicalId, rev).filter((p) => !p.supported).length;
++ }
++
++ function cleanPoints(list, what) {
++ if (list == null) return [];
++ if (!Array.isArray(list)) fail(422, 'summary.invalid', `${what} must be a list of { text, signals }`);
++ if (list.length > MAX_POINTS) fail(422, 'summary.invalid', `at most ${MAX_POINTS} ${what}`);
++ return list.map((p, i) => {
++ if (!p || typeof p !== 'object' || Array.isArray(p)) fail(422, 'summary.invalid', `${what}[${i}] must be { text, signals }`);
++ for (const k of Object.keys(p)) {
++ if (RATING_KEY_RE.test(k)) fail(422, 'summary.rating_forbidden', `${what}[${i}].${k}: a summary carries no rating; ratings come only from source signals`);
++ if (k !== 'text' && k !== 'signals') fail(422, 'summary.invalid', `${what}[${i}].${k} is not a summary field`);
++ }
++ const text = String(p.text == null ? '' : p.text).replace(/\s+/g, ' ').trim();
++ if (!text) fail(422, 'summary.invalid', `${what}[${i}] needs text`);
++ if (text.length > MAX_POINT) fail(422, 'summary.invalid', `${what}[${i}] is at most ${MAX_POINT} characters`);
++ const signals = [...new Set((Array.isArray(p.signals) ? p.signals : [p.signals]).filter((x) => x != null && x !== '').map(String))];
++ if (!signals.length) fail(422, 'summary.uncited_point', `${what}[${i}] cites no signal: every pro and con cites the signals it rests on`);
++ return { text, signals };
++ });
++ }
++
++ /** Validates a summary body. Every cited signal must be active and belong to the entity. */
++ function checkSummaryInput(canonicalId, input, { ai = false } = {}) {
++ if (!input || typeof input !== 'object') fail(422, 'summary.invalid', 'A summary is { overview, pros, cons }');
++ for (const k of Object.keys(input)) {
++ if (RATING_KEY_RE.test(k)) fail(422, 'summary.rating_forbidden', `${k}: a summary carries no rating; ratings come only from source signals`);
++ if (!SUMMARY_KEYS.has(k)) fail(422, 'summary.invalid', `${k} is not a summary field`);
++ }
++ const overview = String(input.overview == null ? '' : input.overview).replace(/\r\n?/g, '\n').trim();
++ if (overview.length > MAX_OVERVIEW) fail(422, 'summary.invalid', `The overview is at most ${MAX_OVERVIEW} characters`);
++ const pros = cleanPoints(input.pros, 'pros');
++ const cons = cleanPoints(input.cons, 'cons');
++ const overviewSignals = [...new Set((Array.isArray(input.overview_signals) ? input.overview_signals : []).map(String))];
++ if (!overview && !pros.length && !cons.length) fail(422, 'summary.empty', 'A summary needs an overview, pros or cons');
++ if (overview && !overviewSignals.length && !pros.length && !cons.length) fail(422, 'summary.uncited_point', 'The overview cites no signal');
++ if (ai) {
++ for (const t of [overview, ...pros.map((p) => p.text), ...cons.map((p) => p.text)]) {
++ if (RATING_TEXT_RE.test(t)) fail(422, 'summary.rating_in_text', 'AI-drafted text states no rating or star count; the aggregate comes only from source signals');
++ }
++ }
++ const members = closure(canonicalId);
++ for (const id of [...overviewSignals, ...pros.flatMap((p) => p.signals), ...cons.flatMap((p) => p.signals)]) {
++ if (!SIGNAL_ID_RE.test(id)) fail(422, 'summary.invalid_citation', `${id} is not a signal id`);
++ const s = q.signal.get(id);
++ if (!s || !members.has(s.entity_id)) fail(422, 'summary.invalid_citation', `Signal ${id} does not belong to this entity`);
++ if (s.status !== 'active') fail(422, 'summary.invalid_citation', `Signal ${id} is ${s.status}; cite the current signal`);
++ }
++ return { content: overview, fields: { pros, cons, overview_signals: overviewSignals } };
++ }
++
++ function ensureSummary(entityId) {
++ let s = q.summary.get(entityId);
++ if (!s) {
++ const t = now();
++ q.insertSummary.run(`sum_${ulid(t)}`, entityId, t, t);
++ s = q.summary.get(entityId);
++ }
++ return s;
++ }
++
++ function writeCitations(summary, rev) {
++ for (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);
++ }
++
++ function revisionStatus(summary, rev) {
++ const review = reviews.latest(summary.id, rev.number);
++ const rec = rev.meta && rev.meta.authorship;
++ if (summary.state === 'published' && summary.published_revision === rev.number) return 'published';
++ if (review && review.decision === 'rejected') return 'rejected';
++ if (summary.published_revision && rev.number < summary.published_revision) return 'superseded';
++ if (rev.meta && rev.meta.system) return 'pending_system';
++ if (rec && authorship.needsReview(rec) && !authorship.isReviewed(rec, review)) return 'pending_ai';
++ return 'draft';
++ }
++
++ function pendingRevisions(summary) {
++ const head = revisions.headNumber(summary.id);
++ const out = [];
++ for (let n = (summary.published_revision || 0) + 1; n <= head; n++) {
++ const rev = revisions.get(summary.id, n);
++ const st = revisionStatus(summary, rev);
++ if (st !== 'rejected') out.push({ rev, status: st });
++ }
++ return out;
++ }
++
++ // ── The gate, the Search document ────────────────────────
++ function publishedSummaryRevision(entityId) {
++ const s = q.summary.get(entityId);
++ if (!s || s.state !== 'published' || !s.published_revision) return { summary: s || null, rev: null };
++ return { summary: s, rev: revisions.get(s.id, s.published_revision) };
++ }
++
++ function gateFacts(e) {
++ const members = closure(e.id);
++ const active = activeSignalsIn(members);
++ const { summary, rev } = publishedSummaryRevision(e.id);
++ const rec = rev && rev.meta && rev.meta.authorship;
++ return {
++ state: e.state === 'deleted' ? 'deleted' : 'published',
++ visibility: 'public',
++ canonicalUrl: entityUrl(e),
++ wordCount: rev ? ssr.wordCount(summaryText(rev)) : 0,
++ citationCount: active.length,
++ unsupportedClaims: rev ? unsupportedCount(e.id, rev) : 0,
++ noindex: !!e.noindex,
++ ...(rec ? authorship.gateFacts(rec, reviews.latest(summary.id, rev.number)) : {}),
++ };
++ }
++
++ function decide(e) {
++ return seo.evaluate(gateFacts(e), { policy, now: now() });
++ }
++
++ /** Sends Search the entity's current document (or a tombstone) when it differs from the last one. */
++ function syncEntity(entityId) {
++ const e = q.entity.get(entityId);
++ if (!e) return null;
++ const live = e.state === 'active';
++ const decision = live ? decide(e) : null;
++ const before = sequencer.current('reviews', 'entity', e.id);
++ const { rev } = live ? publishedSummaryRevision(e.id) : { rev: null };
++ const agg = live ? aggregateView(q.lastAggregate.get(e.id)) : null;
++ const signals = live ? activeSignalsIn(closure(e.id)) : [];
++ const lines = [];
++ if (agg && agg.result) lines.push(...agg.result.computation);
++ const doc = sequencer.stamp(hooks.buildIndexDocument({
++ owner: 'reviews', type: 'entity', id: e.id, revision: 0,
++ state: e.state === 'deleted' ? 'deleted' : 'published', visibility: 'public',
++ deleted: !live || !decision || !decision.listable,
++ canonicalUrl: entityUrl(e), title: e.name,
++ summary: rev ? (rev.content || '').slice(0, 1000) || null : (e.description || null),
++ body: [rev ? summaryText(rev) : '', ...lines].join('\n'),
++ facets: { kind: e.kind, has_aggregate: !!(agg && agg.result), sources: [...new Set(signals.map((s) => s.source_key))].slice(0, 50) },
++ authorship: rev && rev.meta && rev.meta.authorship ? rev.meta.authorship : null,
++ provenance: signals.slice(0, 40).map((s) => ({ service: 'sources', type: 'item', id: s.source_item_id, url: s.canonical_url || undefined, retrievedAt: s.observed_at })),
++ decision,
++ publishedAt: e.created_at, updatedAt: e.updated_at,
++ }));
++ if (doc.revision !== before) emit(hooks.indexEvent({ document: doc, now: now() }));
++ return doc;
++ }
++
++ function touchEntity(entityId) {
++ db.prepare('UPDATE review_entities SET updated_at = ? WHERE id = ?').run(now(), entityId);
++ }
++
++ // Inside batch() (a sync page, an alias that settles many items) the aggregate of each touched
++ // entity is recomputed once at the end: one editorial action or one Sources page → at most one
++ // new aggregate revision per entity.
++ let batchDepth = 0;
++ const dirty = new Map();
++ function flushEntity(canon, trigger) {
++ refreshAggregate(canon, trigger);
++ touchEntity(canon);
++ syncEntity(canon);
++ }
++ function batch(fn) {
++ return tx(() => {
++ batchDepth++;
++ try {
++ return fn();
++ } finally {
++ batchDepth--;
++ if (batchDepth === 0) {
++ const list = [...dirty];
++ dirty.clear();
++ for (const [canon, trigger] of list) flushEntity(canon, trigger);
++ }
++ }
++ });
++ }
++
++ /** After signals of an entity changed: next aggregate revision, Search document. */
++ function afterSignalChange(entityId, trigger) {
++ const canon = canonicalOf(entityId);
++ if (!canon) return;
++ if (batchDepth > 0) { if (!dirty.has(canon)) dirty.set(canon, trigger); return; }
++ flushEntity(canon, trigger);
++ }
++
++ // ── Events ───────────────────────────────────────────────
++ function signalPayload(s) {
++ const p = {
++ signal_id: s.id, entity_id: s.entity_id, canonical_entity_id: canonicalOf(s.entity_id), type: s.type,
++ source_key: s.source_key, source_item_id: s.source_item_id, item_revision: s.item_revision,
++ observed_at: s.observed_at, source_published_at: s.source_published_at, canonical_url: s.canonical_url, license_note: s.license_note,
++ };
++ if (s.type === 'recommendation') p.recommended = s.recommended === 1;
++ if (s.type === 'recommendation_tally') { p.positive_count = s.positive_count; p.total_count = s.total_count; }
++ if (s.type === 'rating' || s.type === 'rating_aggregate') { p.rating_value = s.rating_value; p.rating_best = s.rating_best; p.rating_worst = s.rating_worst; p.rating_count = s.rating_count; }
++ return p;
++ }
++ function signalEvent(type, s, actor, extra = {}) {
++ emit({
++ event_type: type,
++ actor: hooks.subjectRef(actorId(actor)),
++ subject: { type: 'signal', id: s.id },
++ visibility: 'public',
++ payload: { ...signalPayload(s), ...extra },
++ });
++ }
++ function entityEvent(type, e, actor, payload) {
++ emit({ event_type: type, actor: hooks.subjectRef(actorId(actor)), subject: { type: 'entity', id: e.id }, visibility: 'public', payload });
++ }
++
++ // ── Summary flags when cited signals leave ───────────────
++ /**
++ * A summary whose current revision cites a signal that is no longer active (withdrawn by its
++ * source, replaced by a newer revision of the item, or outside the entity after a split) gets a
++ * flag and a pending revision: citations of replaced signals move to their replacement, points
++ * left without a live citation are dropped. The pending revision is published only after an
++ * editor approves it; until then the published text stays up with the flag shown next to it.
++ */
++ function flagSummary(summaryId, reason, signalIds) {
++ const s = q.summaryById.get(summaryId);
++ if (!s) return null;
++ const canon = canonicalOf(s.entity_id);
++ const e = q.entity.get(s.entity_id);
++ if (!e || e.state !== 'active') return null;
++ const headN = revisions.headNumber(s.id);
++ if (!headN) return null;
++ const head = revisions.get(s.id, headN);
++ const base = s.published_revision && !(headN > s.published_revision && head.meta && head.meta.system)
++ ? revisions.get(s.id, s.published_revision) : head;
++ const state = citationState(canon, base);
++ if (state.every((p) => p.cites.every((c) => c.ok))) return null;
++ const members = closure(canon);
++ const carry = (id) => {
++ let sig = q.signal.get(id);
++ const seen = new Set();
++ while (sig && sig.status === 'superseded' && sig.superseded_by && !seen.has(sig.id)) { seen.add(sig.id); sig = q.signal.get(sig.superseded_by); }
++ return sig && sig.status === 'active' && members.has(sig.entity_id) ? sig.id : null;
++ };
++ const mapPoints = (list) => (list || []).map((p) => ({ text: p.text, signals: [...new Set(p.signals.map(carry).filter(Boolean))] }));
++ const f = base.fields || {};
++ const pros = mapPoints(f.pros);
++ const cons = mapPoints(f.cons);
++ const overviewSignals = [...new Set((f.overview_signals || []).map(carry).filter(Boolean))];
++ const dropped = [...pros, ...cons].filter((p) => !p.signals.length).map((p) => p.text);
++ const t = now();
++ q.flagSummary.run(String(reason).slice(0, 300), t, t, s.id);
++ const { revision } = revisions.create({
++ entityId: s.id, expectedRevision: headN, content: base.content,
++ fields: { pros: pros.filter((p) => p.signals.length), cons: cons.filter((p) => p.signals.length), overview_signals: overviewSignals },
++ meta: { ...(base.meta && base.meta.authorship ? { authorship: base.meta.authorship } : {}), system: { reason: String(reason).slice(0, 300), signals: signalIds, base_revision: base.number, dropped_points: dropped } },
++ author: 'svc:reviews', message: `Pending: ${reason}`, allowUnchanged: true,
++ });
++ writeCitations(s, revision);
++ audit('svc:reviews', 'summary.flagged', s.entity_id, s.id, { reason, signals: signalIds, pending_revision: revision.number, dropped_points: dropped.length });
++ syncEntity(canon);
++ return revision;
++ }
++
++ function flagSummariesCiting(signalIds, reason) {
++ const ids = new Set();
++ for (const sid of signalIds) for (const r of q.summariesCiting.all(sid)) ids.add(r.summary_id);
++ for (const id of ids) flagSummary(id, reason, signalIds);
++ }
++
++ // ── Signals ──────────────────────────────────────────────
++ function withdrawSignal(sig, reason, actor, { status = 'withdrawn', supersededBy = null } = {}) {
++ const r = q.setSignalStatus.run({ id: sig.id, status, reason: String(reason).slice(0, 500), at: now(), superseded_by: supersededBy });
++ if (!r.changes) return false;
++ signalEvent('reviews.signal.removed', q.signal.get(sig.id), actor, { status, reason: String(reason).slice(0, 500), ...(supersededBy ? { replaced_by: supersededBy } : {}) });
++ return true;
++ }
++
++ /** A new signal for an item revision; the item's previous active signal is superseded. */
++ function createSignal(itemRow, entityId, sig, actor) {
++ const prev = q.activeSignalOfItem.get(itemRow.id);
++ if (prev && prev.item_revision === itemRow.item_revision && prev.entity_id === entityId) return { signal: prev, created: false };
++ const t = now();
++ const id = `sig_${ulid(t)}`;
++ if (prev) {
++ const why = prev.entity_id !== entityId ? 'reattributed by an editor' : `source item revised (r${prev.item_revision} → r${itemRow.item_revision})`;
++ withdrawSignal(prev, why, actor, prev.entity_id !== entityId ? {} : { status: 'superseded', supersededBy: id });
++ }
++ q.insertSignal.run({
++ id, entity_id: entityId, source_item_id: itemRow.id, source_key: itemRow.source_key, item_revision: itemRow.item_revision,
++ type: sig.type, recommended: sig.recommended == null ? null : sig.recommended,
++ positive_count: sig.positive_count == null ? null : sig.positive_count, total_count: sig.total_count == null ? null : sig.total_count,
++ rating_value: sig.rating_value == null ? null : sig.rating_value, rating_best: sig.rating_best == null ? null : sig.rating_best,
++ rating_worst: sig.rating_worst == null ? null : sig.rating_worst, rating_count: sig.rating_count == null ? null : sig.rating_count,
++ observed_at: itemRow.retrieved_at, source_published_at: itemRow.published_at, canonical_url: itemRow.canonical_url,
++ license_note: itemRow.license_note, trust: JSON.stringify(sig.trust || {}), created_by: actorId(actor), now: t,
++ });
++ const signal = q.signal.get(id);
++ signalEvent('reviews.signal.added', signal, actor, prev ? { replaces: prev.id } : {});
++ if (prev) {
++ flagSummariesCiting([prev.id], prev.entity_id !== entityId ? 'A cited signal was reattributed to another entity' : 'A cited signal was replaced by a newer revision of its source item');
++ if (canonicalOf(prev.entity_id) !== canonicalOf(entityId)) afterSignalChange(prev.entity_id, 'signal_reattributed');
++ }
++ afterSignalChange(entityId, prev ? 'signal_replaced' : 'signal_added');
++ return { signal, created: true, replaced: prev ? prev.id : null };
++ }
++
++ // ── Resolution ───────────────────────────────────────────
++ /**
++ * Deterministic rules, strongest first: a source binding (every item of that source is about one
++ * entity), the item's canonical URL, then GTIN / SKU / MPN. One entity (after following merges)
++ * → resolved. Strong identifiers naming different entities → ambiguous. No strong match: names
++ * give candidates, and a name is never enough on its own → ambiguous (an editor confirms).
++ * Nothing → unmatched (an editor creates or picks the entity).
++ */
++ function resolveIdentifiers({ strong = [], names = [] }) {
++ const hits = [];
++ for (const id of strong) {
++ const n = norm.tryAlias(id.type, id.value);
++ if (!n) continue;
++ const a = q.strongAlias.get(id.type, n);
++ if (a) {
++ const e = q.entity.get(a.entity_id);
++ if (e && e.state !== 'deleted') hits.push({ rule: id.type, alias_id: a.id, entity_id: a.entity_id, canonical: canonicalOf(a.entity_id) });
++ }
++ }
++ const canon = [...new Set(hits.map((h) => h.canonical))];
++ if (canon.length === 1) return { resolution: 'resolved', entity_id: hits[0].entity_id, rule: hits.map((h) => h.rule).join('+'), candidates: [] };
++ if (canon.length > 1) return { resolution: 'ambiguous', entity_id: null, rule: 'conflicting_identifiers', candidates: hits.map((h) => ({ entity_id: h.canonical, rule: h.rule })) };
++ const named = [];
++ for (const name of names) {
++ const n = norm.tryAlias('name', name);
++ if (!n) continue;
++ for (const a of q.nameAliases.all(n)) {
++ const c = canonicalOf(a.entity_id);
++ const e = c && q.entity.get(c);
++ if (e && e.state === 'active' && !named.some((x) => x.entity_id === c)) named.push({ entity_id: c, rule: 'name' });
++ }
++ }
++ if (named.length) return { resolution: 'ambiguous', entity_id: null, rule: 'name_only', candidates: named };
++ return { resolution: 'unmatched', entity_id: null, rule: null, candidates: [] };
++ }
++
++ function itemIdentifiers(row) {
++ return extract.identifiers({ source_key: row.source_key, canonical_url: row.canonical_url, kind: row.kind, title: row.title, fields: parse(row.fields, {}) });
++ }
++
++ function setItemResolution(row, r, by) {
++ q.setResolution.run({ id: row.id, resolution: r.resolution, entity_id: r.entity_id, rule: r.rule, candidates: JSON.stringify(r.candidates || []), by, at: r.resolution === 'resolved' ? now() : null, now: now() });
++ return q.item.get(row.id);
++ }
++
++ /** Create/replace/withdraw the item's signal to match its current content and resolution. */
++ function reconcileItemSignal(row, actor) {
++ const active = q.activeSignalOfItem.get(row.id);
++ if (row.state !== 'active' || row.resolution !== 'resolved') {
++ if (active) {
++ withdrawSignal(active, row.state !== 'active' ? `removed by its source: ${row.removed_reason || 'no reason given'}` : 'the item is no longer attributed to this entity', actor);
++ flagSummariesCiting([active.id], row.state !== 'active' ? 'A cited signal was withdrawn: its source removed the item' : 'A cited signal was withdrawn');
++ afterSignalChange(active.entity_id, 'signal_withdrawn');
++ }
++ return { signal: null };
++ }
++ const { signal } = extract.extractSignal({ kind: row.kind, fields: parse(row.fields, {}) });
++ if (!signal) {
++ if (active) {
++ withdrawSignal(active, `the source no longer states a signal (${row.signal_note || 'no value'})`, actor);
++ flagSummariesCiting([active.id], 'A cited signal was withdrawn: its source no longer states it');
++ afterSignalChange(active.entity_id, 'signal_withdrawn');
++ }
++ return { signal: null };
++ }
++ return createSignal(row, row.entity_id, signal, actor);
++ }
++
++ function upsertSource(key, prov, info) {
++ const t = now();
++ const existing = q.source.get(key);
++ const values = {
++ key, name: info && info.name ? String(info.name).slice(0, 300) : null,
++ homepage_url: info && info.homepage_url ? String(info.homepage_url).slice(0, 1000) : null,
++ category: info && info.category ? String(info.category) : null,
++ license_note: (prov && prov.license_note) || (info && info.license_note) || (existing && existing.license_note) || null,
++ terms_note: (prov && prov.terms_note) || (info && info.terms_note) || (existing && existing.terms_note) || null,
++ status: info && info.status ? String(info.status) : null,
++ stale: info && typeof info.stale === 'boolean' ? (info.stale ? 1 : 0) : null,
++ last_success_at: info && info.last_success_at ? String(info.last_success_at) : null,
++ now: t,
++ };
++ if (existing) q.updateSource.run(values); else q.insertSource.run(values);
++ return q.source.get(key);
++ }
++
++ function checkItem(item) {
++ if (!item || typeof item !== 'object') fail(422, 'signal.invalid_item', 'not a sources.item@1 object');
++ if (!ITEM_RE.test(String(item.id || ''))) fail(422, 'signal.invalid_item', 'item.id must be itm_<ULID>');
++ if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(String(item.source_key || ''))) fail(422, 'signal.invalid_item', 'item.source_key is missing');
++ if (!Number.isInteger(item.revision) || item.revision < 1) fail(422, 'signal.invalid_item', 'item.revision is missing');
++ const p = item.provenance;
++ if (!p || typeof p !== 'object' || !p.retrieved_at || Number.isNaN(Date.parse(p.retrieved_at)) || !/^[0-9a-f]{64}$/.test(String(p.content_hash || ''))) {
++ fail(422, 'signal.no_provenance', 'An item without provenance (retrieved_at, content_hash) cannot become a signal');
++ }
++ }
++
++ /**
++ * Apply one Sources item (sources.item@1). Idempotent: the same item revision twice changes
++ * nothing but the last-seen time. Synchronous (runs inside the caller's transaction).
++ * → { outcome, item, signal }
++ */
++ function applyItem(item, { sourceInfo = null, actor = { kind: 'system', service: 'svc:reviews' } } = {}) {
++ checkItem(item);
++ if (item.category && item.category !== 'reviews') return { outcome: 'ignored:category', item: null, signal: null };
++ const prov = item.provenance;
++ const t = now();
++ return tx(() => {
++ upsertSource(item.source_key, prov, sourceInfo);
++ let row = q.item.get(item.id);
++ const retrievedAt = new Date(prov.retrieved_at).toISOString();
++ if (item.removed) {
++ const reason = String((item.removed && item.removed.reason) || 'removed').slice(0, 500);
++ if (!row) {
++ // Known only as removed: kept for the record, never a signal.
++ const fields = extract.keptFields(item);
++ q.insertItem.run({
++ id: item.id, source_key: item.source_key, kind: String(item.kind || 'unknown').slice(0, 40), identity: String(item.identity || item.id).slice(0, 2048),
++ canonical_url: item.canonical_url || null, title: extract.keptTitle(item), item_revision: item.revision, content_hash: prov.content_hash,
++ published_at: item.published_at || null, retrieved_at: retrievedAt, parser_version: prov.parser_version || null,
++ license_note: prov.license_note || null, terms_note: prov.terms_note || null, fields: JSON.stringify(fields),
++ state: 'removed', removed_at: item.removed.at || retrievedAt, removed_reason: reason, resolution: 'unmatched', entity_id: null,
++ resolution_rule: null, candidates: '[]', resolved_by: null, resolved_at: null, signal_note: 'removed by its source before Reviews read it', now: t,
++ });
++ return { outcome: 'removed:unknown', item: q.item.get(item.id), signal: null };
++ }
++ if (row.state === 'removed') return { outcome: 'unchanged', item: row, signal: null };
++ q.removeItem.run(item.removed.at || retrievedAt, reason, t, row.id);
++ row = q.item.get(row.id);
++ reconcileItemSignal(row, actor);
++ return { outcome: 'removed', item: row, signal: null };
++ }
++ if (row && row.state === 'removed') return { outcome: 'ignored:removed', item: row, signal: null };
++ if (row && item.revision < row.item_revision) return { outcome: 'ignored:older_revision', item: row, signal: null };
++ if (row && item.revision === row.item_revision && row.content_hash === prov.content_hash) {
++ q.touchItem.run(retrievedAt, t, row.id, retrievedAt);
++ return { outcome: 'unchanged', item: q.item.get(row.id), signal: q.activeSignalOfItem.get(row.id) || null };
++ }
++ const fields = extract.keptFields(item);
++ const { note } = extract.extractSignal(item);
++ const values = {
++ id: item.id, source_key: item.source_key, kind: String(item.kind || 'unknown').slice(0, 40), identity: String(item.identity || item.id).slice(0, 2048),
++ canonical_url: item.canonical_url || null, title: extract.keptTitle(item), item_revision: item.revision, content_hash: prov.content_hash,
++ published_at: item.published_at || null, retrieved_at: retrievedAt, parser_version: prov.parser_version || null,
++ license_note: prov.license_note || null, terms_note: prov.terms_note || null, fields: JSON.stringify(fields), signal_note: note, now: t,
++ };
++ let outcome;
++ if (!row) {
++ q.insertItem.run({ ...values, state: 'active', removed_at: null, removed_reason: null, resolution: 'unmatched', entity_id: null, resolution_rule: null, candidates: '[]', resolved_by: null, resolved_at: null });
++ outcome = 'created';
++ } else {
++ q.updateItemContent.run(values);
++ outcome = 'updated';
++ }
++ row = q.item.get(item.id);
++ if (row.resolution === 'unmatched' || row.resolution === 'ambiguous') row = setItemResolution(row, resolveIdentifiers(itemIdentifiers(row)), 'rule');
++ const out = reconcileItemSignal(row, actor);
++ return { outcome, item: q.item.get(item.id), signal: out.signal || null };
++ });
++ }
++
++ /** New aliases may settle items that waited: re-run the rules for every unresolved item. */
++ function reresolvePending(actor) {
++ return batch(() => {
++ let settled = 0;
++ for (const row of q.unresolvedItems.all()) {
++ const r = resolveIdentifiers(itemIdentifiers(row));
++ if (r.resolution === row.resolution && JSON.stringify(r.candidates) === row.candidates) continue;
++ const fresh = setItemResolution(row, r, 'rule');
++ if (fresh.resolution === 'resolved') { reconcileItemSignal(fresh, actor); settled++; }
++ }
++ return settled;
++ });
++ }
++
++ // ── Entities ─────────────────────────────────────────────
++ function uniqueSlug(base) {
++ const root = norm.slug(base) || 'entity';
++ let s = root;
++ for (let i = 2; q.entityBySlug.get(s) || redirects.resolve(`/e/${s}`, { currentPath: () => null }); i++) s = `${root}-${i}`.slice(0, 90);
++ return s;
++ }
++
++ function checkName(name) {
++ const t = String(name == null ? '' : name).replace(/\s+/g, ' ').trim();
++ if (!t) fail(422, 'entity.invalid_name', 'An entity needs a name');
++ if (t.length > 200) fail(422, 'entity.invalid_name', 'A name is at most 200 characters');
++ norm.alias('name', t);
++ return t;
++ }
++
++ function insertAlias(entityId, type, value, actor) {
++ if (!ALIAS_TYPES.includes(type)) fail(422, 'alias.invalid', `alias type is one of ${ALIAS_TYPES.join(', ')}`);
++ let n;
++ try { n = norm.alias(type, value); } catch (err) { fail(422, 'alias.invalid', err.message); }
++ if (type !== 'name') {
++ const taken = q.strongAlias.get(type, n);
++ if (taken) {
++ if (taken.entity_id === entityId) return taken;
++ const other = q.entity.get(taken.entity_id);
++ fail(409, 'alias.taken', `${type} ${value} already identifies "${other ? other.name : taken.entity_id}"`, { entity_id: taken.entity_id });
++ }
++ } else if (q.nameAliases.all(n).some((a) => a.entity_id === entityId)) {
++ return q.nameAliases.all(n).find((a) => a.entity_id === entityId);
++ }
++ const t = now();
++ const id = `als_${ulid(t)}`;
++ q.insertAlias.run({ id, entity_id: entityId, type, value: String(value).trim().slice(0, 1000), norm: n, by: actorId(actor), now: t });
++ return q.alias.get(id);
++ }
++
++ function entityView(e) {
++ return {
++ id: e.id, slug: e.slug, name: e.name, kind: e.kind, description: e.description, state: e.state,
++ merged_into: e.merged_into, canonical_id: canonicalOf(e.id), noindex: !!e.noindex,
++ url: entityUrl(e), created_at: toIso(e.created_at), updated_at: toIso(e.updated_at),
++ };
++ }
++
++ function trustMap(scope, id) {
++ const out = {};
++ for (const r of q.trustCurrent.all(scope, id)) out[r.key] = { value: r.value, note: r.note, set_at: toIso(r.set_at) };
++ return out;
++ }
++
++ function signalView(s) {
++ const item = q.item.get(s.source_item_id);
++ const src = q.source.get(s.source_key);
++ return {
++ ...signalPayload(s), status: s.status, status_reason: s.status_reason, status_at: toIso(s.status_at), superseded_by: s.superseded_by,
++ trust: parse(s.trust, {}), editor_trust: trustMap('signal', s.id), created_at: toIso(s.created_at),
++ provenance: {
++ source_key: s.source_key, source_name: src ? src.name : null, source_homepage: src ? src.homepage_url : null,
++ source_item_id: s.source_item_id, item_revision: s.item_revision, item_kind: item ? item.kind : null,
++ retrieved_at: s.observed_at, last_seen_at: item ? item.retrieved_at : null,
++ canonical_url: s.canonical_url, license_note: s.license_note, terms_note: item ? item.terms_note : (src ? src.terms_note : null),
++ item_state: item ? item.state : null, item_removed_reason: item ? item.removed_reason : null,
++ },
++ };
++ }
++
++ function summaryRevisionView(summary, rev, canonicalId) {
++ if (!rev) return null;
++ const rec = rev.meta && rev.meta.authorship;
++ const review = reviews.latest(summary.id, rev.number);
++ return {
++ number: rev.number, status: revisionStatus(summary, rev), overview: rev.content,
++ points: citationState(canonicalId, rev).map((p) => ({ key: p.key, kind: p.kind, text: p.kind === 'overview' ? null : p.text, supported: p.supported, citations: p.cites })),
++ authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, stub_provider: !!rec.stubProvider } : null,
++ disclosure: rec ? authorship.disclosure(rec, review) : null,
++ review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
++ system: rev.meta && rev.meta.system ? rev.meta.system : null,
++ author: rev.author, message: rev.message, created_at: rev.createdAt,
++ };
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const svc = {
++ access, ReviewsError, entityPath, entityUrl, canonicalOf, closure,
++ ENTITY_KINDS, ALIAS_TYPES, LINK_TYPES, TRUST_KEYS, SUMMARY_WORKFLOW,
++
++ // Lookups ----------------------------------------------------------------------------
++ findEntity(ref) { const s = String(ref || ''); return (ENTITY_ID_RE.test(s) ? q.entity.get(s) : q.entityBySlug.get(s)) || null; },
++ entity(ref) { return entityOrFail(ref); },
++ entityById(id) { return q.entity.get(id) || null; },
++ resolveRedirect(path) {
++ return redirects.resolve(path, { currentPath: (id) => { const e = q.entity.get(id); return e && e.state !== 'deleted' ? entityPath(e) : null; } });
++ },
++ listEntities({ limit = 50, offset = 0 } = {}) { return { entities: q.listActive.all(Math.min(200, limit), Math.max(0, offset)), total: q.countActive.get().n }; },
++ search(text, { limit = 30 } = {}) {
++ const s = String(text || '').trim().slice(0, 200);
++ if (!s) return [];
++ const esc = (x) => x.replace(/[\\%_]/g, (c) => `\\${c}`);
++ const n = norm.tryAlias('name', s) || s.toLowerCase();
++ return q.searchEntities.all({ like: `%${esc(s)}%`, nlike: `%${esc(n)}%`, limit: Math.min(100, limit) });
++ },
++ entityView,
++ signalView,
++ aggregateView,
++ decide,
++ gateFacts,
++
++ /** Everything the entity page shows. `e` must be active (callers redirect merged entities). */
++ page(e, actor) {
++ const members = closure(e.id);
++ const signals = allSignalsIn(members);
++ const active = signals.filter((s) => s.status === 'active');
++ const agg = aggregateView(q.lastAggregate.get(e.id));
++ const summary = q.summary.get(e.id) || null;
++ const pub = summary && summary.state === 'published' && summary.published_revision ? revisions.get(summary.id, summary.published_revision) : null;
++ const sourceKeys = [...new Set(signals.map((s) => s.source_key))].sort();
++ const editor = access.isEditor(actor);
++ return {
++ entity: entityView(e),
++ aliases: q.aliasesOf.all(e.id).map((a) => ({ id: a.id, type: a.type, value: a.value })),
++ merged_from: [...members].filter((id) => id !== e.id).map((id) => entityView(q.entity.get(id))),
++ links: [...q.linksFrom.all(e.id), ...q.linksTo.all(e.id)].map((l) => ({
++ id: l.id, type: l.type, from: l.from_entity, to: l.to_entity, ref: parse(l.ref, null), note: l.note,
++ other: l.to_entity ? entityView(q.entity.get(l.from_entity === e.id ? l.to_entity : l.from_entity)) : null,
++ direction: l.from_entity === e.id ? 'out' : 'in', created_at: toIso(l.created_at),
++ })),
++ aggregate: agg && agg.result ? agg : null,
++ aggregate_revision: agg ? agg.revision : null,
++ signals: active.map(signalView),
++ inactive_signals: signals.filter((s) => s.status !== 'active').map(signalView),
++ sources: sourceKeys.map((k) => { const r = q.source.get(k); return { key: k, name: r && r.name, homepage_url: r && r.homepage_url, license_note: r && r.license_note, terms_note: r && r.terms_note, status: r && r.status, stale: r ? r.stale === 1 : null, last_success_at: r && r.last_success_at, trust: trustMap('source', k) }; }),
++ entity_trust: trustMap('entity', e.id),
++ summary: summary ? {
++ id: summary.id, state: summary.state, head_revision: revisions.headNumber(summary.id), flagged: !!summary.flagged, flag_reason: summary.flag_reason, flagged_at: toIso(summary.flagged_at),
++ published_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),
++ published: pub ? summaryRevisionView(summary, pub, e.id) : null,
++ pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,
++ } : null,
++ open_corrections: q.openCorrectionsOf.get(e.id).n,
++ decision: decide(e),
++ editor,
++ };
++ },
++
++ history(e, actor) {
++ const editor = access.isEditor(actor);
++ const summary = q.summary.get(e.id);
++ const revs = summary ? revisions.list(summary.id, { limit: 200 }) : [];
++ return {
++ entity: entityView(e),
++ aggregates: q.aggregates.all(e.id, 200).map(aggregateView),
++ summary_revisions: summary ? revs.filter((r) => editor || (summary.published_revision && r.number <= summary.published_revision && revisionStatus(summary, r) !== 'rejected'))
++ .map((r) => summaryRevisionView(summary, r, e.id)) : [],
++ merges: q.mergeLinksOf.all(e.id, e.id).map((l) => ({
++ id: l.id, from: entityView(q.entity.get(l.from_entity)), to: entityView(q.entity.get(l.to_entity)), note: l.note,
++ merged_at: toIso(l.created_at), merged_by: editor ? l.created_by : null, split_at: toIso(l.ended_at), split_by: editor ? l.ended_by : null, split_note: l.end_note,
++ })),
++ signals: allSignalsIn(closure(e.id)).map(signalView),
++ audit: q.auditOf.all(e.id, e.id).map((r) => ({
++ id: r.id, at: toIso(r.at), action: r.action, target: r.target,
++ actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: parse(r.detail, {}),
++ })),
++ };
++ },
++
++ summaryRevision(e, n, actor) {
++ const summary = q.summary.get(e.id);
++ if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
++ const rev = revisions.get(summary.id, Number(n));
++ if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
++ const visible = access.isEditor(actor) || (summary.published_revision && rev.number <= summary.published_revision && revisionStatus(summary, rev) !== 'rejected');
++ if (!visible) fail(404, 'revision.not_found', `No revision ${n}`);
++ return summaryRevisionView(summary, rev, e.id);
++ },
++
++ // Resolve (reviews.entity.resolve) ---------------------------------------------------
++ /** { name?, url?, sku?, gtin?, mpn?, source?, external? } → { match, entity, via, rule, candidates } */
++ resolve(input = {}) {
++ const strong = [];
++ for (const t of ['source', 'external', 'url', 'gtin', 'sku', 'mpn']) if (input[t]) strong.push({ type: t, value: String(input[t]) });
++ const names = input.name ? [String(input.name)] : [];
++ if (!strong.length && !names.length) fail(422, 'resolve.empty', 'Give at least one of name, url, gtin, sku, mpn, source, external');
++ const r = resolveIdentifiers({ strong, names });
++ const canon = r.entity_id ? canonicalOf(r.entity_id) : null;
++ return {
++ match: r.resolution === 'resolved' ? 'exact' : r.resolution === 'ambiguous' ? 'ambiguous' : 'none',
++ entity: canon ? entityView(q.entity.get(canon)) : null,
++ via: r.entity_id && r.entity_id !== canon ? entityView(q.entity.get(r.entity_id)) : null,
++ rule: r.rule,
++ candidates: r.candidates.map((c) => ({ rule: c.rule, entity: entityView(q.entity.get(c.entity_id)) })),
++ };
++ },
++
++ // Entities (editors) -----------------------------------------------------------------
++ createEntity({ name, kind = 'other', description = null, slug = null, aliases = [] } = {}, actor) {
++ requireEditor(actor, 'Creating an entity');
++ const clean = checkName(name);
++ if (!ENTITY_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${ENTITY_KINDS.join(', ')}`);
++ if (!Array.isArray(aliases)) fail(422, 'alias.invalid', 'aliases is a list of { type, value }');
++ return tx(() => {
++ const t = now();
++ const id = `ent_${ulid(t)}`;
++ const s = slug ? norm.slug(slug) : null;
++ if (slug && !s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
++ if (s && (q.entityBySlug.get(s))) fail(409, 'entity.slug_taken', `/e/${s} is taken`);
++ const finalSlug = s || uniqueSlug(clean);
++ redirects.release(`/e/${finalSlug}`);
++ q.insertEntity.run({ id, slug: finalSlug, name: clean, kind, description: description ? String(description).trim().slice(0, 2000) || null : null, created_by: actorId(actor), now: t });
++ insertAlias(id, 'name', clean, actor);
++ for (const a of aliases) insertAlias(id, a && a.type, a && a.value, actor);
++ audit(actor, 'entity.created', id, null, { name: clean, kind, aliases });
++ const settled = reresolvePending(actor);
++ syncEntity(id);
++ return { entity: q.entity.get(id), settled_items: settled };
++ });
++ },
++
++ updateEntity(ref, { name, kind, description, slug, noindex } = {}, actor) {
++ requireEditorPerson(actor, 'Editing an entity');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ const next = { ...e };
++ const changes = {};
++ if (name != null && name !== e.name) { next.name = checkName(name); changes.name = [e.name, next.name]; insertAlias(e.id, 'name', next.name, actor); }
++ if (kind != null && kind !== e.kind) { if (!ENTITY_KINDS.includes(kind)) fail(422, 'entity.invalid_kind', `kind is one of ${ENTITY_KINDS.join(', ')}`); next.kind = kind; changes.kind = [e.kind, kind]; }
++ if (description !== undefined && (description || null) !== e.description) { next.description = description ? String(description).trim().slice(0, 2000) || null : null; changes.description = true; }
++ if (noindex != null && !!noindex !== !!e.noindex) { next.noindex = noindex ? 1 : 0; changes.noindex = !!noindex; }
++ if (slug != null && slug !== e.slug) {
++ const s = norm.slug(slug);
++ if (!s) fail(422, 'entity.invalid_slug', 'That slug has no letters or digits');
++ const other = q.entityBySlug.get(s);
++ if (other && other.id !== e.id) fail(409, 'entity.slug_taken', `/e/${s} is taken`);
++ redirects.recordMove(e.id, `/e/${e.slug}`, `/e/${s}`, { reason: 'renamed' });
++ next.slug = s; changes.slug = [e.slug, s];
++ }
++ db.prepare('UPDATE review_entities SET name = ?, kind = ?, description = ?, slug = ?, noindex = ?, updated_at = ? WHERE id = ?')
++ .run(next.name, next.kind, next.description, next.slug, next.noindex, now(), e.id);
++ if (Object.keys(changes).length) audit(actor, 'entity.updated', e.id, null, changes);
++ syncEntity(e.id);
++ return q.entity.get(e.id);
++ });
++ },
++
++ deleteEntity(ref, { note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Deleting an entity');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ if (q.mergedInto.all(e.id).length) fail(409, 'entity.has_merges', 'Split the entities merged into this one first');
++ const active = q.activeSignalsOfEntity.all(e.id);
++ if (active.length) fail(409, 'entity.has_signals', 'This entity has live signals: reattribute or ignore their items first');
++ db.prepare("UPDATE review_entities SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now(), now(), e.id);
++ audit(actor, 'entity.deleted', e.id, null, { note });
++ syncEntity(e.id);
++ return q.entity.get(e.id);
++ });
++ },
++
++ addAlias(ref, { type, value } = {}, actor) {
++ requireEditorPerson(actor, 'Adding an alias');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state === 'deleted') fail(409, 'entity.not_active', 'This entity was deleted');
++ const a = insertAlias(e.id, type, value, actor);
++ audit(actor, 'alias.added', e.id, a.id, { type: a.type, value: a.value });
++ const settled = reresolvePending(actor);
++ return { alias: a, settled_items: settled };
++ });
++ },
++
++ removeAlias(ref, aliasId, actor) {
++ requireEditorPerson(actor, 'Removing an alias');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ const a = q.alias.get(String(aliasId));
++ if (!a || a.entity_id !== e.id || a.removed_at) fail(404, 'alias.not_found', 'No such alias');
++ q.removeAlias.run(now(), actorId(actor), a.id);
++ audit(actor, 'alias.removed', e.id, a.id, { type: a.type, value: a.value });
++ return q.alias.get(a.id);
++ });
++ },
++
++ addLink(ref, { type, to = null, ref: entityRef = null, note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Linking entities');
++ if (!LINK_TYPES.includes(type) || type === 'merged_into') fail(422, 'link.invalid', `type is one of ${LINK_TYPES.filter((t) => t !== 'merged_into').join(', ')} (merges go through merge)`);
++ return tx(() => {
++ const e = entityOrFail(ref);
++ let target = null;
++ let refJson = null;
++ if (type === 'external_ref') {
++ const { entityRef: mk } = require('openvibe-publishing/discussion');
++ try { refJson = JSON.stringify(mk(entityRef || {})); } catch (err) { fail(422, 'link.invalid', err.message); }
++ } else {
++ target = entityOrFail(to);
++ if (target.id === e.id) fail(422, 'link.invalid', 'An entity cannot link to itself');
++ }
++ const t = now();
++ const id = `lnk_${ulid(t)}`;
++ q.insertLink.run({ id, from: e.id, to: target ? target.id : null, type, ref: refJson, note: note ? String(note).slice(0, 500) : null, by: actorId(actor), now: t });
++ audit(actor, 'link.added', e.id, id, { type, to: target ? target.id : null, ref: parse(refJson, null) });
++ return q.link.get(id);
++ });
++ },
++
++ endLink(ref, linkId, { note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Removing a link');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ const l = q.link.get(String(linkId));
++ if (!l || (l.from_entity !== e.id && l.to_entity !== e.id) || l.ended_at || l.type === 'merged_into') fail(404, 'link.not_found', 'No such link (merges are undone with split)');
++ q.endLink.run(now(), actorId(actor), note ? String(note).slice(0, 500) : null, l.id);
++ audit(actor, 'link.ended', e.id, l.id, { type: l.type });
++ return q.link.get(l.id);
++ });
++ },
++
++ // Merge and split (reviews.entity.merge | reviews.entity.split) ----------------------
++ /**
++ * Merge `ref` into `into`. Nothing is rewritten: the merged entity keeps its aliases and its
++ * signals keep their attribution; it becomes state `merged` with an active `merged_into`
++ * link, and the target's aggregate is recomputed over both. That is why a split restores
++ * the pre-merge attribution exactly.
++ */
++ merge(ref, { into, note = null } = {}, actor) {
++ const who = requireEditorPerson(actor, 'Merging entities');
++ return tx(() => {
++ const src = entityOrFail(ref);
++ const tgt = entityOrFail(into);
++ if (src.id === tgt.id) fail(422, 'merge.same_entity', 'An entity cannot be merged into itself');
++ if (src.state !== 'active') fail(409, 'merge.not_active', `"${src.name}" is ${src.state}`);
++ if (tgt.state !== 'active') fail(409, 'merge.target_not_active', `"${tgt.name}" is ${tgt.state}${tgt.state === 'merged' ? `; merge into ${canonicalOf(tgt.id)} instead` : ''}`, { canonical_id: canonicalOf(tgt.id) });
++ if (closure(src.id).has(tgt.id)) fail(409, 'merge.cycle', 'The target is merged into this entity');
++ const t = now();
++ const linkId = `lnk_${ulid(t)}`;
++ const moved = activeSignalsIn(closure(src.id)).map((s) => s.id);
++ q.insertLink.run({ id: linkId, from: src.id, to: tgt.id, type: 'merged_into', ref: null, note: note ? String(note).slice(0, 500) : null, by: who, now: t });
++ db.prepare("UPDATE review_entities SET state = 'merged', merged_into = ?, updated_at = ? WHERE id = ?").run(tgt.id, t, src.id);
++ audit(actor, 'entity.merged', src.id, tgt.id, {
++ link_id: linkId, into: tgt.id, note, signals: moved,
++ aliases: q.aliasesOf.all(src.id).map((a) => a.id),
++ merged_members: [...closure(src.id)].filter((id) => id !== src.id),
++ });
++ refreshAggregate(tgt.id, 'merge');
++ touchEntity(tgt.id);
++ syncEntity(src.id);
++ syncEntity(tgt.id);
++ entityEvent('reviews.entity.merged', src, actor, {
++ entity_id: src.id, into_entity_id: tgt.id, link_id: linkId, signal_count: moved.length,
++ url: entityUrl(src), into_url: entityUrl(tgt), note: note ? String(note).slice(0, 500) : null,
++ });
++ return { link: q.link.get(linkId), entity: q.entity.get(src.id), into: q.entity.get(tgt.id), signals: moved };
++ });
++ },
++
++ split(ref, { note = null } = {}, actor) {
++ const who = requireEditorPerson(actor, 'Splitting entities');
++ return tx(() => {
++ const src = entityOrFail(ref);
++ if (src.state !== 'merged') fail(409, 'split.not_merged', `"${src.name}" is not merged into another entity`);
++ const link = q.activeMerge.get(src.id);
++ if (!link) fail(409, 'split.no_merge_link', 'No active merge link for this entity');
++ const t = now();
++ q.endLink.run(t, who, note ? String(note).slice(0, 500) : null, link.id);
++ db.prepare("UPDATE review_entities SET state = 'active', merged_into = NULL, updated_at = ? WHERE id = ?").run(t, src.id);
++ const restored = activeSignalsIn(closure(src.id)).map((s) => s.id);
++ audit(actor, 'entity.split', src.id, link.to_entity, { link_id: link.id, from: link.to_entity, note, signals: restored });
++ refreshAggregate(src.id, 'split');
++ refreshAggregate(link.to_entity, 'split');
++ // The former target's summary may cite signals that went back with the split.
++ const tgtCanon = canonicalOf(link.to_entity);
++ const tgtSummary = tgtCanon && q.summary.get(tgtCanon);
++ if (tgtSummary) flagSummary(tgtSummary.id, 'Entities were split: a cited signal now belongs to another entity', restored);
++ touchEntity(src.id);
++ if (tgtCanon) touchEntity(tgtCanon);
++ syncEntity(src.id);
++ if (tgtCanon) syncEntity(tgtCanon);
++ const from = q.entity.get(link.to_entity);
++ entityEvent('reviews.entity.split', src, actor, {
++ entity_id: src.id, from_entity_id: link.to_entity, link_id: link.id, signal_count: restored.length,
++ url: entityUrl(q.entity.get(src.id)), from_url: from ? entityUrl(from) : null, note: note ? String(note).slice(0, 500) : null,
++ });
++ return { link: q.link.get(link.id), entity: q.entity.get(src.id), from: from, signals: restored };
++ });
++ },
++
++ // Sources items and signals (reviews.signal.import) ----------------------------------
++ applyItem,
++ /** Run fn in one transaction; aggregates of touched entities are recomputed once at the end. */
++ batch,
++ /** Fetch one item from Sources and apply it. */
++ async importItem(itemId, sources, actor) {
++ const { item, source } = await sources.getItem(String(itemId || ''));
++ let info = source || null;
++ if (!q.source.get(item.source_key)) {
++ try { const full = await sources.getSource(item.source_key); if (full) info = { ...full, ...(source || {}) }; } catch { /* the notes come with the item */ }
++ }
++ return applyItem(item, { sourceInfo: info, actor });
++ },
++
++ /** A source item removal we heard about (sources.item.removed event). */
++ removeItem(itemId, reason, actor) {
++ const row = q.item.get(String(itemId));
++ if (!row) return { outcome: 'ignored:unknown_item' };
++ if (row.state === 'removed') return { outcome: 'unchanged' };
++ return tx(() => {
++ q.removeItem.run(new Date(now()).toISOString(), String(reason || 'removed by its source').slice(0, 500), now(), row.id);
++ reconcileItemSignal(q.item.get(row.id), actor);
++ return { outcome: 'removed', item: q.item.get(row.id) };
++ });
++ },
++
++ item(itemId) { return q.item.get(String(itemId)) || null; },
++ itemView(row) {
++ if (!row) return null;
++ const fields = parse(row.fields, {});
++ return {
++ id: row.id, source_key: row.source_key, kind: row.kind, identity: row.identity, canonical_url: row.canonical_url, title: row.title,
++ item_revision: row.item_revision, published_at: row.published_at, retrieved_at: row.retrieved_at, license_note: row.license_note, terms_note: row.terms_note,
++ fields, state: row.state, removed_at: row.removed_at, removed_reason: row.removed_reason,
++ resolution: row.resolution, entity_id: row.entity_id, resolution_rule: row.resolution_rule,
++ candidates: parse(row.candidates, []).map((c) => ({ ...c, entity: q.entity.get(c.entity_id) ? entityView(q.entity.get(c.entity_id)) : null })),
++ signal_note: row.signal_note, signal: (() => { const s = q.activeSignalOfItem.get(row.id); return s ? signalPayload(s) : null; })(),
++ };
++ },
++ resolutionQueue(limit = 200) { return q.queueItems.all(Math.min(500, limit)); },
++
++ /** An editor settles an item: attach it to an entity (optionally remember the identifier). */
++ confirmResolution(itemId, { entity: entityRef, add_alias: addAlias = null } = {}, actor) {
++ const who = requireEditorPerson(actor, 'Confirming a resolution');
++ return tx(() => {
++ const row = q.item.get(String(itemId));
++ if (!row) fail(404, 'item.not_found', 'Reviews has not read that item');
++ if (row.state !== 'active') fail(409, 'item.removed', 'Its source removed this item');
++ const e = entityOrFail(entityRef);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `"${e.name}" is ${e.state}`);
++ const before = { resolution: row.resolution, entity_id: row.entity_id };
++ if (addAlias) {
++ const ids = itemIdentifiers(row);
++ const pick = addAlias === 'name' ? (ids.names[0] ? { type: 'name', value: ids.names[0] } : null) : ids.strong.find((x) => x.type === addAlias);
++ if (!pick) fail(422, 'alias.invalid', `The item has no ${addAlias} to remember`);
++ insertAlias(e.id, pick.type, pick.value, actor);
++ }
++ const fresh = setItemResolution(row, { resolution: 'resolved', entity_id: e.id, rule: 'editor', candidates: [] }, who);
++ const out = reconcileItemSignal(fresh, actor);
++ audit(actor, 'item.resolved', e.id, row.id, { before, rule: 'editor', add_alias: addAlias || null, signal: out.signal ? out.signal.id : null });
++ const settled = addAlias ? reresolvePending(actor) : 0;
++ return { item: q.item.get(row.id), signal: out.signal || null, settled_items: settled };
++ });
++ },
++
++ ignoreItem(itemId, { note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Ignoring an item');
++ return tx(() => {
++ const row = q.item.get(String(itemId));
++ if (!row) fail(404, 'item.not_found', 'Reviews has not read that item');
++ const fresh = setItemResolution(row, { resolution: 'ignored', entity_id: null, rule: 'editor', candidates: [] }, actorId(actor));
++ reconcileItemSignal(fresh, actor);
++ audit(actor, 'item.ignored', row.entity_id, row.id, { note });
++ return q.item.get(row.id);
++ });
++ },
++
++ // Trust metadata --------------------------------------------------------------------
++ setTrust({ scope, scope_id: scopeId, key, value, note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Trust decisions');
++ if (!['source', 'signal', 'entity'].includes(scope)) fail(422, 'trust.invalid', 'scope is source, signal or entity');
++ if (!TRUST_KEYS.includes(key)) fail(422, 'trust.invalid', `key is one of ${TRUST_KEYS.join(', ')}`);
++ const v = value == null ? '' : String(value).trim().slice(0, 1000);
++ if (key === 'aggregate' && !['include', 'exclude'].includes(v)) fail(422, 'trust.invalid', 'aggregate is include or exclude');
++ if (key === 'aggregate' && v === 'exclude' && !String(note || '').trim()) fail(422, 'trust.reason_required', 'Excluding from the aggregate needs a reason readers can see');
++ if (key === 'aggregate' && scope === 'entity') fail(422, 'trust.invalid', 'Exclude a source or a signal, not a whole entity');
++ return tx(() => {
++ let entityIds = [];
++ if (scope === 'source') { if (!q.source.get(String(scopeId))) fail(404, 'source.not_found', 'Reviews has not seen that source'); entityIds = q.entitiesWithSourceSignals.all(String(scopeId)).map((r) => r.entity_id); }
++ if (scope === 'signal') { const s = q.signal.get(String(scopeId)); if (!s) fail(404, 'signal.not_found', 'No such signal'); entityIds = [s.entity_id]; }
++ if (scope === 'entity') { const e = entityOrFail(scopeId); scopeId = e.id; entityIds = [e.id]; }
++ const cur = q.trustOne.get(scope, String(scopeId), key);
++ const who = actorId(actor);
++ if (cur) q.trustEnd.run(now(), who, cur.id);
++ if (v && !(key === 'aggregate' && v === 'include')) q.trustInsert.run(scope, String(scopeId), key, v, note ? String(note).slice(0, 1000) : null, who, now());
++ audit(actor, 'trust.set', scope === 'entity' ? scopeId : (entityIds.length === 1 ? canonicalOf(entityIds[0]) : null), `${scope}:${scopeId}`, { key, value: v, note, previous: cur ? { value: cur.value, note: cur.note } : null });
++ const canon = [...new Set(entityIds.map(canonicalOf).filter(Boolean))];
++ for (const id of canon) { refreshAggregate(id, 'trust'); touchEntity(id); syncEntity(id); }
++ return { scope, scope_id: scopeId, key, current: q.trustOne.get(scope, String(scopeId), key) || null, affected_entities: canon };
++ });
++ },
++ trustHistory(scope, id) { return q.trustHistory.all(scope, id); },
++ trustMap,
++
++ // Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
++ /** An editor writes a summary revision; publish: true publishes it at once. */
++ writeSummary(ref, input = {}, actor) {
++ const who = requireEditorPerson(actor, 'Writing a summary');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ const body = checkSummaryInput(e.id, input);
++ const summary = ensureSummary(e.id);
++ const head = revisions.headNumber(summary.id);
++ const expected = input.expected_revision != null ? Number(input.expected_revision) : input.expectedRevision != null ? Number(input.expectedRevision) : head;
++ let revision;
++ try {
++ ({ revision } = revisions.create({
++ entityId: summary.id, expectedRevision: expected, content: body.content, fields: body.fields,
++ meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
++ message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
++ }));
++ } catch (err) {
++ if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
++ throw err;
++ }
++ writeCitations(summary, revision);
++ audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
++ let published = null;
++ if (input.publish === true || input.publish === 'true' || input.publish === '1') published = svc.publishSummary(e.id, { revision: revision.number }, actor);
++ return { summary: q.summary.get(e.id), revision: summaryRevisionView(q.summary.get(e.id), revision, e.id), published: !!published };
++ });
++ },
++
++ /**
++ * The OpenVibe.AI seam (workflow reviews.summarize_entity): a draft revision with ai
++ * authorship (workflow + run id). Never published by this call, noindex until a person
++ * approves it, and it can never state a rating: its text is checked, and summaries have no
++ * field that could carry one.
++ */
++ proposeSummary(ref, input = {}, actor) {
++ if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'summary.service_only', 'AI proposals come from a service principal');
++ const wf = input.workflow || {};
++ const runId = wf.run_id || wf.runId;
++ if (!wf.id || !runId) fail(400, 'authorship.workflow_required', `An AI proposal names its OpenVibe.AI workflow (workflow.id, e.g. ${SUMMARY_WORKFLOW}) and run (workflow.run_id)`);
++ let rec;
++ try { rec = authorship.record({ mode: 'ai', workflow: { id: String(wf.id), runId: String(runId), version: wf.version, model: wf.model }, stubProvider: !!(input.stub_provider || input.stubProvider) }); } catch (err) { fail(422, 'authorship.invalid', err.message); }
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ const body = checkSummaryInput(e.id, input, { ai: true });
++ const summary = ensureSummary(e.id);
++ const head = revisions.headNumber(summary.id);
++ const { revision } = revisions.create({
++ entityId: summary.id, expectedRevision: head, content: body.content, fields: body.fields,
++ meta: { authorship: rec }, author: actorId(actor), message: input.note ? `AI proposal: ${String(input.note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
++ });
++ writeCitations(summary, revision);
++ audit(actor, 'summary.proposed', e.id, summary.id, { revision: revision.number, workflow: rec.workflow, stub_provider: !!rec.stubProvider });
++ return { summary: q.summary.get(e.id), revision: summaryRevisionView(q.summary.get(e.id), revision, e.id) };
++ });
++ },
++
++ /** An editor approves (and by default publishes) or rejects a revision. */
++ reviewSummary(ref, n, { decision, note = null, publish = true } = {}, actor) {
++ const who = requireEditorPerson(actor, 'Reviewing a summary');
++ if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ const summary = q.summary.get(e.id);
++ if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
++ const rev = revisions.get(summary.id, Number(n));
++ if (!rev) fail(404, 'revision.not_found', `No revision ${n}`);
++ const review = reviews.record({ entityId: summary.id, revision: rev.number, reviewer: who, decision, note });
++ audit(actor, `summary.${decision}`, e.id, summary.id, { revision: rev.number, note });
++ let published = false;
++ if (decision === 'approved' && publish !== false && publish !== 'false') { svc.publishSummary(e.id, { revision: rev.number }, actor); published = true; }
++ if (decision === 'approved' && !published && summary.state === 'published' && summary.published_revision === rev.number) syncEntity(e.id);
++ return { review, published, summary: q.summary.get(e.id) };
++ });
++ },
++
++ publishSummary(ref, { revision } = {}, actor) {
++ requireEditorPerson(actor, 'Publishing a summary');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
++ const summary = q.summary.get(e.id);
++ if (!summary) fail(404, 'summary.not_found', 'This entity has no summary');
++ const n = revision == null ? revisions.headNumber(summary.id) : Number(revision);
++ const rev = revisions.get(summary.id, n);
++ if (!rev) fail(404, 'revision.not_found', `No revision ${revision}`);
++ const review = reviews.latest(summary.id, n);
++ if (review && review.decision === 'rejected') fail(409, 'summary.rejected', 'This revision was rejected by an editor');
++ const rec = rev.meta && rev.meta.authorship;
++ if (rec) { const ok = authorship.canPublish(rec, review); if (!ok.ok) fail(409, ok.reason, 'AI-drafted summaries are published only after a person approves them'); }
++ if (rev.meta && rev.meta.system && !(review && review.decision === 'approved')) fail(409, 'summary.review_required', 'A revision prepared after a source change is published only after an editor approves it');
++ const bad = citationState(e.id, rev).filter((p) => !p.supported);
++ if (bad.length) fail(409, 'summary.unsupported_points', `${bad.length} point(s) cite no live signal of this entity`, { points: bad.map((p) => p.key) });
++ const wasPublished = summary.state === 'published';
++ const before = summary.published_revision;
++ q.publishSummary.run({ id: summary.id, n, now: now() });
++ audit(actor, 'summary.published', e.id, summary.id, { revision: n, previous: before });
++ touchEntity(e.id);
++ const doc = syncEntity(e.id);
++ const fresh = q.entity.get(e.id);
++ const decision = decide(fresh);
++ const action = !wasPublished ? 'published' : (before !== n ? 'updated' : null);
++ if (action) {
++ emit(hooks.publicationEvent({
++ product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
++ actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
++ document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },
++ extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null },
++ }));
++ }
++ return q.summary.get(e.id);
++ });
++ },
++
++ unpublishSummary(ref, actor) {
++ requireEditorPerson(actor, 'Unpublishing a summary');
++ return tx(() => {
++ const e = entityOrFail(ref);
++ const summary = q.summary.get(e.id);
++ if (!summary || summary.state !== 'published') fail(409, 'summary.not_published', 'Nothing is published');
++ q.unpublishSummary.run(now(), summary.id);
++ audit(actor, 'summary.unpublished', e.id, summary.id, { revision: summary.published_revision });
++ const doc = syncEntity(e.id);
++ const fresh = q.entity.get(e.id);
++ emit(hooks.publicationEvent({
++ product: 'reviews', type: 'summary', action: 'unpublished', id: summary.id, revision: summary.published_revision,
++ actor: hooks.subjectRef(actorId(actor)), decision: decide(fresh), now: now(),
++ document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: true },
++ extra: { entity_id: e.id, entity_slug: fresh.slug },
++ }));
++ return q.summary.get(e.id);
++ });
++ },
++
++ flaggedSummaries() { return q.flaggedSummaries.all().map((s) => ({ ...s, entity: q.entity.get(s.entity_id) })); },
++ pendingSummaries() {
++ return q.allSummaries.all().map((s) => ({ summary: s, entity: q.entity.get(s.entity_id), pending: pendingRevisions(s) }))
++ .filter((x) => x.pending.length && x.entity && x.entity.state === 'active')
++ .map((x) => ({ entity: x.entity, summary: x.summary, pending: x.pending.map((p) => ({ number: p.rev.number, status: p.status, message: p.rev.message, created_at: p.rev.createdAt })) }));
++ },
++
++ /** Published summaries for the feed, newest first, with the gate's decision for the entity. */
++ recentSummaries(limit = 50) {
++ return q.publishedSummaries.all(limit).map((s) => {
++ const e = q.entity.get(s.entity_id);
++ if (!e || e.state !== 'active') return null;
++ const rev = revisions.get(s.id, s.published_revision);
++ return { summary: s, entity: e, rev, decision: decide(e) };
++ }).filter(Boolean);
++ },
++
++ /** Every active entity with its decision (sitemaps). */
++ publicEntities() {
++ return db.prepare("SELECT * FROM review_entities WHERE state = 'active' ORDER BY id").all().map((e) => ({ entity: e, decision: decide(e) }));
++ },
++
++ /** Entities with at least one live signal or a published summary (the home page). */
++ entitiesWithData(limit = 60) {
++ return db.prepare(`SELECT e.* FROM review_entities e WHERE e.state = 'active' AND (
++ EXISTS (SELECT 1 FROM review_signals s JOIN review_entities m ON m.id = s.entity_id WHERE s.status = 'active' AND (m.id = e.id OR m.merged_into = e.id))
++ OR EXISTS (SELECT 1 FROM review_summaries u WHERE u.entity_id = e.id AND u.state = 'published'))
++ ORDER BY e.updated_at DESC LIMIT ?`).all(limit).map((e) => ({ entity: e, aggregate: aggregateView(q.lastAggregate.get(e.id)) }));
++ },
++
++ // Corrections (reviews.correction.submit) --------------------------------------------
++ submitCorrection(ref, { target_type: targetType = 'entity', target_id: targetId = null, body, evidence_url: evidenceUrl = null } = {}, actor) {
++ if (!access.isPerson(actor)) fail(403, 'reviews.person_required', 'Corrections come from a signed-in person (a service must name them in X-OV-Subject)');
++ if (!['entity', 'alias', 'signal', 'summary', 'aggregate'].includes(targetType)) fail(422, 'correction.invalid', 'target_type is entity, alias, signal, summary or aggregate');
++ const text = String(body == null ? '' : body).trim();
++ if (text.length < 10) fail(422, 'correction.invalid', 'Tell the editors what is wrong (at least 10 characters)');
++ if (text.length > 4000) fail(422, 'correction.invalid', 'At most 4000 characters');
++ let url = null;
++ if (evidenceUrl) {
++ try { const u = new URL(String(evidenceUrl)); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('x'); url = u.toString(); } catch { fail(422, 'correction.invalid', 'evidence_url must be an http(s) URL'); }
++ }
++ return tx(() => {
++ const e = entityOrFail(ref);
++ if (e.state === 'deleted') fail(410, 'entity.deleted', 'This entity was deleted');
++ const t = now();
++ const id = `cor_${ulid(t)}`;
++ q.insertCorrection.run({ id, entity_id: canonicalOf(e.id) || e.id, target_type: targetType, target_id: targetId ? String(targetId).slice(0, 100) : null, body: text, evidence_url: url, submitted_by: actor.subject, via: actor.kind === 'service' ? actor.service : null, now: t });
++ audit(actor, 'correction.submitted', canonicalOf(e.id) || e.id, id, { target_type: targetType, target_id: targetId });
++ return q.correction.get(id);
++ });
++ },
++ openCorrections() { return q.openCorrections.all().map((c) => ({ ...c, entity: q.entity.get(c.entity_id) })); },
++ correction(id) { return q.correction.get(String(id)) || null; },
++ resolveCorrection(id, { status, note = null } = {}, actor) {
++ requireEditorPerson(actor, 'Resolving a correction');
++ if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
++ return tx(() => {
++ const c = q.correction.get(String(id));
++ if (!c) fail(404, 'correction.not_found', 'No such correction');
++ if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
++ q.resolveCorrection.run(status, actorId(actor), note ? String(note).slice(0, 2000) : null, now(), c.id);
++ audit(actor, `correction.${status}`, c.entity_id, c.id, { note });
++ return q.correction.get(c.id);
++ });
++ },
++
++ // Discussion (Community, referenced) -------------------------------------------------
++ knownThread(entityId) { const d = discussions.get(entityId); return d ? d.threadId : null; },
++ async discussionThread(e, client) {
++ const known = discussions.get(e.id);
++ if (known) return known.threadId;
++ if (e.state !== 'active') return null;
++ const out = await discussions.threadFor(e.id, { service: 'reviews', type: 'entity', id: e.id, label: e.name.slice(0, 200) }, { client });
++ return out.threadId;
++ },
++
++ // Search reconciliation ---------------------------------------------------------------
++ reconcileIndex() {
++ return tx(() => {
++ let sent = 0;
++ for (const { id } of q.allEntityIds.all()) {
++ if (sequencer.current('reviews', 'entity', id) == null && q.entity.get(id).state !== 'active') continue;
++ const before = sequencer.current('reviews', 'entity', id);
++ const doc = syncEntity(id);
++ if (doc && doc.revision !== before) sent++;
++ }
++ return { sent };
++ });
++ },
++
++ stats() {
++ const one = (sql) => db.prepare(sql).get().n;
++ return {
++ entities: one("SELECT COUNT(*) AS n FROM review_entities WHERE state = 'active'"),
++ signals: one("SELECT COUNT(*) AS n FROM review_signals WHERE status = 'active'"),
++ items_unresolved: one("SELECT COUNT(*) AS n FROM review_source_items WHERE resolution IN ('ambiguous','unmatched') AND state = 'active'"),
++ corrections_open: one("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'"),
++ summaries_flagged: one('SELECT COUNT(*) AS n FROM review_summaries WHERE flagged = 1'),
++ };
++ },
++ };
++ return svc;
++}
++
++module.exports = { createReviewsService, ReviewsError, SUMMARY_WORKFLOW, RATING_TEXT_RE };
ddiff --git a/server/reviews/sync.js b/server/reviews/sync.js
nnew file mode 100644
iindex 0000000..fa384c1
---- /dev/null
++++ b/server/reviews/sync.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * How review items get from OpenVibe.Sources into Reviews. Two paths feed the same applyItem():
++ *
++ * pull GET /api/v1/items?category=reviews&include_removed=1&after=<change_seq> in change order
++ * (sources.item.read). The cursor is stored after each page, so a restart resumes where it
++ * stopped and a missed event is caught up on the next pass.
++ * push sources.item.created|updated|removed events from OpenVibe.Events (signed webhook +
++ * inbox, server/http/consumer.js). Created/updated items are queued here and fetched by
++ * id (event payloads carry no fields); a removal is applied at once.
++ *
++ * A failed fetch leaves everything as it was and is retried with backoff: nothing is invented to
++ * fill the gap, and a signal is never created from anything but an item Sources returned.
++ */
++const BACKOFF_MS = [15000, 60000, 5 * 60000, 30 * 60000, 2 * 3600000];
++const CURSOR = 'sources.items.after';
++
++function createSourcesSync({ db, svc, platform, config, now = () => Date.now(), log = console }) {
++ const q = {
++ get: db.prepare('SELECT value FROM review_sync_state WHERE name = ?'),
++ put: db.prepare('INSERT INTO review_sync_state (name, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'),
++ enqueue: db.prepare(`INSERT INTO review_import_queue (item_id, reason, enqueued_at, next_attempt_at) VALUES (?, ?, ?, 0)
++ ON CONFLICT (item_id) DO UPDATE SET reason = excluded.reason, next_attempt_at = 0`),
++ due: db.prepare('SELECT * FROM review_import_queue WHERE next_attempt_at <= ? ORDER BY enqueued_at LIMIT ?'),
++ done: db.prepare('DELETE FROM review_import_queue WHERE item_id = ?'),
++ failed: db.prepare('UPDATE review_import_queue SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE item_id = ?'),
++ pending: db.prepare('SELECT COUNT(*) AS n FROM review_import_queue'),
++ };
++ const system = { kind: 'system', service: 'svc:reviews' };
++ const state = (name) => { const r = q.get.get(name); return r ? r.value : null; };
++ const setState = (name, value) => q.put.run(name, value == null ? null : String(value), now());
++
++ let pulling = null;
++ let draining = null;
++
++ async function pullOnce({ maxPages = 25 } = {}) {
++ if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured' };
++ const counts = { pages: 0, applied: 0, unchanged: 0, skipped: 0 };
++ let after = Number(state(CURSOR) || 0);
++ const known = new Set();
++ const infos = new Map();
++ try {
++ for (let page = 0; page < maxPages; page++) {
++ const body = await platform.sources.listItems({ after, limit: config.sync.pageSize });
++ counts.pages++;
++ for (const key of new Set(body.items.map((i) => i.source_key))) {
++ if (known.has(key)) continue;
++ known.add(key);
++ if (db.prepare('SELECT 1 FROM review_sources WHERE key = ?').get(key)) continue;
++ try { const full = await platform.sources.getSource(key); if (full) infos.set(key, full); } catch { /* notes still come with the item */ }
++ }
++ svc.batch(() => {
++ for (const item of body.items) {
++ const info = { ...(infos.get(item.source_key) || {}), ...((body.sources && body.sources[item.source_key]) || {}) };
++ try {
++ const out = svc.applyItem(item, { sourceInfo: info, actor: system });
++ if (out.outcome === 'unchanged') counts.unchanged++; else counts.applied++;
++ } catch (err) {
++ if (err && err.status && err.status < 500) { counts.skipped++; log.warn(`[Reviews] item ${item && item.id} skipped: ${err.message}`); } else throw err;
++ }
++ }
++ });
++ if (Number.isInteger(body.next_after) && body.next_after >= after) { after = body.next_after; setState(CURSOR, after); }
++ if (!body.more) break;
++ }
++ setState('sources.last_pull_ok_at', new Date(now()).toISOString());
++ return { ok: true, after, ...counts };
++ } catch (err) {
++ setState('sources.last_pull_error', `${new Date(now()).toISOString()} ${String(err && err.message).slice(0, 300)}`);
++ return { ok: false, reason: err && err.message, after, ...counts };
++ }
++ }
++
++ async function drainQueue({ limit = 50 } = {}) {
++ if (!platform.sources.configured) return { ok: false, reason: 'OpenVibe.Sources is not configured', done: 0 };
++ let done = 0;
++ let failed = 0;
++ for (const row of q.due.all(now(), limit)) {
++ try {
++ await svc.importItem(row.item_id, platform.sources, system);
++ q.done.run(row.item_id);
++ done++;
++ } catch (err) {
++ // A 404/422 is final (the item is gone or unusable); anything else is retried.
++ if (err && (err.status === 404 || err.status === 422)) { q.done.run(row.item_id); log.warn(`[Reviews] queued item ${row.item_id} dropped: ${err.message}`); continue; }
++ q.failed.run(now() + BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)], String(err && err.message).slice(0, 500), row.item_id);
++ failed++;
++ }
++ }
++ return { ok: true, done, failed };
++ }
++
++ return {
++ enqueue(itemId, reason) { q.enqueue.run(String(itemId), String(reason || 'event').slice(0, 60), now()); },
++ pending: () => q.pending.get().n,
++ cursor: () => Number(state(CURSOR) || 0),
++ state,
++ pull(opts) { if (!pulling) pulling = pullOnce(opts).finally(() => { pulling = null; }); return pulling; },
++ drain(opts) { if (!draining) draining = drainQueue(opts).finally(() => { draining = null; }); return draining; },
++ };
++}
++
++module.exports = { createSourcesSync };
ddiff --git a/test/aggregate.test.js b/test/aggregate.test.js
nnew file mode 100644
iindex 0000000..1960bb7
---- /dev/null
++++ b/test/aggregate.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The aggregate as a pure function: absent (null) without qualifying signals, every input and
++ * exclusion exposed, deterministic, and never a default.
++ */
++const assert = require('assert');
++const { computeAggregate, ratingForStructuredData, hashOf } = require('../server/reviews/aggregate');
++const { extractSignal } = require('../server/reviews/extract');
++const { suite } = require('./helpers');
++
++const t = suite('aggregate');
++let n = 0;
++const sig = (o) => ({ id: `sig_${String(++n).padStart(26, '0')}`, status: 'active', source_key: 'src-a', source_item_id: `itm_${n}`, entity_id: 'ent_1', observed_at: `2026-09-20T10:00:${String(n % 60).padStart(2, '0')}.000Z`, ...o });
++
++t('no signals → null, not zero', () => {
++ assert.strictEqual(computeAggregate([]), null);
++ assert.strictEqual(ratingForStructuredData(null), null);
++ assert.strictEqual(ratingForStructuredData(computeAggregate([])), null);
++});
++
++t('only inactive or excluded signals → null', () => {
++ const a = sig({ type: 'recommendation', recommended: 1, status: 'withdrawn' });
++ const b = sig({ type: 'recommendation', recommended: 1 });
++ assert.strictEqual(computeAggregate([a]), null);
++ assert.strictEqual(computeAggregate([b], { exclusions: { sources: new Map([['src-a', 'bought reviews']]), signals: new Map() } }), null);
++});
++
++t('a rating without a stated scale is left out with a reason; alone it yields no aggregate', () => {
++ const r = sig({ type: 'rating_aggregate', rating_value: 4.2, rating_best: null, rating_count: 50 });
++ assert.strictEqual(computeAggregate([r]), null);
++ const ok = sig({ type: 'recommendation', recommended: 0 });
++ const out = computeAggregate([r, ok]);
++ assert.deepStrictEqual(out.excluded.map((x) => [x.signal_id, x.reason]), [[r.id, 'scale_not_stated']]);
++ assert.ok(!out.components.rating);
++});
++
++t('recommendation share from single recommendations; inputs and computation exposed', () => {
++ const list = [1, 1, 1, 0].map((v) => sig({ type: 'recommendation', recommended: v }));
++ const out = computeAggregate(list);
++ assert.deepStrictEqual({ positive: out.components.recommendation.positive, total: out.components.recommendation.total, percent: out.components.recommendation.percent }, { positive: 3, total: 4, percent: 75 });
++ assert.strictEqual(out.inputs.length, 4);
++ assert.ok(out.inputs.every((i) => i.signal_id && i.source_item_id && i.observed_at && i.source_key));
++ assert.ok(out.computation[0].includes('3 of 4'));
++ assert.deepStrictEqual(ratingForStructuredData(out), { value: 75, best: 100, worst: 0, count: 4 });
++});
++
++t('a source tally covers that source\'s single recommendations (no double counting)', () => {
++ const singles = [1, 0].map((v) => sig({ type: 'recommendation', recommended: v, source_key: 'steam' }));
++ const tally = sig({ type: 'recommendation_tally', positive_count: 900, total_count: 1000, source_key: 'steam' });
++ const other = sig({ type: 'recommendation', recommended: 1, source_key: 'other' });
++ const out = computeAggregate([...singles, tally, other]);
++ assert.strictEqual(out.components.recommendation.positive, 901);
++ assert.strictEqual(out.components.recommendation.total, 1001);
++ assert.deepStrictEqual(out.excluded.map((x) => x.reason).sort(), ['covered_by_source_tally', 'covered_by_source_tally']);
++});
++
++t('ratings: count-weighted, on the shared scale when there is one, percentage otherwise', () => {
++ const a = sig({ type: 'rating_aggregate', rating_value: 4, rating_best: 5, rating_count: 100 });
++ const b = sig({ type: 'rating', rating_value: 5, rating_best: 5, rating_count: 1 });
++ const out = computeAggregate([a, b]);
++ assert.strictEqual(out.components.rating.count, 101);
++ assert.strictEqual(out.components.rating.on_scale.best, 5);
++ assert.strictEqual(out.components.rating.on_scale.value, Math.round(((4 * 100 + 5) / 101) * 100) / 100);
++ assert.deepStrictEqual(ratingForStructuredData(out), { value: out.components.rating.on_scale.value, best: 5, worst: undefined, count: 101 });
++ const c = sig({ type: 'rating_aggregate', rating_value: 8, rating_best: 10, rating_count: 99 });
++ const mixed = computeAggregate([a, c]);
++ assert.strictEqual(mixed.components.rating.on_scale, undefined);
++ assert.strictEqual(mixed.components.rating.percent, 80);
++ assert.deepStrictEqual(ratingForStructuredData(mixed), { value: 80, best: 100, worst: 0, count: 199 });
++});
++
++t('deterministic: same signals in any order → same result and hash', () => {
++ const list = [sig({ type: 'recommendation', recommended: 1 }), sig({ type: 'rating', rating_value: 3, rating_best: 5, rating_count: 1 }), sig({ type: 'recommendation', recommended: 0 })];
++ const a = computeAggregate(list);
++ const b = computeAggregate([...list].reverse());
++ assert.deepStrictEqual(a, b);
++ assert.strictEqual(hashOf(a), hashOf(b));
++ assert.notStrictEqual(hashOf(a), hashOf(null));
++});
++
++t('extraction states only what the source stated', () => {
++ assert.deepStrictEqual(extractSignal({ kind: 'review_signal', fields: { voted_up: true, steam_purchase: true } }).signal, { type: 'recommendation', recommended: 1, trust: { steam_purchase: true } });
++ assert.strictEqual(extractSignal({ kind: 'review_signal', fields: {} }).signal, null);
++ assert.strictEqual(extractSignal({ kind: 'product', fields: { aggregate_rating: { value: 4.5, best: 5 } } }).signal, null, 'no count → no signal');
++ assert.strictEqual(extractSignal({ kind: 'product', fields: { aggregate_rating: { value: 6, best: 5, count: 3 } } }).signal, null, 'outside the scale');
++ assert.strictEqual(extractSignal({ kind: 'product', fields: { aggregate_rating: { value: 4.5, count: 3 } } }).signal.rating_best, null, 'unstated scale stays null');
++ assert.strictEqual(extractSignal({ kind: 'article', fields: { rating: 5 } }).signal, null);
++});
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..4ca0ea0
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,242 @@
++'use strict';
++/**
++ * Test helpers: a Reviews instance on a temp database and a random port, a generated Network RSA
++ * key, signed user JWTs and service tokens, and stub upstreams — an in-memory OpenVibe.Sources
++ * speaking sources.item@1 and a Community stub. Nothing here needs the network.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { serviceAuth, ids } = require('openvibe-contracts');
++const { signDelivery } = require('openvibe-sdk/events');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++
++const ISSUER = 'https://network.test';
++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++
++const ALPHA = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++function subject(prefix = 'usr') { let s = ''; for (let i = 0; i < 26; i++) s += ALPHA[crypto.randomInt(32)]; return `${prefix}_${s}`; }
++
++const EDITOR = subject();
++const READER = subject();
++const WEBHOOK_SECRET = 'test-webhook-secret-not-a-real-one';
++
++function userToken({ subject: sub = READER, username = 'someone', role = 'user', aud = ['openvibe.network'], exp = 3600 } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: ISSUER, aud, sub: 42, id: 42, subject_id: sub, username, display_name: username, role, iat: now, exp: now + exp }, privateKey);
++}
++const editorToken = () => userToken({ subject: EDITOR, username: 'editor' });
++const readerToken = () => userToken({ subject: READER, username: 'reader' });
++
++function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.reviews', exp = 300 } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${client}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privateKey);
++}
++
++const quiet = { log() {}, warn() {}, error() {} };
++
++/** In-memory OpenVibe.Sources: items in change order, sources, removal. */
++function fakeSources() {
++ const items = new Map();
++ const sources = new Map();
++ let seq = 0;
++ let down = false;
++ const api = {
++ items, sources,
++ setDown(v) { down = v; },
++ addSource(s) { sources.set(s.key, { status: 'healthy', stale: false, last_success_at: '2026-09-20T10:00:00.000Z', ...s }); },
++ put(item) { item.change_seq = ++seq; items.set(item.id, item); return item; },
++ /** A new revision of an item (content changed at the source). */
++ revise(id, fields, retrievedAt) {
++ const cur = items.get(id);
++ const next = { ...cur, fields: { ...cur.fields, ...fields }, revision: cur.revision + 1 };
++ next.provenance = { ...cur.provenance, retrieved_at: retrievedAt || new Date(Date.parse(cur.provenance.retrieved_at) + 3600000).toISOString() };
++ next.provenance.content_hash = crypto.createHash('sha256').update(JSON.stringify(next.fields)).digest('hex');
++ return api.put(next);
++ },
++ remove(id, reason = 'takedown') {
++ const cur = items.get(id);
++ return api.put({ ...cur, revision: cur.revision + 1, removed: { at: '2026-09-21T09:00:00.000Z', reason } });
++ },
++ async fetch(url) {
++ const u = new URL(url);
++ if (down) throw new Error('connect ECONNREFUSED');
++ const json = (status, body) => ({ status, ok: status < 400, json: async () => body, text: async () => JSON.stringify(body) });
++ let m = u.pathname.match(/^\/api\/v1\/items\/(itm_[0-9A-Z]+)$/);
++ if (m) {
++ const it = items.get(m[1]);
++ if (!it) return json(404, { code: 'sources.not_found' });
++ const s = sources.get(it.source_key);
++ return json(200, { item: it, source: s ? { key: s.key, status: s.status, stale: s.stale, last_success_at: s.last_success_at } : null });
++ }
++ if (u.pathname === '/api/v1/items') {
++ const after = Number(u.searchParams.get('after') || 0);
++ const limit = Number(u.searchParams.get('limit') || 100);
++ const all = [...items.values()].filter((i) => i.change_seq > after && (!u.searchParams.get('category') || i.category === u.searchParams.get('category'))).sort((a, b) => a.change_seq - b.change_seq);
++ const page = all.slice(0, limit);
++ const srcs = {};
++ for (const i of page) { const s = sources.get(i.source_key); if (s) srcs[s.key] = { status: s.status, stale: s.stale, last_success_at: s.last_success_at }; }
++ return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: all.length > limit, sources: srcs });
++ }
++ m = u.pathname.match(/^\/api\/v1\/sources\/([a-z0-9-]+)$/);
++ if (m) { const s = sources.get(m[1]); return s ? json(200, { source: s }) : json(404, { code: 'sources.not_found' }); }
++ return json(404, { code: 'not_found' });
++ },
++ };
++ return api;
++}
++
++const hash = (x) => crypto.createHash('sha256').update(JSON.stringify(x)).digest('hex');
++let clockMs = Date.parse('2026-09-20T12:00:00.000Z');
++
++function baseItem({ source = 'steam-reviews-portal-2', kind, identity, fields, canonicalUrl = null, title = null, retrievedAt, publishedAt = '2026-09-01T00:00:00.000Z', license = null }) {
++ clockMs += 1000;
++ return {
++ id: `itm_${ids.ulid(clockMs)}`, source_key: source, category: 'reviews', kind, identity, canonical_url: canonicalUrl, title, summary: null, authors: [],
++ published_at: publishedAt, source_updated_at: null, fields, revision: 1,
++ provenance: {
++ retrieved_at: retrievedAt || new Date(clockMs).toISOString(), first_seen_at: new Date(clockMs).toISOString(), content_hash: hash(fields),
++ raw_body_hash: hash(identity), parser_version: kind === 'review_signal' ? 'api@1' : 'jsonld@1', fetch_run_id: `frn_${ids.ulid(clockMs)}`,
++ license_note: license, terms_note: 'Terms recorded by a person in OpenVibe.Sources (test).', entered_by: null,
++ },
++ removed: null,
++ };
++}
++
++function steamItem({ votedUp = true, source = 'steam-reviews-portal-2', ...rest } = {}) {
++ return baseItem({ source, kind: 'review_signal', identity: String(crypto.randomInt(1e9)), fields: { voted_up: votedUp, votes_up: 2, steam_purchase: true, received_for_free: false, playtime_at_review_min: 600, language: 'english' }, ...rest });
++}
++
++function productItem({ value = 4.2, best = 5, count = 120, url = 'https://shop.example/p/widget', gtin = null, title = 'Widget 3000', source = 'shop-example', ...rest } = {}) {
++ return baseItem({ source, kind: 'product', identity: url, canonicalUrl: url, title, fields: { schema_type: 'Product', gtin, sku: null, aggregate_rating: { value, best, worst: null, count, review_count: null } }, ...rest });
++}
++
++function fakeCommunity() {
++ const threads = new Map();
++ return {
++ threads,
++ async fetch(url, opts = {}) {
++ const u = new URL(url);
++ const json = (status, body) => ({ status, ok: status < 400, json: async () => body, text: async () => JSON.stringify(body) });
++ if (u.pathname === '/api/v1/comments/threads/resolve') {
++ const ref = JSON.parse(opts.body).ref;
++ const key = `${ref.service}:${ref.type}:${ref.id}`;
++ if (!threads.has(key)) threads.set(key, { id: `thr_${threads.size + 1}`, comments: [] });
++ return json(201, { thread: { id: threads.get(key).id, visibility: 'public' }, created: true });
++ }
++ const m = u.pathname.match(/^\/api\/v1\/comments\/threads\/([^/]+)(\/comments)?$/);
++ if (m) {
++ const t = [...threads.values()].find((x) => x.id === m[1]);
++ if (!t) return json(404, { code: 'thread.not_found' });
++ if (m[2]) { t.comments.push({ id: t.comments.length + 1, message: JSON.parse(opts.body).message, display_name: 'reader', created_at: '2026-09-21T10:00:00.000Z' }); return json(201, { comment: t.comments[t.comments.length - 1] }); }
++ return json(200, { thread: { id: t.id }, comments: t.comments });
++ }
++ return json(404, {});
++ },
++ };
++}
++
++/**
++ * A running Reviews. opts.env overrides env vars; sources/community are the stubs above.
++ */
++async function boot({ env = {}, sources = fakeSources(), community = null, dbPath, now, workers = false } = {}) {
++ const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'reviews-test-'));
++ const config = load({
++ NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',
++ REVIEWS_DB_PATH: dbPath || path.join(dir, 'reviews.db'),
++ OV_NETWORK_URL: ISSUER, OV_NETWORK_INTERNAL_URL: '', REVIEWS_GATE_MIN_WORDS: '20',
++ OV_SOURCES_INTERNAL_URL: 'http://sources.test', OV_COMMUNITY_INTERNAL_URL: 'http://community.test',
++ REVIEWS_EDITORS: EDITOR, REVIEWS_EVENTS_SECRET: WEBHOOK_SECRET,
++ ...env,
++ });
++ const fetchImpl = async (url, opts) => {
++ const host = new URL(url).host;
++ if (host === 'sources.test') return sources.fetch(url, opts);
++ if (host === 'community.test' && community) return community.fetch(url, opts);
++ throw new Error(`unexpected outbound fetch ${url}`);
++ };
++ const h = await start({
++ config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now, fetchImpl,
++ tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
++ });
++ const base = `http://127.0.0.1:${h.server.address().port}`;
++ return { ...h, base, dir, sources, community };
++}
++
++async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
++ const hs = { ...headers };
++ if (token) hs.Authorization = `Bearer ${token}`;
++ if (cookie) hs.Cookie = cookie;
++ let payload;
++ if (body !== undefined) { hs['Content-Type'] = 'application/json'; payload = typeof body === 'string' ? body : JSON.stringify(body); }
++ if (form !== undefined) {
++ hs['Content-Type'] = 'application/x-www-form-urlencoded';
++ const params = new URLSearchParams();
++ for (const [k, v] of Object.entries(form)) for (const x of [].concat(v)) params.append(k, x == null ? '' : String(x));
++ payload = params.toString();
++ }
++ const res = await fetch(h.base + p, { method, headers: hs, body: payload, redirect: 'manual' });
++ const text = await res.text();
++ let json = null;
++ try { json = JSON.parse(text); } catch { /* html or text */ }
++ return { status: res.status, headers: res.headers, text, json };
++}
++
++const cookieFor = (token) => `ov_token=${token}`;
++
++/** Every envelope in the outbox, oldest first. */
++function outbox(h) {
++ return h.db.prepare('SELECT envelope FROM review_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
++}
++
++const IMPORT = () => serviceToken({ client: 'sources-sync', cap: ['reviews.signal.import'] });
++
++/** Put an item in the fake Sources and import it through the API as a granted service. */
++async function importItem(h, item) {
++ h.sources.put(item);
++ const r = await req(h, 'POST', '/api/v1/signals/import', { token: IMPORT(), body: { source_item_id: item.id } });
++ assert.strictEqual(r.status, 200, r.text);
++ return r.json;
++}
++
++async function createEntity(h, body) {
++ const r = await req(h, 'POST', '/api/v1/entities', { token: editorToken(), body });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.entity;
++}
++
++/** A signed delivery from OpenVibe.Events to POST /internal/events. */
++async function deliver(h, envelope, { secret = WEBHOOK_SECRET } = {}) {
++ const raw = JSON.stringify({ event: envelope, seq: 1 });
++ return req(h, 'POST', '/internal/events', { body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw, secret) } });
++}
++
++function sourcesEvent(type, item, extra = {}) {
++ return {
++ event_id: `evt_${ids.ulid(Date.now())}`, event_type: type, version: 1, source: 'sources', actor: { type: 'service', id: 'sources' },
++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'item', id: item.id, revision: item.revision },
++ payload: { item_id: item.id, source_key: item.source_key, category: item.category, kind: item.kind, revision: item.revision, ...extra },
++ };
++}
++
++/** Minimal suite runner: runs named async tests in order, exits non-zero on the first failure. */
++function suite(name) {
++ const tests = [];
++ const t = (title, fn) => tests.push({ title, fn });
++ setImmediate(async () => {
++ for (const x of tests) {
++ try { await x.fn(); console.log(` ok ${name}: ${x.title}`); } catch (err) { console.error(` FAIL ${name}: ${x.title}\n${err && err.stack}`); process.exit(1); }
++ }
++ process.exit(0);
++ });
++ return t;
++}
++
++module.exports = {
++ boot, req, userToken, editorToken, readerToken, serviceToken, subject, cookieFor, outbox, suite,
++ fakeSources, fakeCommunity, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent,
++ IMPORT, EDITOR, READER, WEBHOOK_SECRET, ISSUER, publicKey, privateKey, quiet,
++};
ddiff --git a/test/merge.test.js b/test/merge.test.js
nnew file mode 100644
iindex 0000000..eaa3e77
---- /dev/null
++++ b/test/merge.test.js
@@@ -0,0 +1,139 @@
++'use strict';
++/**
++ * Merges are reversible and audited: a merge rewrites nothing, a split restores the pre-merge
++ * signal attribution, aliases and aggregates exactly, and both are recorded with who, when and why.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, steamItem, productItem, importItem, createEntity, outbox, editorToken, readerToken, serviceToken, EDITOR,
++} = require('./helpers');
++
++const t = suite('merge');
++
++function snapshot(h, ids) {
++ const out = {};
++ for (const id of ids) {
++ out[id] = {
++ entity: h.db.prepare('SELECT id, slug, name, kind, state, merged_into FROM review_entities WHERE id = ?').get(id),
++ signals: h.db.prepare('SELECT id, entity_id, source_item_id, status, type, recommended, rating_value FROM review_signals WHERE entity_id = ? ORDER BY id').all(id),
++ aliases: h.db.prepare('SELECT id, type, norm, removed_at FROM review_entity_aliases WHERE entity_id = ? ORDER BY id').all(id),
++ items: h.db.prepare('SELECT id, entity_id, resolution FROM review_source_items WHERE entity_id = ? ORDER BY id').all(id),
++ aggregate: (() => { const r = h.db.prepare('SELECT result FROM review_aggregates WHERE entity_id = ? ORDER BY revision DESC LIMIT 1').get(id); return r ? r.result : null; })(),
++ };
++ }
++ return out;
++}
++
++t('merge → split round trip restores attribution, aliases and aggregates exactly', async () => {
++ const h = await boot();
++ try {
++ const a = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ const b = await createEntity(h, { name: 'Portal Two', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-two-dup' }] });
++ for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));
++ for (const v of [true, false]) await importItem(h, steamItem({ votedUp: v, source: 'steam-reviews-portal-two-dup' }));
++ const before = snapshot(h, [a.id, b.id]);
++ assert.strictEqual(before[a.id].signals.length, 3);
++ assert.strictEqual(before[b.id].signals.length, 2);
++
++ // Only an editor who is a person can merge.
++ const svcOnly = await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: serviceToken({ cap: ['reviews.entity.merge'] }), body: { into: a.slug } });
++ assert.strictEqual(svcOnly.status, 403);
++ assert.strictEqual(svcOnly.json.code, 'reviews.person_required');
++ const reader = await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: readerToken(), body: { into: a.slug } });
++ assert.strictEqual(reader.status, 403);
++ assert.strictEqual(reader.json.code, 'reviews.editor_required');
++
++ const m = await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: editorToken(), body: { into: a.slug, note: 'Same game, duplicate listing' } });
++ assert.strictEqual(m.status, 200, m.text);
++ assert.strictEqual(m.json.signals.length, 2);
++
++ // Nothing was rewritten: signals keep their entity; the target's aggregate spans both.
++ const during = snapshot(h, [a.id, b.id]);
++ assert.deepStrictEqual(during[b.id].signals, before[b.id].signals);
++ assert.deepStrictEqual(during[a.id].signals, before[a.id].signals);
++ assert.deepStrictEqual(during[b.id].aliases, before[b.id].aliases);
++ const aggA = await req(h, 'GET', `/api/v1/entities/${a.slug}/aggregate`, { token: editorToken() });
++ assert.deepStrictEqual({ p: aggA.json.aggregate.result.components.recommendation.positive, t: aggA.json.aggregate.result.components.recommendation.total }, { p: 3, t: 5 });
++ // The merged entity redirects; its identifiers resolve to the canonical entity.
++ const page = await req(h, 'GET', `/e/${b.slug}`);
++ assert.strictEqual(page.status, 301);
++ assert.strictEqual(page.headers.get('location'), `/e/${a.slug}`);
++ const res = await req(h, 'POST', '/api/v1/resolve', { token: editorToken(), body: { source: 'steam-reviews-portal-two-dup' } });
++ assert.strictEqual(res.json.match, 'exact');
++ assert.strictEqual(res.json.entity.id, a.id);
++ assert.strictEqual(res.json.via.id, b.id);
++ // A new item for B while merged is still attributed to B (and shown under A).
++ await importItem(h, steamItem({ votedUp: true, source: 'steam-reviews-portal-two-dup' }));
++ const late = h.db.prepare("SELECT entity_id FROM review_signals WHERE source_key = 'steam-reviews-portal-two-dup' ORDER BY created_at DESC LIMIT 1").get();
++ assert.strictEqual(late.entity_id, b.id);
++
++ const s = await req(h, 'POST', `/api/v1/entities/${b.slug}/split`, { token: editorToken(), body: { note: 'Actually two different editions' } });
++ assert.strictEqual(s.status, 200, s.text);
++ const after = snapshot(h, [a.id, b.id]);
++ // A is exactly as before; B is as before plus the signal that arrived for B meanwhile.
++ assert.deepStrictEqual(after[a.id], before[a.id]);
++ assert.deepStrictEqual(after[b.id].entity, before[b.id].entity);
++ assert.deepStrictEqual(after[b.id].aliases, before[b.id].aliases);
++ assert.deepStrictEqual(after[b.id].signals.slice(0, 2), before[b.id].signals);
++ assert.strictEqual(after[b.id].signals.length, 3);
++
++ // Audited: the link row, the log and the events.
++ const link = h.db.prepare("SELECT * FROM review_entity_links WHERE type = 'merged_into'").get();
++ assert.strictEqual(link.created_by, EDITOR);
++ assert.strictEqual(link.note, 'Same game, duplicate listing');
++ assert.strictEqual(link.ended_by, EDITOR);
++ assert.strictEqual(link.end_note, 'Actually two different editions');
++ const log = h.db.prepare("SELECT action, actor, detail FROM review_audit WHERE action IN ('entity.merged','entity.split') ORDER BY id").all();
++ assert.deepStrictEqual(log.map((r) => r.action), ['entity.merged', 'entity.split']);
++ assert.deepStrictEqual(JSON.parse(log[0].detail).signals.sort(), before[b.id].signals.map((x) => x.id).sort());
++ const ev = outbox(h).filter((x) => /^reviews\.entity\./.test(x.event_type)).map((x) => x.event_type);
++ assert.deepStrictEqual(ev, ['reviews.entity.merged', 'reviews.entity.split']);
++ const hist = await req(h, 'GET', `/e/${a.slug}/history`);
++ assert.match(hist.text, /Portal Two<\/a> merged into/);
++ assert.match(hist.text, /split again/);
++ } finally { await h.stop(); }
++});
++
++t('merge refuses cycles, self-merges and merged targets; split refuses an active entity', async () => {
++ const h = await boot();
++ try {
++ const a = await createEntity(h, { name: 'Alpha', kind: 'product' });
++ const b = await createEntity(h, { name: 'Beta', kind: 'product' });
++ const c = await createEntity(h, { name: 'Gamma', kind: 'product' });
++ const tok = editorToken();
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${a.slug}/merge`, { token: tok, body: { into: a.slug } })).status, 422);
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${a.slug}/merge`, { token: tok, body: { into: b.slug } })).status, 200);
++ const toMerged = await req(h, 'POST', `/api/v1/entities/${c.slug}/merge`, { token: tok, body: { into: a.slug } });
++ assert.strictEqual(toMerged.status, 409);
++ assert.strictEqual(toMerged.json.canonical_id, b.id);
++ const cycle = await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: tok, body: { into: a.slug } });
++ assert.strictEqual(cycle.status, 409);
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${c.slug}/split`, { token: tok, body: {} })).status, 409);
++ } finally { await h.stop(); }
++});
++
++t('a split flags the former target\'s summary when it cited signals that went back', async () => {
++ const h = await boot();
++ try {
++ const a = await createEntity(h, { name: 'Widget', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget' }] });
++ const b = await createEntity(h, { name: 'Widget Pro', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget-pro' }] });
++ await importItem(h, productItem({ value: 4, best: 5, count: 10 }));
++ await importItem(h, productItem({ value: 3, best: 5, count: 10, url: 'https://shop.example/p/widget-pro', title: 'Widget Pro' }));
++ const sigB = h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(b.id).id;
++ const sigA = h.db.prepare('SELECT id FROM review_signals WHERE entity_id = ?').get(a.id).id;
++ await req(h, 'POST', `/api/v1/entities/${b.slug}/merge`, { token: editorToken(), body: { into: a.slug, note: 'dup' } });
++ const w = await req(h, 'POST', `/api/v1/entities/${a.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: 'Two shop listings rate it.', pros: [{ text: 'Rated well at the first shop', signals: [sigA] }], cons: [{ text: 'Rated lower at the second listing', signals: [sigB] }], publish: true,
++ } });
++ assert.strictEqual(w.status, 201, w.text);
++ await req(h, 'POST', `/api/v1/entities/${b.slug}/split`, { token: editorToken(), body: { note: 'different products' } });
++ const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(a.id);
++ assert.strictEqual(s.flagged, 1);
++ assert.match(s.flag_reason, /split/);
++ const page = await req(h, 'GET', `/api/v1/entities/${a.slug}`, { token: editorToken() });
++ assert.strictEqual(page.json.summary.pending.length, 1);
++ assert.strictEqual(page.json.summary.pending[0].status, 'pending_system');
++ assert.deepStrictEqual(page.json.summary.pending[0].points.map((p) => p.kind), ['pro'], 'the con resting on the split-off signal is dropped');
++ assert.ok(page.json.summary.published.points.find((p) => p.kind === 'con').supported === false);
++ } finally { await h.stop(); }
++});
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
nnew file mode 100644
iindex 0000000..587ba74
---- /dev/null
++++ b/test/nojs.test.js
@@@ -0,0 +1,177 @@
++'use strict';
++/**
++ * Public routes are useful without JavaScript, editors work with plain form posts, and structured
++ * data never claims a rating the signals do not back: AggregateRating (and Product / Review)
++ * JSON-LD is absent without signals.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent, cookieFor, editorToken, readerToken, fakeCommunity,
++} = require('./helpers');
++
++const t = suite('nojs');
++
++const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
++const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++const robots = (html) => (html.match(/<meta name="robots" content="([^"]+)"/) || [])[1];
++
++t('AggregateRating JSON-LD is absent without signals, present only while signals back it', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget' }] });
++ let page = await req(h, 'GET', `/e/${e.slug}`);
++ assert.strictEqual(page.status, 200);
++ let ld = jsonLd(page.text);
++ assert.deepStrictEqual(ld.map((x) => x['@type']), ['BreadcrumbList']);
++ assert.ok(!/AggregateRating|"Product"|ratingValue|reviewRating/.test(page.text));
++ assert.match(noScripts(page.text), /No aggregate\./);
++ assert.strictEqual(robots(page.text), 'noindex, follow', 'thin and unsourced');
++
++ const item = productItem({ value: 4.4, best: 5, count: 321 });
++ await importItem(h, item);
++ page = await req(h, 'GET', `/e/${e.slug}`);
++ ld = jsonLd(page.text);
++ const product = ld.find((x) => x['@type'] === 'Product');
++ assert.ok(product, 'Product with AggregateRating once a signal exists');
++ assert.deepStrictEqual(product.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.4, ratingCount: 321, bestRating: 5 });
++ assert.ok(!ld.some((x) => x['@type'] === 'Review'), 'no Review without a published summary');
++ const visible = noScripts(page.text);
++ assert.match(visible, /4\.4 \/ 5<\/strong> from 321 ratings/);
++ assert.match(visible, new RegExp(item.id));
++
++ await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(item.id, 'takedown'), { reason: 'takedown' }));
++ page = await req(h, 'GET', `/e/${e.slug}`);
++ assert.ok(!/AggregateRating|"Product"|ratingValue/.test(page.text), 'gone with the signal');
++ assert.match(noScripts(page.text), /No aggregate\./);
++ const json = await req(h, 'GET', `/e/${e.slug}.json`);
++ assert.strictEqual(json.json.aggregate, null);
++ assert.strictEqual(json.json.signals.length, 0);
++ assert.strictEqual(json.json.inactive_signals[0].status, 'withdrawn');
++ } finally { await h.stop(); }
++});
++
++t('a recommendation-only entity: AggregateRating is the real share and count, on a 0–100 scale', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ for (const v of [true, true, true, false]) await importItem(h, steamItem({ votedUp: v }));
++ const page = await req(h, 'GET', `/e/${e.slug}`);
++ const game = jsonLd(page.text).find((x) => x['@type'] === 'VideoGame');
++ assert.deepStrictEqual(game.aggregateRating, { '@type': 'AggregateRating', ratingValue: 75, ratingCount: 4, bestRating: 100, worstRating: 0 });
++ assert.match(noScripts(page.text), /75%<\/strong> recommend: 3 of 4/);
++ assert.ok(!/★/.test(page.text));
++ } finally { await h.stop(); }
++});
++
++t('every public page reads without JavaScript; sitemaps, feeds and robots follow the gate', async () => {
++ const h = await boot({ community: fakeCommunity() });
++ try {
++ const home0 = await req(h, 'GET', '/');
++ assert.match(noScripts(home0.text), /Nothing yet\./, 'nothing seeded');
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', description: 'A puzzle game.', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ for (const v of [true, false]) await importItem(h, steamItem({ votedUp: v }));
++ const sig = h.db.prepare('SELECT id, recommended FROM review_signals').all();
++ let sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
++ assert.ok(!sm.text.includes(`/e/${e.slug}`), 'thin: not in the sitemap');
++ await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: 'In the small recent sample Reviews has read, opinion is split evenly between players who recommend it and players who do not, which says more about the sample than the game.',
++ overview_signals: sig.map((s) => s.id),
++ pros: [{ text: 'Half of the sample recommends it', signals: sig.filter((s) => s.recommended).map((s) => s.id) }],
++ cons: [{ text: 'Half does not', signals: sig.filter((s) => !s.recommended).map((s) => s.id) }], publish: true,
++ } });
++ const page = await req(h, 'GET', `/e/${e.slug}`);
++ assert.strictEqual(robots(page.text), 'index, follow');
++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60');
++ const text = noScripts(page.text);
++ for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
++ assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
++ sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
++ assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
++ const idx = await req(h, 'GET', '/sitemap.xml');
++ assert.match(idx.text, /sitemaps\/entities-1\.xml/);
++ const atom = await req(h, 'GET', '/feed.atom');
++ assert.strictEqual(atom.status, 200);
++ assert.match(atom.text, /Portal 2: summary/);
++ const feed = await req(h, 'GET', '/feed.json');
++ assert.strictEqual(JSON.parse(feed.text).items.length, 1);
++ const rb = await req(h, 'GET', '/robots.txt');
++ assert.match(rb.text, /Sitemap: http:\/\/reviews\.test\/sitemap\.xml/);
++ assert.match(rb.text, /Disallow: \/editor/);
++ assert.match((await req(h, 'GET', '/llms.txt')).text, /OpenVibe\.Reviews/);
++ for (const p of ['/', '/about', `/e/${e.slug}/history`, `/e/${e.slug}/summary/1`, '/search?q=portal']) {
++ const r = await req(h, 'GET', p);
++ assert.strictEqual(r.status, 200, p);
++ assert.ok(noScripts(r.text).includes('<main id="main"'), p);
++ }
++ assert.match(noScripts((await req(h, 'GET', '/search?q=portal')).text), /Portal 2/);
++ assert.match(noScripts((await req(h, 'GET', '/')).text), /Portal 2/);
++
++ // Discussion through Community: a person comments with a plain form post.
++ const c = await req(h, 'POST', `/e/${e.slug}/discuss`, { cookie: cookieFor(readerToken()), form: { message: 'Is the co-op counted here?' } });
++ assert.strictEqual(c.status, 303);
++ const after = await req(h, 'GET', `/e/${e.slug}`, { cookie: cookieFor(readerToken()) });
++ assert.match(after.text, /Is the co-op counted here\?/);
++ // Referenced, not duplicated: the comment text exists in Community only.
++ for (const { name } of h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
++ for (const row of h.db.prepare(`SELECT * FROM "${name}"`).all()) assert.ok(!JSON.stringify(row).includes('co-op counted'), `comment text copied into ${name}`);
++ }
++ } finally { await h.stop(); }
++});
++
++t('editors work with plain forms: create, resolve an ambiguous item, summarise, merge, split, correct', async () => {
++ const h = await boot();
++ try {
++ const ed = cookieFor(editorToken());
++ // Readers do not get the editor desk.
++ assert.strictEqual((await req(h, 'GET', '/editor', { cookie: cookieFor(readerToken()) })).status, 403);
++ assert.strictEqual((await req(h, 'GET', '/editor')).status, 401);
++
++ const made = await req(h, 'POST', '/editor/entities/new', { cookie: ed, form: { name: 'Widget 3000', kind: 'product', alias_gtin: '0012345678905' } });
++ assert.strictEqual(made.status, 303);
++ assert.strictEqual(made.headers.get('location'), '/e/widget-3000/edit');
++ // A shop item that names the product but carries no identifier Reviews knows: name only → ambiguous.
++ const it = productItem({ url: 'https://another.example/w3000', title: 'Widget 3000', source: 'another-shop' });
++ const imp = await importItem(h, it);
++ assert.strictEqual(imp.item.resolution, 'ambiguous');
++ assert.strictEqual(imp.item.resolution_rule, 'name_only');
++ const desk = await req(h, 'GET', '/editor', { cookie: ed });
++ assert.match(noScripts(desk.text), new RegExp(it.id));
++ const form = await req(h, 'GET', `/editor/items/${it.id}`, { cookie: ed });
++ assert.match(form.text, /<form method="post" action="\/editor\/items\//);
++ const res = await req(h, 'POST', `/editor/items/${it.id}`, { cookie: ed, form: { action: 'resolve', entity: 'widget-3000', add_alias: 'url' } });
++ assert.strictEqual(res.status, 303);
++ const sig = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").get();
++ assert.ok(sig, 'confirmed → signal');
++ assert.strictEqual(h.db.prepare('SELECT resolution_rule, resolved_by FROM review_source_items WHERE id = ?').get(it.id).resolution_rule, 'editor');
++
++ const edit = await req(h, 'GET', '/e/widget-3000/edit', { cookie: ed });
++ assert.match(edit.text, new RegExp(`<option value="${sig.id}"`));
++ const saved = await req(h, 'POST', '/e/widget-3000/summary', { cookie: ed, form: { overview: 'One shop listing rates it.', overview_signals: sig.id, pro_text_0: 'Well rated at one shop', pro_signals_0: sig.id, expected_revision: '0', publish: '1' } });
++ assert.strictEqual(saved.status, 303, saved.text.slice(0, 300));
++ assert.match(noScripts((await req(h, 'GET', '/e/widget-3000')).text), /Well rated at one shop/);
++
++ const other = await createEntity(h, { name: 'Widget 3000 (2024)', kind: 'product' });
++ const m = await req(h, 'POST', `/e/${other.slug}/merge`, { cookie: ed, form: { into: 'widget-3000', note: 'same product' } });
++ assert.strictEqual(m.status, 303);
++ assert.strictEqual((await req(h, 'GET', `/e/${other.slug}`)).status, 301);
++ const s = await req(h, 'POST', `/e/${other.slug}/split`, { cookie: ed, form: { note: 'not the same after all' } });
++ assert.strictEqual(s.status, 303);
++ assert.strictEqual((await req(h, 'GET', `/e/${other.slug}`)).status, 200);
++
++ // Rename keeps the old address working.
++ const rn = await req(h, 'POST', '/e/widget-3000/edit', { cookie: ed, form: { action: 'details', name: 'Widget 3000', slug: 'widget-3000-classic', kind: 'product', description: '' } });
++ assert.strictEqual(rn.status, 303);
++ const old = await req(h, 'GET', '/e/widget-3000/history');
++ assert.strictEqual(old.status, 301);
++ assert.strictEqual(old.headers.get('location'), '/e/widget-3000-classic/history');
++
++ // A reader sends a correction with a form; anonymous people are asked to sign in.
++ assert.strictEqual((await req(h, 'GET', '/e/widget-3000-classic/correct')).status, 401);
++ const cor = await req(h, 'POST', '/e/widget-3000-classic/correct', { cookie: cookieFor(readerToken()), form: { target_type: 'signal', target_id: sig.id, body: 'That listing is for the older model.' } });
++ assert.strictEqual(cor.status, 201);
++ assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM review_corrections WHERE status = 'open'").get().n, 1);
++ // Cross-site posts are refused.
++ const xs = await req(h, 'POST', '/e/widget-3000-classic/correct', { cookie: cookieFor(readerToken()), headers: { Origin: 'https://evil.example' }, form: { target_type: 'entity', body: 'cross-site attempt here' } });
++ assert.strictEqual(xs.status, 403);
++ } finally { await h.stop(); }
++});
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
nnew file mode 100644
iindex 0000000..738a473
---- /dev/null
++++ b/test/proposals.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * Proposals for OpenVibe.Contracts are valid against the contracts' own schemas and match what the
++ * code enforces; the nine §15.13 tables exist; nothing that looks like content is seeded.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { CAPS } = require('../server/auth/capabilities');
++const { openDb, createStores, AUTHORITY_TABLES } = require('../server/db');
++const { suite, boot } = require('./helpers');
++
++const t = suite('proposals');
++const DOCS = path.join(__dirname, '..', 'docs');
++const SRC = path.join(__dirname, '..', 'server');
++
++function sourceFiles(dir) {
++ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? sourceFiles(path.join(dir, e.name)) : e.name.endsWith('.js') ? [path.join(dir, e.name)] : []));
++}
++
++t('capability proposals are valid capabilities.capability@1 and cover exactly the guarded ids', () => {
++ const dir = path.join(DOCS, 'capabilities-proposal');
++ const ids = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => {
++ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
++ const v = contracts.validate('capabilities.capability@1', m);
++ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(`${m.id}.json`, f);
++ assert.strictEqual(m.owner, 'reviews');
++ assert.ok(m.id.split('.').length === 3);
++ return m.id;
++ });
++ assert.deepStrictEqual(ids.sort(), Object.values(CAPS).sort());
++ const guarded = new Set();
++ for (const f of sourceFiles(SRC)) for (const m of fs.readFileSync(f, 'utf8').matchAll(/guard\('([a-z0-9_.]+)'\)/g)) guarded.add(m[1]);
++ assert.deepStrictEqual([...guarded].sort(), Object.values(CAPS).sort());
++ for (const id of ['reviews.entity.resolve', 'reviews.entity.merge', 'reviews.entity.split', 'reviews.signal.import', 'reviews.summary.publish', 'reviews.correction.submit']) assert.ok(ids.includes(id), `§15.13 minimum capability ${id}`);
++});
++
++t('the service manifest proposal is a valid registry.service-manifest@1 and lists the §15.13 events', () => {
++ const m = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
++ const v = contracts.validate('registry.service-manifest@1', m);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.deepStrictEqual([...m.capabilities].sort(), Object.values(CAPS).sort());
++ for (const ev of ['reviews.entity.merged', 'reviews.entity.split', 'reviews.signal.added', 'reviews.signal.removed', 'reviews.summary.published', 'reviews.summary.updated']) assert.ok(m.eventsProduced.includes(ev), ev);
++ assert.ok(m.eventsConsumed.includes('sources.item.removed'));
++ // Every event type the code can enqueue is declared.
++ const produced = new Set();
++ for (const f of sourceFiles(SRC)) for (const x of fs.readFileSync(f, 'utf8').matchAll(/(?:entityEvent|signalEvent)\('([a-z_.]+)'/g)) produced.add(x[1]);
++ assert.ok(produced.size >= 4);
++ for (const ev of produced) assert.ok(m.eventsProduced.includes(ev), `undeclared event ${ev}`);
++});
++
++t('the nine authority tables exist; a fresh database holds no content', async () => {
++ const db = openDb(':memory:');
++ createStores(db);
++ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ assert.strictEqual(AUTHORITY_TABLES.length, 9);
++ for (const name of AUTHORITY_TABLES) assert.ok(tables.has(name), name);
++ for (const name of [...tables].filter((n) => n.startsWith('review_'))) {
++ assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get().n, 0, `${name} is empty`);
++ }
++ // Booting the service seeds nothing either.
++ const h = await boot();
++ try {
++ for (const name of ['review_entities', 'review_signals', 'review_summaries', 'review_aggregates', 'review_sources', 'review_source_items']) {
++ assert.strictEqual(h.db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get().n, 0, name);
++ }
++ } finally { await h.stop(); }
++ assert.ok(!fs.existsSync(path.join(__dirname, '..', 'seeds')), 'no seed directory');
++});
ddiff --git a/test/resolution.test.js b/test/resolution.test.js
nnew file mode 100644
iindex 0000000..3cbe935
---- /dev/null
++++ b/test/resolution.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * Resolution is deterministic: strong identifiers resolve, a name alone never does, conflicting
++ * identifiers wait for an editor, and every editorial decision is recorded.
++ */
++const assert = require('assert');
++const norm = require('../server/reviews/normalize');
++const {
++ boot, req, suite, productItem, importItem, createEntity, editorToken, readerToken, serviceToken,
++} = require('./helpers');
++
++const t = suite('resolution');
++
++t('normalisation', () => {
++ assert.strictEqual(norm.alias('url', 'https://WWW.Shop.example/p/Widget/?utm_source=x&b=2&a=1#top'), 'shop.example/p/Widget?a=1&b=2');
++ assert.strictEqual(norm.alias('url', 'http://shop.example/p/Widget'), norm.alias('url', 'https://www.shop.example/p/Widget/'));
++ assert.strictEqual(norm.alias('gtin', '012345678905'), '00012345678905');
++ assert.strictEqual(norm.alias('gtin', '0012345678905'), '00012345678905');
++ assert.strictEqual(norm.alias('name', 'Pokémon Red & Blue!'), 'pokemon red and blue');
++ assert.throws(() => norm.alias('gtin', '12345'), /GTIN/);
++ assert.strictEqual(norm.slug('Portal 2: Co-op Édition'), 'portal-2-co-op-edition');
++});
++
++t('strong identifiers resolve; names only propose candidates; conflicts wait for an editor', async () => {
++ const h = await boot();
++ try {
++ const w = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'gtin', value: '012345678905' }] });
++ const w2 = await createEntity(h, { name: 'Widget 3000', kind: 'product', description: 'A different product with the same name', aliases: [{ type: 'url', value: 'https://b.example/w' }] });
++
++ const byGtin = await importItem(h, productItem({ url: 'https://a.example/w', gtin: '0012345678905', source: 'shop-a' }));
++ assert.strictEqual(byGtin.item.resolution, 'resolved');
++ assert.strictEqual(byGtin.item.entity_id, w.id);
++ assert.strictEqual(byGtin.item.resolution_rule, 'gtin');
++
++ const nameOnly = await importItem(h, productItem({ url: 'https://c.example/w', source: 'shop-c' }));
++ assert.strictEqual(nameOnly.item.resolution, 'ambiguous');
++ assert.strictEqual(nameOnly.item.candidates.length, 2);
++ assert.strictEqual(nameOnly.signal, null, 'no signal until an editor decides');
++
++ const conflict = await importItem(h, productItem({ url: 'https://b.example/w', gtin: '012345678905', source: 'shop-b' }));
++ assert.strictEqual(conflict.item.resolution, 'ambiguous');
++ assert.strictEqual(conflict.item.resolution_rule, 'conflicting_identifiers');
++
++ const api = await req(h, 'POST', '/api/v1/resolve', { token: serviceToken({ cap: ['reviews.entity.resolve'] }), body: { name: 'widget 3000' } });
++ assert.strictEqual(api.json.match, 'ambiguous');
++ assert.strictEqual(api.json.entity, null);
++ const exact = await req(h, 'POST', '/api/v1/resolve', { token: serviceToken({ cap: ['reviews.entity.resolve'] }), body: { url: 'https://www.b.example/w/' } });
++ assert.strictEqual(exact.json.match, 'exact');
++ assert.strictEqual(exact.json.entity.id, w2.id);
++ assert.strictEqual((await req(h, 'POST', '/api/v1/resolve', { token: serviceToken({ cap: [] }), body: { name: 'x' } })).status, 403);
++
++ // An editor confirms; a reader cannot.
++ assert.strictEqual((await req(h, 'POST', `/api/v1/items/${nameOnly.item.id}/resolution`, { token: readerToken(), body: { entity: w2.slug } })).status, 403);
++ const ok = await req(h, 'POST', `/api/v1/items/${nameOnly.item.id}/resolution`, { token: editorToken(), body: { entity: w2.slug, add_alias: 'url' } });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.item.resolution, 'resolved');
++ assert.strictEqual(ok.json.item.resolution_rule, 'editor');
++ assert.ok(ok.json.signal);
++ // Re-attribution withdraws the old signal and creates a new one for the other entity.
++ const moved = await req(h, 'POST', `/api/v1/items/${nameOnly.item.id}/resolution`, { token: editorToken(), body: { entity: w.slug } });
++ assert.strictEqual(moved.status, 200, moved.text);
++ const sigs = h.db.prepare('SELECT entity_id, status FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(nameOnly.item.id);
++ assert.deepStrictEqual(sigs.map((s) => [s.entity_id, s.status]), [[w2.id, 'withdrawn'], [w.id, 'active']]);
++ const log = h.db.prepare("SELECT COUNT(*) AS n FROM review_audit WHERE action = 'item.resolved'").get().n;
++ assert.strictEqual(log, 2);
++
++ // Strong identifiers are unique across entities.
++ const dup = await req(h, 'POST', `/api/v1/entities/${w2.slug}/aliases`, { token: editorToken(), body: { type: 'gtin', value: '0012345678905' } });
++ assert.strictEqual(dup.status, 409);
++ assert.strictEqual(dup.json.code, 'alias.taken');
++ } finally { await h.stop(); }
++});
++
++t('API identity: bad tokens are refused, never downgraded; editor actions need a person', async () => {
++ const h = await boot();
++ try {
++ assert.strictEqual((await req(h, 'GET', '/api/v1/entities', { token: 'not.a.token' })).status, 401);
++ assert.strictEqual((await req(h, 'GET', '/api/v1/entities', { token: serviceToken({ aud: 'openvibe.wiki', cap: ['reviews.entity.resolve'] }) })).status, 401);
++ const anon = await req(h, 'POST', '/api/v1/entities', { body: { name: 'X' } });
++ assert.strictEqual(anon.status, 403);
++ const svc = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ cap: ['reviews.entity.manage'] }), body: { name: 'X' } });
++ assert.strictEqual(svc.status, 403);
++ const asEditor = await req(h, 'POST', '/api/v1/entities', { token: serviceToken({ cap: ['reviews.entity.manage'] }), headers: { 'X-OV-Subject': require('./helpers').EDITOR }, body: { name: 'Made For An Editor', kind: 'software' } });
++ assert.strictEqual(asEditor.status, 201, asEditor.text);
++ const staff = await req(h, 'POST', '/api/v1/entities', { token: require('./helpers').userToken({ role: 'global_mod' }), body: { name: 'Made By Staff' } });
++ assert.strictEqual(staff.status, 201);
++ const ready = await req(h, 'GET', '/api/ready');
++ assert.strictEqual(ready.status, 200);
++ assert.strictEqual(ready.json.ready, true);
++ assert.strictEqual((await req(h, 'GET', '/api/health')).json.service, 'openvibe-reviews');
++ const nf = await req(h, 'GET', '/api/v1/nope');
++ assert.strictEqual(nf.status, 404);
++ assert.match(nf.headers.get('content-type'), /problem\+json/);
++ } finally { await h.stop(); }
++});
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..35b9837
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if
++ * any of them fails. They use temp SQLite databases, a generated RSA key and stub upstreams; none
++ * of them needs the network or a running OpenVibe service.
++ *
++ * npm test # everything
++ * npm test -- revisions seo # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/signals.test.js b/test/signals.test.js
nnew file mode 100644
iindex 0000000..49640f7
---- /dev/null
++++ b/test/signals.test.js
@@@ -0,0 +1,209 @@
++'use strict';
++/**
++ * Signals from OpenVibe.Sources items: provenance on every signal, resolution by source binding,
++ * no aggregate without signals, removal and update reflected in the next aggregate revision,
++ * the webhook + inbox path and the pull path.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const {
++ boot, req, suite, steamItem, productItem, importItem, createEntity, deliver, sourcesEvent, outbox, serviceToken, editorToken, IMPORT,
++} = require('./helpers');
++
++const t = suite('signals');
++
++async function aggregateOf(h, slug) {
++ const r = await req(h, 'GET', `/api/v1/entities/${slug}/aggregate`, { token: editorToken() });
++ assert.strictEqual(r.status, 200, r.text);
++ return r.json;
++}
++
++t('items without an entity wait unmatched; a source binding settles them; every signal has provenance', async () => {
++ const h = await boot();
++ try {
++ h.sources.addSource({ key: 'steam-reviews-portal-2', name: 'Steam user review signals: Portal 2 (app 620)', homepage_url: 'https://store.steampowered.com/app/620/', category: 'reviews' });
++ const items = [steamItem({ votedUp: true }), steamItem({ votedUp: true }), steamItem({ votedUp: false })];
++ for (const it of items) {
++ const out = await importItem(h, it);
++ assert.strictEqual(out.outcome, 'created');
++ assert.strictEqual(out.item.resolution, 'unmatched');
++ assert.strictEqual(out.signal, null);
++ }
++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM review_signals').get().n, 0);
++
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ const agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate.revision, 1);
++ assert.deepStrictEqual({ p: agg.aggregate.result.components.recommendation.positive, t: agg.aggregate.result.components.recommendation.total }, { p: 2, t: 3 });
++
++ const page = await req(h, 'GET', `/api/v1/entities/${e.slug}`, { token: editorToken() });
++ assert.strictEqual(page.json.signals.length, 3);
++ for (const s of page.json.signals) {
++ assert.match(s.source_item_id, /^itm_/);
++ assert.strictEqual(s.source_key, 'steam-reviews-portal-2');
++ assert.ok(s.observed_at && !Number.isNaN(Date.parse(s.observed_at)), 'retrieval time');
++ assert.ok(Number.isInteger(s.item_revision));
++ assert.strictEqual(s.provenance.source_name, 'Steam user review signals: Portal 2 (app 620)');
++ assert.ok(s.provenance.terms_note);
++ assert.ok('license_note' in s.provenance);
++ }
++ // The table itself refuses a signal without provenance.
++ assert.throws(() => h.db.prepare("INSERT INTO review_signals (id, entity_id, source_item_id, source_key, item_revision, type, recommended, observed_at, created_by, created_at) VALUES ('sig_x', ?, 'itm_nope', 'steam-reviews-portal-2', 1, 'recommendation', 1, NULL, 'x', 0)").run(e.id));
++ // Signal values and provenance are immutable.
++ assert.throws(() => h.db.prepare('UPDATE review_signals SET observed_at = ? WHERE id = ?').run('2020-01-01T00:00:00.000Z', page.json.signals[0].signal_id), /immutable/);
++ assert.throws(() => h.db.prepare('DELETE FROM review_signals WHERE id = ?').run(page.json.signals[0].signal_id), /never deleted/);
++ // Review text is never kept.
++ for (const row of h.db.prepare('SELECT fields, title FROM review_source_items').all()) {
++ assert.ok(!/summary|review_text|body/.test(row.fields));
++ assert.strictEqual(row.title, null);
++ }
++ } finally { await h.stop(); }
++});
++
++t('an entity without signals has no aggregate (null, not 0) and no aggregate row', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Nothing Known Yet', kind: 'product' });
++ const agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate, null);
++ assert.strictEqual(agg.aggregate_revision, null);
++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM review_aggregates').get().n, 0);
++ } finally { await h.stop(); }
++});
++
++t('source removal (webhook) withdraws the signal and the next aggregate revision reflects it', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'url', value: 'https://shop.example/p/widget' }] });
++ const a = productItem({ value: 4, best: 5, count: 100 });
++ const out = await importItem(h, a);
++ assert.strictEqual(out.item.resolution, 'resolved');
++ assert.strictEqual(out.item.resolution_rule, 'url');
++ const b = productItem({ value: 2, best: 5, count: 100, url: 'https://other.example/widget-3000', source: 'other-shop' });
++ await req(h, 'POST', `/api/v1/entities/${e.slug}/aliases`, { token: editorToken(), body: { type: 'url', value: 'https://www.other.example/widget-3000/?utm_source=x' } });
++ await importItem(h, b);
++ let agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate.result.components.rating.on_scale.value, 3);
++ assert.strictEqual(agg.aggregate.result.components.rating.count, 200);
++ const rev = agg.aggregate.revision;
++
++ const removed = h.sources.remove(b.id, 'licence withdrawn');
++ const r = await deliver(h, sourcesEvent('sources.item.removed', removed, { reason: 'licence withdrawn' }));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.outcome, 'removed');
++ agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate.revision, rev + 1, 'a new aggregate revision');
++ assert.strictEqual(agg.aggregate.result.components.rating.on_scale.value, 4);
++ assert.strictEqual(agg.aggregate.result.components.rating.count, 100);
++ assert.ok(!agg.aggregate.result.inputs.some((i) => i.source_item_id === b.id));
++ const sig = h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ?').get(b.id);
++ assert.strictEqual(sig.status, 'withdrawn');
++ assert.match(sig.status_reason, /licence withdrawn/);
++ const ev = outbox(h).filter((x) => x.event_type === 'reviews.signal.removed');
++ assert.strictEqual(ev.length, 1);
++ assert.strictEqual(ev[0].payload.source_item_id, b.id);
++
++ // Redelivery is a no-op (inbox), and removing the last signal leaves no aggregate.
++ const again = await deliver(h, sourcesEvent('sources.item.removed', removed, { reason: 'licence withdrawn' }));
++ assert.strictEqual(again.status, 200);
++ const last = h.sources.remove(a.id, 'takedown');
++ await deliver(h, sourcesEvent('sources.item.removed', last, { reason: 'takedown' }));
++ agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate, null, 'no qualifying signal → no aggregate');
++ assert.strictEqual(agg.aggregate_revision, rev + 2, 'the disappearance is itself a recorded revision');
++ const rows = h.db.prepare('SELECT revision, result FROM review_aggregates WHERE entity_id = ? ORDER BY revision').all(e.id);
++ assert.strictEqual(rows[rows.length - 1].result, null);
++ } finally { await h.stop(); }
++});
++
++t('an item update supersedes the signal; the pull path follows Sources in change order', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ const a = h.sources.put(steamItem({ votedUp: true }));
++ h.sources.put(steamItem({ votedUp: true }));
++ h.sources.put(productItem({ url: 'https://unrelated.example/x', title: 'Unrelated' }));
++ const sync = await req(h, 'POST', '/api/v1/sources/sync', { token: IMPORT() });
++ assert.strictEqual(sync.status, 200, sync.text);
++ assert.strictEqual(sync.json.pull.ok, true);
++ assert.strictEqual(sync.json.pull.applied, 3);
++ let agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate.result.components.recommendation.percent, 100);
++
++ h.sources.revise(a.id, { voted_up: false });
++ const again = await req(h, 'POST', '/api/v1/sources/sync', { token: IMPORT() });
++ assert.strictEqual(again.json.pull.applied, 1);
++ agg = await aggregateOf(h, e.slug);
++ assert.strictEqual(agg.aggregate.result.components.recommendation.percent, 50);
++ const sigs = h.db.prepare('SELECT * FROM review_signals WHERE source_item_id = ? ORDER BY created_at').all(a.id);
++ assert.deepStrictEqual(sigs.map((s) => s.status), ['superseded', 'active']);
++ assert.strictEqual(sigs[0].superseded_by, sigs[1].id);
++ assert.strictEqual(sigs[1].item_revision, 2);
++ // Unchanged items are only refreshed.
++ const third = await req(h, 'POST', '/api/v1/sources/sync', { token: IMPORT() });
++ assert.strictEqual(third.json.pull.applied, 0);
++ // The unrelated product waits for an editor, it is never guessed onto an entity.
++ const queue = await req(h, 'GET', '/api/v1/items', { token: editorToken() });
++ assert.strictEqual(queue.json.items.length, 1);
++ assert.strictEqual(queue.json.items[0].resolution, 'unmatched');
++ } finally { await h.stop(); }
++});
++
++t('a Sources outage changes nothing and invents nothing', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ await importItem(h, steamItem({ votedUp: true }));
++ const before = await aggregateOf(h, e.slug);
++ h.sources.setDown(true);
++ const sync = await req(h, 'POST', '/api/v1/sources/sync', { token: IMPORT() });
++ assert.strictEqual(sync.status, 200);
++ assert.strictEqual(sync.json.pull.ok, false);
++ const imp = await req(h, 'POST', '/api/v1/signals/import', { token: IMPORT(), body: { source_item_id: 'itm_01J00000000000000000000000' } });
++ assert.strictEqual(imp.status, 503);
++ const after = await aggregateOf(h, e.slug);
++ assert.deepStrictEqual(after, before);
++ // An event for an item Sources cannot serve right now is queued and retried, not faked.
++ const ev = await deliver(h, sourcesEvent('sources.item.created', steamItem()));
++ assert.strictEqual(ev.json.outcome, 'queued');
++ assert.strictEqual(h.sync.pending(), 1);
++ } finally { await h.stop(); }
++});
++
++t('every produced event is a valid events.event-envelope@1', async () => {
++ const h = await boot();
++ try {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ const it = steamItem({ votedUp: true });
++ await importItem(h, it);
++ await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(it.id), { reason: 'takedown' }));
++ const events = outbox(h);
++ assert.ok(events.some((x) => x.event_type === 'reviews.signal.added'));
++ assert.ok(events.some((x) => x.event_type === 'reviews.signal.removed'));
++ assert.ok(events.some((x) => x.event_type === 'reviews.index_document.upserted'));
++ for (const env of events) {
++ const v = contracts.validate('events.event-envelope@1', env);
++ assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(env.source, 'reviews');
++ }
++ for (const env of events.filter((x) => x.event_type === 'reviews.index_document.upserted')) {
++ const v = contracts.validate('search.index-document@1', env.payload);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ }
++ assert.ok(e);
++ } finally { await h.stop(); }
++});
++
++t('import needs the capability; a bad webhook signature is refused', async () => {
++ const h = await boot();
++ try {
++ const it = h.sources.put(steamItem());
++ const denied = await req(h, 'POST', '/api/v1/signals/import', { token: serviceToken({ cap: ['reviews.entity.resolve'] }), body: { source_item_id: it.id } });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json.code, 'capability.denied');
++ const bad = await deliver(h, sourcesEvent('sources.item.created', it), { secret: 'wrong' });
++ assert.strictEqual(bad.status, 401);
++ const other = await deliver(h, { ...sourcesEvent('sources.item.created', it), source: 'news' });
++ assert.strictEqual(other.json.outcome, 'ignored:source');
++ } finally { await h.stop(); }
++});
ddiff --git a/test/summaries.test.js b/test/summaries.test.js
nnew file mode 100644
iindex 0000000..8e86499
---- /dev/null
++++ b/test/summaries.test.js
@@@ -0,0 +1,167 @@
++'use strict';
++/**
++ * Summaries: every point cites signals; AI drafts (reviews.summarize_entity) are never published
++ * on their own, stay out of the page and the index until a person approves them, and AI text can
++ * never produce a rating. A withdrawn cited signal flags the summary and prepares a pending
++ * revision for an editor.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, steamItem, importItem, createEntity, outbox, deliver, sourcesEvent, editorToken, readerToken, serviceToken,
++} = require('./helpers');
++
++const t = suite('summaries');
++const AI = () => serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] });
++const WF = { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 };
++
++async function setup(h) {
++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
++ const items = [];
++ for (const v of [true, true, true, false]) items.push((await importItem(h, steamItem({ votedUp: v }))).item);
++ const sigs = h.db.prepare("SELECT id, recommended, source_item_id FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
++ return { e, items, sigs, pos: sigs.filter((s) => s.recommended === 1).map((s) => s.id), neg: sigs.filter((s) => s.recommended === 0).map((s) => s.id) };
++}
++
++const aggRevision = (h, id) => (h.db.prepare('SELECT MAX(revision) AS r FROM review_aggregates WHERE entity_id = ?').get(id).r);
++
++t('AI text never yields a rating: rating fields and rating text are refused, the aggregate is untouched', async () => {
++ const h = await boot();
++ try {
++ const { e, pos } = await setup(h);
++ const rev = aggRevision(h, e.id);
++ const withField = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body: { overview: 'Fine.', overview_signals: pos, rating: 4.5, workflow: WF } });
++ assert.strictEqual(withField.status, 422);
++ assert.strictEqual(withField.json.code, 'summary.rating_forbidden');
++ const inPoint = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body: { pros: [{ text: 'Good', signals: pos, stars: 5 }], workflow: WF } });
++ assert.strictEqual(inPoint.json.code, 'summary.rating_forbidden');
++ for (const text of ['Players rate it 4.5/5.', 'A solid 9 out of 10.', '★★★★☆ overall', 'Easily five stars... 5 stars']) {
++ const r = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body: { overview: text, overview_signals: pos, workflow: WF } });
++ assert.strictEqual(r.status, 422, text);
++ assert.strictEqual(r.json.code, 'summary.rating_in_text', text);
++ }
++ assert.strictEqual(aggRevision(h, e.id), rev);
++ // Even an entity with no signals gets no aggregate from AI text: summaries cannot cite what does not exist.
++ const empty = await createEntity(h, { name: 'Unknown Game', kind: 'game' });
++ const noSig = await req(h, 'POST', `/api/v1/entities/${empty.slug}/summary/proposals`, { token: AI(), body: { overview: 'Widely loved.', pros: [{ text: 'Loved', signals: pos }], workflow: WF } });
++ assert.strictEqual(noSig.status, 422);
++ assert.strictEqual(noSig.json.code, 'summary.invalid_citation');
++ const agg = await req(h, 'GET', `/api/v1/entities/${empty.slug}/aggregate`, { token: editorToken() });
++ assert.strictEqual(agg.json.aggregate, null);
++ const page = await req(h, 'GET', `/e/${empty.slug}`);
++ assert.ok(!/AggregateRating/.test(page.text));
++ } finally { await h.stop(); }
++});
++
++t('an AI draft is held: not on the page, not publishable, not indexed, until a person approves it', async () => {
++ const h = await boot();
++ try {
++ const { e, pos, neg } = await setup(h);
++ const body = {
++ overview: 'Most recent Steam reviewers in the sample recommend it. The sample is small and recent, so it says little about long-term opinion across every player who reviewed it on Steam in any language.',
++ pros: [{ text: 'Most sampled reviewers recommend it', signals: pos }], cons: [{ text: 'One sampled reviewer does not', signals: neg }],
++ workflow: WF, stub_provider: false,
++ };
++ // Only a service proposes; a person cannot pose as the AI.
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: editorToken(), body })).status, 403);
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: serviceToken({ cap: ['reviews.summary.publish'] }), body })).status, 403);
++ const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: AI(), body });
++ assert.strictEqual(p.status, 201, p.text);
++ assert.strictEqual(p.json.revision.status, 'pending_ai');
++ assert.strictEqual(p.json.revision.authorship.mode, 'ai');
++ assert.strictEqual(p.json.summary.state, 'draft');
++
++ const pageBefore = await req(h, 'GET', `/e/${e.slug}`);
++ assert.match(pageBefore.text, /No published summary/);
++ assert.ok(!pageBefore.text.includes('Most sampled reviewers recommend it'));
++ assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.published'));
++ assert.ok(!outbox(h).some((x) => JSON.stringify(x.payload).includes('Most sampled reviewers')), 'the draft never reaches Search');
++ const feed = await req(h, 'GET', '/feed.json');
++ assert.strictEqual(JSON.parse(feed.text).items.length, 0);
++
++ const pub = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/publish`, { token: editorToken(), body: { revision: 1 } });
++ assert.strictEqual(pub.status, 409);
++ assert.strictEqual(pub.json.code, 'ai_generated_unreviewed');
++ // A reader is not an editor.
++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/1/review`, { token: readerToken(), body: { decision: 'approved' } })).status, 403);
++
++ const ok = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/1/review`, { token: editorToken(), body: { decision: 'approved', note: 'checked against the signals' } });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.published, true);
++ const page = await req(h, 'GET', `/e/${e.slug}`);
++ assert.match(page.text, /AI-generated by workflow reviews\.summarize_entity v1, reviewed by a person/);
++ assert.match(page.text, /Most sampled reviewers recommend it/);
++ const ev = outbox(h).filter((x) => x.event_type === 'reviews.summary.published');
++ assert.strictEqual(ev.length, 1);
++ // The review JSON-LD carries no reviewRating: a summary never states a rating.
++ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const review = ld.find((x) => x['@type'] === 'Review');
++ assert.ok(review, 'Review JSON-LD for a signal-backed summary');
++ assert.strictEqual(review.reviewRating, undefined);
++ } finally { await h.stop(); }
++});
++
++t('human summaries cite signals for every point; a withdrawn cited signal flags the summary and prepares a pending revision', async () => {
++ const h = await boot();
++ try {
++ const { e, pos, neg, sigs } = await setup(h);
++ const uncited = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { pros: [{ text: 'Clever puzzles', signals: [] }] } });
++ assert.strictEqual(uncited.status, 422);
++ assert.strictEqual(uncited.json.code, 'summary.uncited_point');
++ const foreign = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { pros: [{ text: 'x', signals: ['sig_01J9ZZZZZZZZZZZZZZZZZZZZZZ'] }] } });
++ assert.strictEqual(foreign.json.code, 'summary.invalid_citation');
++ const scored = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: { score: 9, pros: [{ text: 'x', signals: pos }] } });
++ assert.strictEqual(scored.json.code, 'summary.rating_forbidden');
++
++ const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
++ overview: 'Three of the four sampled Steam reviewers recommend it.', overview_signals: sigs.map((s) => s.id),
++ pros: [{ text: 'Recommended by most of the sample', signals: pos }], cons: [{ text: 'Not by everyone', signals: neg }], publish: true,
++ } });
++ assert.strictEqual(w.status, 201, w.text);
++ assert.strictEqual(w.json.published, true);
++
++ // The source removes the only negative review: the con loses its support.
++ const negItem = sigs.find((s) => s.recommended === 0).source_item_id;
++ await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(negItem, 'author deleted the review'), { reason: 'author deleted the review' }));
++ const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ assert.strictEqual(s.flagged, 1);
++ assert.match(s.flag_reason, /withdrawn/);
++ const page = await req(h, 'GET', `/api/v1/entities/${e.slug}`, { token: editorToken() });
++ assert.strictEqual(page.json.summary.published.number, 1, 'the published text stays until an editor decides');
++ assert.strictEqual(page.json.summary.pending.length, 1);
++ const pending = page.json.summary.pending[0];
++ assert.strictEqual(pending.status, 'pending_system');
++ assert.deepStrictEqual(pending.points.map((p) => p.kind).sort(), ['overview', 'pro']);
++ const html = await req(h, 'GET', `/e/${e.slug}`);
++ assert.match(html.text, /Under review/);
++ assert.match(html.text, /rv-unsupported/);
++ // The gate: an unsupported point means noindex until it is resolved.
++ assert.ok(page.json.decision.reasons.includes('unsupported_claims'));
++ // A system revision needs a person's approval.
++ const direct = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/publish`, { token: editorToken(), body: { revision: pending.number } });
++ assert.strictEqual(direct.status, 409);
++ const ok = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/${pending.number}/review`, { token: editorToken(), body: { decision: 'approved' } });
++ assert.strictEqual(ok.status, 200, ok.text);
++ const fresh = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
++ assert.strictEqual(fresh.flagged, 0);
++ assert.strictEqual(fresh.published_revision, pending.number);
++ assert.ok(outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));
++ } finally { await h.stop(); }
++});
++
++t('corrections from people queue for editors', async () => {
++ const h = await boot();
++ try {
++ const { e } = await setup(h);
++ const anon = await req(h, 'POST', `/api/v1/entities/${e.slug}/corrections`, { body: { target_type: 'entity', body: 'This is the wrong game entirely.' } });
++ assert.strictEqual(anon.status, 403);
++ const svcAlone = await req(h, 'POST', `/api/v1/entities/${e.slug}/corrections`, { token: serviceToken({ cap: ['reviews.correction.submit'] }), body: { target_type: 'entity', body: 'This is the wrong game entirely.' } });
++ assert.strictEqual(svcAlone.status, 403);
++ const c = await req(h, 'POST', `/api/v1/entities/${e.slug}/corrections`, { token: readerToken(), body: { target_type: 'aggregate', body: 'The sample is only recent English reviews.', evidence_url: 'https://store.steampowered.com/app/620/' } });
++ assert.strictEqual(c.status, 201, c.text);
++ const list = await req(h, 'GET', '/api/v1/corrections', { token: editorToken() });
++ assert.strictEqual(list.json.corrections.length, 1);
++ assert.strictEqual((await req(h, 'GET', '/api/v1/corrections', { token: readerToken() })).status, 403);
++ const done = await req(h, 'PATCH', `/api/v1/corrections/${c.json.correction.id}`, { token: editorToken(), body: { status: 'accepted', note: 'Added a limitation note' } });
++ assert.strictEqual(done.json.correction.status, 'accepted');
++ } finally { await h.stop(); }
++});