- JavaScript 97.7%
- CSS 2.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| migrations | ||
| public/css | ||
| scripts | ||
| server | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Deals
Deals submitted and voted on by the community, with source, price and freshness always shown.
Status: alpha (roadmap Wave 18, Deals). The service runs and its tests pass. It is public at
openvibe.deals since 2026-10-08 (contracts 0.118.0), with offers from the DealNews daily feed through OpenVibe.Sources. Its capabilities and
service manifest are registered in openvibe-contracts v0.97.0.
Domain: openvibe.deals · Port: 4840 · Service id: deals
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.9; roadmap §15.13, §29, §32.
License: AGPL-3.0 (same as every OpenVibe service).
Purpose
Normalised deal submissions and imports, product and store records, offers with timestamped price, shipping, condition and availability observations, server-authoritative votes and hotness, duplicate resolution, watches and saved searches. Discussion is OpenVibe.Community's; imports come from OpenVibe.Sources; notifications are events for OpenVibe.Network.
The one rule everything else follows: a price is an observation. It has a source and an
observed_at, it is only ever shown "as of" that time, and an offer is never presented as current
without a fresh observation. A price nobody stated stays "not stated" — in the page, the JSON, the
structured data, the comparison, the feeds and Search.
Owns
The nine charter tables live in Deals' own PostgreSQL database (ov_deals on the host's data role, ADR-035; schema in migrations/):
| Charter table | What it is |
|---|---|
deal_products |
product records (name, brand, category), /p/:slug |
deal_product_aliases |
gtin (normalised to 14 digits), mpn, sku, exact name, url → one product; an alias never silently moves to another product |
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 |
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) |
deal_votes |
one row per (offer, subject): +1 / -1 / 0 (removed, kept as history), with the weight and IP hash it was cast with |
deal_hotness_snapshots |
every hotness computation with all of its inputs (formula hot@1) |
deal_watches |
keyword / product / price-below watches and saved searches |
deal_price_observations |
the only place a price lives: price and currency as stated, shipping, condition, availability, observed_at, source |
deal_flags |
reports by people and system flags (vote rings) for moderators |
Companion tables (not charter tables): deal_stores (store records by domain — the plan's "store
records" obligation; a deviation from the nine-table list, see the final section), watch_notifications
(the (watch, observation) uniqueness), moderation_log, rate_events, import_state,
subject_projections, deal_campaigns (crowdfunding discovery), the SDK's event_outbox / idempotency_receipts, and openvibe-publishing's
deal_offer_discussion_refs and deal_index_revisions.
Telemetry (openvibe-sdk 0.46, platform.telemetry-sample@1): requests are aggregated by route template, method and status class,
never raw URL; /shared, static assets, health, readiness and /metrics are skipped. server/app.js builds createHttpTelemetry
and mounts its middleware; server/index.js writes one http.request sample per key plus the active-requests, p95 and event-loop-lag
gauges to telemetry_samples every minute and on stop, and deletes rows older than 14 days daily (migrations/0004_telemetry.sql).
Does not own
- coupon codes (OpenVibe.Coupons; cross-linked later, separate database)
- discussion (OpenVibe.Community threads, referenced by id)
- source registry, fetching, robots and terms (OpenVibe.Sources)
- notifications delivery (OpenVibe.Network; Deals only emits
deals.watch.matchedand never emails) - identity (OpenVibe.Network subjects; Deals stores
usr_…ids only)
What works
Routes (server-rendered, useful without JavaScript)
| Route | What it is |
|---|---|
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). |
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 |
GET /c, GET /c/:category |
deals by category, as each deal's source names it (DealNews' dealnews:category, Sources feed@3; migration 0007) |
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. |
GET /p/:slug |
a product: price comparison across its offers, each with its own latest observation and time (.json too) |
GET /s/:domain |
a store's deals |
GET /search?q= |
search (noindex), with "save this search" / "watch these words" |
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 *). |
GET/POST /submit |
submit a deal (signed in) |
GET/POST /watches |
watches and saved searches; POST /watches/:id/delete |
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) |
GET /feed.xml, /atom.xml, /feed.json |
newest active deals, with "as of" times and stable ids deals:offer:<id> |
GET /robots.txt, /llms.txt, /sitemap.xml, /sitemaps/pages.xml, /sitemaps/offers.xml, /sitemaps/products.xml |
discovery (below); pages.xml lists the front page and every list (hot, new, ending, picks, each category and shop, crowdfunding) while it has deals |
GET /api/health, /api/ready, /release.json, /metrics |
operations (/metrics loopback only; nginx never proxies it) |
/auth/* |
Network SSO (the same session layer as Community/Blog) |
Sitemap entries carry lastmod from each offer or product page's latest recorded change.
API (/api/v1, problem+json errors)
Service tokens (audience openvibe.deals) are checked for one capability per route and act for
the person in X-OV-Subject; browsers or apps with a Network user JWT are judged by the domain
(signed in, submitter, moderator). Public reads need nothing.
| Route | Capability |
|---|---|
GET /offers?sort=hot|new, GET /offers/:id, GET /offers/:id/hotness, GET /products/:slug, GET /stores/:domain |
public |
POST /offers |
deals.offer.submit |
PATCH /offers/:id, POST /offers/:id/observations |
deals.offer.update |
POST /offers/:id/expire |
deals.offer.expire |
PUT /offers/:id/vote · DELETE /offers/:id/vote |
deals.vote.set · deals.vote.remove |
POST /offers/:id/flags |
deals.flag.create |
POST /offers/:id/merge, /unmerge |
deals.offer.merge |
POST /offers/:id/disable|enable|review, GET /flags, POST /flags/:id/resolve |
deals.offer.moderate |
POST /products/resolve |
deals.product.resolve |
GET /watches · POST /watches · DELETE /watches/:id |
deals.watch.read · deals.watch.create · deals.watch.delete |
The eight charter capabilities are all there; deals.offer.merge, deals.offer.moderate,
deals.watch.read and deals.flag.create are additions (released in openvibe-contracts, pinned at
v0.97.0; the source proposals stay in docs/capabilities-proposal/ and
docs/service-manifest-proposal.json); server/auth/capabilities.js
decides them with the contracts grant rule.
Events (SDK outbox, written in the same transaction as the change)
| Event | When | Visibility |
|---|---|---|
deals.offer.created |
a submission or an import created an offer | public if listable, else internal |
deals.offer.updated |
edit, new observation, merge/unmerge, review, disable/enable, source removed | public if listable, else internal |
deals.offer.expired |
submitter, moderator or stated end time | internal once expired |
deals.vote.changed |
a person's effective vote changed (payload: counts and hotness) | internal |
deals.watch.matched |
a watch matched a new observation (payload: recipient, offer, observation) — for Network's notification consumer, which does not exist yet |
internal |
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 |
deals.index_document.upserted|deleted |
the OpenVibe.Search document (search.index-document@1) or tombstone for offers and products |
internal |
Consumed: sources.item.created|updated|removed at POST /internal/events (signed,
DEALS_EVENTS_SECRET, inbox-deduplicated). They carry no prices, so they only wake the importer.
Account export and deletion (ADR-033): network.account.export_requested and network.account.deleted arrive at the
same route and are answered by server/domain/account-data.js (openvibe-sdk/account-data). An export part carries
the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion
removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports,
products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's
evidence names them as deleted. A staff member's moderation_log rows stay attributed (counted as retained). The
two subscriptions are created at boot when missing (startSubscriptions, needs EVENTS_URL and
DEALS_EVENTS_SECRET); account_data_events keeps one receipt per export and deletion.
Prices and freshness
- Every price, shipping cost, condition and availability is a row in
deal_price_observationswithobserved_at(when it was seen: the moment a person reported it, or the item'sprovenance.retrieved_atfor imports) and its source. Prices are never edited in place. - The latest observation is fresh while younger than
DEALS_FRESHNESS_HOURS(48 h), stale after; no observation is unobserved. Pages print "as of - Stale →
noindex(gate reasonstale_price), out ofsitemaps/offers.xml, the Offer JSON-LD loses its price/currency/availability, and the worker re-sends the Search document (freshness facet,noindex) — only when it actually changed. - A price needs its currency:
10withoutUSDis refused (422), never completed.$10is refused (amounts are plain decimals). Imported values are copied as stated; a malformed one isnull. A price in an imported headline is not parsed out of the text. A deal feed that states its offers in its own elements (Sourcesfeed@2: DealNews' price with currency, retailer and end date) is a statement: the price is an observation, the end date the stated end, and the retailer the offer'sseller("at Amazon" on cards, "Sold by" on the page,sellerin JSON and the Offer JSON-LD). The store stays the link's domain; a source whose links are kept verbatim (an aggregator's own pages) never names that store after a retailer (migration0006_offer_seller.sql). Shops:/atlists every seller with its count and/at/<seller>its deals (newest first, paginated); the "at Amazon" on a card and "Sold by" on a page link there. Like store pages, they are not in the sitemap. - JSON-LD:
Productwith oneOfferper offer;price/priceCurrencyonly from a fresh observation that stated both,availability/itemConditiononly when stated,validThroughonly from a stated end. Unknown fields are omitted, never 0 orInStock. - Expiry is only ever stated (by the submitter or the source's
valid_until); the worker expires those. An unknown end stays "not stated" and never expires by itself (it goes stale instead).
Duplicates and merging
- The same link (normalised: fragment and tracking/affiliate parameters removed,
www.and scheme ignored) cannot be posted twice: a person gets the existing deal (409 / redirect), a second Sources item attaches to it as another source. A link a source policy keeps verbatim is identified as received, so it never strips parameters to collide with a normalised one. - Moderators merge listings of one deal (
/mod, "possible duplicates" = same store and same product or title). Merging moves nothing: B getsmerged_into = A; A's page, JSON, votes, history and sources read across the group; B's URL answers 301. A person who voted on both counts once (their most recent row in the group). Unmerge clears the pointer and B has exactly its own votes, observations and sources again. Both are inmoderation_logwith the tallies before and after, and Search gets a tombstone for B (and B back on unmerge). Cycles are refused.
Votes, hotness and abuse controls
Votes are one row per (offer, subject); the server reads only value from a request. A vote cast
through a merged listing lands on the canonical offer.
Hotness hot@1 (deterministic, computed only on the server, every snapshot stores its inputs
and GET /api/v1/offers/:id/hotness recomputes it):
score = upWeight − downWeight (weighted effective votes, 4 decimals)
ageHours = max(0, (t − firstSeenAt) / 3 600 000) (firstSeenAt: earliest created_at in the merge group)
hot = round6( score / (ageHours + 2) ^ 1.5 )
Worked examples (tested): up 3, down 1, 10 h old → 2 / 12^1.5 = 0.048113; up 2, 0 h → 0.707107.
Snapshots are taken on every vote change, merge, unmerge and creation, and by the worker for every
active offer of the last 14 days at one shared t, so the hot list compares numbers computed at
the same moment. The worker's tick snapshots are kept for a day, the others for 7 days, and every offer's latest
snapshot always.
Abuse controls (server-side, database windows shared by forms and API):
| Control | Default |
|---|---|
| votes per person per hour | 60 (DEALS_VOTE_LIMIT_SUBJECT_HOUR) |
votes per IP hash per hour (HMAC under DEALS_IP_HASH_SECRET; raw IPs are never stored) |
120 |
| vote changes per person per offer per hour | 6 |
| no votes on a deal you posted (any listing in the group) | — |
| 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) |
vote-ring flag shared_ip |
≥ 3 distinct voters on one offer from one IP hash |
vote-ring flag covote |
two voters who voted the same way on ≥ 5 offers within 10 minutes of each other each time |
| submissions / observations / reports | 20 per day / 30 per hour / 30 per day per person |
Ring flags are for moderators only (/mod, GET /api/v1/flags); they never remove votes on their
own. One open flag per ring (or per person and offer for reports).
Per-actor limits (server/http/actor-limits.js, openvibe-sdk/limits, roadmap WS-R task 4) cap
requests, refused ones included, by who makes them, once req.viewer is resolved and before any work
(before the body is read). Each write's cap sits above the abuse control for the same action, which
keeps deciding what is recorded. Counted: a person as user:usr_… (their own token or cookie, or named
by a service in X-OV-Subject); a first-party service relaying a signed-out visitor by the address it
forwards; a service or app acting as itself (such as svc:ai for deals.enrich_deal) by its principal;
a signed-out caller by address. A first-party service reading for itself is not counted on reads. Past
a limit: 429 problem+json rate_limited with Retry-After, one [Limits] log line and
deals_rate_limited_total{limit,window}. A page form and the API route that do the same thing share one
budget.
| Routes (API and forms) | Per caller, a minute / an hour |
|---|---|
| API reads | DEALS_LIMITS_MINUTE / DEALS_LIMITS_HOUR (120 / 3000) |
| Submit | 30 / 120 |
| Edit, expire | 30 / 300 |
| Price observations | 40 / 200 |
| Votes (set and remove) | 60 / 600 |
| Flags | 30 / 120 |
| Moderation (merge, unmerge, disable, enable, review, resolve flags) | 60 / 600 |
| Product resolve | 60 / 1200 |
| Watches (create and delete) | 30 / 300 |
| Comments (sent to Community) | 20 / 300 |
Never limited per actor: /api/health, /api/ready, /release.json, /metrics, sign-in, the signed
Events deliveries at /internal/events, and the pages and feeds people read. test/actor-limits.test.js.
Watches and saved searches
keyword (every word must appear), product, price_below (product or words, a price stated in
the watch's currency below the limit — an unknown price never matches) and search (a saved
search that never notifies). Matching runs in the observation's transaction; watch_notifications
has primary key (watch_id, observation_id), so an observation can produce at most one
deals.watch.matched per watch — replays, retries and re-runs cannot repeat it. On top: a keyword
or product watch notifies once per offer, a price watch again only for a lower price; stale
observations, inactive offers and a person's own reports do not notify.
Imports from OpenVibe.Sources
The importer pulls GET /api/v1/items?category=deals&after=<cursor>&include_removed=1 (cursor in
import_state, one savepoint per item so one bad item cannot stall it) every
DEALS_IMPORT_INTERVAL_MS, and early when a sources.item.* event arrives. offer items → one
offer; product items → a product and one offer per stated offer; article items (RSS) → an offer
with no price. A new revision or a newer retrieval is a new observation; a replay changes nothing.
Offers whose latest imported observation is getting old are re-confirmed from GET /api/v1/items/:id
when Sources fetched them again. A removed item (takedown, licence) removes its source; an imported
offer with no source left is disabled. Imported text is review_state = pending → noindex until a
moderator records a review (a usr_ subject; a service cannot review).
The crowdfunding feed (category=crowdfunding, server/domain/campaigns.js) is pulled through the same chassis with its
own cursor (sources.crowdfunding.after in deals_ingest_cursor) into deal_campaigns (migration
0005_crowdfunding.sql), never into offers: name, summary, type, first tag, currency, the total received and yearly
budget in cents and the contributor count, exactly as stated, with the time Sources fetched them. A removed item is
deleted. Each campaign links to its page on its platform (Open Collective: https://opencollective.com/<slug>).
Per-source policy: verbatim links, attribution and browser extensions
Deals normalises an imported offer's link by default: the fragment and tracking/affiliate parameters
are removed so two posts of one deal share an identity. A source whose terms require otherwise gets
an explicit entry in server/domain/source-policy.js, keyed by the Sources source_key — the prose
license_note / terms_note are never parsed, the map is data:
| Policy field | Effect |
|---|---|
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 |
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 |
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 |
DealNews (feed terms, checked 2026-10-07) allows public
use of its feeds only without removing or adding content within the feed display, without modifying
the links or referral codes, with attribution to DealNews as text (e.g. "DealNews") or their 88x31
logo, and never inside a browser extension. dealnews-daily therefore sets all three:
'dealnews-daily': {
verbatimLinks: true,
attribution: { text: 'DealNews', url: 'https://www.dealnews.com/' },
noExtension: true,
},
Community submissions and every source with no entry keep today's behaviour (normalised links, no
attribution, visible to every caller). test/import.test.js and test/api.test.js cover the verbatim
link byte-for-byte, the attribution on card, page and JSON, the untouched default, and the
extension-facing exclusion.
Discoverability (roadmap §32)
- SSR HTML with canonical, Open Graph, JSON-LD (
Product/Offer,BreadcrumbList,WebSitewithSearchAction),meta robots+X-Robots-Tagfrom the openvibe-publishing gate. Gate reasons:takedown(disabled: hidden),expired,stale_price,noindex_requestedwith detailreview_pending(imported/AI text). Empty lists and search/forms arenoindex. - Sitemaps list only offers and products the gate calls indexable now;
lastmodis the latest observation or edit, never "now".robots.txtstates the automated-consumer policy,llms.txtexplains observations and the JSON twins. - AI-assisted text (
X-OV-Origin: ai, e.g. OpenVibe.AI'sdeals.enrich_deal) is stored separately (ai_summary), disclosed on the page andnoindexuntil a person reviews it. - IndexNow (
openvibe-shared/indexnow): withINDEXNOW_KEYset, the key file is served at/<key>.txtand an indexable deal or product page appearing, changing or disappearing pings the engines with the page's path and/sitemap.xml. Unset, the feature is off — nothing is mounted and nothing is sent. Drafts, private and noindex pages are never pinged (test/indexnow.test.js).
Caching
Only pages shown to anonymous visitors are public, max-age=60 (freshness labels are computed at
render time, so the cache stays short); signed-in views, forms, API responses, errors and 410s are
private, no-store. Pages vary on Cookie and Authorization.
Depends on
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host
roles/data/, ADR-035): every read and write is async throughopenvibe-sdk/db; Valkey holds the per-actor limit counters (optional: withoutVALKEY_URLthey count per process). - Packages (pinned release tarballs):
openvibe-publishingv1.4.1 (seo gate, JSON-LD, feeds, sitemaps, index-hooks, discussion, ssr, and the shared ingest/publication chassis — the Sources client, change cursor and pull loop, signed event consumer, common normalisers, and the publication glue Deals' indexing uses),openvibe-contractsv0.97.0,openvibe-sharedv4.15.2 (Frame, app icon, footer, legal, release, metrics, ready),openvibe-sdkv0.35.0 (sso, outbox, inbox, webhook signatures v2, service tokens, per-actor limits). - OpenVibe.Network: SSO (OAuth client
deals, registered in production), JWKS,identity.subject.resolve. - OpenVibe.Sources:
sources.item.read; adeals-category source must be registered and enabled there (the seededdealnews-dailyis disabled until a person re-verifies its terms; its feed terms are now encoded inserver/domain/source-policy.js, so enabling it stays within them). - OpenVibe.Community:
community.comment.write, optionallycommunity.comment.moderate. - OpenVibe.Events:
events.event.publish;events.subscription.manageonce, forscripts/subscribe.js. - OpenVibe.Search: consumes
deals.index_document.*;dealsis already in Search's defaultSEARCH_EVENT_OWNERS. - OpenVibe.Network notifications: a future consumer of
deals.watch.matched(it does not exist yet).
Grants the Network must hold
Each grant is [client, capability, audience]:
[deals, identity.subject.resolve, openvibe.network][deals, events.event.publish, openvibe.events][deals, events.subscription.manage, openvibe.events](scripts/subscribe.js, and the account subscriptions at boot)[deals, network.account.export.contribute, openvibe.network]and[deals, network.account.deletion.confirm, openvibe.network](ADR-033; granted last, once this release is live)[deals, sources.item.read, openvibe.sources][deals, community.comment.write, openvibe.community][deals, community.comment.moderate, openvibe.community](optional: hides a disabled deal's thread)- For OpenVibe.AI enrichment (later):
[ai, deals.offer.update, openvibe.deals] - For a future Network notification consumer that reads watches:
[network, deals.watch.read, openvibe.deals]
Capabilities
Implemented here (the service manifest's capabilities, audience openvibe.deals, one per route; the
route table is under API): deals.offer.submit, deals.offer.update,
deals.offer.expire, deals.offer.merge, deals.offer.moderate, deals.vote.set,
deals.vote.remove, deals.watch.create, deals.watch.read, deals.watch.delete,
deals.product.resolve and deals.flag.create.
Called elsewhere, as the service principal deals: identity.subject.resolve (Network),
events.event.publish and, once, events.subscription.manage (Events), sources.item.read
(Sources), community.comment.write and optionally community.comment.moderate (Community). The
full list with audiences is under Grants the Network must hold.
Acceptance (automated: npm test)
| Charter / roadmap requirement | Test |
|---|---|
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 |
| 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 |
| 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 |
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 |
| 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 |
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 |
| 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 |
| 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 |
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 |
| The contract proposals are valid against the released schemas and match the code | test/contracts.test.js |
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 |
Launch rule
This repository alone doesn't make the product live. The plan §12.12 launch rule is met:
openvibe.deals is served from this service's own domain since 2026-10-08, no longer as a placeholder
on OpenVibers/OpenVibe.Sites. Status against each point:
- Runtime, health, readiness, observability: done (
/api/health,/api/readywith six checks,/metrics,/release.json). - Canonical identity and auth: done; the
dealsOAuth client, service principal and grants exist in production. - SSR public routes useful without JS: done.
- Persistence and end-to-end workflows: done and deployed on the host; offers are imported from the enabled
dealnews-dailysource in OpenVibe.Sources (936 offers on 2026-10-12). - Capability and event registration against OpenVibe.Contracts: done (openvibe-contracts v0.97.0).
- 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.
- Acceptance tests: done.
The launch release did all of these in one release: removed openvibe.deals from
OpenVibe.Sites/sites.json, switched routing (nginx vhost, DNS, TLS), flipped the Network hub entry
(server/chrome/sites.js, status: 'soon') and registered maturity in the ecosystem registry. A
placeholder never counts as an implemented service, and the site is public.
Seed
None. Deals starts empty: no example deals, no invented prices. Content arrives from members and from sources registered in OpenVibe.Sources.
Security and threat review
Reporting a vulnerability: SECURITY.md.
- Identity: only verified Network JWTs (offline RS256 against JWKS) and service tokens for audience
openvibe.deals; a bad service token is refused, never downgraded to anonymous; identity never comes from a body or query;X-OV-*headers are ignored for browsers; pages ignore service tokens. - CSRF: SameSite=Lax session cookie plus an HMAC form token (
DEALS_FORM_SECRET) on every form. - XSS: every value goes through openvibe-publishing/ssr auto-escaping; descriptions use its safe
Markdown subset (no raw HTML, no images); only
http(s)links are rendered; user links getrel="nofollow noopener ugc"; CSP from helmet. - Manipulation: votes, weights and hotness are computed only on the server from stored rows; the abuse controls above; merges and moderation are audited; flags never act on their own.
- Privacy: raw IPs are never stored (HMAC, truncated);
deals.vote.changedanddeals.watch.matchedare internal; watches are visible only to their owner. - SSRF: Deals never fetches user-supplied URLs. It calls only its configured Network, Community, Sources and Events hosts. Links are stored and rendered, never followed.
- Honesty: no fabricated prices, currencies, availability, expiry or ratings in pages, JSON, JSON-LD, feeds or Search; stale and unknown are labelled; AI and imported text need a person's review before indexing.
- Abuse: Express rate limits on
/auth, forms and/api/v1, mirrored in the nginx reference, and per-actor limits on the API and forms (above);/internal/and/metricsare host-local only. - Known gaps:
- Watch notifications are events only; nobody delivers them to people until Network's consumer exists.
- The Community thread visibility sync on disable is best effort (needs
community.comment.moderate). - Products are never merged automatically or through the UI yet; alias conflicts are reported by
products/resolveand left to a person. - The covote detector compares votes on the same listing only (not across a merge group) and runs on each vote; it is a signal for moderators, not a proof.
- Deals has no AI enrichment client of its own; it accepts
ai_summaryfrom OpenVibe.AI when granted. - Currency conversion is deliberately absent: the comparison groups by currency, it never converts.
Development
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm test # temp databases and in-process mocks, no network
fnm exec --using=22.22.1 npm run dev # http://localhost:4840 (set OV_OAUTH_CLIENT_SECRET to sign in)
Deploy (for the lead)
Production deploys with sudo ovhost deploy deals on the host (strategy git-checkout: fetch,
fast-forward /opt/openvibe.deals, install on a lockfile change, restart, wait for /api/ready).
The unit is openvibe-deals.service on 127.0.0.1:4840, the env file /etc/openvibe/deals.env. The database is
ov_deals on the host's data role (sudo /opt/openvibe.host/roles/data/add-service.sh deals writes its settings); the
release migrates it at boot.
Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the
restart; afterwards sudo ovhost rollback deals --to <sha>. Migrations only add tables and columns.
First install (done once; kept for a rebuild):
- Code and config: put the code at
/opt/openvibe.dealsand runnpm ci --omit=devon Node 22 (on the host, npm 9 may rewritepackage-lock.json; restore it withgit checkout -- package-lock.json). Create/etc/openvibe/deals.env(0600) from.env.example; setOV_OAUTH_CLIENT_SECRET,DEALS_FORM_SECRET,DEALS_IP_HASH_SECRET,DEALS_EVENTS_SECRET(openssl rand -hex 32each),EVENTS_URL=http://127.0.0.1:4300,BASE_URL=https://openvibe.deals, andDEALS_MODERATORSif anyone besides Network admins/global mods moderates. - Network: add
{ client_id: 'deals', name: 'OpenVibe.Deals', redirect_uris: ['https://openvibe.deals/auth/callback'] }to the seeded clients, create the principal withserver/setup/service-principal.js(secret intodeals.env), and add the grants listed above. - Sources: register and enable a
deals-category source once a person has checked its terms. - Search: nothing to do unless
SEARCH_EVENT_OWNERSis overridden there (the default includesdeals). - systemd: install
deploy/systemd/openvibe-deals.service(port 4840). - nginx: install
deploy/nginx/openvibe.deals.conf./metricsand/internal/are never proxied. - Events subscription:
node scripts/subscribe.js(optional — the importer polls anyway). - Contracts: done: the capabilities and manifest are released in openvibe-contracts v0.97.0, and CI's contracts check runs against them.
- Launch: in the same release, remove
openvibe.dealsfrom OpenVibe.Sites and flip the Network hub entry (see the launch rule above).
Deviations from the charter
deal_storesis a tenthdeal_table (store records by domain), next to the nine charter tables.- Capabilities beyond the charter's eight:
deals.offer.merge,deals.offer.moderate,deals.watch.read,deals.flag.create. - Saved searches are
deal_watchesrows of kindsearchwithnotify = 0, not a separate table.
Part of the OpenVibe network. Built in the open by OpenVibers.
- openvibe-contracts: v0.217.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-publishing: v1.4.1