openvibers/openvibe.deals Commits

README (purpose, owns, does not own, depends on, grants, acceptance, launch rule, threat review, deploy), STATUS alpha W18, .env.example, deploy (systemd unit, nginx vhost on 4840), CI on Node 22.22.1 (contracts check continue-on-error until release), capability and service manifest proposals, Events subscription script

OP OpenVibers committed 23 Sept 2026 · 94f99e9454 · 20 files, +958 −44


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..05f1902
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,83 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.deals — environment configuration (names only; never commit values)
++# Copy to .env for development. Production: /etc/openvibe/deals.env (0600).
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4840
++HOST=127.0.0.1
++NODE_ENV=development
++# Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it
++BASE_URL=http://localhost:4840
++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2). The abuse controls read the client IP from it.
++TRUST_PROXY=2
++
++# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)
++DEALS_DB_PATH=./data/deals.db
++
++# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# OAuth client `deals` (redirect https://openvibe.deals/auth/callback). The same client is Deals'
++# service principal svc:deals for client-credentials grants (see README, "Grants").
++OV_OAUTH_CLIENT_ID=deals
++OV_OAUTH_CLIENT_SECRET=
++# Production: https://openvibe.deals/auth/callback (default: BASE_URL + /auth/callback)
++#OV_OAUTH_REDIRECT_URI=
++# Cookies: Secure by default in production; set false for plain-http local development
++#COOKIE_SECURE=false
++# Signs the no-JS form tokens (CSRF). Unset: random per process.
++#DEALS_FORM_SECRET=
++# Keys the HMAC that turns client IPs into ip hashes (vote limits, ring detection). Unset: random per
++# process, so hashes reset on restart (/api/ready reports abuse_keys as degraded).
++#DEALS_IP_HASH_SECRET=
++
++# Moderators besides Network admins / global mods: usr_… subjects, comma-separated
++#DEALS_MODERATORS=
++
++# Freshness: an observation older than this is shown as stale and the page is noindex
++#DEALS_FRESHNESS_HOURS=48
++
++# Abuse controls (defaults shown)
++#DEALS_VOTE_LIMIT_SUBJECT_HOUR=60
++#DEALS_VOTE_LIMIT_IP_HOUR=120
++#DEALS_VOTE_LIMIT_OFFER_HOUR=6
++#DEALS_SUBMIT_LIMIT_DAY=20
++#DEALS_OBSERVE_LIMIT_HOUR=30
++#DEALS_FLAG_LIMIT_DAY=30
++#DEALS_NEW_ACCOUNT_DAYS=7
++#DEALS_NEW_ACCOUNT_WEIGHT=0.25
++#DEALS_RING_IP_MIN=3
++#DEALS_RING_COVOTE_MIN=5
++#DEALS_RING_WINDOW_MIN=10
++#DEALS_WATCH_LIMIT=50
++
++# OpenVibe.Community — comment threads (referenced, never copied)
++OV_COMMUNITY_URL=https://openvibe.community
++OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
++
++# OpenVibe.Sources — deals-category items (needs sources.item.read). DEALS_IMPORT=off disables the importer.
++OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
++#DEALS_IMPORT=on
++#DEALS_IMPORT_INTERVAL_MS=300000
++#DEALS_IMPORT_PAGE_SIZE=100
++#DEALS_IMPORT_MAX_PAGES=10
++#DEALS_IMPORT_REFRESH_BATCH=50
++
++# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
++#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_RELAY_INTERVAL_MS=2000
++# Webhook secret(s) of Deals' sources.item.* subscription (comma-separated for rotation). Unset: /internal/events answers 503.
++#DEALS_EVENTS_SECRET=
++
++# Worker (stated expiry, hotness snapshots, freshness re-indexing, Sources pull); off: DEALS_WORKER=off
++#DEALS_WORKER=on
++#DEALS_WORKER_INTERVAL_MS=600000
++#DEALS_HOT_WINDOW_DAYS=14
++
++# Browser origins that may call /api/v1 with a Bearer Network JWT
++#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.coupons
++
++# release.json (openvibe-shared/release): the deployed commit when git is not available
++#RELEASE_COMMIT=
++#MIN_CLIENT_RELEASE=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..7762420
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,20 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # Deals' service manifest and capabilities are proposals (docs/) until the lead releases them in
++ # openvibe-contracts; until then the check reports "capability deals.* is not defined".
++ - run: npx openvibe-contracts-check --service deals --src server
++ continue-on-error: true
ddiff --git a/README.md b/README.md
iindex 0660b27..44278b1 100644
---- a/README.md
++++ b/README.md
@@@ -2,74 +2,353 @@
> Deals submitted and voted on by the community, with source, price and freshness always shown.
--**Status:** placeholder — planning only, no runnable code yet.
--**Domain:** `openvibe.deals`
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.9.
++**Status:** alpha (roadmap Wave 18, Deals). The service runs and its tests pass. It is **not
++deployed**, `openvibe.deals` still shows its placeholder from OpenVibe.Sites, and its capabilities and
++service manifest are proposals that the next openvibe-contracts release has to include.
++**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/imports, product/offer references, votes/hotness, watches and price/shipping/condition context. Source adapters and AI may normalise/explain; Community provides durable discussion.
++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
--- `deal_products`, `deal_product_aliases`, `deal_offers`, `deal_offer_sources`, `deal_votes`, `deal_hotness_snapshots`, `deal_watches`, `deal_price_observations`, `deal_flags`
++The nine charter tables live in Deals' own SQLite (`DEALS_DB_PATH`):
++
++| 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), 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`, the SDK's `event_outbox` / `idempotency_receipts`, and openvibe-publishing's
++`deal_offer_discussion_refs` and `deal_index_revisions`.
## Does not own
--- coupon codes (OpenVibe.Coupons; cross-linked, separate database)
--- discussion (Community)
++- 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 /`, `GET /new` | hot deals (latest `hot@1` snapshots) and newest deals; each card shows the latest price **as of** its time with a fresh/stale badge |
++| `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/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/offers.xml`, `/sitemaps/products.xml` | discovery (below) |
++| `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) |
++
++### 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 (proposals in
++[docs/capabilities-proposal/](docs/capabilities-proposal/), service manifest in
++[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).
++
++### 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.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.
++
++## 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 <time>", a stale price says it may no
++ longer be available, and nothing on the site calls a price "current".
++- 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.
++- 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.
++- 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):
--## Planned surfaces
++```
++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 )
++```
--- submission, merchant/product normalisation and duplicate detection, timestamped price/shipping/trust display, add/change/remove votes, deterministic hotness, keyword/product watches via Notifications, expiry handling
++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.
--## Data (authority tables / families)
++**Abuse controls (server-side, SQLite windows shared by forms and API):**
--- see above
++| 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 |
--## Capabilities and events
++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).
--- `deals.offer.submit|update|expire`, `deals.vote.set|remove`, `deals.watch.create|delete`, `deals.product.resolve`
++## Watches and saved searches
--Events: ``deals.offer.created|updated|expired``, ``deals.vote.changed``, ``deals.watch.matched``
++`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).
++
++## 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.
++
++### 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
--- source registry
--- OpenVibe.Community
--- Notifications
--- Search
--- OpenVibe.Events
++- **Packages** (pinned release tarballs): `openvibe-publishing` v0.2.0 (seo gate, JSON-LD, feeds,
++ sitemaps, index-hooks, discussion, ssr), `openvibe-contracts` v0.19.0, `openvibe-shared` v1.3.0
++ (chrome, app icon, footer, legal, release, metrics, ready), `openvibe-sdk` v0.2.2 (outbox, inbox,
++ webhook signatures, service tokens).
++- **OpenVibe.Network:** SSO (OAuth client `deals` — not yet in Network's seeded client list), 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).
++- **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`.
++
++### Grants the Network must hold
--## Acceptance (must be true before "done")
++Each grant is `[client, capability, audience]`:
--- price/availability is timestamped and never silently treated as current
--- duplicate offers merge without losing votes/history
--- vote abuse controls are server-side
--- watches never notify twice for the same observation
++- `[deals, identity.subject.resolve, openvibe.network]`
++- `[deals, events.event.publish, openvibe.events]`
++- `[deals, events.subscription.manage, openvibe.events]` (only to run `scripts/subscribe.js`)
++- `[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]`
--## Bootstrap / extraction source
++## Acceptance (automated: `npm test`)
--No current implementation; Wave 16.
++| 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` |
## Launch rule
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
++This repository alone doesn't make the product live. `openvibe.deals` keeps its placeholder on
++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of plan §12.12
++exists. 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 in code; the Network still needs the `deals` OAuth client and the grants above.
++3. **SSR public routes useful without JS:** done.
++4. **Persistence and end-to-end workflows:** done; imports need a registered, enabled deals source in Sources.
++5. **Capability and event registration against OpenVibe.Contracts:** proposals are in `docs/`, waiting on the release.
++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 does all of these in one release:** removes `openvibe.deals` from
++`OpenVibe.Sites/sites.json`, switches routing (nginx vhost, DNS, TLS), flips the Network hub entry
++(`server/chrome/sites.js`, `status: 'soon'`) and registers maturity in the ecosystem registry. A
++placeholder never counts as an implemented service, and this README doesn't call the service live.
++
++## 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
++
++- **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;
++ `/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
++
++```bash
++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)
++
++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, `StateDirectory=openvibe-deals`).
++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:** release the capability and manifest proposals in openvibe-contracts. Then CI's
++ contracts check can drop `continue-on-error`.
++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.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex f0986e7..7e9d2e6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,38 @@
{
"repository": "OpenVibers/OpenVibe.Deals",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "runtime": "node 22, express 4, better-sqlite3 11",
++ "port": 4840,
++ "domain": "openvibe.deals",
++ "packages": [
++ "openvibe-publishing v0.2.0",
++ "openvibe-contracts v0.19.0",
++ "openvibe-shared v1.3.0",
++ "openvibe-sdk v0.2.2"
++ ],
++ "capabilities": [
++ "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",
++ "deals.flag.create"
++ ],
++ "capabilitiesRegistered": false,
++ "serviceManifestRegistered": false,
++ "seedDeals": "none",
++ "sourcesImport": "code and tests; needs the sources.item.read grant and an enabled deals-category source in OpenVibe.Sources",
++ "watchNotifications": "deals.watch.matched events only; OpenVibe.Network's notification consumer does not exist yet",
++ "deployed": false,
++ "placeholderInSites": true,
++ "updated": "2026-09-22"
}
ddiff --git a/deploy/nginx/openvibe.deals.conf b/deploy/nginx/openvibe.deals.conf
nnew file mode 100644
iindex 0000000..f520f73
---- /dev/null
++++ b/deploy/nginx/openvibe.deals.conf
@@@ -0,0 +1,109 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.deals — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.deals.conf — keep them in sync and reload nginx
++## after changing it. The Node app (port 4840, systemd unit openvibe-deals) renders every page;
++## nginx terminates TLS, redirects www → apex, rate-limits auth, forms and API, keeps the events
++## webhook host-local, and compresses text. The abuse controls read the client IP from
++## X-Forwarded-For (TRUST_PROXY=2 behind Cloudflare → nginx).
++##
++## No proxy_cache here on purpose: the app decides cacheability per response (Cache-Control
++## public, max-age=60 only for pages shown to anonymous visitors — freshness labels are computed
++## at render time, so a longer cache would show "fresh" for longer than it is true; private,
++## no-store for everything else, Vary: Cookie, Authorization).
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovdeals_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovdeals_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=ovdeals_write:10m rate=5r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.deals www.openvibe.deals;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.deals$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.deals;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.deals/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.deals/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.deals$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.deals;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.deals/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.deals/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.deals.access.log;
++ error_log /var/log/nginx/openvibe.deals.error.log;
++
++ # Forms are URL-encoded and small; there are no uploads.
++ client_max_body_size 256k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header X-Frame-Options SAMEORIGIN always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/feed+json image/svg+xml text/plain application/xml text/xml application/rss+xml application/atom+xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # ── Network SSO session endpoints (strict) ──────────────
++ location /auth/ {
++ limit_req zone=ovdeals_auth burst=10 nodelay;
++ proxy_pass http://127.0.0.1:4840;
++ }
++
++ # ── Forms (submit, watches, moderation) ─────────────────
++ location ~ ^/(submit|watches|mod)(/|$) {
++ limit_req zone=ovdeals_write burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4840;
++ }
++
++ # ── OpenVibe.Events deliveries come from the host (127.0.0.1:4840/internal/events), never from the internet ──
++ location /internal/ { return 404; }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=ovdeals_api burst=30 nodelay;
++ proxy_pass http://127.0.0.1:4840;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4840/metrics) ──
++ location = /metrics { return 404; }
++
++ # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
++ location / {
++ proxy_pass http://127.0.0.1:4840;
++ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-deals.service b/deploy/systemd/openvibe-deals.service
nnew file mode 100644
iindex 0000000..16863cf
---- /dev/null
++++ b/deploy/systemd/openvibe-deals.service
@@@ -0,0 +1,31 @@
++[Unit]
++Description=OpenVibe.Deals — community deals with timestamped price observations
++# Network (sign-in, JWKS, service tokens), Events (the outbox relay) and Sources (imports) should be up first, but Deals
++# degrades gracefully without them: pages and feeds serve; sign-in, events and imports wait (see /api/ready).
++After=network.target
++Wants=openvibe-network.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.deals
++EnvironmentFile=/etc/openvibe/deals.env
++Environment=NODE_ENV=production
++Environment=PORT=4840
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.deals/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++TimeoutStopSec=10
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# Deals' own database lives in /var/lib/openvibe-deals; the code tree stays read-only.
++StateDirectory=openvibe-deals
++Environment=DEALS_DB_PATH=/var/lib/openvibe-deals/deals.db
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/capabilities-proposal/deals.flag.create.json b/docs/capabilities-proposal/deals.flag.create.json
nnew file mode 100644
iindex 0000000..fcc5896
---- /dev/null
++++ b/docs/capabilities-proposal/deals.flag.create.json
@@@ -0,0 +1,19 @@
++{
++ "id": "deals.flag.create",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Report a problem with an offer for the acting person (expired, price_wrong, duplicate, spam, other). One open report per person and offer; rate-limited. Flags never change an offer by themselves.",
++ "permissions": [
++ "deals:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/offers/:id/flags"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.offer.expire.json b/docs/capabilities-proposal/deals.offer.expire.json
nnew file mode 100644
iindex 0000000..176c689
---- /dev/null
++++ b/docs/capabilities-proposal/deals.offer.expire.json
@@@ -0,0 +1,22 @@
++{
++ "id": "deals.offer.expire",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Mark an active offer expired (the person who submitted it, or a moderator). Unknown expiry is never assumed; offers with a stated end expire on their own.",
++ "permissions": [
++ "deals:write"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "deals-write",
++ "events": [
++ "deals.offer.expired",
++ "deals.index_document.upserted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/offers/:id/expire"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.offer.merge.json b/docs/capabilities-proposal/deals.offer.merge.json
nnew file mode 100644
iindex 0000000..427c6ac
---- /dev/null
++++ b/docs/capabilities-proposal/deals.offer.merge.json
@@@ -0,0 +1,24 @@
++{
++ "id": "deals.offer.merge",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Duplicate resolution (moderation): merge an offer into another, or unmerge it. Nothing is copied or deleted: votes, observations, sources and comments stay where they were written and the canonical offer reads across the group (a voter counts once). Audited with the tallies before and after; reversible.",
++ "permissions": [
++ "deals:moderate"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-moderation",
++ "events": [
++ "deals.offer.updated",
++ "deals.index_document.upserted",
++ "deals.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/offers/:id/merge",
++ "POST /api/v1/offers/:id/unmerge"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.offer.moderate.json b/docs/capabilities-proposal/deals.offer.moderate.json
nnew file mode 100644
iindex 0000000..d7d2767
---- /dev/null
++++ b/docs/capabilities-proposal/deals.offer.moderate.json
@@@ -0,0 +1,27 @@
++{
++ "id": "deals.offer.moderate",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Moderation: disable or enable an offer, record a person's review of imported or AI-assisted text (reviews are recorded for a usr_ subject only), read and resolve flags including vote-ring flags. Audited.",
++ "permissions": [
++ "deals:moderate"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-moderation",
++ "events": [
++ "deals.offer.updated",
++ "deals.index_document.upserted",
++ "deals.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/offers/:id/disable",
++ "POST /api/v1/offers/:id/enable",
++ "POST /api/v1/offers/:id/review",
++ "GET /api/v1/flags",
++ "POST /api/v1/flags/:id/resolve"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.offer.submit.json b/docs/capabilities-proposal/deals.offer.submit.json
nnew file mode 100644
iindex 0000000..fc3b331
---- /dev/null
++++ b/docs/capabilities-proposal/deals.offer.submit.json
@@@ -0,0 +1,23 @@
++{
++ "id": "deals.offer.submit",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Submit a deal for the acting person (X-OV-Subject): a link, title and what the person sees now (price with its currency, shipping, condition, availability — each optional, never defaulted). The first observation is recorded with its time. A link already posted answers 409 offer.duplicate with the existing offer. With X-OV-Origin: ai the text is AI-assisted: disclosed and noindex until a person reviews it. Rate-limited per person.",
++ "permissions": [
++ "deals:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-write",
++ "events": [
++ "deals.offer.created",
++ "deals.watch.matched",
++ "deals.index_document.upserted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/offers"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.offer.update.json b/docs/capabilities-proposal/deals.offer.update.json
nnew file mode 100644
iindex 0000000..446103d
---- /dev/null
++++ b/docs/capabilities-proposal/deals.offer.update.json
@@@ -0,0 +1,24 @@
++{
++ "id": "deals.offer.update",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Edit an offer the acting person submitted (title, description, category, stated expiry, product link), or record a new observation of any active offer for the acting person (POST …/observations: price with currency, shipping, condition, availability, with the time it was observed). Prices are never edited in place; each observation is a new row. Services may state observed_at (never in the future).",
++ "permissions": [
++ "deals:write"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "deals-write",
++ "events": [
++ "deals.offer.updated",
++ "deals.watch.matched",
++ "deals.index_document.upserted"
++ ],
++ "implementedBy": [
++ "PATCH /api/v1/offers/:id",
++ "POST /api/v1/offers/:id/observations"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.product.resolve.json b/docs/capabilities-proposal/deals.product.resolve.json
nnew file mode 100644
iindex 0000000..7f4599d
---- /dev/null
++++ b/docs/capabilities-proposal/deals.product.resolve.json
@@@ -0,0 +1,21 @@
++{
++ "id": "deals.product.resolve",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Resolve a product by its aliases (gtin, mpn, sku, exact name, url), creating it when none matches (create: false to only look up). Aliases that already name another product are reported as conflicts, never re-pointed.",
++ "permissions": [
++ "deals:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-write",
++ "events": [
++ "deals.index_document.upserted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/products/resolve"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.vote.remove.json b/docs/capabilities-proposal/deals.vote.remove.json
nnew file mode 100644
iindex 0000000..f796412
---- /dev/null
++++ b/docs/capabilities-proposal/deals.vote.remove.json
@@@ -0,0 +1,21 @@
++{
++ "id": "deals.vote.remove",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Remove the acting person's vote on an offer (kept as a removed row for history). Same abuse controls as deals.vote.set.",
++ "permissions": [
++ "deals:vote"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-vote",
++ "events": [
++ "deals.vote.changed"
++ ],
++ "implementedBy": [
++ "DELETE /api/v1/offers/:id/vote"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.vote.set.json b/docs/capabilities-proposal/deals.vote.set.json
nnew file mode 100644
iindex 0000000..bed4247
---- /dev/null
++++ b/docs/capabilities-proposal/deals.vote.set.json
@@@ -0,0 +1,21 @@
++{
++ "id": "deals.vote.set",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Set the acting person's vote (+1 / -1) on an offer. Server-authoritative: weight and hotness are computed by Deals (hot@1), never taken from the request. Rate-limited per person, per IP hash and per offer; no votes on one's own offer; vote-ring detection flags moderators.",
++ "permissions": [
++ "deals:vote"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "deals-vote",
++ "events": [
++ "deals.vote.changed"
++ ],
++ "implementedBy": [
++ "PUT /api/v1/offers/:id/vote"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.watch.create.json b/docs/capabilities-proposal/deals.watch.create.json
nnew file mode 100644
iindex 0000000..02f995b
---- /dev/null
++++ b/docs/capabilities-proposal/deals.watch.create.json
@@@ -0,0 +1,19 @@
++{
++ "id": "deals.watch.create",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Create a keyword, product or price-below watch, or a saved search (never notifies), for the acting person. A watch notifies at most once per observation (deals.watch.matched, internal; Deals never emails).",
++ "permissions": [
++ "deals:watch"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "deals-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/watches"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.watch.delete.json b/docs/capabilities-proposal/deals.watch.delete.json
nnew file mode 100644
iindex 0000000..fa188d8
---- /dev/null
++++ b/docs/capabilities-proposal/deals.watch.delete.json
@@@ -0,0 +1,19 @@
++{
++ "id": "deals.watch.delete",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Delete one of the acting person's watches or saved searches.",
++ "permissions": [
++ "deals:watch"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "deals-write",
++ "events": [],
++ "implementedBy": [
++ "DELETE /api/v1/watches/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/deals.watch.read.json b/docs/capabilities-proposal/deals.watch.read.json
nnew file mode 100644
iindex 0000000..cc52b0a
---- /dev/null
++++ b/docs/capabilities-proposal/deals.watch.read.json
@@@ -0,0 +1,19 @@
++{
++ "id": "deals.watch.read",
++ "version": "1.0",
++ "owner": "deals",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "List the acting person's watches and saved searches with their notification counts.",
++ "permissions": [
++ "deals:watch"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "deals-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/watches"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..222e7f1
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,48 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.19.0 <1.0.0"
++ },
++ "id": "deals",
++ "name": "OpenVibe.Deals",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Deals",
++ "domains": [
++ "openvibe.deals"
++ ],
++ "publicOrigin": "https://openvibe.deals",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "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",
++ "deals.flag.create"
++ ],
++ "eventsProduced": [
++ "deals.offer.created",
++ "deals.offer.updated",
++ "deals.offer.expired",
++ "deals.vote.changed",
++ "deals.watch.matched",
++ "deals.index_document.upserted",
++ "deals.index_document.deleted"
++ ],
++ "eventsConsumed": [
++ "sources.item.created",
++ "sources.item.updated",
++ "sources.item.removed"
++ ],
++ "namespacesOwned": [
++ "deals.*"
++ ],
++ "notes": "Port 4840. Owns deal_products, deal_product_aliases, deal_offers, deal_offer_sources, deal_votes, deal_hotness_snapshots, deal_watches, deal_price_observations, deal_flags (+ deal_stores) in its own SQLite. Consumes OpenVibe.Network (SSO, identity.subject.resolve), OpenVibe.Sources (sources.item.read; sources.item.* events only wake the pull), OpenVibe.Community (community.comment.write, community.comment.moderate) and publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads deals.index_document.* from Events; deals.watch.matched is for Network notifications (consumer not built yet)."
++}
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..aae5fa2
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -0,0 +1,54 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Create Deals' OpenVibe.Events subscription for OpenVibe.Sources item changes (they only wake the
++ * importer, which reads the deals change feed from its cursor; nothing is lost if this is never run).
++ *
++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4840/internal/events] [--pattern 'sources.item.*']
++ *
++ * Reads the environment (.env or /etc/openvibe/deals.env):
++ * EVENTS_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the deals
++ * principal needs events.subscription.manage for audience openvibe.events)
++ * DEALS_EVENTS_SECRET the delivery signing secret; the first value is handed to Events, so
++ * generate it before running this (e.g. `openssl rand -hex 32`). Nothing
++ * secret is printed.
++ * An existing identical subscription is reported, not duplicated.
++ */
++require('dotenv').config();
++const { serviceAuth } = require('openvibe-contracts');
++const { load } = require('../server/config');
++
++const args = process.argv.slice(2);
++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
++
++(async () => {
++ const config = load();
++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
++ const pattern = opt('pattern', 'sources.item.*');
++ const secret = config.events.webhookSecrets[0];
++ if (!config.events.url) throw new Error('EVENTS_URL is not set');
++ if (!secret || secret.length < 32) throw new Error('DEALS_EVENTS_SECRET must be set (32+ characters) before subscribing');
++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.events',
++ scope: 'events.subscription.manage',
++ });
++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 409 && body.subscription_id) {
++ console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`);
++ return;
++ }
++ if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());
++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
++})().catch((err) => {
++ console.error(`subscribe failed: ${err.message}`);
++ process.exit(1);
++});