# 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`, port `4840`, origin `https://openvibe.deals`, unit `openvibe-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 audience `openvibe.deals` and 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 seeded `dealnews-daily` is disabled until a person re-verifies its terms; its terms are encoded in `server/domain/source-policy.js`); **Community** (`community.comment.write`, optionally moderate); **Events** (outbox, and `events.subscription.manage` once for `scripts/subscribe.js`); **Search** (consumes `deals.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 ```bash 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 audience `openvibe.deals` and the route's capability; `X-OV-Subject` scopes 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 `; 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 and `observed_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_at` and 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 in `server/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 `0` is a removal kept as history; a vote ring raises a flag. - **No delivery pretence:** `deals.watch.matched` is 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.