Mirror of github.com/OpenVibers/OpenVibe.Deals
  • JavaScript 97.7%
  • CSS 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers a63a5940ad
openvibe-contracts v0.217.0 (was v0.216.0) (#105)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.217.0 release.
2026-10-27 05:17:09 -07:00
.github/workflows Deals moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end. 2026-09-28 07:22:02 -07:00
deploy nginx: GET /auth/me on the API zone (it runs on every page load; the sign-in flow's 10/min showed a visitor opening a dozen pages as signed out, the browser check's 429s) (#69) 2026-10-17 00:43:24 -07:00
docs The SQLite era is gone (on PostgreSQL since 2026-09-28; no compatibility kept): the one-time migrate-to-postgres script, *_DB_PATH settings (config, .env.example, systemd unit, test helpers), the unused better-sqlite3 dependency and its lockfile entries, and docs, docstrings and STATUS lines about SQLite or its rollback window. Reviewed independently; its blockers (an edited applied migration, the lockfile) are resolved here, Host's inventory follow-up is separate. npm test 14/14 test files passed. 2026-09-28 19:45:54 -07:00
migrations The Deals front page shows editors' picks, the newest deals, what ends soon, deals under $20, categories, shops and crowdfunding, with the site's numbers, how it works and answers to common questions (#75) 2026-10-22 07:34:04 -07:00
public/css Phones: list grids size their columns with minmax(0, 1fr) (or a min(…, 100%) minimum), so one long line can never widen a page past the screen (#91) 2026-10-25 03:35:12 -07:00
scripts The SQLite era is gone (on PostgreSQL since 2026-09-28; no compatibility kept): the one-time migrate-to-postgres script, *_DB_PATH settings (config, .env.example, systemd unit, test helpers), the unused better-sqlite3 dependency and its lockfile entries, and docs, docstrings and STATUS lines about SQLite or its rollback window. Reviewed independently; its blockers (an edited applied migration, the lockfile) are resolved here, Host's inventory follow-up is separate. npm test 14/14 test files passed. 2026-09-28 19:45:54 -07:00
server /auth/me is not counted by the sign-in limiter: 60 page views in 15 minutes made a signed-in visitor look signed out (#100) 2026-10-26 22:13:49 -07:00
test openvibe-shared v4.15.0 (was v4.14.0) (#98) 2026-10-26 19:46:15 -07:00
.env.example The sitemaps' offer and product lists are built once for concurrent readers, 8 views at a time, and reused for 10 minutes in production: each /sitemap.xml fetch read every view at once (#82) 2026-10-23 14:55:41 -07:00
.gitignore The SQLite era is gone (on PostgreSQL since 2026-09-28; no compatibility kept): the one-time migrate-to-postgres script, *_DB_PATH settings (config, .env.example, systemd unit, test helpers), the unused better-sqlite3 dependency and its lockfile entries, and docs, docstrings and STATUS lines about SQLite or its rollback window. Reviewed independently; its blockers (an edited applied migration, the lockfile) are resolved here, Host's inventory follow-up is separate. npm test 14/14 test files passed. 2026-09-28 19:45:54 -07:00
CHANGELOG.md The Deals front page shows editors' picks, the newest deals, what ends soon, deals under $20, categories, shops and crowdfunding, with the site's numbers, how it works and answers to common questions (#75) 2026-10-22 07:34:04 -07:00
LICENSE Charter: OpenVibe.Deals as defined in the OpenVibe realignment plan (placeholder, no code yet) 2026-09-21 09:18:58 -07:00
package-lock.json openvibe-contracts v0.217.0 (was v0.216.0) (#105) 2026-10-27 05:17:09 -07:00
package.json openvibe-contracts v0.217.0 (was v0.216.0) (#105) 2026-10-27 05:17:09 -07:00
README.md openvibe-contracts v0.217.0 (was v0.216.0) (#105) 2026-10-27 05:17:09 -07:00
SECURITY.md SECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions) 2026-09-23 19:27:31 -07:00
STATUS.json openvibe-contracts v0.217.0 (was v0.216.0) (#105) 2026-10-27 05:17:09 -07:00

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.matched and 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_observations with observed_at (when it was seen: the moment a person reported it, or the item's provenance.retrieved_at for 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 reason stale_price), out of sitemaps/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: 10 without USD is refused (422), never completed. $10 is refused (amounts are plain decimals). Imported values are copied as stated; a malformed one is null. A price in an imported headline is not parsed out of the text. A deal feed that states its offers in its own elements (Sources feed@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's seller ("at Amazon" on cards, "Sold by" on the page, seller in 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 (migration 0006_offer_seller.sql). Shops: /at lists 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: Product with one Offer per offer; price/priceCurrency only from a fresh observation that stated both, availability/itemCondition only when stated, validThrough only from a stated end. Unknown fields are omitted, never 0 or InStock.
  • 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 gets merged_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 in moderation_log with 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>).

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, WebSite with SearchAction), meta robots + X-Robots-Tag from the openvibe-publishing gate. Gate reasons: takedown (disabled: hidden), expired, stale_price, noindex_requested with detail review_pending (imported/AI text). Empty lists and search/forms are noindex.
  • Sitemaps list only offers and products the gate calls indexable now; lastmod is the latest observation or edit, never "now". robots.txt states the automated-consumer policy, llms.txt explains observations and the JSON twins.
  • AI-assisted text (X-OV-Origin: ai, e.g. OpenVibe.AI's deals.enrich_deal) is stored separately (ai_summary), disclosed on the page and noindex until a person reviews it.
  • IndexNow (openvibe-shared/indexnow): with INDEXNOW_KEY set, the key file is served at /<key>.txt and 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 through openvibe-sdk/db; Valkey holds the per-actor limit counters (optional: without VALKEY_URL they count per process).
  • Packages (pinned release tarballs): openvibe-publishing v1.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-contracts v0.97.0, openvibe-shared v4.15.2 (Frame, app icon, footer, legal, release, metrics, ready), openvibe-sdk v0.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; a deals-category source must be registered and enabled there (the seeded dealnews-daily is disabled until a person re-verifies its terms; its feed terms are now encoded in server/domain/source-policy.js, so enabling it stays within them).
  • OpenVibe.Community: community.comment.write, optionally community.comment.moderate.
  • OpenVibe.Events: events.event.publish; events.subscription.manage once, for scripts/subscribe.js.
  • OpenVibe.Search: consumes deals.index_document.*; deals is already in Search's default SEARCH_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:

  1. Runtime, health, readiness, observability: done (/api/health, /api/ready with six checks, /metrics, /release.json).
  2. Canonical identity and auth: done; the deals OAuth client, service principal and grants exist in production.
  3. SSR public routes useful without JS: done.
  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).
  5. Capability and event registration against OpenVibe.Contracts: done (openvibe-contracts v0.97.0).
  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.
  7. 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 get rel="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.changed and deals.watch.matched are 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 /metrics are 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/resolve and 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_summary from 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):

  1. Code and config: put the code at /opt/openvibe.deals and run npm ci --omit=dev on Node 22 (on the host, npm 9 may rewrite package-lock.json; restore it with git checkout -- package-lock.json). Create /etc/openvibe/deals.env (0600) from .env.example; set OV_OAUTH_CLIENT_SECRET, DEALS_FORM_SECRET, DEALS_IP_HASH_SECRET, DEALS_EVENTS_SECRET (openssl rand -hex 32 each), EVENTS_URL=http://127.0.0.1:4300, BASE_URL=https://openvibe.deals, and DEALS_MODERATORS if anyone besides Network admins/global mods moderates.
  2. Network: add { client_id: 'deals', name: 'OpenVibe.Deals', redirect_uris: ['https://openvibe.deals/auth/callback'] } to the seeded clients, create the principal with server/setup/service-principal.js (secret into deals.env), and add the grants listed above.
  3. Sources: register and enable a deals-category source once a person has checked its terms.
  4. Search: nothing to do unless SEARCH_EVENT_OWNERS is overridden there (the default includes deals).
  5. systemd: install deploy/systemd/openvibe-deals.service (port 4840).
  6. nginx: install deploy/nginx/openvibe.deals.conf. /metrics and /internal/ are never proxied.
  7. Events subscription: node scripts/subscribe.js (optional — the importer polls anyway).
  8. Contracts: done: the capabilities and manifest are released in openvibe-contracts v0.97.0, and CI's contracts check runs against them.
  9. Launch: in the same release, remove openvibe.deals from OpenVibe.Sites and flip the Network hub entry (see the launch rule above).

Deviations from the charter

  • deal_stores is a tenth deal_ 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_watches rows of kind search with notify = 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