openvibers/openvibe.deals README.md

README.md

main 38.1 KB · 508 lines · markdown

1# OpenVibe.Deals
2
3> Deals submitted and voted on by the community, with source, price and freshness always shown.
4
5**Live site:** https://openvibe.deals · **API docs:** https://openvibe.services · **For AI:** https://openvibe.deals/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha (roadmap Wave 18, Deals). The service runs and its tests pass. It is **public at
9`openvibe.deals` since 2026-10-08** (contracts 0.118.0), with offers from the DealNews daily feed through OpenVibe.Sources. Its capabilities and
10service manifest are registered in openvibe-contracts v0.97.0.
11**Domain:** `openvibe.deals` · **Port:** 4840 · **Service id:** `deals`
12**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.9; roadmap §15.13, §29, §32.
13**License:** AGPL-3.0 (same as every OpenVibe service).
14
15## Purpose
16
17Normalised deal submissions and imports, product and store records, offers with timestamped price,
18shipping, condition and availability observations, server-authoritative votes and hotness, duplicate
19resolution, watches and saved searches. Discussion is OpenVibe.Community's; imports come from
20OpenVibe.Sources; notifications are events for OpenVibe.Network.
21
22The one rule everything else follows: **a price is an observation.** It has a source and an
23`observed_at`, it is only ever shown "as of" that time, and an offer is never presented as current
24without a fresh observation. A price nobody stated stays "not stated" — in the page, the JSON, the
25structured data, the comparison, the feeds and Search.
26
27## Owns
28
29The nine charter tables live in Deals' own PostgreSQL database (`ov_deals` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
30
31| Charter table | What it is |
32|---|---|
33| `deal_products` | product records (name, brand, category), `/p/:slug` |
34| `deal_product_aliases` | `gtin` (normalised to 14 digits), `mpn`, `sku`, exact `name`, `url` → one product; an alias never silently moves to another product |
35| `deal_offers` | an offer: title, link (tracking parameters stripped, except where a source policy keeps it verbatim — see "Per-source policy"), store, product, status (`active`/`expired`/`disabled`), **stated** expiry, review state, and the merge pointer `merged_into` |
36| `deal_offer_sources` | where an offer came from: the submission, a person's observations, or an OpenVibe.Sources item (typed reference `{sources, item, itm_…, revision}`; the item is never copied) |
37| `deal_votes` | one row per (offer, subject): `+1` / `-1` / `0` (removed, kept as history), with the weight and IP hash it was cast with |
38| `deal_hotness_snapshots` | every hotness computation with all of its inputs (formula `hot@1`) |
39| `deal_watches` | keyword / product / price-below watches and saved searches |
40| `deal_price_observations` | the only place a price lives: price and currency as stated, shipping, condition, availability, `observed_at`, source |
41| `deal_flags` | reports by people and system flags (vote rings) for moderators |
42
43Companion tables (not charter tables): `deal_stores` (store records by domain — the plan's "store
44records" obligation; a deviation from the nine-table list, see the final section), `watch_notifications`
45(the (watch, observation) uniqueness), `moderation_log`, `rate_events`, `import_state`,
46`subject_projections`, `deal_campaigns` (crowdfunding discovery), the SDK's `event_outbox` / `idempotency_receipts`, and openvibe-publishing's
47`deal_offer_discussion_refs` and `deal_index_revisions`.
48
49Telemetry (openvibe-sdk 0.46, `platform.telemetry-sample@1`): requests are aggregated by route template, method and status class,
50never raw URL; `/shared`, static assets, health, readiness and `/metrics` are skipped. `server/app.js` builds `createHttpTelemetry`
51and mounts its middleware; `server/index.js` writes one `http.request` sample per key plus the active-requests, p95 and event-loop-lag
52gauges to `telemetry_samples` every minute and on stop, and deletes rows older than 14 days daily (`migrations/0004_telemetry.sql`).
53
54## Does not own
55
56- coupon codes (OpenVibe.Coupons; cross-linked later, separate database)
57- discussion (OpenVibe.Community threads, referenced by id)
58- source registry, fetching, robots and terms (OpenVibe.Sources)
59- notifications delivery (OpenVibe.Network; Deals only emits `deals.watch.matched` and never emails)
60- identity (OpenVibe.Network subjects; Deals stores `usr_…` ids only)
61
62## What works
63
64### Routes (server-rendered, useful without JavaScript)
65
66| Route | What it is |
67|---|---|
68| `GET /` | the front page (`server/render/home.js`): a search, the site's numbers (active deals, shops, prices seen in 24 hours, ending this week, categories, crowdfunding campaigns), then editors' picks, hot (voted up), just in, ending soon, under $20 (a fresh stated price), categories, shops and crowdfunding, how it works and questions (FAQPage JSON-LD). A deal shows once; a section with nothing to show is left out. `/?page=N` moved to `/hot?page=N` (301). |
69| `GET /hot`, `GET /new`, `GET /ending`, `GET /picks` | hot deals (latest `hot@1` snapshots), newest deals, deals whose stated end is in the next 7 days (soonest first) and a source's editors' picks; each card shows the latest price **as of** its time with a fresh/stale badge |
70| `GET /c`, `GET /c/:category` | deals by category, as each deal's source names it (DealNews' `dealnews:category`, Sources feed@3; migration 0007) |
71| `GET /d/:slug` | an offer: latest observation "as of", freshness, stated end (or "not stated"), votes and hotness with the formula, description (safe Markdown), AI/import disclosure, **price history table** (every observation, newest first, with its source), sources, merged listings, discussion. Forms: vote, report what the page says now, report a problem, mark expired (submitter), moderation. `…/:slug.json` is the same facts as data. A merged listing answers 301 to its canonical offer; a disabled one 410. |
72| `GET /p/:slug` | a product: **price comparison** across its offers, each with its own latest observation and time (`.json` too) |
73| `GET /s/:domain` | a store's deals |
74| `GET /search?q=` | search (noindex), with "save this search" / "watch these words" |
75| `GET /crowdfunding?q=&sort=` | crowdfunding discovery: one search opens Kickstarter, Indiegogo, Crowd Supply, Ulule, GoFundMe, Open Collective, Patreon, Wefunder and StartEngine at the words (deep links, nothing read from them), and the campaigns Sources collected (Open Collective) by contributors, newest or most received, amounts "as of" their fetch. Indexable without words; a search is noindex. `/crowdfunding.json` is the same as data (CORS *). |
76| `GET/POST /submit` | submit a deal (signed in) |
77| `GET/POST /watches` | watches and saved searches; `POST /watches/:id/delete` |
78| `GET /mod` | moderators: open flags (incl. vote rings), text waiting for review, possible duplicates; `POST /mod/offers/:slug/(merge\|unmerge\|disable\|enable\|review\|expire)`, `POST /mod/flags/:id/(resolve\|dismiss)` |
79| `GET /feed.xml`, `/atom.xml`, `/feed.json` | newest active deals, with "as of" times and stable ids `deals:offer:<id>` |
80| `GET /robots.txt`, `/llms.txt`, `/sitemap.xml`, `/sitemap-pages.xml`, `/sitemap-<n>.xml` | discovery (below): the index names `sitemap-pages.xml` (the front page and every list — hot, new, ending, picks, each category and shop, crowdfunding — while it has deals) and one `/sitemap-<n>.xml` child per 10,000 catalog URLs (offers, then products, always in that order); a child number past the last is 404 |
81| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) |
82| `/auth/*` | Network SSO (the same session layer as Community/Blog) |
83
84Sitemap entries carry lastmod from each offer or product page's latest recorded change.
85
86### API (`/api/v1`, problem+json errors)
87
88Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for
89the person in `X-OV-Subject`; browsers or apps with a Network user JWT are judged by the domain
90(signed in, submitter, moderator). Public reads need nothing.
91
92| Route | Capability |
93|---|---|
94| `GET /offers?sort=hot\|new`, `GET /offers/:id`, `GET /offers/:id/hotness`, `GET /products/:slug`, `GET /stores/:domain` | public |
95| `POST /offers` | `deals.offer.submit` |
96| `PATCH /offers/:id`, `POST /offers/:id/observations` | `deals.offer.update` |
97| `POST /offers/:id/expire` | `deals.offer.expire` |
98| `PUT /offers/:id/vote` · `DELETE /offers/:id/vote` | `deals.vote.set` · `deals.vote.remove` |
99| `POST /offers/:id/flags` | `deals.flag.create` |
100| `POST /offers/:id/merge`, `/unmerge` | `deals.offer.merge` |
101| `POST /offers/:id/disable\|enable\|review`, `GET /flags`, `POST /flags/:id/resolve` | `deals.offer.moderate` |
102| `POST /products/resolve` | `deals.product.resolve` |
103| `GET /watches` · `POST /watches` · `DELETE /watches/:id` | `deals.watch.read` · `deals.watch.create` · `deals.watch.delete` |
104
105The eight charter capabilities are all there; `deals.offer.merge`, `deals.offer.moderate`,
106`deals.watch.read` and `deals.flag.create` are additions (released in openvibe-contracts, pinned at
107v0.97.0; the source proposals stay in [docs/capabilities-proposal/](docs/capabilities-proposal/) and
108[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)); `server/auth/capabilities.js`
109decides them with the contracts grant rule.
110
111### Events (SDK outbox, written in the same transaction as the change)
112
113| Event | When | Visibility |
114|---|---|---|
115| `deals.offer.created` | a submission or an import created an offer | public if listable, else internal |
116| `deals.offer.updated` | edit, new observation, merge/unmerge, review, disable/enable, source removed | public if listable, else internal |
117| `deals.offer.expired` | submitter, moderator or stated end time | internal once expired |
118| `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal |
119| `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal |
120| `deals.moderation.action` | a moderator edited, expired, disabled, enabled, reviewed, merged or unmerged someone else's offer (`offer.*`), or resolved or dismissed a report (`flag.*`); for the network's moderation audit log (ADR-022, `common.moderation-action@1`), never the text | internal |
121| `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal |
122
123Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
124`DEALS_EVENTS_SECRET`, inbox-deduplicated). They carry no prices, so they only wake the importer.
125
126Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` arrive at the
127same route and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`). An export part carries
128the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion
129removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports,
130products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's
131evidence names them as `deleted`. A staff member's `moderation_log` rows stay attributed (counted as retained). The
132two subscriptions are created at boot when missing (`startSubscriptions`, needs `EVENTS_URL` and
133`DEALS_EVENTS_SECRET`); `account_data_events` keeps one receipt per export and deletion.
134
135## Prices and freshness
136
137- Every price, shipping cost, condition and availability is a row in `deal_price_observations` with
138 `observed_at` (when it was seen: the moment a person reported it, or the item's
139 `provenance.retrieved_at` for imports) and its source. Prices are never edited in place.
140- The latest observation is **fresh** while younger than `DEALS_FRESHNESS_HOURS` (48 h), **stale**
141 after; no observation is **unobserved**. Pages print "as of <time>", a stale price says it may no
142 longer be available, and nothing on the site calls a price "current".
143- Stale → `noindex` (gate reason `stale_price`), out of the catalog sitemaps (`/sitemap-<n>.xml`), the Offer JSON-LD
144 loses its price/currency/availability, and the worker re-sends the Search document (freshness
145 facet, `noindex`) — only when it actually changed.
146- A price needs its currency: `10` without `USD` is refused (422), never completed. `$10` is refused
147 (amounts are plain decimals). Imported values are copied as stated; a malformed one is `null`. A
148 price in an imported headline is **not** parsed out of the text. A deal feed that states its offers in its own
149 elements (Sources `feed@2`: DealNews' price with currency, retailer and end date) is a statement: the price is an
150 observation, the end date the stated end, and the retailer the offer's `seller` ("at Amazon" on cards, "Sold by" on
151 the page, `seller` in JSON and the Offer JSON-LD). The store stays the link's domain; a source whose links are kept
152 verbatim (an aggregator's own pages) never names that store after a retailer (migration `0006_offer_seller.sql`).
153 **Shops:** `/at` lists every seller with its count and `/at/<seller>` its deals (newest first, paginated); the
154 "at Amazon" on a card and "Sold by" on a page link there. Like store pages, they are not in the sitemap.
155- JSON-LD: `Product` with one `Offer` per offer; `price`/`priceCurrency` only from a fresh observation
156 that stated both, `availability`/`itemCondition` only when stated, `validThrough` only from a stated
157 end. Unknown fields are omitted, never 0 or `InStock`.
158- Expiry is only ever **stated** (by the submitter or the source's `valid_until`); the worker expires
159 those. An unknown end stays "not stated" and never expires by itself (it goes stale instead).
160
161## Duplicates and merging
162
163- The same link (normalised: fragment and tracking/affiliate parameters removed, `www.` and scheme
164 ignored) cannot be posted twice: a person gets the existing deal (409 / redirect), a second Sources
165 item attaches to it as another source. A link a source policy keeps verbatim is identified as
166 received, so it never strips parameters to collide with a normalised one.
167- Moderators merge listings of one deal (`/mod`, "possible duplicates" = same store and same product
168 or title). **Merging moves nothing:** B gets `merged_into = A`; A's page, JSON, votes, history and
169 sources read across the group; B's URL answers 301. A person who voted on both counts **once**
170 (their most recent row in the group). Unmerge clears the pointer and B has exactly its own votes,
171 observations and sources again. Both are in `moderation_log` with the tallies before and after, and
172 Search gets a tombstone for B (and B back on unmerge). Cycles are refused.
173
174## Votes, hotness and abuse controls
175
176Votes are one row per (offer, subject); the server reads only `value` from a request. A vote cast
177through a merged listing lands on the canonical offer.
178
179**Hotness `hot@1`** (deterministic, computed only on the server, every snapshot stores its inputs
180and `GET /api/v1/offers/:id/hotness` recomputes it):
181
182```
183score = upWeight − downWeight (weighted effective votes, 4 decimals)
184ageHours = max(0, (t − firstSeenAt) / 3 600 000) (firstSeenAt: earliest created_at in the merge group)
185hot = round6( score / (ageHours + 2) ^ 1.5 )
186```
187
188Worked examples (tested): up 3, down 1, 10 h old → 2 / 12^1.5 = **0.048113**; up 2, 0 h → **0.707107**.
189Snapshots are taken on every vote change, merge, unmerge and creation, and by the worker for every
190active offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed at
191the same moment. The worker's tick snapshots are kept for a day, the others for 7 days, and every offer's latest
192snapshot always.
193
194**Abuse controls (server-side, database windows shared by forms and API):**
195
196| Control | Default |
197|---|---|
198| votes per person per hour | 60 (`DEALS_VOTE_LIMIT_SUBJECT_HOUR`) |
199| votes per IP hash per hour (HMAC under `DEALS_IP_HASH_SECRET`; raw IPs are never stored) | 120 |
200| vote changes per person per offer per hour | 6 |
201| no votes on a deal you posted (any listing in the group) | — |
202| new-account weight | 0.25 until there is evidence the account is ≥ 7 days old: the time in its `usr_` ULID or when Deals first saw it sign in, whichever is older (subjects were minted when identity moved to Network, so the ULID time is an upper bound on account age) |
203| vote-ring flag `shared_ip` | ≥ 3 distinct voters on one offer from one IP hash |
204| vote-ring flag `covote` | two voters who voted the same way on ≥ 5 offers within 10 minutes of each other each time |
205| submissions / observations / reports | 20 per day / 30 per hour / 30 per day per person |
206
207Ring flags are for moderators only (`/mod`, `GET /api/v1/flags`); they never remove votes on their
208own. One open flag per ring (or per person and offer for reports).
209
210**Per-actor limits** (`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4) cap
211requests, refused ones included, by who makes them, once `req.viewer` is resolved and before any work
212(before the body is read). Each write's cap sits above the abuse control for the same action, which
213keeps deciding what is recorded. Counted: a person as `user:usr_…` (their own token or cookie, or named
214by a service in `X-OV-Subject`); a first-party service relaying a signed-out visitor by the address it
215forwards; a service or app acting as itself (such as `svc:ai` for `deals.enrich_deal`) by its principal;
216a signed-out caller by address. A first-party service reading for itself is not counted on reads. Past
217a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
218`deals_rate_limited_total{limit,window}`. A page form and the API route that do the same thing share one
219budget.
220
221| Routes (API and forms) | Per caller, a minute / an hour |
222|---|---|
223| API reads | `DEALS_LIMITS_MINUTE` / `DEALS_LIMITS_HOUR` (120 / 3000) |
224| Submit | 30 / 120 |
225| Edit, expire | 30 / 300 |
226| Price observations | 40 / 200 |
227| Votes (set and remove) | 60 / 600 |
228| Flags | 30 / 120 |
229| Moderation (merge, unmerge, disable, enable, review, resolve flags) | 60 / 600 |
230| Product resolve | 60 / 1200 |
231| Watches (create and delete) | 30 / 300 |
232| Comments (sent to Community) | 20 / 300 |
233
234Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the signed
235Events deliveries at `/internal/events`, and the pages and feeds people read. `test/actor-limits.test.js`.
236
237## Watches and saved searches
238
239`keyword` (every word must appear), `product`, `price_below` (product or words, a price **stated in
240the watch's currency** below the limit — an unknown price never matches) and `search` (a saved
241search that never notifies). Matching runs in the observation's transaction; `watch_notifications`
242has primary key `(watch_id, observation_id)`, so an observation can produce at most one
243`deals.watch.matched` per watch — replays, retries and re-runs cannot repeat it. On top: a keyword
244or product watch notifies once per offer, a price watch again only for a lower price; stale
245observations, inactive offers and a person's own reports do not notify.
246
247## Imports from OpenVibe.Sources
248
249The importer pulls `GET /api/v1/items?category=deals&after=<cursor>&include_removed=1` (cursor in
250`import_state`, one savepoint per item so one bad item cannot stall it) every
251`DEALS_IMPORT_INTERVAL_MS`, and early when a `sources.item.*` event arrives. `offer` items → one
252offer; `product` items → a product and one offer per stated offer; `article` items (RSS) → an offer
253with no price. A new revision or a newer retrieval is a new observation; a replay changes nothing.
254Offers whose latest imported observation is getting old are re-confirmed from `GET /api/v1/items/:id`
255when Sources fetched them again. A removed item (takedown, licence) removes its source; an imported
256offer with no source left is disabled. Imported text is `review_state = pending` → `noindex` until a
257moderator records a review (a `usr_` subject; a service cannot review).
258
259**The automatic review of imports** (`server/domain/import-review.js`): an offer imported from a
260configured feed source is checked when it is imported. It passes when its link is https and points at
261that source's retailer domain (or a subdomain of it), its title is not empty and at most 300
262characters, its price is a positive number with a currency, it has not expired and its title and
263description state none of the blocked/adult terms. A pass sets `review_state = auto_approved`
264(published, indexable, `reviewed_by = system:import-checks`); a fail stays `pending` with the failed
265check's name in `review_failed_check`. The configured sources and their retailer domains are in
266`server/config.js` (`DEALS_IMPORT_REVIEW_SOURCES` replaces them, `DEALS_IMPORT_REVIEW=off` turns the
267review off); a source that is not configured is never auto-approved. The offers imported before this
268existed are checked by `scripts/backfill-import-review.js` (`--dry-run` prints counts only).
269Offers people submit by hand are not touched: their review flow is unchanged, and a moderator can
270still hide any offer.
271
272The crowdfunding feed (`category=crowdfunding`, server/domain/campaigns.js) is pulled through the same chassis with its
273own cursor (`sources.crowdfunding.after` in `deals_ingest_cursor`) into `deal_campaigns` (migration
274`0005_crowdfunding.sql`), never into offers: name, summary, type, first tag, currency, the total received and yearly
275budget in cents and the contributor count, exactly as stated, with the time Sources fetched them. A removed item is
276deleted. Each campaign links to its page on its platform (Open Collective: `https://opencollective.com/<slug>`).
277
278### Per-source policy: verbatim links, attribution and browser extensions
279
280Deals normalises an imported offer's link by default: the fragment and tracking/affiliate parameters
281are removed so two posts of one deal share an identity. A source whose terms require otherwise gets
282an explicit entry in `server/domain/source-policy.js`, keyed by the Sources `source_key` — the prose
283`license_note` / `terms_note` are never parsed, the map is data:
284
285| Policy field | Effect |
286|---|---|
287| `verbatimLinks` | the link is stored and shown exactly as received (no fragment or tracking-parameter stripping, no rewriting, no `rel` change that alters the URL), and the title is used as the source stated it, with no added words |
288| `attribution` | `"Deal via <text>"` linking to `url` is shown on the offer card, the offer page and the product comparison, and carried in the offer's JSON (`.json` / API `attribution`) and its RSS/Atom/JSON-Feed summary and tags |
289| `noExtension` | the offer is excluded from the API when the caller's `Origin` is a browser-extension scheme (`chrome-extension://`, `moz-extension://`, …): absent from `GET /offers`, `GET /products/:slug` and `GET /stores/:domain`, and `GET /offers/:id` answers 404 |
290
291**DealNews** ([feed terms](https://www.dealnews.com/pages/rss.html), checked 2026-10-07) allows public
292use of its feeds only without removing or adding content within the feed display, without modifying
293the links or referral codes, with attribution to DealNews as text (e.g. "DealNews") or their 88x31
294logo, and never inside a browser extension. `dealnews-daily` therefore sets all three:
295
296```js
297'dealnews-daily': {
298 verbatimLinks: true,
299 attribution: { text: 'DealNews', url: 'https://www.dealnews.com/' },
300 noExtension: true,
301},
302```
303
304Community submissions and every source with no entry keep today's behaviour (normalised links, no
305attribution, visible to every caller). `test/import.test.js` and `test/api.test.js` cover the verbatim
306link byte-for-byte, the attribution on card, page and JSON, the untouched default, and the
307extension-facing exclusion.
308
309## Discoverability (roadmap §32)
310
311- SSR HTML with canonical, Open Graph, JSON-LD (`Product`/`Offer`, `BreadcrumbList`, `WebSite` with
312 `SearchAction`), `meta robots` + `X-Robots-Tag` from the openvibe-publishing gate. Gate reasons:
313 `takedown` (disabled: hidden), `expired`, `stale_price`, `noindex_requested` with detail
314 `review_pending` (imported/AI text). Empty lists and search/forms are `noindex`.
315- Sitemaps list only offers and products the gate calls indexable **now**; `lastmod` is the latest
316 observation or edit, never "now". `robots.txt` states the automated-consumer policy, `llms.txt`
317 explains observations and the JSON twins.
318- AI-assisted text (`X-OV-Origin: ai`, e.g. OpenVibe.AI's `deals.enrich_deal`) is stored separately
319 (`ai_summary`), disclosed on the page and `noindex` until a person reviews it.
320- IndexNow (`openvibe-shared/indexnow`): with `INDEXNOW_KEY` set, the key file is served at
321 `/<key>.txt` and an indexable deal or product page appearing, changing or disappearing pings the
322 engines with the page's path and `/sitemap.xml`. Unset, the feature is off — nothing is mounted and
323 nothing is sent. Drafts, private and noindex pages are never pinged (`test/indexnow.test.js`).
324
325### Caching
326
327Only pages shown to anonymous visitors are `public, max-age=60` (freshness labels are computed at
328render time, so the cache stays short); signed-in views, forms, API responses, errors and 410s are
329`private, no-store`. Pages vary on `Cookie` and `Authorization`.
330
331## Depends on
332
333- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
334 `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
335- **Packages** (pinned release tarballs): `openvibe-publishing` v1.7.0 (seo gate, JSON-LD, feeds,
336 sitemaps, index-hooks, discussion, ssr, and the shared ingest/publication chassis — the Sources
337 client, change cursor and pull loop, signed event consumer, common normalisers, and the publication
338 glue Deals' indexing uses), `openvibe-contracts` v0.97.0, `openvibe-shared` v4.33.0
339 (Frame, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.150.0 (sso, outbox,
340 inbox, webhook signatures v2, service tokens, per-actor limits).
341- **OpenVibe.Network:** SSO (OAuth client `deals`, registered in production), JWKS,
342 `identity.subject.resolve`.
343- **OpenVibe.Sources:** `sources.item.read`; a `deals`-category source must be registered and enabled
344 there (the seeded `dealnews-daily` is disabled until a person re-verifies its terms; its feed terms
345 are now encoded in `server/domain/source-policy.js`, so enabling it stays within them).
346- **OpenVibe.Community:** `community.comment.write`, optionally `community.comment.moderate`.
347- **OpenVibe.Events:** `events.event.publish`; `events.subscription.manage` once, for
348 `scripts/subscribe.js`.
349- **OpenVibe.Search:** consumes `deals.index_document.*`; `deals` is already in Search's default
350 `SEARCH_EVENT_OWNERS`.
351- **OpenVibe.Network notifications:** a future consumer of `deals.watch.matched` (it does not exist yet).
352
353### Grants the Network must hold
354
355Each grant is `[client, capability, audience]`:
356
357- `[deals, identity.subject.resolve, openvibe.network]`
358- `[deals, events.event.publish, openvibe.events]`
359- `[deals, events.subscription.manage, openvibe.events]` (`scripts/subscribe.js`, and the account subscriptions at boot)
360- `[deals, network.account.export.contribute, openvibe.network]` and `[deals, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)
361- `[deals, sources.item.read, openvibe.sources]`
362- `[deals, community.comment.write, openvibe.community]`
363- `[deals, community.comment.moderate, openvibe.community]` (optional: hides a disabled deal's thread)
364- For OpenVibe.AI enrichment (later): `[ai, deals.offer.update, openvibe.deals]`
365- For a future Network notification consumer that reads watches: `[network, deals.watch.read, openvibe.deals]`
366
367## Capabilities
368
369Implemented here (the service manifest's `capabilities`, audience `openvibe.deals`, one per route; the
370route table is under [API](#api-apiv1-problemjson-errors)): `deals.offer.submit`, `deals.offer.update`,
371`deals.offer.expire`, `deals.offer.merge`, `deals.offer.moderate`, `deals.vote.set`,
372`deals.vote.remove`, `deals.watch.create`, `deals.watch.read`, `deals.watch.delete`,
373`deals.product.resolve` and `deals.flag.create`.
374
375Called elsewhere, as the service principal `deals`: `identity.subject.resolve` (Network),
376`events.event.publish` and, once, `events.subscription.manage` (Events), `sources.item.read`
377(Sources), `community.comment.write` and optionally `community.comment.moderate` (Community). The
378full list with audiences is under [Grants the Network must hold](#grants-the-network-must-hold).
379
380## Acceptance (automated: `npm test`)
381
382| Charter / roadmap requirement | Test |
383|---|---|
384| Price and availability are timestamped and never silently current: "as of" on every price, stale after the window (label, `noindex`, no JSON-LD price, out of sitemaps, Search re-indexed once), imports dated by retrieval, no "current" anywhere, no future observations | `test/freshness.test.js` |
385| Never fabricate missing prices: unknown stays unknown in page, JSON, JSON-LD (no price, no 0), comparison order, feeds and Search; no assumed currency; headline prices not parsed; malformed values null | `test/missing-price.test.js` |
386| Duplicate offers merge without losing votes or history: a voter on both counts once, all observations and sources kept, 301, tombstone, audit with tallies, unmerge restores exactly, no cycles, moderators only | `test/merge.test.js` |
387| Hotness is server-authoritative and deterministic: worked examples, order independence, recomputation of every snapshot, request fields ignored, new-account weight, shared-`t` ticks | `test/hotness.test.js` |
388| Watches never notify twice for one observation: re-run matcher, replayed import, primary-key guarantee, unknown/other-currency prices, own reports, stale imports, deleted watches, saved searches | `test/watches.test.js` |
389| Vote abuse controls are server-side: per person / IP hash / offer limits (form and API), own-offer refusal, no raw IPs, `shared_ip` and `covote` ring flags for moderators only, CSRF | `test/abuse.test.js` |
390| Sources imports: mapping as stated, review gate, idempotent replays, revisions, dedupe by link, re-confirmation, removal → disabled + tombstone, signed event wake-up with inbox, honest failure | `test/import.test.js` |
391| Useful without JavaScript: no seed deals, forms with tokens, votes, Community comments (referenced), product/store/search pages, expiry (submitter and stated), moderation (410, feeds, sitemaps, Search, thread hidden), feeds, robots, llms, sitemaps, operations endpoints | `test/pages.test.js` |
392| API: one capability per route, `X-OV-Subject`, problem+json with request ids, AI text held for a person's review, votes, product resolve, merge grants, every event and Search document valid against openvibe-contracts | `test/api.test.js` |
393| The contract proposals are valid against the released schemas and match the code | `test/contracts.test.js` |
394| Service-token capabilities are the contracts library's decision, not a local one: every guarded id is defined by the release and the deals manifest, and exact, prefix, denied and unknown ids answer as `capabilities.check` does | `test/capabilities.test.js` |
395
396## Launch rule
397
398This repository alone doesn't make the product live. The plan §12.12 launch rule is met:
399`openvibe.deals` is served from this service's own domain since 2026-10-08, no longer as a placeholder
400on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites). Status against each point:
401
4021. **Runtime, health, readiness, observability:** done (`/api/health`, `/api/ready` with six checks, `/metrics`, `/release.json`).
4032. **Canonical identity and auth:** done; the `deals` OAuth client, service principal and grants exist in production.
4043. **SSR public routes useful without JS:** done.
4054. **Persistence and end-to-end workflows:** done and deployed on the host; offers are imported from the enabled `dealnews-daily` source in OpenVibe.Sources (936 offers on 2026-10-12).
4065. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts v0.97.0).
4076. **Migration and seed strategy, threat review, sitemap/robots/feed behaviour:** done. There is nothing to migrate and no seed (below); the threat review is below.
4087. **Acceptance tests:** done.
409
410**The launch release did all of these in one release:** removed `openvibe.deals` from
411`OpenVibe.Sites/sites.json`, switched routing (nginx vhost, DNS, TLS), flipped the Network hub entry
412(`server/chrome/sites.js`, `status: 'soon'`) and registered maturity in the ecosystem registry. A
413placeholder never counts as an implemented service, and the site is public.
414
415## Seed
416
417**None.** Deals starts empty: no example deals, no invented prices. Content arrives from members and
418from sources registered in OpenVibe.Sources.
419
420## Security and threat review
421
422Reporting a vulnerability: [SECURITY.md](SECURITY.md).
423
424- **Identity:** only verified Network JWTs (offline RS256 against JWKS) and service tokens for audience
425 `openvibe.deals`; a bad service token is refused, never downgraded to anonymous; identity never
426 comes from a body or query; `X-OV-*` headers are ignored for browsers; pages ignore service tokens.
427- **CSRF:** SameSite=Lax session cookie plus an HMAC form token (`DEALS_FORM_SECRET`) on every form.
428- **XSS:** every value goes through openvibe-publishing/ssr auto-escaping; descriptions use its safe
429 Markdown subset (no raw HTML, no images); only `http(s)` links are rendered; user links get
430 `rel="nofollow noopener ugc"`; CSP from helmet.
431- **Manipulation:** votes, weights and hotness are computed only on the server from stored rows; the
432 abuse controls above; merges and moderation are audited; flags never act on their own.
433- **Privacy:** raw IPs are never stored (HMAC, truncated); `deals.vote.changed` and
434 `deals.watch.matched` are internal; watches are visible only to their owner.
435- **SSRF:** Deals never fetches user-supplied URLs. It calls only its configured Network, Community,
436 Sources and Events hosts. Links are stored and rendered, never followed.
437- **Honesty:** no fabricated prices, currencies, availability, expiry or ratings in pages, JSON,
438 JSON-LD, feeds or Search; stale and unknown are labelled; AI and imported text need a person's review
439 before indexing.
440- **Abuse:** Express rate limits on `/auth`, forms and `/api/v1`, mirrored in the nginx reference, and
441 per-actor limits on the API and forms (above); `/internal/` and `/metrics` are host-local only.
442- **Known gaps:**
443 - Watch notifications are events only; nobody delivers them to people until Network's consumer exists.
444 - The Community thread visibility sync on disable is best effort (needs `community.comment.moderate`).
445 - Products are never merged automatically or through the UI yet; alias conflicts are reported by
446 `products/resolve` and left to a person.
447 - The covote detector compares votes on the same listing only (not across a merge group) and runs
448 on each vote; it is a signal for moderators, not a proof.
449 - Deals has no AI enrichment client of its own; it accepts `ai_summary` from OpenVibe.AI when granted.
450 - Currency conversion is deliberately absent: the comparison groups by currency, it never converts.
451
452## Development
453
454```bash
455fnm exec --using=22.22.1 npm install
456fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
457fnm exec --using=22.22.1 npm run dev # http://localhost:4840 (set OV_OAUTH_CLIENT_SECRET to sign in)
458```
459
460## Deploy (for the lead)
461
462Production deploys with `sudo ovhost deploy deals` on the host (strategy `git-checkout`: fetch,
463fast-forward `/opt/openvibe.deals`, install on a lockfile change, restart, wait for `/api/ready`).
464The unit is `openvibe-deals.service` on `127.0.0.1:4840`, the env file `/etc/openvibe/deals.env`. The database is
465`ov_deals` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh deals` writes its settings); the
466release migrates it at boot.
467Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
468restart; afterwards `sudo ovhost rollback deals --to <sha>`. Migrations only add tables and columns.
469
470First install (done once; kept for a rebuild):
471
4721. **Code and config:** put the code at `/opt/openvibe.deals` and run `npm ci --omit=dev` on Node 22
473 (on the host, npm 9 may rewrite `package-lock.json`; restore it with `git checkout -- package-lock.json`).
474 Create `/etc/openvibe/deals.env` (0600) from `.env.example`; set `OV_OAUTH_CLIENT_SECRET`,
475 `DEALS_FORM_SECRET`, `DEALS_IP_HASH_SECRET`, `DEALS_EVENTS_SECRET` (`openssl rand -hex 32` each),
476 `EVENTS_URL=http://127.0.0.1:4300`, `BASE_URL=https://openvibe.deals`, and `DEALS_MODERATORS` if
477 anyone besides Network admins/global mods moderates.
4782. **Network:** add `{ client_id: 'deals', name: 'OpenVibe.Deals', redirect_uris: ['https://openvibe.deals/auth/callback'] }`
479 to the seeded clients, create the principal with `server/setup/service-principal.js` (secret into
480 `deals.env`), and add the grants listed above.
4813. **Sources:** register and enable a `deals`-category source once a person has checked its terms.
4824. **Search:** nothing to do unless `SEARCH_EVENT_OWNERS` is overridden there (the default includes `deals`).
4835. **systemd:** install `deploy/systemd/openvibe-deals.service` (port 4840).
4846. **nginx:** install `deploy/nginx/openvibe.deals.conf`. `/metrics` and `/internal/` are never proxied.
4857. **Events subscription:** `node scripts/subscribe.js` (optional — the importer polls anyway).
4868. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0,
487 and CI's contracts check runs against them.
4889. **Launch:** in the same release, remove `openvibe.deals` from OpenVibe.Sites and flip the Network
489 hub entry (see the launch rule above).
490
491## Deviations from the charter
492
493- `deal_stores` is a tenth `deal_` table (store records by domain), next to the nine charter tables.
494- Capabilities beyond the charter's eight: `deals.offer.merge`, `deals.offer.moderate`,
495 `deals.watch.read`, `deals.flag.create`.
496- Saved searches are `deal_watches` rows of kind `search` with `notify = 0`, not a separate table.
497
498---
499
500Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
501
502<!-- versions:start -->
503- openvibe-contracts: v0.419.0
504- openvibe-sdk: v0.166.0
505- openvibe-shared: v4.241.0
506- openvibe-publishing: v1.7.0
507<!-- versions:end -->
508