openvibers/openvibe.deals README.md
README.md
| 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 |
| 10 | service 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 | |
| 17 | Normalised deal submissions and imports, product and store records, offers with timestamped price, |
| 18 | shipping, condition and availability observations, server-authoritative votes and hotness, duplicate |
| 19 | resolution, watches and saved searches. Discussion is OpenVibe.Community's; imports come from |
| 20 | OpenVibe.Sources; notifications are events for OpenVibe.Network. |
| 21 | |
| 22 | The 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 |
| 24 | without a fresh observation. A price nobody stated stays "not stated" — in the page, the JSON, the |
| 25 | structured data, the comparison, the feeds and Search. |
| 26 | |
| 27 | ## Owns |
| 28 | |
| 29 | The 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 | |
| 43 | Companion tables (not charter tables): `deal_stores` (store records by domain — the plan's "store |
| 44 | records" 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 | |
| 49 | Telemetry (openvibe-sdk 0.46, `platform.telemetry-sample@1`): requests are aggregated by route template, method and status class, |
| 50 | never raw URL; `/shared`, static assets, health, readiness and `/metrics` are skipped. `server/app.js` builds `createHttpTelemetry` |
| 51 | and mounts its middleware; `server/index.js` writes one `http.request` sample per key plus the active-requests, p95 and event-loop-lag |
| 52 | gauges 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 | |
| 84 | Sitemap entries carry lastmod from each offer or product page's latest recorded change. |
| 85 | |
| 86 | ### API (`/api/v1`, problem+json errors) |
| 87 | |
| 88 | Service tokens (audience `openvibe.deals`) are checked for **one capability per route** and act for |
| 89 | the 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 | |
| 105 | The 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 |
| 107 | v0.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` |
| 109 | decides 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 | |
| 123 | Consumed: `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 | |
| 126 | Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` arrive at the |
| 127 | same route and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`). An export part carries |
| 128 | the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion |
| 129 | removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports, |
| 130 | products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's |
| 131 | evidence names them as `deleted`. A staff member's `moderation_log` rows stay attributed (counted as retained). The |
| 132 | two 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 | |
| 176 | Votes are one row per (offer, subject); the server reads only `value` from a request. A vote cast |
| 177 | through a merged listing lands on the canonical offer. |
| 178 | |
| 179 | **Hotness `hot@1`** (deterministic, computed only on the server, every snapshot stores its inputs |
| 180 | and `GET /api/v1/offers/:id/hotness` recomputes it): |
| 181 | |
| 182 | ``` |
| 183 | score = upWeight − downWeight (weighted effective votes, 4 decimals) |
| 184 | ageHours = max(0, (t − firstSeenAt) / 3 600 000) (firstSeenAt: earliest created_at in the merge group) |
| 185 | hot = round6( score / (ageHours + 2) ^ 1.5 ) |
| 186 | ``` |
| 187 | |
| 188 | Worked examples (tested): up 3, down 1, 10 h old → 2 / 12^1.5 = **0.048113**; up 2, 0 h → **0.707107**. |
| 189 | Snapshots are taken on every vote change, merge, unmerge and creation, and by the worker for every |
| 190 | active offer of the last 14 days at **one shared `t`**, so the hot list compares numbers computed at |
| 191 | the same moment. The worker's tick snapshots are kept for a day, the others for 7 days, and every offer's latest |
| 192 | snapshot 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 | |
| 207 | Ring flags are for moderators only (`/mod`, `GET /api/v1/flags`); they never remove votes on their |
| 208 | own. 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 |
| 211 | requests, 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 |
| 213 | keeps deciding what is recorded. Counted: a person as `user:usr_…` (their own token or cookie, or named |
| 214 | by a service in `X-OV-Subject`); a first-party service relaying a signed-out visitor by the address it |
| 215 | forwards; a service or app acting as itself (such as `svc:ai` for `deals.enrich_deal`) by its principal; |
| 216 | a signed-out caller by address. A first-party service reading for itself is not counted on reads. Past |
| 217 | a 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 |
| 219 | budget. |
| 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 | |
| 234 | Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the signed |
| 235 | Events 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 |
| 240 | the watch's currency** below the limit — an unknown price never matches) and `search` (a saved |
| 241 | search that never notifies). Matching runs in the observation's transaction; `watch_notifications` |
| 242 | has 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 |
| 244 | or product watch notifies once per offer, a price watch again only for a lower price; stale |
| 245 | observations, inactive offers and a person's own reports do not notify. |
| 246 | |
| 247 | ## Imports from OpenVibe.Sources |
| 248 | |
| 249 | The 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 |
| 252 | offer; `product` items → a product and one offer per stated offer; `article` items (RSS) → an offer |
| 253 | with no price. A new revision or a newer retrieval is a new observation; a replay changes nothing. |
| 254 | Offers whose latest imported observation is getting old are re-confirmed from `GET /api/v1/items/:id` |
| 255 | when Sources fetched them again. A removed item (takedown, licence) removes its source; an imported |
| 256 | offer with no source left is disabled. Imported text is `review_state = pending` → `noindex` until a |
| 257 | moderator 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 |
| 260 | configured feed source is checked when it is imported. It passes when its link is https and points at |
| 261 | that source's retailer domain (or a subdomain of it), its title is not empty and at most 300 |
| 262 | characters, its price is a positive number with a currency, it has not expired and its title and |
| 263 | description 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 |
| 265 | check'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 |
| 267 | review off); a source that is not configured is never auto-approved. The offers imported before this |
| 268 | existed are checked by `scripts/backfill-import-review.js` (`--dry-run` prints counts only). |
| 269 | Offers people submit by hand are not touched: their review flow is unchanged, and a moderator can |
| 270 | still hide any offer. |
| 271 | |
| 272 | The crowdfunding feed (`category=crowdfunding`, server/domain/campaigns.js) is pulled through the same chassis with its |
| 273 | own 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 |
| 275 | budget in cents and the contributor count, exactly as stated, with the time Sources fetched them. A removed item is |
| 276 | deleted. 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 | |
| 280 | Deals normalises an imported offer's link by default: the fragment and tracking/affiliate parameters |
| 281 | are removed so two posts of one deal share an identity. A source whose terms require otherwise gets |
| 282 | an 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 |
| 292 | use of its feeds only without removing or adding content within the feed display, without modifying |
| 293 | the links or referral codes, with attribution to DealNews as text (e.g. "DealNews") or their 88x31 |
| 294 | logo, 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 | |
| 304 | Community submissions and every source with no entry keep today's behaviour (normalised links, no |
| 305 | attribution, visible to every caller). `test/import.test.js` and `test/api.test.js` cover the verbatim |
| 306 | link byte-for-byte, the attribution on card, page and JSON, the untouched default, and the |
| 307 | extension-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 | |
| 327 | Only pages shown to anonymous visitors are `public, max-age=60` (freshness labels are computed at |
| 328 | render 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 | |
| 355 | Each 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 | |
| 369 | Implemented here (the service manifest's `capabilities`, audience `openvibe.deals`, one per route; the |
| 370 | route 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 | |
| 375 | Called 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 |
| 378 | full 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 | |
| 398 | This 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 |
| 400 | on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites). Status against each point: |
| 401 | |
| 402 | 1. **Runtime, health, readiness, observability:** done (`/api/health`, `/api/ready` with six checks, `/metrics`, `/release.json`). |
| 403 | 2. **Canonical identity and auth:** done; the `deals` OAuth client, service principal and grants exist in production. |
| 404 | 3. **SSR public routes useful without JS:** done. |
| 405 | 4. **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). |
| 406 | 5. **Capability and event registration against OpenVibe.Contracts:** done (openvibe-contracts v0.97.0). |
| 407 | 6. **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. |
| 408 | 7. **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 |
| 413 | placeholder 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 |
| 418 | from sources registered in OpenVibe.Sources. |
| 419 | |
| 420 | ## Security and threat review |
| 421 | |
| 422 | Reporting 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 |
| 455 | fnm exec --using=22.22.1 npm install |
| 456 | fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network |
| 457 | fnm 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 | |
| 462 | Production deploys with `sudo ovhost deploy deals` on the host (strategy `git-checkout`: fetch, |
| 463 | fast-forward `/opt/openvibe.deals`, install on a lockfile change, restart, wait for `/api/ready`). |
| 464 | The 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 |
| 466 | release migrates it at boot. |
| 467 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 468 | restart; afterwards `sudo ovhost rollback deals --to <sha>`. Migrations only add tables and columns. |
| 469 | |
| 470 | First install (done once; kept for a rebuild): |
| 471 | |
| 472 | 1. **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. |
| 478 | 2. **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. |
| 481 | 3. **Sources:** register and enable a `deals`-category source once a person has checked its terms. |
| 482 | 4. **Search:** nothing to do unless `SEARCH_EVENT_OWNERS` is overridden there (the default includes `deals`). |
| 483 | 5. **systemd:** install `deploy/systemd/openvibe-deals.service` (port 4840). |
| 484 | 6. **nginx:** install `deploy/nginx/openvibe.deals.conf`. `/metrics` and `/internal/` are never proxied. |
| 485 | 7. **Events subscription:** `node scripts/subscribe.js` (optional — the importer polls anyway). |
| 486 | 8. **Contracts:** done: the capabilities and manifest are released in openvibe-contracts v0.97.0, |
| 487 | and CI's contracts check runs against them. |
| 488 | 9. **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 | |
| 500 | Part 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 |