openvibers/openvibe.deals AGENTS.md
AGENTS.md
AGENTS.md — OpenVibe.Deals
Project overview
Deals submitted and voted on by the community, with source, price and freshness always shown:
normalised deal submissions and imports, product/store records, offers with timestamped 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 Network.
Node.js/Express, PostgreSQL (ov_deals), server-rendered, CommonJS, no build step.
- Service id
deals, port4840, originhttps://openvibe.deals, unitopenvibe-deals.service,
release root/opt/openvibe.deals. - The one rule: a price is an observation. It has a source and an
observed_at, it is 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, JSON, structured data, comparison, feeds and Search.
What it owns
The nine charter tables in [migrations/](migrations/):
| Table | What |
|---|---|
| deal_products | product records (name, brand, category), /p/:slug |
| deal_product_aliases | gtin (14 digits), mpn, sku, exact name, url → one product; an alias never silently moves |
| deal_offers | title, link (tracking stripped except per-source policy), store, product, status active\|expired\|disabled, stated expiry, review state, merged_into |
| deal_offer_sources | the submission, a person's observations, or a Sources item reference {sources, item, itm_…, revision} — the item is never copied |
| deal_votes | one per (offer, subject): +1/-1/0 (kept as history), weight and IP hash |
| deal_hotness_snapshots | every hotness computation with all inputs (hot@1) |
| deal_watches | keyword / product / price-below watches and saved searches |
| deal_price_observations | the only place a price lives: price/currency, shipping, condition, availability, observed_at, source |
| deal_flags | reports by people and system flags (vote rings) |
Companion tables include deal_stores, watch_notifications (unique watch+observation),
moderation_log, rate_events, import_state, subject_projections, deal_campaigns,
event_outbox/idempotency_receipts, and publishing's deal_offer_discussion_refs/
deal_index_revisions. [docs/](docs/) records the deviations from the charter list.
Not owned: comment content (Community), the source items (Sources), notification delivery and
membership (Network/VIP), payments (Billing), file bytes (Media).
Who calls it, and whom it calls
- Callers: people in the browser; the browser extension/Embed for offers; services with a Network
service token audienceopenvibe.dealsand the route's capability; OpenVibe.Sources' pull loop and
signed event consumer. - Depends on: Network (SSO client
deals, JWKS,identity.subject.resolve, future notification
consumer); Sources (sources.item.read; the seededdealnews-dailyis disabled until a person
re-verifies its terms; its terms are encoded inserver/domain/source-policy.js); Community
(community.comment.write, optionally moderate); Events (outbox, andevents.subscription.manage
once forscripts/subscribe.js); Search (consumesdeals.index_document.*); PostgreSQL and
Valkey;openvibe-publishing(seo/JSON-LD/feeds/sitemaps/index hooks, ingest, SSR).
Repository map
- Entry: [server/index.js](server/index.js) — app, worker (Sources pull, hotness, expiry),
outbox; graceful stop. - App: [server/app.js](server/app.js) — helmet/CSP,
/auth,/shared,/internal/config,
/internal/events,/api/v1, pages, discovery, health/ready. - Config/db: [server/config.js](server/config.js), [server/db.js](server/db.js),
[server/settings.js](server/settings.js). - Domain: [server/domain/](server/domain/) —
reads.js,catalog.js,publication.js,
indexing.js,hotness.js,watches.js,limits.js,access.js,offers.js,flags.js,
votes.js,listings.js,source-items.js,source-policy.js,account-data.js. - Clients: [server/clients/network.js](server/clients/network.js),
community.js, and publishing's
openvibe-publishing/ingest. - HTTP: [server/http/api.js](server/http/api.js),
pages.js,discovery.js,events.js,
actor-limits.js; views [server/render/views.js](server/render/views.js). - Auth: [server/auth/viewer.js](server/auth/viewer.js), [server/sharing.js](server/sharing.js).
- Worker: [server/worker.js](server/worker.js).
- Deploy: [deploy/systemd/openvibe-deals.service](deploy/systemd/openvibe-deals.service),
[deploy/nginx/openvibe.deals.conf](deploy/nginx/openvibe.deals.conf).
Commands
npm ci
npm run dev # NODE_ENV=development node server/index.js
npm start # node server/index.js, http://127.0.0.1:4840
npm test # test/run.js, PGlite, mock Network/Sources/Community
npm run test:pg # same on PostgreSQL (DEALS_TEST_STORE=pg)
npm test -- freshness # filter by file name; --strict makes a skip fail
ov test # the harness's sharded run of what the change reaches
Database and migrations
ov_deals on the host's data role (ADR-035); PGlite in development/tests. Async through
openvibe-sdk/db; migrations migrations/NNNN_*.sql applied at boot; never edit an applied one, add a
new number. The price observation is the only price storage; do not add a price column to
deal_offers. Votes keep history (0 means removed); merges keep merged_into and never delete the
loser. Outbox rows are written in the same transaction as the change.
Auth, votes and moderation
- People sign in through Network SSO (OAuth client
deals); a person's subject is the identity, never a
body/query integer. Service callers need a service token audienceopenvibe.dealsand the route's
capability;X-OV-Subjectscopes a first-party service call. - Votes are server-authoritative. The server computes the weight and hotness; a client-supplied
score is ignored. Vote rings raise a system flag for moderators. - Watches and saved searches are per person; notifications are events (
deals.watch.matched) — no
consumer exists yet, so nothing is "sent" by Deals. - Moderation actions are audited; a flagged vote ring can be removed without erasing history.
Frontend loading model
Every page is server-rendered and useful without JavaScript; there is no SPA, bundler or
transpiler. openvibe-shared supplies the Frame, legal/SEO and /shared/* from the pinned
node_modules; openvibe-publishing supplies JSON-LD, feeds, sitemaps and SSR. Discovery is generated
from the same data. CSP and cache policy are in [server/app.js](server/app.js) and
openvibe-shared/cache-policy.
Deploy
sudo ovhost deploy deals (git-checkout: fetch, fast-forward /opt/openvibe.deals, install on a
lockfile change, restart, wait for /api/ready). Unit openvibe-deals.service on
127.0.0.1:4840, env /etc/openvibe/deals.env; the release migrates ov_deals at boot; vhost
[deploy/nginx/openvibe.deals.conf](deploy/nginx/openvibe.deals.conf). Rollback
sudo ovhost rollback deals --to <sha>; migrations only add tables and columns.
Tests
npm test runs each test/*.test.js in its own process with PGlite and in-process Network/Sources/
Community mocks. The README's acceptance table maps each guarantee to its test: price observations and
freshness/as-of rendering, unknown stays unknown, votes/hotness/abuse, duplicate merge, watches and
saved searches, Sources import and per-source link policy, deletion/export, privacy and discovery.
ov test runs the same.
Conventions
- CommonJS, 4-space indent, single quotes, semicolons; domain in
server/domain/, HTTP in
server/http/, no SQL in routes. - Money is integer minor units with an ISO 4217 currency; a price is never a float and never a bare
number without its currency andobserved_at. - IDs/errors/capabilities from
openvibe-contracts; errors are problem+json with stable codes. - Time is UTC and rendered "as of" the observation; a page may say "not stated", never invent a price.
Pitfalls
- Never present a price as current. Render the observation with
observed_atand the source, or
say it is not stated. A cached/stale page must keep its as-of date (and Search/JSON must agree). - Do not copy a Sources item into Deals. Store the typed reference
{sources, item, itm_…, revision}and read through the source; a copy drifts and breaks attribution. - An alias never silently moves to another product; a wrong mapping needs a correction and an audit
trail, not an update in place. - Track-only links are stripped, except where a source policy keeps a link verbatim; the policy
lives inserver/domain/source-policy.js, not in the renderer. - A merge keeps both records: the loser gets
merged_into, its votes/observations stay for history,
and the API redirects/canonicalises; do not hard-delete. - Only one notification per (watch, observation) — the unique row is the guard; do not "notify
again" on a retry. - Votes are not client-computed and
0is a removal kept as history; a vote ring raises a flag. - No delivery pretence:
deals.watch.matchedis emitted but no consumer exists yet; the UI must not
say a notification was sent. - The charter list is nine tables; the deviations are documented (
deal_stores, etc.) — adding a
tenth charter table needs the doc updated, not just the migration.
Documentation
- [README.md](README.md) — purpose, tables, routes, API, events, prices/freshness, duplicates, votes,
watches, imports, discovery, acceptance, security and deploy. - [docs/](docs/) — the charter deviations and source policy notes.
- OpenVibe.Contracts holds
deals.*shapes/capabilities; Sources, Community and publishing own their
contracts.