openvibers/openvibe.deals Commits

OpenVibe.Deals service (Wave 18, Deals): the nine deal_* tables in its own SQLite; community submissions and Sources imports as offers whose every price, shipping, condition and availability is a timestamped observation with its source (fresh/stale, never "current", unknown stays unknown in pages, JSON, JSON-LD, comparison, feeds and Search); product and store records with aliases; reversible, audited duplicate merges that keep every vote (a voter counts once) and all history; server-authoritative votes with rate limits per subject, IP hash and offer, new-account weight and vote-ring flags; deterministic hotness hot@1 snapshots; keyword/product/price-below watches and saved searches that notify at most once per observation (deals.watch.matched); flags and moderation; Community comment threads; SSR pages useful without JavaScript, feeds, sitemaps, robots, llms.txt and the indexability gate; /api/v1 with one capability per route; outbox events and Search index documents

OP OpenVibers committed 23 Sept 2026 · 040cca527f · 52 files, +8009 −0


Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..1358cb1
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1562 @@
++{
++ "name": "openvibe-deals",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-deals",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "devDependencies": {
++ "jsonwebtoken": "^9.0.2"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.19.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-publishing": {
++ "version": "0.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..9353613
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,34 @@
++{
++ "name": "openvibe-deals",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Deals — community-submitted and source-imported deals with timestamped price observations, server-authoritative votes and hotness, reversible duplicate merges, watches and saved searches, server-rendered and useful without JavaScript.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js"
++ },
++ "keywords": ["openvibe", "deals", "prices", "publishing"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Deals",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "devDependencies": {
++ "jsonwebtoken": "^9.0.2"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/public/css/deals.css b/public/css/deals.css
nnew file mode 100644
iindex 0000000..5732d02
---- /dev/null
++++ b/public/css/deals.css
@@@ -0,0 +1,73 @@
++/* OpenVibe.Deals — readable, theme-token driven (openvibe-shared presets). No JavaScript needed. */
++:root { color-scheme: dark; }
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 17px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--accent-light, #60a5fa); }
++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.skip { position: absolute; left: -9999px; }
++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
++.account-bar { max-width: 760px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.page { max-width: 760px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
++h1 { font-size: 2.1rem; line-height: 1.2; margin: 1.2rem 0 .6rem; }
++h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
++.lede { color: var(--text-secondary, #96a7c2); font-size: 1.1rem; }
++.meta, .byline, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; }
++.badge { display: inline-block; font-size: .75rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary); vertical-align: middle; }
++.summary { margin: 6px 0; }
++.tags a, .categories a { margin-right: 6px; }
++.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
++.sources ol { padding-left: 1.2rem; }
++.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
++.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
++.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
++.comments { margin-top: 40px; border-top: 1px solid var(--border, #1f2d47); }
++.comment { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); }
++.replies { margin-left: 22px; }
++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
++.notice.error { border-color: var(--danger, #ef4444); }
++.card { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 16px; margin: 16px 0; background: var(--bg-card, #131c2e); }
++label { display: block; margin: 10px 0 4px; font-weight: 600; font-size: .95rem; }
++label input[type=checkbox] { margin-right: 6px; }
++input, textarea, select { width: 100%; font: inherit; color: var(--text-primary); background: var(--bg-input, #0d1424); border: 1px solid var(--border-light, #2c3d5c); border-radius: 6px; padding: 8px 10px; }
++input[type=checkbox] { width: auto; }
++textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; }
++button, .button { display: inline-block; margin-top: 10px; font: inherit; font-weight: 600; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 8px 16px; cursor: pointer; text-decoration: none; }
++button.danger { background: var(--danger, #ef4444); }
++form.inline { display: inline; }
++form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
++.actions form { margin: 10px 0; }
++@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
++@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } }
++/* Deals */
++.page { max-width: 900px; }
++.account-bar { max-width: 900px; }
++.sr { position: absolute; left: -9999px; }
++.tabs { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 8px; }
++.tabs [aria-current] { font-weight: 700; }
++.tabs form.search { margin-left: auto; display: flex; gap: 6px; }
++.tabs form.search input { width: 200px; }
++.tabs form.search button { margin: 0; }
++.deal-list { list-style: none; padding: 0; }
++.deal-item { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border, #1f2d47); }
++.deal-item .score { min-width: 48px; text-align: center; font-weight: 700; font-size: 1.1rem; padding-top: 4px; }
++.deal-body { min-width: 0; flex: 1; }
++.deal-title { margin: 0 0 4px; font-size: 1.15rem; overflow-wrap: anywhere; }
++.deal-title a { color: var(--text-primary); text-decoration: none; }
++.deal-title a:hover { text-decoration: underline; }
++.price-line { margin: 4px 0; }
++.price { font-size: 1.1rem; }
++.badge.fresh { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
++.stale-notice { border-color: var(--warning, #f59e0b); }
++.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; }
++.facts dt { color: var(--text-muted, #7386a3); }
++.facts dd { margin: 0; }
++table.obs, table.compare { width: 100%; border-collapse: collapse; font-size: .92rem; display: block; overflow-x: auto; }
++table.obs th, table.obs td, table.compare th, table.compare td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }
++table.obs caption, table.compare caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding: 4px 0; }
++tr.removed { opacity: .6; }
++.vote button[aria-pressed="true"] { outline: 2px solid var(--on-accent, #fff); }
++.mod form { margin: 10px 0; }
++details.card summary { cursor: pointer; font-weight: 600; }
++code { overflow-wrap: anywhere; }
++@media (max-width: 600px) { .tabs form.search { margin-left: 0; width: 100%; } .tabs form.search input { width: 100%; } .deal-item .score { min-width: 36px; } }
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..25285b0
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,157 @@
++'use strict';
++
++/**
++ * OpenVibe.Deals — Express app factory. server/index.js listens and starts the worker; tests build
++ * their own instance with a temp database, an injectable clock and mock neighbours.
++ *
++ * Pages (server-rendered, http/pages.js) Discovery (robots, llms, sitemaps) /auth/* (Network SSO)
++ * API (/api/v1, http/api.js) /internal/events (Sources wake-ups) /api/health, /api/ready,
++ * /release.json, /metrics
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createAuthClient, createAuthRoutes } = require('./auth/sso');
++const { createViewerResolver } = require('./auth/viewer');
++const { createPeople } = require('./clients/network');
++const { createCommunity } = require('./clients/community');
++const { createSources } = require('./clients/sources');
++const { createDealsOutbox } = require('./events/outbox');
++const { createReads } = require('./domain/reads');
++const { createCatalog } = require('./domain/catalog');
++const { createPublication } = require('./domain/publication');
++const { createIndexing } = require('./domain/indexing');
++const { createHotness } = require('./domain/hotness');
++const { createWatches } = require('./domain/watches');
++const { createLimits } = require('./domain/limits');
++const { createAccess } = require('./domain/access');
++const { createOffers } = require('./domain/offers');
++const { createFlags } = require('./domain/flags');
++const { createVotes } = require('./domain/votes');
++const { createListings } = require('./domain/listings');
++const { createImporter } = require('./domain/importer');
++const { createPages } = require('./http/pages');
++const { createApi } = require('./http/api');
++const { createDiscoveryRoutes } = require('./http/discovery');
++const { createInternalRoutes } = require('./http/internal');
++const { createDealsReadiness } = require('./observability');
++const { createWorker } = require('./worker');
++const { assetVersion } = require('./render/layout');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++/** opts: config, dbPath, now (clock), fetchImpl, auth, log */
++function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
++
++ const outbox = createDealsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ const people = createPeople({ store, config, fetchImpl });
++ const community = createCommunity({ store, config, fetchImpl });
++ const sources = createSources({ config, fetchImpl });
++ const reads = createReads({ store });
++ const catalog = createCatalog({ store });
++ const publication = createPublication({ config, store, reads, catalog });
++ const indexing = createIndexing({ store, publication, outbox, catalog });
++ const hotness = createHotness({ store, reads });
++ const watches = createWatches({ config, store, reads, catalog, publication, outbox });
++ const limits = createLimits({ config, store });
++ const access = createAccess();
++ const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community });
++ const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction });
++ const votes = createVotes({ config, store, reads, hotness, limits, flags, people, outbox });
++ const listings = createListings({ store });
++ const importer = createImporter({ config, store, reads, catalog, offers, indexing, sources, log });
++ const auth = opts.auth || createAuthClient(config);
++ const viewers = createViewerResolver({ auth, config, people });
++ const worker = createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log });
++
++ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'deals', root: path.join(__dirname, '..') });
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'deals', release: release.release });
++ app.locals.metrics = metrics.registry;
++ app.locals.ctx = ctx;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https:'],
++ connectSrc: ["'self'", 'https://openvibe.network'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'self'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-deals', version: VERSION }));
++ app.get('/release.json', release.handler);
++ const readiness = createDealsReadiness({ store, auth, outbox, importer, worker, limits, release: release.release });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', createAuthRoutes(config, auth));
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'deals', service: 'deals', host: 'openvibe.deals', name: 'OpenVibe.Deals', profile: 'ugc' })); }
++
++ // ── Static assets (content-hashed ?v= → immutable) ──────
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
++ },
++ }));
++
++ // ── Events consumer, API ────────────────────────────────
++ app.use(createInternalRoutes({ config, store, importer }));
++ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
++
++ // ── Discovery, public pages ─────────────────────────────
++ app.use(createDiscoveryRoutes(ctx));
++ const pages = createPages(ctx);
++ const formLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
++ app.use(['/submit', '/watches', '/mod', '/d/:slug/:action'], (req, res, next) => (req.method === 'POST' ? formLimit(req, res, next) : next()));
++ app.use(pages.router);
++ app.use((req, res) => pages.notFound(req, res));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[Deals]', err && err.stack ? err.stack : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', 'private, no-store');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..f7b6ff3
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/**
++ * Capability checks for service tokens (audience openvibe.deals), including the ids Deals
++ * introduces before the contracts library knows them.
++ *
++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
++ * manifests yet. Deals' ids are proposed in docs/capabilities-proposal/ for the next contracts
++ * release; until then a grant is decided locally with the library's own matching rule (the exact
++ * id, or a `prefix.*` grant covering it). An id the library does know always goes through the
++ * library, so the day the release lands nothing changes here.
++ *
++ * Browsers (Network user JWTs) are never judged by capabilities: they are judged by the domain
++ * (signed in, submitter, moderator). A service token is judged by its capability AND, for actions a
++ * person takes, by the person it acts for (X-OV-Subject).
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const CAPABILITIES = Object.freeze({
++ OFFER_SUBMIT: 'deals.offer.submit',
++ OFFER_UPDATE: 'deals.offer.update', // edit text, record an observation
++ OFFER_EXPIRE: 'deals.offer.expire',
++ OFFER_MERGE: 'deals.offer.merge', // duplicate resolution: merge / unmerge (moderation)
++ OFFER_MODERATE: 'deals.offer.moderate', // disable / enable / review / flag queue
++ VOTE_SET: 'deals.vote.set',
++ VOTE_REMOVE: 'deals.vote.remove',
++ WATCH_CREATE: 'deals.watch.create',
++ WATCH_READ: 'deals.watch.read',
++ WATCH_DELETE: 'deals.watch.delete',
++ PRODUCT_RESOLVE: 'deals.product.resolve',
++ FLAG_CREATE: 'deals.flag.create',
++});
++const PROPOSED = new Set(Object.values(CAPABILITIES));
++
++/** → { allowed, code, reason } like capabilities.check(). */
++function checkCapability(claims, capabilityId) {
++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
++ return capabilities.grants(claims && claims.cap, capabilityId)
++ ? { allowed: true, code: null, reason: null }
++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
++ }
++ return capabilities.check(claims, capabilityId);
++}
++
++module.exports = { CAPABILITIES, PROPOSED, checkCapability };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..2214c4c
---- /dev/null
++++ b/server/auth/forms.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++/**
++ * Form tokens for the no-JavaScript submit, vote, watch, report, comment and moderation forms (CSRF). The session cookie is
++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
++ * DEALS_FORM_SECRET (a random per-process key when unset, so forms opened before a restart must be
++ * reloaded). Never derived from anything a cross-site page can read.
++ */
++const crypto = require('crypto');
++
++const fallback = crypto.randomBytes(32).toString('hex');
++
++function csrfToken(config, viewer) {
++ if (!viewer || !viewer.subject) return '';
++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`deals-form:${viewer.subject}`).digest('base64url').slice(0, 32);
++}
++
++function checkCsrf(config, viewer, token) {
++ const expected = csrfToken(config, viewer);
++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
++}
++
++module.exports = { csrfToken, checkCsrf };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..145501a
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,420 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.deals — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
++//
++// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
++// Deals' client id: registered in the Network as OAuth client `deals`, redirect
++// https://openvibe.deals/auth/callback.
++//
++// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none;
++// ?next=… same-site path or an https://openvibe.network/… URL)
++// GET /auth/callback → server-side code exchange, set cookies
++// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
++// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
++// GET /auth/me → offline-verify ov_token, return profile
++// POST /auth/refresh → rotate tokens via refresh_token grant
++//
++// Cookies (host-only for openvibe.deals):
++// ov_token access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
++// ov_refresh opaque refresh token, httpOnly, Path=/auth
++// ov_sso_hint 'account' after a sign-in, 'guest' after a sign-out (navbar silent-login hint)
++// Pages never trust a body or query for identity: only the verified ov_token (or a Bearer token).
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const crypto = require('crypto');
++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++/**
++ * Create the auth client + JWKS fetcher shared by the whole app.
++ * Verification is OFFLINE: we cache the Network's RS256 public key from
++ * GET /api/.well-known/jwks and verify JWTs locally on every request.
++ */
++function createAuthClient(config) {
++ const client = new OpenVibeAuthClient({
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ publicKey: null, // filled by ensureKey()
++ authBase: config.networkUrl,
++ internalBase: config.networkInternalUrl,
++ });
++
++ let lastFetch = 0;
++ let inflight = null;
++ async function ensureKey() {
++ if (client.publicKey) return client.publicKey;
++ // A request that arrives while the key is being fetched waits for that fetch rather than
++ // being treated as signed out.
++ if (inflight) return inflight;
++ // Don't hammer the Network if it's down — retry at most every 30s
++ if (Date.now() - lastFetch < 30_000) return null;
++ lastFetch = Date.now();
++ inflight = fetchKey().finally(() => { inflight = null; });
++ return inflight;
++ }
++ async function fetchKey() {
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) continue;
++ const jwks = await res.json();
++ if (jwks.public_key) {
++ client.publicKey = jwks.public_key;
++ console.log(`[Deals] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
++ return client.publicKey;
++ }
++ } catch (err) {
++ console.warn(`[Deals auth] JWKS fetch failed from ${base}: ${err.message}`);
++ }
++ }
++ return null;
++ }
++
++ /** Offline JWT verification. Returns decoded claims or null. */
++ async function verify(token) {
++ if (!token) return null;
++ await ensureKey();
++ return client.verifyToken(token);
++ }
++
++ // Warm the key cache at boot (non-fatal if the Network is down)
++ ensureKey().catch(() => {});
++
++ return { client, ensureKey, verify };
++}
++
++/** Token from Authorization header or the ov_token cookie. */
++function extractToken(req) {
++ const h = req.headers.authorization;
++ if (h && h.startsWith('Bearer ')) return h.slice(7);
++ return req.cookies?.[ACCESS_COOKIE] || null;
++}
++
++/** Strip registered claims — what the site shows as "the user". */
++function claimsToUser(claims) {
++ if (!claims) return null;
++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
++ return user;
++}
++
++/**
++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
++ * we cross-check locally (the FedCM nonce) before the Network — which does verify
++ * the signature — sees the assertion. Never use it to trust an identity.
++ */
++function decodeJwtPayload(token) {
++ if (typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
++ } catch { return null; }
++}
++
++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const claims = decodeJwtPayload(token);
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/**
++ * Only allow same-site relative paths, this site's own https origin, or an
++ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
++ */
++function sanitizeNext(next, config) {
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.networkUrl]
++ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
++ .filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
++function withParam(target, key, value) {
++ const hashAt = target.indexOf('#');
++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
++ const sep = base.includes('?') ? '&' : '?';
++ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++/**
++ * Build the Network authorize URL for a login. Exported (and pure) so tests can
++ * cover the silent-login shape without a browser.
++ */
++function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
++ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
++ if (!silent) return { url, state };
++ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
++ // network has no session either, it bounces back with error=login_required and we stay quiet.
++ const u = new URL(url);
++ u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state };
++}
++
++function createAuthRoutes(config, auth) {
++ const router = express.Router();
++
++ const accessCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
++ path: '/',
++ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
++ });
++
++ const refreshCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: true,
++ path: '/auth', // only ever sent back to this session layer
++ maxAge: 30 * 24 * 60 * 60 * 1000,
++ });
++
++ const flowCookieOpts = () => ({
++ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
++ path: '/auth', maxAge: 10 * 60 * 1000,
++ });
++
++ const hintCookieOpts = () => ({
++ sameSite: 'lax',
++ secure: config.cookies.secure,
++ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
++ path: '/',
++ maxAge: 365 * 24 * 60 * 60 * 1000,
++ });
++
++ function setSessionCookies(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
++ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
++ }
++
++ function clearSessionCookies(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
++ }
++
++ function clearFlowCookies(res) {
++ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });
++ }
++
++ /** Exchange at the Network — internal URL first, public as fallback. */
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetch(`${base}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ client_id: config.oauth.clientId,
++ client_secret: config.oauth.clientSecret,
++ ...body,
++ }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
++ err.status = res.status;
++ err.error = data.error || 'invalid_grant';
++ err.error_description = data.error_description || null;
++ throw err;
++ }
++ return data;
++ } catch (err) {
++ lastErr = err;
++ // 4xx = the grant itself is bad; retrying against another base won't help
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ // ── GET /auth/login ──────────────────────────────────────
++ router.get('/login', async (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ if (silent) {
++ // Already signed in here? Then the silent round trip through the Network would
++ // only hand back the session we have: go straight to where the caller wanted.
++ const existing = req.cookies?.[ACCESS_COOKIE];
++ if (existing && await auth.verify(existing)) {
++ clearFlowCookies(res);
++ return res.redirect(sanitizeNext(req.query.next, config));
++ }
++ }
++ const { url, state } = buildAuthorizeUrl(config, auth, { silent });
++ res.cookie(STATE_COOKIE, state, flowCookieOpts());
++ const next = sanitizeNext(req.query.next, config);
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
++ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
++ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(url);
++ });
++
++ // ── GET /auth/callback ───────────────────────────────────
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
++ const silent = req.cookies?.[SILENT_COOKIE] === '1';
++
++ if (error) {
++ clearFlowCookies(res);
++ // Silent sign-in found no network session — go back quietly as a guest. The
++ // ?sso=none marker stops navbar.js from trying again on that page.
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).send('Missing authorization code');
++
++ const expectedState = req.cookies?.[STATE_COOKIE];
++ clearFlowCookies(res);
++ // The state cookie is required, not just compared when present: skipping the check when
++ // the cookie is missing would let a crafted link sign a visitor into someone else's account.
++ if (!expectedState || !state || !crypto.timingSafeEqual(
++ Buffer.from(String(state).padEnd(64).slice(0, 64)),
++ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
++ )) {
++ return res.status(400).send('OAuth state mismatch — please try signing in again.');
++ }
++
++ try {
++ const data = await tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: config.oauth.redirectUri,
++ code,
++ });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ console.error('[Deals auth] Code exchange failed:', err.message);
++ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ // ── POST /auth/fedcm ─────────────────────────────────────
++ // The shared navbar obtained a FedCM assertion from openvibe.network (the
++ // browser's own account chooser) and posts it same-origin with the nonce it
++ // put in the FedCM request. We only pre-check the nonce; the Network verifies
++ // the assertion's signature when it swaps it for tokens.
++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
++ }
++ if (!fedcmNonceMatches(token, nonce)) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
++ }
++ try {
++ const data = await tokenGrant({
++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
++ assertion: token,
++ });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ const user = data.user || claimsToUser(await auth.verify(data.access_token));
++ return res.json({ ok: true, user });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
++ }
++ console.error('[Deals auth] FedCM exchange failed:', err.message);
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ // ── GET /auth/logout ─────────────────────────────────────
++ router.get('/logout', async (req, res) => {
++ // Best-effort refresh revocation. The Network's rotating refresh tokens
++ // self-invalidate, so a failure here is harmless.
++ const refresh = req.cookies?.[REFRESH_COOKIE];
++ if (refresh) {
++ try {
++ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ client_id: config.oauth.clientId,
++ client_secret: config.oauth.clientSecret,
++ token: refresh,
++ }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSessionCookies(res);
++ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ // ── GET /auth/me ─────────────────────────────────────────
++ router.get('/me', async (req, res) => {
++ const token = extractToken(req);
++ if (!token) return res.status(401).json({ error: 'Not authenticated' });
++ const claims = await auth.verify(token);
++ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
++ });
++
++ // ── POST /auth/refresh ───────────────────────────────────
++ // Small endpoint the shared navbar can call when ov_token expires.
++ router.post('/refresh', async (req, res) => {
++ const refresh = req.cookies?.[REFRESH_COOKIE];
++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ const claims = await auth.verify(data.access_token);
++ return res.json({ token: data.access_token, user: claimsToUser(claims) });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ clearSessionCookies(res);
++ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
++ }
++ console.error('[Deals auth] Refresh failed:', err.message);
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
++function optionalAuth(auth) {
++ return async (req, _res, next) => {
++ const token = extractToken(req);
++ if (token) {
++ const claims = await auth.verify(token);
++ if (claims) { req.user = claimsToUser(claims); req.token = token; }
++ }
++ next();
++ };
++}
++
++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..de08806
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -0,0 +1,107 @@
++'use strict';
++
++/**
++ * Who is calling, resolved once per request into `req.viewer`:
++ *
++ * { kind: 'anonymous', subject: null, staff: false }
++ * { kind: 'user', subject: 'usr_…'|null, staff, user, token }
++ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the
++ * token's subject_id claim. Role admin or global_mod, or a subject in DEALS_MODERATORS, makes
++ * the viewer a moderator (staff). X-OV-* headers are ignored for browsers.
++ * { kind: 'service', service: 'svc:ai', claims, subject: 'usr_…'|null, origin: 'user'|'ai' }
++ * A first-party service with a Network client-credentials token for audience openvibe.deals.
++ * X-OV-Subject names the person it acts for; X-OV-Origin: ai marks AI output (OpenVibe.AI's
++ * deals.enrich_deal), which is disclosed and noindex until a person reviews it.
++ *
++ * Identity never comes from a request body or query. A request presenting a service token is judged
++ * on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.
++ */
++const contracts = require('openvibe-contracts');
++const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
++const { checkCapability } = require('./capabilities');
++
++const { ids, serviceAuth, http } = contracts;
++const STAFF_ROLES = new Set(['admin', 'global_mod']);
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++const AUDIENCE = 'openvibe.deals';
++
++class ViewerError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
++
++function createViewerResolver({ auth, config, people }) {
++ const moderators = new Set(config.moderators || []);
++
++ async function fromServiceToken(req, token) {
++ const publicKey = await auth.ensureKey();
++ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
++ if (!r.ok) throw new ViewerError(401, r.code, r.reason);
++ const originHeader = req.get('x-ov-origin');
++ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
++ const subjectHeader = req.get('x-ov-subject');
++ let subject = null;
++ if (subjectHeader) {
++ if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
++ subject = subjectHeader;
++ }
++ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };
++ }
++
++ async function fromUserToken(token) {
++ const claims = await auth.verify(token);
++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
++ const staff = STAFF_ROLES.has(claims.role) || Boolean(subject && moderators.has(subject));
++ return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
++ }
++
++ /** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */
++ async function resolve(req, opts = {}) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodeJwtPayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ if (opts.services === false) return ANONYMOUS;
++ return fromServiceToken(req, token);
++ }
++ }
++ const token = extractToken(req);
++ if (!token) return ANONYMOUS;
++ return (await fromUserToken(token)) || ANONYMOUS;
++ }
++
++ function middleware(opts = {}) {
++ return async (req, res, next) => {
++ try {
++ req.viewer = await resolve(req, opts);
++ next();
++ } catch (err) {
++ if (!(err instanceof ViewerError)) return next(err);
++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ }
++ };
++ }
++
++ return { resolve, middleware };
++}
++
++/**
++ * Route guard: a service token must hold `cap`; browsers and anonymous callers pass here and are
++ * judged by the domain (signed in, submitter, moderator).
++ */
++function guard(cap) {
++ return (req, res, next) => {
++ const v = req.viewer;
++ if (!v || v.kind !== 'service') return next();
++ const c = checkCapability(v.claims, cap);
++ if (c.allowed) return next();
++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++}
++
++module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };
ddiff --git a/server/clients/community.js b/server/clients/community.js
nnew file mode 100644
iindex 0000000..54c408b
---- /dev/null
++++ b/server/clients/community.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * Comments are OpenVibe.Community threads, referenced — never copied. An offer's thread is the one
++ * Community resolves for EntityRef { service: 'deals', type: 'offer', id } (openvibe-publishing/
++ * discussion stores only the thread id, in deal_offer_discussion_refs). Pages read the thread from
++ * Community on render, so moderation and deletion there are always what readers see; a failure is
++ * shown as "comments are unavailable", never as an empty or invented thread. A merged offer keeps
++ * its own thread; the canonical offer's page shows it under "discussion from merged listings".
++ *
++ * Calls with Deals' service token (audience openvibe.community):
++ * community.comment.write resolve a thread; comment as the signed-in member (X-OV-Subject)
++ * community.comment.moderate (optional) hide the thread of an offer moderators disabled
++ */
++const { serviceAuth, http } = require('openvibe-contracts');
++const { createDiscussionClient } = require('openvibe-publishing/discussion');
++
++function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
++ const base = config.community.internalUrl;
++ const enabled = Boolean(config.oauth.clientSecret && base);
++ const tokenClient = (scope) => serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.community', scope, fetchImpl,
++ });
++ const writeTokens = enabled ? tokenClient('community.comment.write') : null;
++ const modTokens = enabled ? tokenClient('community.comment.moderate') : null;
++ const discussion = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;
++
++ const refFor = (offer, label) => ({ service: 'deals', type: 'offer', id: offer.id, ...(label ? { label: String(label).slice(0, 200) } : {}) });
++
++ async function call(method, path, { tokens, subject, body, ctx, timeoutMs = 4000 } = {}) {
++ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };
++ if (tokens) Object.assign(headers, await tokens.authHeaders());
++ if (subject) headers['X-OV-Subject'] = subject;
++ if (body) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
++ const data = await res.json().catch(() => null);
++ if (res.status === 401 && tokens && tokens.invalidate) tokens.invalidate();
++ if (!res.ok) {
++ const err = new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);
++ err.status = res.status;
++ err.code = data && data.code;
++ throw err;
++ }
++ return data;
++ }
++
++ return {
++ enabled,
++ publicUrl: config.community.publicUrl,
++
++ /** The stored thread id (no network call), or null. */
++ knownThread(offer) { const k = store.discussion.get(offer.id); return k ? k.threadId : null; },
++
++ /** The stored thread id, or resolve it once through Community. */
++ async threadFor(offer, label, ctx) {
++ const known = store.discussion.get(offer.id);
++ if (known) return known.threadId;
++ if (!discussion) return null;
++ const out = await store.discussion.threadFor(offer.id, refFor(offer, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
++ return out.threadId;
++ },
++
++ /** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
++ async readThread(threadId, { after, ctx } = {}) {
++ const qs = after ? `?after=${encodeURIComponent(after)}` : '';
++ return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
++ },
++
++ /** Comment as the signed-in member. */
++ async comment(threadId, subject, { message, parentId } = {}, ctx) {
++ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
++ return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
++ tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
++ });
++ },
++
++ /**
++ * Best effort: hide an offer's thread when moderators disable it, show it again when enabled. Needs community.comment.moderate.
++ */
++ async setThreadVisibility(offer, visibility, ctx) {
++ const known = store.discussion.get(offer.id);
++ if (!known || !modTokens) return false;
++ try {
++ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
++ return true;
++ } catch (err) {
++ console.warn(`[Deals] could not set the comment thread of ${offer.id} to ${visibility}: ${err.message}`);
++ return false;
++ }
++ },
++ };
++}
++
++module.exports = { createCommunity };
ddiff --git a/server/clients/network.js b/server/clients/network.js
nnew file mode 100644
iindex 0000000..3398b49
---- /dev/null
++++ b/server/clients/network.js
@@@ -0,0 +1,93 @@
++'use strict';
++
++/**
++ * Who a subject is, for display (submitters, voters, moderators).
++ *
++ * Deals stores people as Network subject ids (usr_…) only. Names come from the signed-in member's
++ * own token claims and, for everyone else, from the Network's resolve-batch endpoint
++ * (capability identity.subject.resolve, a client-credentials token for audience openvibe.network).
++ * They are cached in subject_projections; the cache is never authority, and an unresolvable
++ * subject is shown as "a member", never as a made-up name.
++ */
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++const TTL_MS = 6 * 60 * 60 * 1000;
++const WAIT_MS = 2500;
++
++function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
++ const { db } = store;
++ const enabled = Boolean(config.oauth.clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
++ }) : null;
++ const q = {
++ get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
++ byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
++ put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
++ avatar_url = excluded.avatar_url, refreshed_at = excluded.refreshed_at`),
++ seen: db.prepare('UPDATE subject_projections SET first_seen_at = ? WHERE subject = ? AND first_seen_at IS NULL'),
++ firstSeen: db.prepare('SELECT first_seen_at FROM subject_projections WHERE subject = ?'),
++ };
++ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
++
++ function remember(subject, p) {
++ if (!ids.isSubjectId('user', subject) || !p) return;
++ q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
++ }
++
++ async function resolve(subjects) {
++ if (!tokens || !subjects.length) return;
++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/identity/resolve-batch`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ subject_ids: subjects.slice(0, 500) }),
++ signal: AbortSignal.timeout(8000),
++ });
++ if (res.status === 401) tokens.invalidate && tokens.invalidate();
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
++ for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);
++ }
++
++ const shape = (row, subject) => (row
++ ? { subject: row.subject, username: row.username, name: row.display_name || row.username || 'a member', avatarUrl: row.avatar_url, known: Boolean(row.username || row.display_name) }
++ : { subject, username: null, name: 'a member', avatarUrl: null, known: false });
++
++ return {
++ enabled,
++ /** From a verified sign-in: the member's own claims are a fresh projection. */
++ rememberClaims(subject, claims) { remember(subject, claims); q.seen.run(store.now(), subject); },
++
++ /** When Deals first saw this person signed in (abuse controls: account age evidence), or null. */
++ firstSeen(subject) { const r = q.firstSeen.get(subject); return r ? r.first_seen_at : null; },
++
++ /** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
++ async many(subjects) {
++ const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
++ const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
++ if (missing.length && tokens) {
++ let timer;
++ await Promise.race([
++ resolve(missing).catch((err) => console.warn('[Deals] identity resolve failed:', err.message)),
++ new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
++ ]);
++ clearTimeout(timer);
++ }
++ return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));
++ },
++
++ async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
++
++ /** The subject a username belongs to, from people Deals has seen. */
++ byUsername(username) {
++ const row = q.byUsername.get(String(username || '').toLowerCase());
++ return row ? shape(row, row.subject) : null;
++ },
++
++ cached: (subject) => shape(q.get.get(subject), subject),
++ };
++}
++
++module.exports = { createPeople };
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
nnew file mode 100644
iindex 0000000..16221e8
---- /dev/null
++++ b/server/clients/sources.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * OpenVibe.Sources — the deals-category items (capability sources.item.read, audience
++ * openvibe.sources, Deals' client-credentials token).
++ *
++ * items({ after, limit }) GET /api/v1/items?category=deals&after=<change_seq>&include_removed=1
++ * → { items, next_after, more, sources: { key: { status, stale, last_success_at } } }
++ * item(id) GET /api/v1/items/:id → { item, source }
++ *
++ * A failed call throws; the importer keeps its cursor and tries again later. Nothing is invented
++ * for an item Deals could not read.
++ */
++const { serviceAuth, http } = require('openvibe-contracts');
++
++function createSources({ config, fetchImpl = globalThis.fetch }) {
++ const base = config.sources.internalUrl;
++ const enabled = Boolean(config.sources.enabled && config.oauth.clientSecret && base);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.sources', scope: 'sources.item.read', fetchImpl,
++ }) : null;
++
++ async function call(path, ctx) {
++ if (!tokens) throw new Error('Sources import is not configured (OV_OAUTH_CLIENT_SECRET / OV_SOURCES_INTERNAL_URL / DEALS_IMPORT)');
++ const res = await fetchImpl(`${base}${path}`, {
++ headers: { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}), ...(await tokens.authHeaders()) },
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data) {
++ const err = new Error(`Sources answered ${res.status}${data && data.code ? ` (${data.code})` : ''}`);
++ err.status = res.status;
++ throw err;
++ }
++ return data;
++ }
++
++ return {
++ enabled,
++ items: ({ after = 0, limit = 100 } = {}, ctx) => call(`/api/v1/items?category=deals&after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}&include_removed=1`, ctx),
++ item: (id, ctx) => call(`/api/v1/items/${encodeURIComponent(id)}`, ctx),
++ };
++}
++
++module.exports = { createSources };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..60056d2
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,110 @@
++'use strict';
++
++/**
++ * OpenVibe.Deals configuration. Every value comes from the environment (production:
++ * /etc/openvibe/deals.env, see .env.example). Only environment variable NAMES appear in code and
++ * docs; secrets are never logged.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++require('dotenv').config();
++
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Number(v) : def);
++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
++const HOUR = 60 * 60 * 1000;
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4840);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.deals' : `http://localhost:${port}`));
++
++ return {
++ service: 'deals',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ // Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++
++ dbPath: env.DEALS_DB_PATH || './data/deals.db',
++
++ // OpenVibe.Network: SSO (OAuth2 authorization server), JWKS, client-credentials tokens.
++ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'deals',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile theme',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // Signs the per-session form token (CSRF). Unset: a random per-process key.
++ formSecret: env.DEALS_FORM_SECRET || '',
++ // Keys the HMAC that turns a client IP into the ip_hash used by the abuse controls. Unset: a
++ // random per-process key (hashes are then not comparable across restarts; /api/ready says so).
++ ipHashSecret: env.DEALS_IP_HASH_SECRET || '',
++
++ // Moderators besides Network admins and global mods: usr_… subjects, comma-separated.
++ moderators: list(env.DEALS_MODERATORS),
++
++ // Freshness: an observation older than this is shown as stale, never as the price now.
++ freshnessMs: num(env.DEALS_FRESHNESS_HOURS, 48) * HOUR,
++
++ // Abuse controls (server-side; see README "Votes, hotness and abuse controls").
++ abuse: {
++ voteSubjectPerHour: int(env.DEALS_VOTE_LIMIT_SUBJECT_HOUR, 60),
++ voteIpPerHour: int(env.DEALS_VOTE_LIMIT_IP_HOUR, 120),
++ voteOfferChangesPerHour: int(env.DEALS_VOTE_LIMIT_OFFER_HOUR, 6),
++ submitPerDay: int(env.DEALS_SUBMIT_LIMIT_DAY, 20),
++ observePerHour: int(env.DEALS_OBSERVE_LIMIT_HOUR, 30),
++ flagPerDay: int(env.DEALS_FLAG_LIMIT_DAY, 30),
++ newAccountDays: num(env.DEALS_NEW_ACCOUNT_DAYS, 7),
++ newAccountWeight: num(env.DEALS_NEW_ACCOUNT_WEIGHT, 0.25),
++ ringIpMin: int(env.DEALS_RING_IP_MIN, 3),
++ ringCovoteMin: int(env.DEALS_RING_COVOTE_MIN, 5),
++ ringWindowMs: int(env.DEALS_RING_WINDOW_MIN, 10) * 60 * 1000,
++ watchesPerSubject: int(env.DEALS_WATCH_LIMIT, 50),
++ },
++
++ // OpenVibe.Community: comment threads (referenced by id, never copied).
++ community: {
++ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
++ internalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),
++ },
++
++ // OpenVibe.Sources: deals-category items (pull by change cursor; events only wake the pull).
++ sources: {
++ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720'),
++ enabled: env.DEALS_IMPORT !== 'off',
++ intervalMs: int(env.DEALS_IMPORT_INTERVAL_MS, 5 * 60 * 1000),
++ pageSize: int(env.DEALS_IMPORT_PAGE_SIZE, 100),
++ maxPages: int(env.DEALS_IMPORT_MAX_PAGES, 10),
++ refreshBatch: int(env.DEALS_IMPORT_REFRESH_BATCH, 50),
++ },
++
++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set;
++ // the webhook consumer (/internal/events) only when DEALS_EVENTS_SECRET is set.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ webhookSecrets: list(env.DEALS_EVENTS_SECRET),
++ },
++
++ // In-process worker: hotness snapshots, stated-expiry, re-indexing when freshness changes.
++ worker: {
++ enabled: env.DEALS_WORKER !== 'off',
++ intervalMs: int(env.DEALS_WORKER_INTERVAL_MS, 10 * 60 * 1000),
++ hotWindowDays: int(env.DEALS_HOT_WINDOW_DAYS, 14),
++ },
++
++ // Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
++ apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.coupons'),
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..31d1f59
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,297 @@
++'use strict';
++
++/**
++ * Deals' own SQLite database: created on boot, idempotently. Nothing here is shared with another
++ * service.
++ *
++ * The nine charter tables (roadmap §15.13):
++ *
++ * deal_products product records (name, brand, category); slug for /p/:slug
++ * deal_product_aliases name / gtin / mpn / sku / url aliases that resolve to a product
++ * deal_offers an offer: title, link, store, product, state, stated expiry, merge pointer
++ * deal_offer_sources where an offer came from: the submission, a person's observation,
++ * an OpenVibe.Sources item (typed reference, never a copy of the item)
++ * deal_votes one row per (offer, subject): +1 / -1 / 0 (removed), with the weight
++ * and ip hash it was cast with
++ * deal_hotness_snapshots server-computed hotness (formula hot@1) with every input it used
++ * deal_watches keyword / product / price-below watches and saved searches
++ * deal_price_observations every price / shipping / condition / availability observation with
++ * its source and observed_at — the only place a price lives
++ * deal_flags reports by people and system flags (vote rings) for moderators
++ *
++ * Companions (not charter tables):
++ * deal_stores store records by domain (the plan's "store records" obligation)
++ * watch_notifications one row per (watch, observation): the uniqueness that makes a watch
++ * notify at most once per observation
++ * moderation_log every merge, unmerge, expiry, disable, enable and review, with before/after
++ * rate_events the sliding windows of the abuse controls
++ * import_state the OpenVibe.Sources change cursor
++ * subject_projections display cache of Network names + when Deals first saw a subject
++ * event_outbox, idempotency_receipts openvibe-sdk outbox / inbox
++ * deal_offer_discussion_refs, deal_index_revisions openvibe-publishing (discussion, index-hooks)
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { createDiscussionRefs } = require('openvibe-publishing/discussion');
++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS deal_stores (
++ id TEXT PRIMARY KEY, -- dst_<ULID>
++ domain TEXT NOT NULL UNIQUE, -- lower-case host without www.
++ name TEXT, -- NULL: shown as the domain (never invented)
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS deal_products (
++ id TEXT PRIMARY KEY, -- dpr_<ULID>
++ slug TEXT NOT NULL UNIQUE,
++ name TEXT NOT NULL,
++ brand TEXT,
++ category TEXT,
++ description TEXT,
++ created_by TEXT, -- usr_… or svc:… (imports)
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS deal_product_aliases (
++ id TEXT PRIMARY KEY, -- dpa_<ULID>
++ product_id TEXT NOT NULL REFERENCES deal_products(id),
++ kind TEXT NOT NULL CHECK (kind IN ('name','gtin','mpn','sku','url')),
++ value TEXT NOT NULL,
++ value_norm TEXT NOT NULL,
++ source TEXT NOT NULL CHECK (source IN ('community','import','moderator')),
++ created_by TEXT,
++ created_at INTEGER NOT NULL,
++ UNIQUE (kind, value_norm)
++);
++CREATE INDEX IF NOT EXISTS deal_product_aliases_product ON deal_product_aliases (product_id);
++
++CREATE TABLE IF NOT EXISTS deal_offers (
++ id TEXT PRIMARY KEY, -- dof_<ULID>
++ slug TEXT NOT NULL UNIQUE,
++ title TEXT NOT NULL,
++ description TEXT,
++ url TEXT NOT NULL, -- the offer link as normalised (tracking stripped)
++ url_norm TEXT NOT NULL,
++ store_id TEXT REFERENCES deal_stores(id),
++ product_id TEXT REFERENCES deal_products(id),
++ category TEXT,
++ origin TEXT NOT NULL CHECK (origin IN ('community','import')),
++ submitted_by TEXT, -- usr_… (community) / NULL (import)
++ text_origin TEXT NOT NULL DEFAULT 'human' CHECK (text_origin IN ('human','imported','ai')),
++ review_state TEXT NOT NULL DEFAULT 'not_required' CHECK (review_state IN ('not_required','pending','reviewed')),
++ reviewed_by TEXT,
++ reviewed_at INTEGER,
++ ai_summary TEXT, -- AI-assisted text, disclosed, noindex until reviewed
++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','disabled')),
++ expires_at INTEGER, -- a STATED expiry (NULL: unknown, never assumed)
++ expired_at INTEGER,
++ expired_reason TEXT,
++ disabled_at INTEGER,
++ disabled_reason TEXT,
++ disabled_by TEXT,
++ merged_into TEXT REFERENCES deal_offers(id), -- duplicate of … (reversible; nothing is moved)
++ merged_at INTEGER,
++ merged_by TEXT,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ CHECK (merged_into IS NULL OR merged_into <> id)
++);
++CREATE INDEX IF NOT EXISTS deal_offers_url ON deal_offers (url_norm);
++CREATE INDEX IF NOT EXISTS deal_offers_listing ON deal_offers (status, merged_into, created_at);
++CREATE INDEX IF NOT EXISTS deal_offers_merged ON deal_offers (merged_into);
++CREATE INDEX IF NOT EXISTS deal_offers_product ON deal_offers (product_id);
++CREATE INDEX IF NOT EXISTS deal_offers_store ON deal_offers (store_id);
++
++CREATE TABLE IF NOT EXISTS deal_offer_sources (
++ id TEXT PRIMARY KEY, -- dos_<ULID>
++ offer_id TEXT NOT NULL REFERENCES deal_offers(id),
++ kind TEXT NOT NULL CHECK (kind IN ('submission','community','sources_item')),
++ ref_service TEXT NOT NULL, -- 'deals' | 'sources'
++ ref_type TEXT NOT NULL, -- 'submission' | 'observer' | 'item'
++ ref_id TEXT NOT NULL, -- offer id | usr_… | itm_…
++ ref_part TEXT NOT NULL DEFAULT '', -- which offer inside one Sources item
++ ref_revision INTEGER, -- the Sources item revision last imported
++ source_key TEXT, -- the Sources registry key (display)
++ url TEXT,
++ label TEXT,
++ license_note TEXT,
++ submitted_by TEXT,
++ retrieved_at INTEGER, -- Sources provenance.retrieved_at
++ removed_at INTEGER,
++ removed_reason TEXT,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ UNIQUE (kind, ref_service, ref_id, ref_part)
++);
++CREATE INDEX IF NOT EXISTS deal_offer_sources_offer ON deal_offer_sources (offer_id);
++
++CREATE TABLE IF NOT EXISTS deal_price_observations (
++ id TEXT PRIMARY KEY, -- dpo_<ULID>
++ offer_id TEXT NOT NULL REFERENCES deal_offers(id),
++ source_id TEXT NOT NULL REFERENCES deal_offer_sources(id),
++ observed_at INTEGER NOT NULL, -- when the source saw it (not when we stored it)
++ recorded_at INTEGER NOT NULL,
++ price TEXT, -- decimal exactly as stated; NULL = not stated
++ price_num REAL,
++ currency TEXT, -- ISO 4217; NULL = not stated
++ shipping TEXT, -- decimal as stated (same currency); NULL = not stated
++ shipping_num REAL,
++ shipping_note TEXT,
++ condition TEXT CHECK (condition IS NULL OR condition IN ('new','used','refurbished','damaged')),
++ availability TEXT CHECK (availability IS NULL OR availability IN ('in_stock','out_of_stock','preorder','discontinued','limited','sold_out','online_only','in_store_only')),
++ origin TEXT NOT NULL CHECK (origin IN ('community','import','import_refresh')),
++ observed_by TEXT, -- usr_… for people; NULL for imports
++ source_revision INTEGER,
++ note TEXT,
++ CHECK ((price IS NULL) = (price_num IS NULL)),
++ CHECK ((shipping IS NULL) = (shipping_num IS NULL))
++);
++CREATE INDEX IF NOT EXISTS deal_price_observations_offer ON deal_price_observations (offer_id, observed_at);
++
++CREATE TABLE IF NOT EXISTS deal_votes (
++ offer_id TEXT NOT NULL REFERENCES deal_offers(id),
++ subject TEXT NOT NULL, -- usr_…
++ value INTEGER NOT NULL CHECK (value IN (-1, 0, 1)), -- 0 = removed (kept for history)
++ weight REAL NOT NULL, -- the weight when cast (new accounts count less)
++ ip_hash TEXT,
++ via TEXT NOT NULL DEFAULT 'user' CHECK (via IN ('user','service')),
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ PRIMARY KEY (offer_id, subject)
++);
++CREATE INDEX IF NOT EXISTS deal_votes_subject ON deal_votes (subject, updated_at);
++CREATE INDEX IF NOT EXISTS deal_votes_ip ON deal_votes (ip_hash, offer_id);
++
++CREATE TABLE IF NOT EXISTS deal_hotness_snapshots (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ offer_id TEXT NOT NULL REFERENCES deal_offers(id), -- the canonical (root) offer
++ computed_at INTEGER NOT NULL,
++ formula TEXT NOT NULL,
++ up_count INTEGER NOT NULL,
++ down_count INTEGER NOT NULL,
++ up_weight REAL NOT NULL,
++ down_weight REAL NOT NULL,
++ score REAL NOT NULL,
++ hot REAL NOT NULL,
++ first_seen_at INTEGER NOT NULL,
++ reason TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS deal_hotness_offer ON deal_hotness_snapshots (offer_id, id);
++
++CREATE TABLE IF NOT EXISTS deal_watches (
++ id TEXT PRIMARY KEY, -- dwt_<ULID>
++ subject TEXT NOT NULL, -- usr_…
++ kind TEXT NOT NULL CHECK (kind IN ('keyword','product','price_below','search')),
++ query TEXT,
++ product_id TEXT REFERENCES deal_products(id),
++ max_price TEXT,
++ max_price_num REAL,
++ currency TEXT,
++ notify INTEGER NOT NULL DEFAULT 1, -- 0: a saved search (never notifies)
++ label TEXT,
++ created_at INTEGER NOT NULL,
++ deleted_at INTEGER,
++ CHECK (kind <> 'search' OR notify = 0),
++ CHECK (kind <> 'price_below' OR (max_price_num IS NOT NULL AND currency IS NOT NULL))
++);
++CREATE INDEX IF NOT EXISTS deal_watches_subject ON deal_watches (subject, deleted_at);
++CREATE INDEX IF NOT EXISTS deal_watches_active ON deal_watches (notify, deleted_at, kind);
++
++CREATE TABLE IF NOT EXISTS deal_flags (
++ id TEXT PRIMARY KEY, -- dfl_<ULID>
++ offer_id TEXT REFERENCES deal_offers(id),
++ kind TEXT NOT NULL CHECK (kind IN ('expired','price_wrong','spam','duplicate','other','vote_ring')),
++ origin TEXT NOT NULL CHECK (origin IN ('user','system')),
++ reporter TEXT, -- usr_… (NULL for system flags)
++ reason TEXT,
++ details TEXT, -- JSON
++ dedupe_key TEXT NOT NULL,
++ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dismissed')),
++ resolved_by TEXT,
++ resolved_at INTEGER,
++ resolution TEXT,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS deal_flags_open ON deal_flags (dedupe_key) WHERE status = 'open';
++CREATE INDEX IF NOT EXISTS deal_flags_status ON deal_flags (status, created_at);
++
++CREATE TABLE IF NOT EXISTS watch_notifications (
++ watch_id TEXT NOT NULL REFERENCES deal_watches(id),
++ observation_id TEXT NOT NULL REFERENCES deal_price_observations(id),
++ offer_id TEXT NOT NULL, -- the canonical offer when it matched
++ price_num REAL,
++ currency TEXT,
++ event_id TEXT,
++ created_at INTEGER NOT NULL,
++ PRIMARY KEY (watch_id, observation_id)
++);
++CREATE INDEX IF NOT EXISTS watch_notifications_offer ON watch_notifications (watch_id, offer_id);
++
++CREATE TABLE IF NOT EXISTS moderation_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ action TEXT NOT NULL, -- merge | unmerge | expire | disable | enable | review | flag.resolve | flag.dismiss
++ offer_id TEXT,
++ target_id TEXT,
++ actor TEXT NOT NULL, -- usr_… | svc:…
++ reason TEXT,
++ before TEXT, -- JSON
++ after TEXT, -- JSON
++ at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS moderation_log_offer ON moderation_log (offer_id, id);
++
++CREATE TABLE IF NOT EXISTS rate_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ kind TEXT NOT NULL,
++ key TEXT NOT NULL,
++ at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS rate_events_key ON rate_events (kind, key, at);
++
++CREATE TABLE IF NOT EXISTS import_state (
++ key TEXT PRIMARY KEY,
++ value TEXT,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS subject_projections (
++ subject TEXT PRIMARY KEY,
++ username TEXT,
++ display_name TEXT,
++ avatar_url TEXT,
++ first_seen_at INTEGER, -- when Deals first saw this person sign in
++ refreshed_at INTEGER NOT NULL
++);
++`;
++
++const CHARTER_TABLES = ['deal_products', 'deal_product_aliases', 'deal_offers', 'deal_offer_sources', 'deal_votes',
++ 'deal_hotness_snapshots', 'deal_watches', 'deal_price_observations', 'deal_flags'];
++
++/**
++ * Open (or create) the database. opts.now — injectable clock (epoch ms) shared by everything, so
++ * tests and replays are deterministic.
++ */
++function openStore(dbPath, { now = () => Date.now() } = {}) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ return {
++ db,
++ now,
++ discussion: createDiscussionRefs(db, { prefix: 'deal_offer', now }),
++ sequencer: createIndexSequencer(db, { prefix: 'deal', now }),
++ tx: (fn) => db.transaction(fn)(),
++ close: () => db.close(),
++ };
++}
++
++module.exports = { openStore, CHARTER_TABLES };
ddiff --git a/server/domain/access.js b/server/domain/access.js
nnew file mode 100644
iindex 0000000..fec0542
---- /dev/null
++++ b/server/domain/access.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/**
++ * Who may do what (the domain's decisions; routes only check capabilities of service tokens).
++ *
++ * moderators Network role admin or global_mod, or a subject listed in DEALS_MODERATORS; a
++ * service token is a moderator only for the moderation capability it holds
++ * (deals.offer.moderate / deals.offer.merge) — the grant is the decision
++ * editors the person who submitted an offer (or a service acting for them), and moderators
++ */
++const { checkCapability } = require('../auth/capabilities');
++
++function createAccess() {
++ const isStaff = (viewer) => Boolean(viewer && viewer.kind === 'user' && viewer.staff);
++
++ function isModerator(viewer, cap = 'deals.offer.moderate') {
++ if (!viewer) return false;
++ if (viewer.kind === 'user') return Boolean(viewer.staff);
++ if (viewer.kind === 'service') return checkCapability(viewer.claims, cap).allowed;
++ return false;
++ }
++
++ function canEdit(viewer, root) {
++ if (!viewer || viewer.kind === 'anonymous') return false;
++ if (isModerator(viewer)) return true;
++ return Boolean(viewer.subject && root.submitted_by && viewer.subject === root.submitted_by);
++ }
++
++ return { isStaff, isModerator, canEdit };
++}
++
++module.exports = { createAccess };
ddiff --git a/server/domain/catalog.js b/server/domain/catalog.js
nnew file mode 100644
iindex 0000000..647e903
---- /dev/null
++++ b/server/domain/catalog.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Store and product records.
++ *
++ * Stores are keyed by domain (from the offer link). A store has a name only when a person or a
++ * source stated one; otherwise it is shown as its domain.
++ * Products resolve through aliases (gtin, mpn, sku, exact name, url): resolve() returns the one
++ * product an alias names, or creates a product when allowed. Two products that turn out to be the
++ * same are left to moderators (Deals does not auto-merge products).
++ */
++const { ApiError, newId, slugify, text, longText } = require('./util');
++
++const ALIAS_KINDS = ['gtin', 'mpn', 'sku', 'url', 'name'];
++const normAlias = (kind, v) => {
++ const s = String(v || '').trim();
++ if (!s) return null;
++ if (kind === 'gtin') { const d = s.replace(/[\s-]/g, ''); return /^\d{8,14}$/.test(d) ? d.padStart(14, '0') : null; }
++ if (kind === 'name') return s.toLowerCase().normalize('NFKC').replace(/\s+/g, ' ').slice(0, 300);
++ return s.toLowerCase().replace(/\s+/g, '').slice(0, 300);
++};
++
++function createCatalog({ store }) {
++ const { db } = store;
++ const q = {
++ storeByDomain: db.prepare('SELECT * FROM deal_stores WHERE domain = ?'),
++ storeById: db.prepare('SELECT * FROM deal_stores WHERE id = ?'),
++ insertStore: db.prepare('INSERT INTO deal_stores (id, domain, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'),
++ nameStore: db.prepare('UPDATE deal_stores SET name = ?, updated_at = ? WHERE id = ? AND name IS NULL'),
++ productById: db.prepare('SELECT * FROM deal_products WHERE id = ?'),
++ productBySlug: db.prepare('SELECT * FROM deal_products WHERE slug = ?'),
++ insertProduct: db.prepare(`INSERT INTO deal_products (id, slug, name, brand, category, description, created_by, created_at, updated_at)
++ VALUES (@id, @slug, @name, @brand, @category, @description, @created_by, @now, @now)`),
++ alias: db.prepare('SELECT * FROM deal_product_aliases WHERE kind = ? AND value_norm = ?'),
++ insertAlias: db.prepare(`INSERT OR IGNORE INTO deal_product_aliases (id, product_id, kind, value, value_norm, source, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
++ aliases: db.prepare('SELECT kind, value, source, created_at FROM deal_product_aliases WHERE product_id = ? ORDER BY kind, value'),
++ };
++
++ function ensureStore(domain, name = null) {
++ if (!domain) return null;
++ const existing = q.storeByDomain.get(domain);
++ if (existing) {
++ if (name && !existing.name) q.nameStore.run(String(name).slice(0, 120), store.now(), existing.id);
++ return q.storeByDomain.get(domain);
++ }
++ const id = newId('dst');
++ q.insertStore.run(id, domain, name ? String(name).slice(0, 120) : null, store.now(), store.now());
++ return q.storeById.get(id);
++ }
++
++ function storeLabel(s) { return s ? (s.name || s.domain) : null; }
++
++ /**
++ * Find the product any given alias names; create one (with every alias given) when none does and
++ * `create` is true. Aliases that already point at a different product are left alone (conflict
++ * reported in `conflicts`), never re-pointed silently.
++ * input: { name, brand, category, description, gtin, mpn, sku, url }, source, actor
++ */
++ function resolve(input, { source = 'community', actor = null, create = true } = {}) {
++ const given = ALIAS_KINDS.map((kind) => ({ kind, value: input[kind], norm: normAlias(kind, input[kind]) })).filter((a) => a.norm);
++ if (input.gtin && !normAlias('gtin', input.gtin)) throw new ApiError(422, 'request.invalid', 'gtin must be 8 to 14 digits');
++ let product = null;
++ for (const a of given) {
++ const hit = q.alias.get(a.kind, a.norm);
++ if (hit) { product = q.productById.get(hit.product_id); break; }
++ }
++ let created = false;
++ if (!product) {
++ if (!create) return { product: null, created: false, conflicts: [] };
++ const name = text(input.name, { field: 'product name', min: 2, max: 200, required: true });
++ const id = newId('dpr');
++ q.insertProduct.run({
++ id, slug: slugify(name, id), name, brand: text(input.brand, { max: 120 }), category: text(input.category, { max: 60 }),
++ description: longText(input.description, 2000), created_by: actor, now: store.now(),
++ });
++ product = q.productById.get(id);
++ created = true;
++ }
++ const conflicts = [];
++ for (const a of given) {
++ const hit = q.alias.get(a.kind, a.norm);
++ if (hit && hit.product_id !== product.id) { conflicts.push({ kind: a.kind, value: a.value, product_id: hit.product_id }); continue; }
++ if (!hit) q.insertAlias.run(newId('dpa'), product.id, a.kind, String(a.value).trim().slice(0, 300), a.norm, source, actor, store.now());
++ }
++ return { product, created, conflicts };
++ }
++
++ return {
++ ensureStore,
++ storeLabel,
++ store: (id) => (id ? q.storeById.get(id) : null),
++ storeByDomain: (d) => q.storeByDomain.get(String(d || '').toLowerCase()),
++ product: (id) => (id ? q.productById.get(id) : null),
++ productBySlug: (slug) => q.productBySlug.get(String(slug || '')),
++ aliases: (productId) => q.aliases.all(productId),
++ resolve,
++ normAlias,
++ };
++}
++
++module.exports = { createCatalog, ALIAS_KINDS };
ddiff --git a/server/domain/flags.js b/server/domain/flags.js
nnew file mode 100644
iindex 0000000..5f62968
---- /dev/null
++++ b/server/domain/flags.js
@@@ -0,0 +1,97 @@
++'use strict';
++
++/**
++ * Flags: reports from people (expired, wrong price, spam, duplicate, other) and system flags
++ * (vote_ring) for moderators. At most one OPEN flag per dedupe key: a person reporting the same
++ * offer twice, or the detector seeing the same ring again, updates the open flag instead of adding
++ * noise. Flags never change an offer by themselves; a moderator decides (expire, disable, merge,
++ * dismiss).
++ */
++const { ApiError, newId, text, iso } = require('./util');
++
++const USER_KINDS = ['expired', 'price_wrong', 'spam', 'duplicate', 'other'];
++
++function createFlags({ config, store, reads, limits, access, publication, logAction }) {
++ const { db } = store;
++ const q = {
++ get: db.prepare('SELECT * FROM deal_flags WHERE id = ?'),
++ openByKey: db.prepare("SELECT * FROM deal_flags WHERE dedupe_key = ? AND status = 'open'"),
++ insert: db.prepare(`INSERT INTO deal_flags (id, offer_id, kind, origin, reporter, reason, details, dedupe_key, status, created_at, updated_at)
++ VALUES (@id, @offer_id, @kind, @origin, @reporter, @reason, @details, @dedupe_key, 'open', @now, @now)`),
++ touch: db.prepare('UPDATE deal_flags SET details = ?, reason = COALESCE(?, reason), updated_at = ? WHERE id = ?'),
++ list: db.prepare('SELECT * FROM deal_flags WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'),
++ forOffer: db.prepare('SELECT * FROM deal_flags WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC'),
++ resolve: db.prepare('UPDATE deal_flags SET status = ?, resolved_by = ?, resolved_at = ?, resolution = ?, updated_at = ? WHERE id = ?'),
++ };
++
++ /** A person's report. */
++ function report(viewer, idOrSlug, input = {}, { ip = null } = {}) {
++ const subject = viewer && viewer.subject;
++ if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a deal');
++ const root = reads.root(reads.mustFind(idOrSlug));
++ const kind = String(input.kind || '');
++ if (!USER_KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${USER_KINDS.join(', ')}`);
++ const reason = text(input.reason, { field: 'reason', max: 500 });
++ let details = {};
++ if (kind === 'duplicate') {
++ const other = input.duplicate_of ? reads.find(String(input.duplicate_of).trim().replace(/^.*\/d\//, '').replace(/(\.json)?([?#].*)?$/, '')) : null;
++ if (!other) throw new ApiError(422, 'request.invalid', 'duplicate_of must name the other deal (id or slug)');
++ details = { duplicate_of: reads.root(other).id };
++ }
++ return store.tx(() => {
++ const key = `user:${subject}:${root.id}`;
++ const open = q.openByKey.get(key);
++ if (open) {
++ q.touch.run(JSON.stringify({ ...JSON.parse(open.details || '{}'), ...details, kind }), reason, store.now(), open.id);
++ return { flag: q.get.get(open.id), created: false };
++ }
++ limits.check('flag', subject, config.abuse.flagPerDay, 24 * 3600 * 1000, 'flag.rate_limited');
++ const id = newId('dfl');
++ q.insert.run({ id, offer_id: root.id, kind, origin: 'user', reporter: subject, reason, details: JSON.stringify(details), dedupe_key: key, now: store.now() });
++ limits.hit('flag', subject);
++ if (ip) limits.hit('flag_ip', ip);
++ return { flag: q.get.get(id), created: true };
++ });
++ }
++
++ /** A system flag (inside the caller's transaction): opened once per key, details refreshed. */
++ function raise({ kind, offerId, key, reason, details }) {
++ const open = q.openByKey.get(key);
++ if (open) { q.touch.run(JSON.stringify(details || {}), reason || null, store.now(), open.id); return { flag: q.get.get(open.id), created: false }; }
++ const id = newId('dfl');
++ q.insert.run({ id, offer_id: offerId, kind, origin: 'system', reporter: null, reason: reason || null, details: JSON.stringify(details || {}), dedupe_key: key, now: store.now() });
++ return { flag: q.get.get(id), created: true };
++ }
++
++ function resolve(viewer, flagId, { status = 'resolved', resolution } = {}) {
++ if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
++ if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
++ const f = q.get.get(String(flagId || ''));
++ if (!f) throw new ApiError(404, 'flag.not_found', 'No such flag');
++ if (f.status !== 'open') return { flag: f, changed: false };
++ const actor = viewer.subject || viewer.service;
++ return store.tx(() => {
++ q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
++ logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
++ return { flag: q.get.get(f.id), changed: true };
++ });
++ }
++
++ function dto(f) {
++ const offer = f.offer_id ? reads.get(f.offer_id) : null;
++ return {
++ id: f.id, kind: f.kind, origin: f.origin, reporter: f.reporter, reason: f.reason, status: f.status,
++ details: JSON.parse(f.details || '{}'), created_at: iso(f.created_at), updated_at: iso(f.updated_at),
++ resolved_by: f.resolved_by, resolved_at: iso(f.resolved_at), resolution: f.resolution,
++ offer: offer ? { id: offer.id, slug: offer.slug, title: offer.title, url: publication.abs(publication.offerPath(offer)) } : null,
++ };
++ }
++
++ return {
++ report, raise, resolve, dto, USER_KINDS,
++ list: ({ status = 'open', limit = 100, offset = 0 } = {}) => q.list.all(status, limit, offset),
++ forOffer: (ids) => q.forOffer.all(JSON.stringify(ids)),
++ };
++}
++
++module.exports = { createFlags, USER_KINDS };
ddiff --git a/server/domain/hotness.js b/server/domain/hotness.js
nnew file mode 100644
iindex 0000000..d59bc2d
---- /dev/null
++++ b/server/domain/hotness.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/**
++ * Hotness — computed only on the server, from stored rows, with one documented formula.
++ *
++ * hot@1(upWeight, downWeight, firstSeenAt, t):
++ * score = upWeight − downWeight (weighted votes, 4 decimals)
++ * ageHours = max(0, (t − firstSeenAt) / 3 600 000)
++ * hot = round6( score / (ageHours + 2) ^ 1.5 )
++ *
++ * upWeight / downWeight: the sum of the weights of each voter's effective vote across the offer's
++ * merge group (a voter counts once; weight 1, or DEALS_NEW_ACCOUNT_WEIGHT for accounts new to
++ * Deals, recorded when the vote was cast). firstSeenAt: the earliest created_at in the group.
++ * t: the snapshot time (explicit; there is no hidden clock).
++ *
++ * The same inputs always give the same number (pure function, fixed rounding). Every snapshot
++ * stores all of its inputs, so anyone can recompute it (GET /api/v1/offers/:id/hotness).
++ * Snapshots are taken when votes change, on merge/unmerge, on creation, and by the worker for all
++ * active offers at one shared `t` (so the hot list compares numbers computed at the same moment).
++ */
++const FORMULA = 'hot@1';
++const HOUR = 3600 * 1000;
++
++function round(x, places) {
++ const f = 10 ** places;
++ return Math.round((x + Number.EPSILON * Math.sign(x)) * f) / f;
++}
++
++function formula({ upWeight, downWeight, firstSeenAt, t }) {
++ for (const [k, v] of Object.entries({ upWeight, downWeight, firstSeenAt, t })) {
++ if (!Number.isFinite(v)) throw new TypeError(`hot@1 needs a finite ${k}`);
++ }
++ const score = round(upWeight - downWeight, 4);
++ const ageHours = Math.max(0, (t - firstSeenAt) / HOUR);
++ const hot = round(score / Math.pow(ageHours + 2, 1.5), 6);
++ return { formula: FORMULA, score, hot, ageHours: round(ageHours, 4) };
++}
++
++function createHotness({ store, reads }) {
++ const { db } = store;
++ const insert = db.prepare(`INSERT INTO deal_hotness_snapshots (offer_id, computed_at, formula, up_count, down_count, up_weight, down_weight, score, hot, first_seen_at, reason)
++ VALUES (@offer_id, @computed_at, @formula, @up_count, @down_count, @up_weight, @down_weight, @score, @hot, @first_seen_at, @reason)`);
++ const firstSeen = db.prepare('SELECT MIN(created_at) AS t FROM deal_offers WHERE id IN (SELECT value FROM json_each(?))');
++
++ /** Inputs for a canonical offer at time t (from the database, never from a request). */
++ function inputs(rootId, t) {
++ const ids = reads.groupIds(rootId);
++ const tally = reads.tally(ids);
++ return { ids, tally, firstSeenAt: firstSeen.get(JSON.stringify(ids)).t, t };
++ }
++
++ /** Compute and store a snapshot for a canonical offer. Inside the caller's transaction. */
++ function snapshot(rootId, reason, t = store.now()) {
++ const { tally, firstSeenAt } = inputs(rootId, t);
++ const r = formula({ upWeight: tally.upWeight, downWeight: tally.downWeight, firstSeenAt, t });
++ const row = {
++ offer_id: rootId, computed_at: t, formula: r.formula, up_count: tally.up, down_count: tally.down,
++ up_weight: tally.upWeight, down_weight: tally.downWeight, score: r.score, hot: r.hot, first_seen_at: firstSeenAt, reason,
++ };
++ insert.run(row);
++ return row;
++ }
++
++ /** Recompute from a stored snapshot's own inputs (the audit check). */
++ function recompute(snap) {
++ return formula({ upWeight: snap.up_weight, downWeight: snap.down_weight, firstSeenAt: snap.first_seen_at, t: snap.computed_at });
++ }
++
++ /** Worker: snapshot every active canonical offer created in the window, all at the same t. */
++ function tick({ windowDays = 14, t = store.now() } = {}) {
++ const ids = db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR).map((r) => r.id);
++ store.tx(() => { for (const id of ids) snapshot(id, 'tick', t); });
++ // Keep the latest snapshot per offer and everything from the last 7 days.
++ db.prepare(`DELETE FROM deal_hotness_snapshots WHERE computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)`).run(t - 7 * 24 * HOUR);
++ return ids.length;
++ }
++
++ return { snapshot, recompute, inputs, tick, formula, FORMULA };
++}
++
++module.exports = { createHotness, formula, FORMULA };
ddiff --git a/server/domain/importer.js b/server/domain/importer.js
nnew file mode 100644
iindex 0000000..91c001e
---- /dev/null
++++ b/server/domain/importer.js
@@@ -0,0 +1,253 @@
++'use strict';
++
++/**
++ * Imports deals-category items from OpenVibe.Sources.
++ *
++ * The truth is Sources' change feed (GET /api/v1/items?category=deals&after=<cursor>): the importer
++ * pulls pages in change order and keeps its cursor in import_state. sources.item.* events delivered
++ * to /internal/events only wake the pull early (they carry no prices), so a lost or repeated event
++ * changes nothing.
++ *
++ * Item → offer mapping (values exactly as the source stated them; missing = null):
++ * kind offer one offer: fields.price + fields.currency, availability / condition (schema.org
++ * terms mapped to Deals' enums, unknown terms → null), valid_until → stated expiry,
++ * seller → store name
++ * kind product a product (resolved by gtin / mpn / sku / name) and one offer per stated offer
++ * kind article (RSS/Atom) one offer with the title and link; the price is NOT parsed out of the
++ * headline — it stays "not stated" until a structured source or a person states it
++ * other kinds skipped
++ *
++ * observed_at = the item's provenance.retrieved_at (when Sources saw it). An item Deals already
++ * imported produces a new observation only when its revision or its retrieved_at advanced, so
++ * replays are no-ops. Two items (or a submission and an item) with the same link attach to the same
++ * offer as separate sources. A removed item (takedown, licence) removes its source; an imported
++ * offer left with no source is disabled.
++ *
++ * Imported text is third-party: review_state = pending, so the page is noindex until a person
++ * reviews it.
++ */
++const {
++ normalizeUrl, urlKey, parseAmount, SCHEMA_AVAILABILITY, SCHEMA_CONDITION, iso,
++} = require('./util');
++
++const CURSOR_KEY = 'sources.deals.after';
++
++function createImporter({ config, store, reads, catalog, offers, indexing, sources, log = console }) {
++ const { db } = store;
++ const q = {
++ getState: db.prepare('SELECT value FROM import_state WHERE key = ?'),
++ setState: db.prepare(`INSERT INTO import_state (key, value, updated_at) VALUES (?, ?, ?)
++ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`),
++ sourceRow: db.prepare("SELECT * FROM deal_offer_sources WHERE kind = 'sources_item' AND ref_service = 'sources' AND ref_id = ? AND ref_part = ?"),
++ itemRows: db.prepare("SELECT * FROM deal_offer_sources WHERE kind = 'sources_item' AND ref_service = 'sources' AND ref_id = ? AND removed_at IS NULL"),
++ bumpSource: db.prepare('UPDATE deal_offer_sources SET ref_revision = ?, retrieved_at = ?, label = COALESCE(?, label), updated_at = ? WHERE id = ?'),
++ removeSource: db.prepare('UPDATE deal_offer_sources SET removed_at = ?, removed_reason = ?, updated_at = ? WHERE id = ?'),
++ liveSources: db.prepare('SELECT COUNT(*) AS n FROM deal_offer_sources WHERE offer_id = ? AND removed_at IS NULL'),
++ refreshDue: db.prepare(`SELECT s.ref_id, MAX(o.observed_at) AS last FROM deal_offer_sources s
++ JOIN deal_offers f ON f.id = s.offer_id
++ JOIN deal_price_observations o ON o.source_id = s.id
++ WHERE s.kind = 'sources_item' AND s.removed_at IS NULL AND f.status = 'active'
++ GROUP BY s.ref_id HAVING last < ? ORDER BY last LIMIT ?`),
++ };
++ let lastError = null;
++ let lastRunAt = null;
++ let running = null;
++ let kickTimer = null;
++
++ const cursor = () => Number((q.getState.get(CURSOR_KEY) || {}).value || 0);
++
++ function amount(v) { try { return parseAmount(v, 'price'); } catch { return null; } }
++ function currencyOf(v) { return typeof v === 'string' && /^[A-Z]{3}$/.test(v) ? v : null; }
++
++ /** Stated fields → observation fields. A malformed value is null, never repaired or guessed. */
++ function obsFields(f = {}) {
++ const price = amount(f.price);
++ return {
++ price: price ? price.text : null, price_num: price ? price.num : null, currency: currencyOf(f.currency),
++ shipping: null, shipping_num: null, shipping_note: null,
++ condition: SCHEMA_CONDITION[f.condition] || null,
++ availability: SCHEMA_AVAILABILITY[f.availability] || null,
++ };
++ }
++
++ function candidates(item) {
++ const f = item.fields || {};
++ const stated = (t) => { const ms = t ? Date.parse(t) : NaN; return Number.isFinite(ms) ? ms : null; };
++ if (item.kind === 'offer') {
++ const url = normalizeUrl(f.url || item.canonical_url);
++ if (!url || !item.title) return [];
++ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), store_name: f.seller || null }];
++ }
++ if (item.kind === 'product') {
++ if (!item.title) return [];
++ const seen = new Set();
++ const out = [];
++ for (const o of (Array.isArray(f.offers) ? f.offers : []).slice(0, 20)) {
++ const url = normalizeUrl(o.url || item.canonical_url);
++ if (!url || seen.has(urlKey(url))) continue;
++ seen.add(urlKey(url));
++ out.push({
++ part: urlKey(url), url, title: o.seller ? `${item.title} — ${o.seller}` : item.title, description: item.summary,
++ obs: obsFields(o), expires_at: stated(o.valid_until), store_name: o.seller || null,
++ product: { name: item.title, brand: f.brand, gtin: f.gtin, mpn: f.mpn, sku: f.sku },
++ });
++ }
++ return out;
++ }
++ if (item.kind === 'article' || item.kind === 'record') {
++ const url = normalizeUrl(item.canonical_url);
++ if (!url || !item.title) return [];
++ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields({}), expires_at: null, store_name: null }];
++ }
++ return [];
++ }
++
++ function removeItem(item) {
++ const reason = (item.removed && item.removed.reason) || 'removed at the source';
++ const rows = q.itemRows.all(item.id);
++ for (const s of rows) q.removeSource.run(store.now(), reason, store.now(), s.id);
++ const touched = [...new Set(rows.map((s) => s.offer_id))];
++ for (const id of touched) {
++ const offer = reads.get(id);
++ if (offer.origin === 'import' && offer.status !== 'disabled' && q.liveSources.get(id).n === 0) {
++ const now = store.now();
++ db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = 'svc:deals', updated_at = ? WHERE id = ?")
++ .run(now, `source item removed: ${reason}`, now, id);
++ offers.logAction('disable', { offerId: id, actor: 'svc:deals', reason: `source item removed: ${reason}`, before: { status: offer.status }, after: { status: 'disabled' } });
++ }
++ const after = reads.get(id);
++ indexing.emitOffer('deals.offer.updated', after, { actor: 'svc:deals', extra: { changed: ['sources'], source_removed: item.id } });
++ indexing.reindex(after);
++ }
++ return touched.length ? 'removed' : 'removed:unknown';
++ }
++
++ /** One item, inside a transaction. Returns what happened (for logs and tests). */
++ function importItem(item) {
++ if (!item || item.category !== 'deals') return 'skipped:category';
++ if (item.removed) return removeItem(item);
++ const retrievedAt = Date.parse(item.provenance && item.provenance.retrieved_at);
++ if (!Number.isFinite(retrievedAt)) return 'skipped:no_observation_time';
++ const list = candidates(item);
++ if (!list.length) return `skipped:${item.kind}`;
++ const outcomes = [];
++ for (const c of list) {
++ const src = {
++ kind: 'sources_item', ref_service: 'sources', ref_type: 'item', ref_id: item.id, ref_part: c.part, ref_revision: item.revision,
++ source_key: item.source_key, url: item.canonical_url, label: item.title,
++ license_note: item.provenance.license_note || null, retrieved_at: retrievedAt,
++ };
++ const existing = q.sourceRow.get(item.id, c.part);
++ if (existing) {
++ const advanced = item.revision > (existing.ref_revision || 0) || retrievedAt > (existing.retrieved_at || 0);
++ if (!advanced || existing.removed_at) { outcomes.push('unchanged'); continue; }
++ const origin = item.revision > (existing.ref_revision || 0) ? 'import' : 'import_refresh';
++ q.bumpSource.run(Math.max(item.revision, existing.ref_revision || 0), Math.max(retrievedAt, existing.retrieved_at || 0), item.title, store.now(), existing.id);
++ const obs = offers.recordObservation(existing.offer_id, existing.id, c.obs, { origin, observedAt: retrievedAt, sourceRevision: item.revision });
++ const offer = reads.get(existing.offer_id);
++ if (origin === 'import' && c.expires_at && offer.status === 'active' && offer.origin === 'import' && c.expires_at !== offer.expires_at) {
++ db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);
++ }
++ indexing.emitOffer('deals.offer.updated', reads.get(offer.id), { actor: 'svc:deals', extra: { changed: ['observation'], observation_id: obs.id } });
++ indexing.reindex(reads.get(offer.id));
++ outcomes.push(origin === 'import' ? 'updated' : 'refreshed');
++ continue;
++ }
++ let productId = null;
++ if (c.product) {
++ try { productId = catalog.resolve(c.product, { source: 'import', actor: 'svc:deals' }).product.id; } catch { productId = null; }
++ }
++ const dup = offers.findByUrl(c.url);
++ if (dup) {
++ const s = offers.ensureSource(dup.id, src);
++ const obs = offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
++ if (productId && !dup.product_id) db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
++ indexing.emitOffer('deals.offer.updated', reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
++ indexing.reindex(reads.get(dup.id));
++ outcomes.push('attached');
++ continue;
++ }
++ offers.createImported({
++ url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId,
++ store_name: c.store_name, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
++ }, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
++ outcomes.push('created');
++ }
++ return outcomes.join(',');
++ }
++
++ const importOne = db.transaction((item) => importItem(item));
++
++ /** Pull the change feed from the cursor. Never throws; the cursor only moves past applied items. */
++ async function pull() {
++ if (!sources.enabled) return { skipped: 'import off' };
++ if (running) return running;
++ running = (async () => {
++ const summary = { pages: 0, items: 0, outcomes: {} };
++ try {
++ for (let p = 0; p < config.sources.maxPages; p++) {
++ const after = cursor();
++ const page = await sources.items({ after, limit: config.sources.pageSize });
++ summary.pages++;
++ store.tx(() => {
++ for (const item of page.items || []) {
++ let out;
++ // Each item in its own savepoint: one unreadable item is reported, not a stuck cursor.
++ try { out = importOne(item); } catch (err) { out = 'failed'; log.warn(`[Deals] import of ${item && item.id} failed: ${err.message}`); }
++ summary.items++;
++ for (const o of out.split(',')) summary.outcomes[o] = (summary.outcomes[o] || 0) + 1;
++ }
++ if (Number.isFinite(page.next_after) && page.next_after > after) q.setState.run(CURSOR_KEY, String(page.next_after), store.now());
++ });
++ if (!page.more) break;
++ }
++ lastError = null;
++ } catch (err) {
++ lastError = err.message;
++ log.warn('[Deals] Sources import failed (will retry):', err.message);
++ } finally {
++ lastRunAt = store.now();
++ running = null;
++ }
++ return summary;
++ })();
++ return running;
++ }
++
++ /**
++ * Re-confirm imported offers whose latest observation is getting old: when Sources has fetched
++ * the item again (retrieved_at advanced) the unchanged item is a new observation at that time.
++ * A source that has not been fetched successfully simply lets the offer go stale.
++ */
++ async function refresh() {
++ if (!sources.enabled) return { skipped: 'import off' };
++ const due = q.refreshDue.all(store.now() - config.freshnessMs / 2, config.sources.refreshBatch);
++ const out = { checked: 0, outcomes: {} };
++ for (const { ref_id: id } of due) {
++ try {
++ const data = await sources.item(id);
++ out.checked++;
++ const o = store.tx(() => importItem(data.item));
++ for (const x of o.split(',')) out.outcomes[x] = (out.outcomes[x] || 0) + 1;
++ } catch (err) {
++ if (err.status === 404) continue;
++ lastError = err.message;
++ break;
++ }
++ }
++ return out;
++ }
++
++ function kick(delayMs = 1000) {
++ if (kickTimer) return;
++ kickTimer = setTimeout(() => { kickTimer = null; pull().catch(() => {}); }, delayMs);
++ kickTimer.unref();
++ }
++
++ return {
++ pull, refresh, kick, importItem, cursor,
++ status: () => ({ enabled: sources.enabled, cursor: cursor(), last_error: lastError, last_run_at: iso(lastRunAt) }),
++ };
++}
++
++module.exports = { createImporter };
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
nnew file mode 100644
iindex 0000000..2800df1
---- /dev/null
++++ b/server/domain/indexing.js
@@@ -0,0 +1,123 @@
++'use strict';
++
++/**
++ * Events and OpenVibe.Search documents. Everything here runs INSIDE the transaction that made the
++ * change (the outbox rule): an event exists if and only if its change committed.
++ *
++ * emitOffer('deals.offer.created'|'deals.offer.updated'|'deals.offer.expired', offer, …)
++ * indexOffer(offer) deals.index_document.upserted|deleted for the canonical offer, or a
++ * tombstone for a merged one. The openvibe-publishing sequencer gives the
++ * same document the same revision, so re-sending an unchanged document emits
++ * nothing; a document that changed (text, status, freshness, indexability)
++ * gets revision + 1 and one event.
++ * indexProduct(id) the same for product pages.
++ */
++const hooks = require('openvibe-publishing/index-hooks');
++const { iso } = require('./util');
++
++function actorRef(actor) {
++ const s = String(actor || '');
++ if (/^usr_/.test(s)) return { type: 'user', id: s };
++ if (/^svc:/.test(s)) return { type: 'service', id: s.slice(4) };
++ return { type: 'service', id: 'deals' };
++}
++
++function createIndexing({ store, publication, outbox, catalog }) {
++ const { sequencer } = store;
++
++ function latestPayload(v) {
++ return v.latest ? { price: v.latest.price, currency: v.latest.currency, availability: v.latest.availability, observed_at: iso(v.latest.observed_at) } : null;
++ }
++
++ /** deals.offer.* for the canonical offer. */
++ function emitOffer(type, offer, { actor = null, extra = {}, traceparent } = {}) {
++ const v = publication.offerView(offer);
++ const r = v.root;
++ return outbox.emit({
++ event_type: type,
++ version: 1,
++ source: 'deals',
++ actor: actorRef(actor),
++ subject: { type: 'offer', id: r.id },
++ visibility: r.status === 'active' && v.decision.listable ? 'public' : 'internal',
++ payload: {
++ offer_id: r.id, slug: r.slug, url: publication.abs(publication.offerPath(r)), title: r.title,
++ status: r.status, store: v.store ? v.store.domain : null, product_id: r.product_id,
++ latest_observation: latestPayload(v), freshness: v.freshness.state,
++ indexability: hooks.searchIndexability(v.decision),
++ ...extra,
++ },
++ }, { traceparent });
++ }
++
++ function emitIfNew(doc, before) {
++ if (before != null && doc.revision === before) return null;
++ return outbox.emit(hooks.indexEvent({ document: doc, now: store.now() }));
++ }
++
++ function offerDocument(v) {
++ const r = v.root;
++ const provenance = v.sources.filter((s) => s.ref_service === 'sources' && !s.removed_at).slice(0, 40).map((s) => ({
++ service: 'sources', type: 'item', id: s.ref_id, ...(Number.isInteger(s.ref_revision) ? { revision: s.ref_revision } : {}),
++ url: s.url || undefined, retrievedAt: s.retrieved_at || undefined, label: s.source_key || undefined,
++ }));
++ const priceText = v.latest && v.latest.price != null ? `${v.latest.price}${v.latest.currency ? ` ${v.latest.currency}` : ''} as of ${iso(v.latest.observed_at)}` : 'price not stated';
++ return hooks.buildIndexDocument({
++ owner: 'deals', type: 'offer', id: r.id, revision: 0,
++ state: r.status === 'disabled' ? 'unpublished' : 'published', visibility: 'public',
++ canonicalUrl: publication.abs(publication.offerPath(r)),
++ title: r.title,
++ summary: r.description ? r.description.slice(0, 1000) : null,
++ body: [r.title, r.description, v.product && v.product.name, v.store && (v.store.name || v.store.domain), priceText].filter(Boolean).join('\n'),
++ facets: {
++ status: r.status, freshness: v.freshness.state, store: v.store ? v.store.domain : null, category: r.category,
++ currency: v.latest && v.latest.currency ? v.latest.currency : null, product: v.product ? v.product.slug : null,
++ },
++ authorship: { mode: r.text_origin === 'human' ? 'human' : r.text_origin === 'imported' ? 'imported' : 'hybrid' },
++ provenance,
++ decision: v.decision,
++ publishedAt: r.created_at,
++ updatedAt: v.latest ? Math.max(v.latest.observed_at, r.updated_at) : r.updated_at,
++ });
++ }
++
++ function indexOffer(offer) {
++ if (!offer) return null;
++ if (offer.merged_into) {
++ const before = sequencer.current('deals', 'offer', offer.id);
++ return emitIfNew(sequencer.stamp(hooks.tombstone({ owner: 'deals', type: 'offer', id: offer.id, revision: 0 })), before);
++ }
++ const v = publication.offerView(offer);
++ const before = sequencer.current('deals', 'offer', v.root.id);
++ return emitIfNew(sequencer.stamp(offerDocument(v)), before);
++ }
++
++ function indexProduct(product) {
++ if (!product) return null;
++ const pv = publication.productView(product);
++ const before = sequencer.current('deals', 'product', product.id);
++ const lines = pv.offers.filter((v) => v.root.status === 'active').slice(0, 20).map((v) => `${v.root.title}${v.latest && v.latest.price != null ? ` — ${v.latest.price} ${v.latest.currency || ''} as of ${iso(v.latest.observed_at)}` : ''}`);
++ const doc = hooks.buildIndexDocument({
++ owner: 'deals', type: 'product', id: product.id, revision: 0, state: 'published', visibility: 'public',
++ canonicalUrl: publication.abs(publication.productPath(product)), title: product.name,
++ summary: product.description || null,
++ body: [product.name, product.brand, ...pv.aliases.map((a) => a.value), ...lines].filter(Boolean).join('\n'),
++ facets: { brand: product.brand || null, category: product.category || null, offers: pv.offers.filter((v) => v.root.status === 'active').length },
++ authorship: { mode: 'human' },
++ decision: pv.decision,
++ publishedAt: product.created_at,
++ updatedAt: pv.freshestObservedAt || product.updated_at,
++ });
++ return emitIfNew(sequencer.stamp(doc), before);
++ }
++
++ /** After any change to an offer: its index document and its product's. */
++ function reindex(offer) {
++ indexOffer(offer);
++ if (offer && offer.product_id) indexProduct(catalog.product(offer.product_id));
++ }
++
++ return { emitOffer, indexOffer, indexProduct, reindex, actorRef, offerDocument };
++}
++
++module.exports = { createIndexing, actorRef };
ddiff --git a/server/domain/limits.js b/server/domain/limits.js
nnew file mode 100644
iindex 0000000..b165ebc
---- /dev/null
++++ b/server/domain/limits.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Server-side sliding-window limits kept in SQLite (rate_events), so they hold across restarts and
++ * across every route that performs the same action (page forms and the API share them).
++ *
++ * limits.check(kind, key, max, windowMs) → throws 429 with retry_after when the window is full
++ * limits.hit(kind, key) records one action (inside the action's transaction)
++ *
++ * IP addresses are never stored: ipHash() is an HMAC under DEALS_IP_HASH_SECRET, truncated.
++ */
++const crypto = require('crypto');
++const { ApiError } = require('./util');
++
++const fallbackKey = crypto.randomBytes(32);
++
++function createLimits({ config, store }) {
++ const { db } = store;
++ const count = db.prepare('SELECT COUNT(*) AS n, MIN(at) AS oldest FROM rate_events WHERE kind = ? AND key = ? AND at > ?');
++ const insert = db.prepare('INSERT INTO rate_events (kind, key, at) VALUES (?, ?, ?)');
++ const prune = db.prepare('DELETE FROM rate_events WHERE at < ?');
++
++ function check(kind, key, max, windowMs, code = 'rate.limited') {
++ if (!key || !(max > 0)) return;
++ const since = store.now() - windowMs;
++ const r = count.get(kind, key, since);
++ if (r.n >= max) {
++ const retry = Math.max(1, Math.ceil((r.oldest + windowMs - store.now()) / 1000));
++ throw new ApiError(429, code, `Too many ${kind.replace(/_/g, ' ')} actions; try again in ${retry} s`, { retry_after: retry });
++ }
++ }
++
++ function hit(kind, key) { if (key) insert.run(kind, key, store.now()); }
++
++ function ipHash(ip) {
++ if (!ip) return null;
++ return crypto.createHmac('sha256', config.ipHashSecret || fallbackKey).update(`deals-ip:${ip}`).digest('base64url').slice(0, 22);
++ }
++
++ return { check, hit, ipHash, prune: (olderThanMs = 3 * 24 * 3600 * 1000) => prune.run(store.now() - olderThanMs).changes, stableIpHash: Boolean(config.ipHashSecret) };
++}
++
++module.exports = { createLimits };
ddiff --git a/server/domain/listings.js b/server/domain/listings.js
nnew file mode 100644
iindex 0000000..53f4789
---- /dev/null
++++ b/server/domain/listings.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * Lists of canonical offers (merged listings never appear on their own; disabled ones never appear).
++ *
++ * hot active offers by their latest hotness snapshot (hot@1), newest first on ties
++ * newest active offers by first posting time
++ * search active (or all non-disabled, with expired) offers whose title/description contain every word
++ * store a store's offers, expired ones included and marked
++ */
++const { tokens } = require('./util');
++
++function createListings({ store }) {
++ const { db } = store;
++ const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';
++ const q = {
++ hot: db.prepare(`SELECT o.*, h.hot AS hot FROM deal_offers o LEFT JOIN deal_hotness_snapshots h ON ${LATEST}
++ WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY COALESCE(h.hot, 0) DESC, o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),
++ newest: db.prepare(`SELECT o.* FROM deal_offers o WHERE o.status = 'active' AND o.merged_into IS NULL ORDER BY o.created_at DESC, o.rowid DESC LIMIT ? OFFSET ?`),
++ countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
++ byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
++ countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
++ indexable: db.prepare(`SELECT * FROM deal_offers WHERE merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT 50000`),
++ products: db.prepare(`SELECT DISTINCT p.* FROM deal_products p JOIN deal_offers o ON o.product_id = p.id WHERE o.merged_into IS NULL AND o.status <> 'disabled' LIMIT 50000`),
++ duplicates: db.prepare(`SELECT a.id AS a, b.id AS b FROM deal_offers a JOIN deal_offers b
++ ON b.store_id = a.store_id AND b.id > a.id
++ AND ((a.product_id IS NOT NULL AND b.product_id = a.product_id) OR lower(b.title) = lower(a.title))
++ WHERE a.merged_into IS NULL AND b.merged_into IS NULL AND a.status <> 'disabled' AND b.status <> 'disabled'
++ ORDER BY b.created_at DESC LIMIT ?`),
++ pendingReview: db.prepare(`SELECT * FROM deal_offers WHERE review_state = 'pending' AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT ?`),
++ };
++
++ function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {
++ const words = [...new Set(tokens(query))].slice(0, 6);
++ if (!words.length) return { total: 0, rows: [] };
++ const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');
++ const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));
++ const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";
++ const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;
++ const total = db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params).n;
++ const rows = db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
++ return { total, rows, words };
++ }
++
++ return {
++ hot: (limit, offset) => q.hot.all(limit, offset),
++ newest: (limit, offset) => q.newest.all(limit, offset),
++ countActive: () => q.countActive.get().n,
++ byStore: (storeId, limit, offset) => q.byStore.all(storeId, limit, offset),
++ countStore: (storeId) => q.countStore.get(storeId).n,
++ indexable: () => q.indexable.all(),
++ products: () => q.products.all(),
++ possibleDuplicates: (limit = 50) => q.duplicates.all(limit),
++ pendingReview: (limit = 50) => q.pendingReview.all(limit),
++ search,
++ };
++}
++
++module.exports = { createListings };
ddiff --git a/server/domain/offers.js b/server/domain/offers.js
nnew file mode 100644
iindex 0000000..327d178
---- /dev/null
++++ b/server/domain/offers.js
@@@ -0,0 +1,401 @@
++'use strict';
++
++/**
++ * Offer writes: submission, edits, observations, expiry, moderation (disable / enable / review) and
++ * duplicate resolution (merge / unmerge). Every write is one SQLite transaction that also writes its
++ * events (outbox), its Search documents and, for observations, its watch notifications.
++ *
++ * Prices are never edited in place. A price, shipping cost, condition or availability is an
++ * observation row (deal_price_observations) with its source and observed_at; "the price" of an
++ * offer is always "the latest observation, as of its time".
++ *
++ * Merge B into A: B.merged_into = A. Nothing is copied, moved or deleted, so votes, observations,
++ * sources and comments all stay where they were written; A's pages read across the group. Unmerge
++ * clears the pointer. Both are moderator actions, recorded in moderation_log with the vote tallies
++ * before and after.
++ */
++const {
++ ApiError, newId, normalizeUrl, urlKey, domainOf, slugify, parseAmount, parseCurrency, parseEnum, parseInstant,
++ text, longText, CONDITIONS, AVAILABILITY,
++} = require('./util');
++
++const FUTURE_SKEW_MS = 5 * 60 * 1000;
++
++function actorOf(viewer) {
++ if (!viewer) return null;
++ if (viewer.kind === 'service') return viewer.subject || viewer.service;
++ return viewer.subject || null;
++}
++
++function createOffers(deps) {
++ const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null } = deps;
++ const { db } = store;
++ const q = {
++ byUrl: db.prepare("SELECT * FROM deal_offers WHERE url_norm = ? AND status <> 'disabled' ORDER BY created_at LIMIT 1"),
++ insertOffer: db.prepare(`INSERT INTO deal_offers (id, slug, title, description, url, url_norm, store_id, product_id, category, origin, submitted_by,
++ text_origin, review_state, ai_summary, status, expires_at, created_at, updated_at)
++ VALUES (@id, @slug, @title, @description, @url, @url_norm, @store_id, @product_id, @category, @origin, @submitted_by,
++ @text_origin, @review_state, @ai_summary, 'active', @expires_at, @now, @now)`),
++ insertSource: db.prepare(`INSERT INTO deal_offer_sources (id, offer_id, kind, ref_service, ref_type, ref_id, ref_part, ref_revision, source_key, url, label,
++ license_note, submitted_by, retrieved_at, created_at, updated_at)
++ VALUES (@id, @offer_id, @kind, @ref_service, @ref_type, @ref_id, @ref_part, @ref_revision, @source_key, @url, @label,
++ @license_note, @submitted_by, @retrieved_at, @now, @now)`),
++ sourceByRef: db.prepare('SELECT * FROM deal_offer_sources WHERE kind = ? AND ref_service = ? AND ref_id = ? AND ref_part = ?'),
++ insertObs: db.prepare(`INSERT INTO deal_price_observations (id, offer_id, source_id, observed_at, recorded_at, price, price_num, currency, shipping,
++ shipping_num, shipping_note, condition, availability, origin, observed_by, source_revision, note)
++ VALUES (@id, @offer_id, @source_id, @observed_at, @recorded_at, @price, @price_num, @currency, @shipping,
++ @shipping_num, @shipping_note, @condition, @availability, @origin, @observed_by, @source_revision, @note)`),
++ obs: db.prepare('SELECT * FROM deal_price_observations WHERE id = ?'),
++ log: db.prepare('INSERT INTO moderation_log (action, offer_id, target_id, actor, reason, before, after, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
++ logFor: db.prepare(`SELECT * FROM moderation_log WHERE offer_id IN (SELECT value FROM json_each(?)) OR target_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
++ dueExpiry: db.prepare("SELECT * FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at IS NOT NULL AND expires_at <= ?"),
++ };
++
++ // ── helpers ─────────────────────────────────────────────
++
++ /** Stated observation fields. A price needs its currency; nothing is defaulted. */
++ function parseObservation(input = {}) {
++ const price = parseAmount(input.price, 'price');
++ const shipping = parseAmount(input.shipping, 'shipping');
++ const currency = parseCurrency(input.currency);
++ if ((price || shipping) && !currency) throw new ApiError(422, 'request.invalid', 'a price or shipping cost needs its currency (e.g. USD); Deals never assumes one');
++ return {
++ price: price ? price.text : null, price_num: price ? price.num : null,
++ currency,
++ shipping: shipping ? shipping.text : null, shipping_num: shipping ? shipping.num : null,
++ shipping_note: text(input.shipping_note, { field: 'shipping_note', max: 200 }),
++ condition: parseEnum(input.condition, CONDITIONS, 'condition'),
++ availability: parseEnum(input.availability, AVAILABILITY, 'availability'),
++ note: text(input.note, { field: 'note', max: 300 }),
++ };
++ }
++
++ function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
++ q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
++ }
++
++ function ensureSource(offerId, s) {
++ const part = s.ref_part || '';
++ const existing = q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
++ if (existing) return existing;
++ const row = {
++ id: newId('dos'), offer_id: offerId, kind: s.kind, ref_service: s.ref_service, ref_type: s.ref_type, ref_id: s.ref_id, ref_part: part,
++ ref_revision: s.ref_revision == null ? null : s.ref_revision, source_key: s.source_key || null, url: s.url || null, label: s.label || null,
++ license_note: s.license_note || null, submitted_by: s.submitted_by || null, retrieved_at: s.retrieved_at == null ? null : s.retrieved_at, now: store.now(),
++ };
++ q.insertSource.run(row);
++ return q.sourceByRef.get(s.kind, s.ref_service, s.ref_id, part);
++ }
++
++ /** Insert one observation and run the watch matcher. Inside the caller's transaction. */
++ function recordObservation(offerId, sourceId, fields, { origin, observedBy = null, observedAt, sourceRevision = null }) {
++ const now = store.now();
++ if (!Number.isFinite(observedAt)) throw new ApiError(422, 'request.invalid', 'an observation needs the time it was observed');
++ if (observedAt > now + FUTURE_SKEW_MS) throw new ApiError(422, 'request.invalid', 'observed_at is in the future');
++ const row = {
++ id: newId('dpo'), offer_id: offerId, source_id: sourceId, observed_at: Math.min(observedAt, now), recorded_at: now,
++ price: fields.price, price_num: fields.price_num, currency: fields.currency, shipping: fields.shipping, shipping_num: fields.shipping_num,
++ shipping_note: fields.shipping_note || null, condition: fields.condition || null, availability: fields.availability || null,
++ origin, observed_by: observedBy, source_revision: sourceRevision, note: fields.note || null,
++ };
++ q.insertObs.run(row);
++ const obs = q.obs.get(row.id);
++ watches.onObservation(obs);
++ return obs;
++ }
++
++ function insertOffer(f) {
++ const id = newId('dof');
++ q.insertOffer.run({
++ id, slug: slugify(f.title, id), title: f.title, description: f.description || null, url: f.url, url_norm: urlKey(f.url),
++ store_id: f.store_id || null, product_id: f.product_id || null, category: f.category || null, origin: f.origin,
++ submitted_by: f.submitted_by || null, text_origin: f.text_origin || 'human', review_state: f.review_state || 'not_required',
++ ai_summary: f.ai_summary || null, expires_at: f.expires_at == null ? null : f.expires_at, now: store.now(),
++ });
++ return reads.get(id);
++ }
++
++ const findByUrl = (url) => { const hit = q.byUrl.get(urlKey(url)); return hit ? reads.root(hit) : null; };
++
++ function mustRoot(idOrSlug) { return reads.root(reads.mustFind(idOrSlug)); }
++
++ function tallies(root) { return reads.tally(reads.groupIds(root.id)); }
++
++ // ── submission ──────────────────────────────────────────
++
++ function submit(viewer, input = {}, { ip = null, traceparent } = {}) {
++ const subject = viewer && viewer.subject;
++ if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to submit a deal');
++ const url = normalizeUrl(input.url);
++ if (!url) throw new ApiError(422, 'request.invalid', 'url must be an http(s) link to the offer');
++ const title = text(input.title, { field: 'title', min: 3, max: 200, required: true });
++ const description = longText(input.description, 4000);
++ const obs = parseObservation(input);
++ const expiresAt = parseInstant(input.expires_at, 'expires_at');
++ if (expiresAt != null && expiresAt <= store.now()) throw new ApiError(422, 'request.invalid', 'expires_at is already in the past');
++ const ai = viewer.kind === 'service' && viewer.origin === 'ai';
++ return store.tx(() => {
++ limits.check('submit', subject, config.abuse.submitPerDay, 24 * 3600 * 1000, 'submit.rate_limited');
++ const dup = findByUrl(url);
++ if (dup) throw new ApiError(409, 'offer.duplicate', 'This link has already been posted', { existing: { id: dup.id, slug: dup.slug, url: publication.abs(publication.offerPath(dup)) } });
++ const st = catalog.ensureStore(domainOf(url), text(input.store_name, { max: 120 }));
++ let product = null;
++ const p = input.product && typeof input.product === 'object' ? input.product : null;
++ if (input.product_slug) {
++ product = catalog.productBySlug(input.product_slug);
++ if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
++ } else if (p && (p.name || p.gtin || p.mpn || p.sku)) {
++ product = catalog.resolve(p, { source: 'community', actor: subject }).product;
++ }
++ const offer = insertOffer({
++ title, description, url, store_id: st ? st.id : null, product_id: product ? product.id : null,
++ category: text(input.category, { max: 60 }), origin: 'community', submitted_by: subject,
++ text_origin: ai ? 'ai' : 'human', review_state: ai ? 'pending' : 'not_required', expires_at: expiresAt,
++ });
++ const src = ensureSource(offer.id, { kind: 'submission', ref_service: 'deals', ref_type: 'submission', ref_id: offer.id, url, submitted_by: subject });
++ limits.hit('submit', subject);
++ if (ip) limits.hit('submit_ip', ip);
++ hotness.snapshot(offer.id, 'create');
++ recordObservation(offer.id, src.id, obs, { origin: 'community', observedBy: subject, observedAt: store.now() });
++ indexing.emitOffer('deals.offer.created', offer, { actor: subject, traceparent });
++ indexing.reindex(reads.get(offer.id));
++ return { offer: reads.get(offer.id), created: true };
++ });
++ }
++
++ /** Created by the Sources importer (no person); text is third-party and waits for review. */
++ function createImported(f, source, obsFields, { observedAt, sourceRevision }) {
++ const st = catalog.ensureStore(domainOf(f.url), f.store_name || null);
++ const offer = insertOffer({
++ title: f.title, description: f.description, url: f.url, store_id: st ? st.id : null, product_id: f.product_id || null,
++ category: f.category || null, origin: 'import', submitted_by: null, text_origin: 'imported', review_state: 'pending',
++ expires_at: f.expires_at == null ? null : f.expires_at,
++ });
++ const src = ensureSource(offer.id, source);
++ hotness.snapshot(offer.id, 'create');
++ recordObservation(offer.id, src.id, obsFields, { origin: 'import', observedAt, sourceRevision });
++ indexing.emitOffer('deals.offer.created', offer, { actor: 'svc:deals', extra: { imported_from: { service: 'sources', type: 'item', id: source.ref_id } } });
++ indexing.reindex(reads.get(offer.id));
++ return reads.get(offer.id);
++ }
++
++ // ── edits and observations ──────────────────────────────
++
++ function update(viewer, idOrSlug, input = {}, { traceparent } = {}) {
++ const root = mustRoot(idOrSlug);
++ const actor = actorOf(viewer);
++ if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can edit it');
++ if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
++ const changes = {};
++ if (input.title !== undefined) changes.title = text(input.title, { field: 'title', min: 3, max: 200, required: true });
++ if (input.description !== undefined) changes.description = longText(input.description, 4000);
++ if (input.category !== undefined) changes.category = text(input.category, { max: 60 });
++ if (input.expires_at !== undefined) {
++ const t = parseInstant(input.expires_at, 'expires_at');
++ if (t != null && t <= store.now()) throw new ApiError(422, 'request.invalid', 'expires_at is already in the past (expire the deal instead)');
++ changes.expires_at = t;
++ }
++ const ai = viewer.kind === 'service' && viewer.origin === 'ai';
++ if (input.ai_summary !== undefined) {
++ if (!ai) throw new ApiError(422, 'request.invalid', 'ai_summary is written only by OpenVibe.AI (X-OV-Origin: ai)');
++ changes.ai_summary = longText(input.ai_summary, 2000);
++ changes.review_state = 'pending';
++ } else if (ai && (changes.title || changes.description)) {
++ changes.text_origin = 'ai';
++ changes.review_state = 'pending';
++ }
++ return store.tx(() => {
++ if (input.product_slug !== undefined || input.product !== undefined) {
++ if (!input.product_slug && !input.product) changes.product_id = null;
++ else if (input.product_slug) {
++ const p = catalog.productBySlug(input.product_slug);
++ if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
++ changes.product_id = p.id;
++ } else changes.product_id = catalog.resolve(input.product, { source: 'community', actor }).product.id;
++ }
++ const keys = Object.keys(changes);
++ if (!keys.length) return { offer: root, changed: false };
++ const oldProduct = root.product_id;
++ db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
++ const after = reads.get(root.id);
++ indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
++ indexing.reindex(after);
++ if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));
++ return { offer: after, changed: true };
++ });
++ }
++
++ /** A person (or a service acting for one) reports what they see at the link right now. */
++ function observe(viewer, idOrSlug, input = {}, { ip = null, traceparent } = {}) {
++ const subject = viewer && viewer.subject;
++ if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to report a price');
++ const root = mustRoot(idOrSlug);
++ if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
++ const fields = parseObservation(input);
++ if (fields.price == null && fields.shipping == null && !fields.availability && !fields.condition) {
++ throw new ApiError(422, 'request.invalid', 'state at least a price, shipping cost, availability or condition');
++ }
++ let observedAt = store.now();
++ if (viewer.kind === 'service' && input.observed_at != null) observedAt = parseInstant(input.observed_at, 'observed_at');
++ return store.tx(() => {
++ limits.check('observe', subject, config.abuse.observePerHour, 3600 * 1000, 'observe.rate_limited');
++ const src = ensureSource(root.id, { kind: 'community', ref_service: 'deals', ref_type: 'observer', ref_id: subject, ref_part: root.id, url: root.url, submitted_by: subject });
++ const obs = recordObservation(root.id, src.id, fields, { origin: 'community', observedBy: subject, observedAt });
++ limits.hit('observe', subject);
++ if (ip) limits.hit('observe_ip', ip);
++ db.prepare('UPDATE deal_offers SET updated_at = ? WHERE id = ?').run(store.now(), root.id);
++ indexing.emitOffer('deals.offer.updated', root, { actor: subject, extra: { changed: ['observation'], observation_id: obs.id }, traceparent });
++ indexing.reindex(reads.get(root.id));
++ return { offer: reads.get(root.id), observation: obs };
++ });
++ }
++
++ // ── expiry and moderation ───────────────────────────────
++
++ function expireRow(root, { actor, reason, traceparent }) {
++ const now = store.now();
++ db.prepare("UPDATE deal_offers SET status = 'expired', expired_at = ?, expired_reason = ?, updated_at = ? WHERE id = ?").run(now, reason, now, root.id);
++ logAction('expire', { offerId: root.id, actor: actor || 'svc:deals', reason, before: { status: root.status }, after: { status: 'expired' } });
++ const after = reads.get(root.id);
++ indexing.emitOffer('deals.offer.expired', after, { actor, extra: { reason }, traceparent });
++ indexing.reindex(after);
++ return after;
++ }
++
++ function expire(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
++ const root = mustRoot(idOrSlug);
++ if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
++ if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
++ const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
++ return store.tx(() => expireRow(root, { actor: actorOf(viewer), reason: why, traceparent }));
++ }
++
++ /** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
++ function expireDue() {
++ const due = q.dueExpiry.all(store.now());
++ store.tx(() => { for (const o of due) expireRow(o, { actor: null, reason: 'stated_expiry' }); });
++ return due.length;
++ }
++
++ function requireStaff(viewer, cap) {
++ if (!access.isModerator(viewer, cap)) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
++ }
++
++ function disable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
++ requireStaff(viewer, 'deals.offer.moderate');
++ const root = mustRoot(idOrSlug);
++ const why = text(reason, { field: 'reason', min: 3, max: 300, required: true });
++ if (root.status === 'disabled') return { offer: root, changed: false };
++ const out = store.tx(() => {
++ const now = store.now();
++ db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
++ logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
++ const after = reads.get(root.id);
++ indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
++ indexing.reindex(after);
++ return { offer: after, changed: true };
++ });
++ threadVisibility(out.offer, 'hidden');
++ return out;
++ }
++
++ /** After commit, best effort: a disabled offer's Community thread is hidden, and shown again on enable. */
++ function threadVisibility(offer, visibility) {
++ if (community && community.enabled) community.setThreadVisibility(offer, visibility).catch(() => {});
++ }
++
++ function enable(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
++ requireStaff(viewer, 'deals.offer.moderate');
++ const root = mustRoot(idOrSlug);
++ if (root.status === 'active') return { offer: root, changed: false };
++ const wasDisabled = root.status === 'disabled';
++ const out = store.tx(() => {
++ const now = store.now();
++ const keepExpiry = root.expires_at != null && root.expires_at > now ? root.expires_at : null;
++ db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
++ expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
++ logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
++ const after = reads.get(root.id);
++ indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
++ indexing.reindex(after);
++ return { offer: after, changed: true };
++ });
++ if (wasDisabled) threadVisibility(out.offer, 'public');
++ return out;
++ }
++
++ /** A person confirms imported or AI-assisted text (only a usr_ subject can review). */
++ function review(viewer, idOrSlug, { note } = {}, { traceparent } = {}) {
++ requireStaff(viewer, 'deals.offer.moderate');
++ const reviewer = viewer.subject;
++ if (!reviewer) throw new ApiError(403, 'review.person_required', 'A review is recorded for a person (usr_…), never for a service');
++ const root = mustRoot(idOrSlug);
++ if (root.review_state !== 'pending') return { offer: root, changed: false };
++ return store.tx(() => {
++ const now = store.now();
++ db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
++ logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
++ const after = reads.get(root.id);
++ indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
++ indexing.reindex(after);
++ return { offer: after, changed: true };
++ });
++ }
++
++ function merge(viewer, idOrSlug, intoIdOrSlug, { reason } = {}, { traceparent } = {}) {
++ requireStaff(viewer, 'deals.offer.merge');
++ const dup = reads.mustFind(idOrSlug);
++ const target = reads.root(reads.mustFind(intoIdOrSlug));
++ if (dup.merged_into) throw new ApiError(409, 'offer.already_merged', 'This offer is already merged; unmerge it first');
++ if (reads.groupIds(dup.id).includes(target.id)) throw new ApiError(409, 'offer.merge_cycle', 'Cannot merge an offer into itself or into an offer merged into it');
++ const actor = actorOf(viewer);
++ return store.tx(() => {
++ const before = { target: tallies(target), duplicate: tallies(dup) };
++ const now = store.now();
++ db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
++ const after = { target: tallies(target) };
++ logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
++ hotness.snapshot(target.id, 'merge');
++ indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
++ indexing.indexOffer(reads.get(dup.id));
++ indexing.reindex(reads.get(target.id));
++ if (dup.product_id && dup.product_id !== target.product_id) indexing.indexProduct(catalog.product(dup.product_id));
++ return { offer: reads.get(target.id), merged: reads.get(dup.id), before, after };
++ });
++ }
++
++ function unmerge(viewer, idOrSlug, { reason } = {}, { traceparent } = {}) {
++ requireStaff(viewer, 'deals.offer.merge');
++ const dup = reads.mustFind(idOrSlug);
++ if (!dup.merged_into) throw new ApiError(409, 'offer.not_merged', 'This offer is not merged');
++ const parentRoot = reads.root(dup);
++ const actor = actorOf(viewer);
++ return store.tx(() => {
++ const before = { target: tallies(parentRoot) };
++ const now = store.now();
++ db.prepare('UPDATE deal_offers SET merged_into = NULL, merged_at = NULL, merged_by = NULL, updated_at = ? WHERE id = ?').run(now, dup.id);
++ const restored = reads.get(dup.id);
++ const after = { target: tallies(parentRoot), duplicate: tallies(restored) };
++ logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
++ hotness.snapshot(parentRoot.id, 'unmerge');
++ hotness.snapshot(restored.id, 'unmerge');
++ indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
++ indexing.emitOffer('deals.offer.updated', restored, { actor, extra: { changed: ['unmerged'], unmerged_from: parentRoot.id }, traceparent });
++ indexing.reindex(reads.get(parentRoot.id));
++ indexing.reindex(restored);
++ return { offer: restored, from: reads.get(parentRoot.id), before, after };
++ });
++ }
++
++ function moderationLog(root) {
++ const ids = JSON.stringify(reads.groupIds(root.id));
++ return q.logFor.all(ids, ids);
++ }
++
++ return {
++ submit, createImported, update, observe, expire, expireDue, disable, enable, review, merge, unmerge,
++ recordObservation, ensureSource, parseObservation, findByUrl, moderationLog, logAction, actorOf,
++ };
++}
++
++module.exports = { createOffers, actorOf };
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
nnew file mode 100644
iindex 0000000..0f5cc99
---- /dev/null
++++ b/server/domain/publication.js
@@@ -0,0 +1,186 @@
++'use strict';
++
++/**
++ * What an offer or product page says, and whether a crawler may index it.
++ *
++ * Freshness: every price is an observation with an observed_at. The latest observation of an
++ * offer's group is `fresh` while it is younger than DEALS_FRESHNESS_HOURS, `stale` after, and an
++ * offer with no observation is `unobserved`. Pages always print "as of <time>"; a stale price is
++ * labelled stale and never presented as the price now.
++ *
++ * Indexability (openvibe-publishing/seo gate, policy priceMaxAgeMs = the freshness window):
++ * disabled by moderators takedown hidden (410 page, out of feeds, sitemaps, Search)
++ * expired (stated or marked) expired noindex
++ * latest observation stale/none stale_price noindex
++ * imported or AI text not reviewed by a person noindex_requested (detail: review_pending)
++ *
++ * Structured data: Offer JSON-LD is built only from the latest observation's stated fields, and
++ * only while that observation is fresh. A missing price is omitted (never 0), a missing currency
++ * drops the price, a missing availability is omitted (never an assumed InStock).
++ */
++const seo = require('openvibe-publishing/seo');
++const { iso } = require('./util');
++
++function createPublication({ config, store, reads, catalog }) {
++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
++ const offerPath = (o) => `/d/${encodeURIComponent(o.slug)}`;
++ const productPath = (p) => `/p/${encodeURIComponent(p.slug)}`;
++ const storePath = (s) => `/s/${encodeURIComponent(s.domain)}`;
++
++ function freshness(obs, now = store.now()) {
++ if (!obs) return { state: 'unobserved', observed_at: null, age_ms: null, max_age_ms: config.freshnessMs };
++ const age = Math.max(0, now - obs.observed_at);
++ return { state: age <= config.freshnessMs ? 'fresh' : 'stale', observed_at: iso(obs.observed_at), age_ms: age, max_age_ms: config.freshnessMs };
++ }
++
++ function decideOffer(root, latest, now = store.now()) {
++ const facts = {
++ state: 'published',
++ visibility: 'public',
++ canonicalUrl: abs(offerPath(root)),
++ wordCount: 0,
++ price: { observedAt: latest ? latest.observed_at : null },
++ takedown: root.status === 'disabled',
++ noindex: root.review_state === 'pending',
++ };
++ const expiry = root.status === 'expired' ? (root.expired_at || now) : root.expires_at;
++ if (expiry != null) facts.expiresAt = expiry;
++ const d = seo.evaluate(facts, { policy: { minWords: 0, priceMaxAgeMs: config.freshnessMs }, now });
++ for (const r of d.reasons) if (r.code === 'noindex_requested') r.detail = `review_pending: ${root.text_origin} text not yet reviewed by a person`;
++ return d;
++ }
++
++ /** A product page is indexable while at least one of its active offers has a fresh observation. */
++ function decideProduct(product, freshestObservedAt, now = store.now()) {
++ return seo.evaluate({
++ state: 'published', visibility: 'public', canonicalUrl: abs(productPath(product)), wordCount: 0,
++ price: { observedAt: freshestObservedAt },
++ }, { policy: { minWords: 0, priceMaxAgeMs: config.freshnessMs }, now });
++ }
++
++ /** Everything a page, the JSON twin, an event or the index needs about one canonical offer. */
++ function offerView(offer, now = store.now()) {
++ const root = reads.root(offer);
++ const ids = reads.groupIds(root.id);
++ const latest = reads.latestObservation(ids);
++ const tally = reads.tally(ids);
++ const snap = reads.lastSnapshot(root.id);
++ return {
++ root,
++ ids,
++ store: catalog.store(root.store_id),
++ product: catalog.product(root.product_id),
++ observations: reads.observations(ids),
++ latest,
++ freshness: freshness(latest, now),
++ sources: reads.sources(ids),
++ members: ids.filter((id) => id !== root.id).map((id) => reads.get(id)),
++ tally,
++ hotness: snap,
++ decision: decideOffer(root, latest, now),
++ now,
++ };
++ }
++
++ function observationDto(o) {
++ return {
++ id: o.id, observed_at: iso(o.observed_at), recorded_at: iso(o.recorded_at),
++ price: o.price, currency: o.currency, shipping: o.shipping, shipping_note: o.shipping_note,
++ condition: o.condition, availability: o.availability, origin: o.origin,
++ observed_by: o.observed_by || null, listing: o.offer_id,
++ source: { id: o.source_id, kind: o.source_kind, ref: o.ref_service === 'sources' ? { service: 'sources', type: 'item', id: o.ref_id, revision: o.source_revision } : null, key: o.source_key || null, url: o.source_url || null },
++ };
++ }
++
++ /** The JSON representation (API and /d/:slug.json): the same facts as the page. */
++ function offerDto(v) {
++ const r = v.root;
++ return {
++ id: r.id, slug: r.slug, url: abs(offerPath(r)), title: r.title, description: r.description,
++ link: r.url, status: r.status, category: r.category, origin: r.origin, submitted_by: r.submitted_by,
++ text_origin: r.text_origin, review_state: r.review_state, ai_summary: r.ai_summary,
++ expires_at: iso(r.expires_at), expired_at: iso(r.expired_at), expired_reason: r.expired_reason,
++ disabled_reason: r.status === 'disabled' ? r.disabled_reason : null,
++ created_at: iso(r.created_at), updated_at: iso(r.updated_at),
++ store: v.store ? { id: v.store.id, domain: v.store.domain, name: v.store.name, url: abs(storePath(v.store)) } : null,
++ product: v.product ? { id: v.product.id, slug: v.product.slug, name: v.product.name, url: abs(productPath(v.product)) } : null,
++ // "latest", never "current": whether it still holds is what freshness says.
++ latest_observation: v.latest ? observationDto(v.latest) : null,
++ freshness: v.freshness,
++ observations: v.observations.map(observationDto),
++ sources: v.sources.map((s) => ({
++ id: s.id, kind: s.kind, listing: s.offer_id,
++ ref: s.ref_service === 'sources' ? { service: 'sources', type: 'item', id: s.ref_id, revision: s.ref_revision } : null,
++ key: s.source_key, url: s.url, label: s.label, license_note: s.license_note,
++ retrieved_at: iso(s.retrieved_at), removed_at: iso(s.removed_at), removed_reason: s.removed_reason,
++ })),
++ merged_from: v.members.map((m) => ({ id: m.id, slug: m.slug, title: m.title, merged_at: iso(m.merged_at) })),
++ votes: { up: v.tally.up, down: v.tally.down, up_weight: v.tally.upWeight, down_weight: v.tally.downWeight },
++ hotness: v.hotness ? { formula: v.hotness.formula, hot: v.hotness.hot, score: v.hotness.score, computed_at: iso(v.hotness.computed_at) } : null,
++ indexability: { indexable: v.decision.indexable, robots: v.decision.robots, reasons: v.decision.reasons },
++ };
++ }
++
++ /** Offer JSON-LD fields from a fresh observation only; unknown stays out. */
++ function offerLd(v) {
++ const o = v.latest;
++ const fresh = v.freshness.state === 'fresh' && v.root.status === 'active';
++ const ld = {
++ '@type': 'Offer',
++ url: abs(offerPath(v.root)),
++ seller: v.store ? { '@type': 'Organization', name: v.store.name || v.store.domain } : undefined,
++ };
++ if (fresh && o) {
++ if (o.price != null && o.currency) { ld.price = o.price; ld.priceCurrency = o.currency; }
++ if (o.availability) ld.availability = `https://schema.org/${{ in_stock: 'InStock', out_of_stock: 'OutOfStock', preorder: 'PreOrder', discontinued: 'Discontinued', limited: 'LimitedAvailability', sold_out: 'SoldOut', online_only: 'OnlineOnly', in_store_only: 'InStoreOnly' }[o.availability]}`;
++ if (o.condition) ld.itemCondition = `https://schema.org/${{ new: 'NewCondition', used: 'UsedCondition', refurbished: 'RefurbishedCondition', damaged: 'DamagedCondition' }[o.condition]}`;
++ if (o.shipping != null && o.currency) ld.shippingDetails = { '@type': 'OfferShippingDetails', shippingRate: { '@type': 'MonetaryAmount', value: o.shipping, currency: o.currency } };
++ }
++ if (v.root.expires_at && v.root.status === 'active') ld.validThrough = iso(v.root.expires_at);
++ return ld;
++ }
++
++ function offerJsonLd(v) {
++ const name = v.product ? v.product.name : v.root.title;
++ return seo.compact({
++ '@context': 'https://schema.org', '@type': 'Product', name, url: abs(offerPath(v.root)),
++ description: v.root.description || undefined,
++ brand: v.product && v.product.brand ? { '@type': 'Brand', name: v.product.brand } : undefined,
++ offers: [offerLd(v)],
++ });
++ }
++
++ const productOfferIds = store.db.prepare(`SELECT id FROM deal_offers WHERE product_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at`);
++
++ /**
++ * A product's price comparison: every canonical, non-disabled offer with its own latest
++ * observation and freshness. Order: fresh offers with a stated price first (by currency, then
++ * price ascending), then fresh offers without a price, then stale / unobserved / expired ones —
++ * an old or unknown price never sorts as if it were the price now, and unknown never sorts as 0.
++ */
++ function productView(product, now = store.now()) {
++ const rows = productOfferIds.all(product.id).map((r) => offerView(reads.get(r.id), now));
++ const rank = (v) => (v.root.status !== 'active' ? 4 : v.freshness.state !== 'fresh' ? 3 : (v.latest && v.latest.price != null && v.latest.currency) ? 1 : 2);
++ rows.sort((a, b) => rank(a) - rank(b)
++ || (rank(a) === 1 ? (a.latest.currency < b.latest.currency ? -1 : a.latest.currency > b.latest.currency ? 1 : a.latest.price_num - b.latest.price_num) : 0)
++ || b.root.created_at - a.root.created_at);
++ const fresh = rows.filter((v) => v.root.status === 'active' && v.freshness.state === 'fresh').map((v) => v.latest.observed_at);
++ const freshest = fresh.length ? Math.max(...fresh) : null;
++ return { product, offers: rows, aliases: catalog.aliases(product.id), decision: decideProduct(product, freshest, now), freshestObservedAt: freshest };
++ }
++
++ function productJsonLd(pv) {
++ return seo.compact({
++ '@context': 'https://schema.org', '@type': 'Product', name: pv.product.name, url: abs(productPath(pv.product)),
++ description: pv.product.description || undefined,
++ brand: pv.product.brand ? { '@type': 'Brand', name: pv.product.brand } : undefined,
++ gtin: (pv.aliases.find((a) => a.kind === 'gtin') || {}).value,
++ mpn: (pv.aliases.find((a) => a.kind === 'mpn') || {}).value,
++ offers: pv.offers.filter((v) => v.root.status === 'active').map(offerLd),
++ });
++ }
++
++ return { abs, offerPath, productPath, storePath, freshness, decideOffer, decideProduct, offerView, offerDto, observationDto, offerLd, offerJsonLd, productView, productJsonLd };
++}
++
++module.exports = { createPublication };
ddiff --git a/server/domain/reads.js b/server/domain/reads.js
nnew file mode 100644
iindex 0000000..008629e
---- /dev/null
++++ b/server/domain/reads.js
@@@ -0,0 +1,92 @@
++'use strict';
++
++/**
++ * Read-side queries over offers and their merge groups.
++ *
++ * Merging never moves rows. Offer B merged into A only sets B.merged_into = A; A's group is A plus
++ * every offer whose merged_into chain ends at A. Everything shown for A — observations, sources,
++ * votes — is read across the group, and unmerging (clearing the pointer) gives B back exactly what
++ * it had. A vote a person cast on both A and B counts once: their most recent row in the group.
++ */
++const { ApiError } = require('./util');
++
++function createReads({ store }) {
++ const { db } = store;
++ const q = {
++ byId: db.prepare('SELECT * FROM deal_offers WHERE id = ?'),
++ bySlug: db.prepare('SELECT * FROM deal_offers WHERE slug = ?'),
++ group: db.prepare(`WITH RECURSIVE g(id, depth) AS (SELECT ?, 0 UNION SELECT o.id, g.depth + 1 FROM deal_offers o JOIN g ON o.merged_into = g.id WHERE g.depth < 50)
++ SELECT id FROM g`),
++ observations: db.prepare(`SELECT o.*, o.rowid AS seq, s.kind AS source_kind, s.ref_service, s.ref_type, s.ref_id, s.ref_revision, s.source_key,
++ s.url AS source_url, s.label AS source_label, s.license_note, s.removed_at AS source_removed_at
++ FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
++ WHERE o.offer_id IN (SELECT value FROM json_each(?))
++ ORDER BY o.observed_at DESC, o.rowid DESC`),
++ latest: db.prepare(`SELECT o.*, s.kind AS source_kind, s.ref_service, s.ref_id, s.source_key, s.url AS source_url, s.label AS source_label
++ FROM deal_price_observations o JOIN deal_offer_sources s ON s.id = o.source_id
++ WHERE o.offer_id IN (SELECT value FROM json_each(?)) AND s.removed_at IS NULL
++ ORDER BY o.observed_at DESC, o.rowid DESC LIMIT 1`),
++ sources: db.prepare(`SELECT * FROM deal_offer_sources WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, rowid`),
++ votes: db.prepare(`SELECT offer_id, subject, value, weight, ip_hash, updated_at, rowid AS seq FROM deal_votes
++ WHERE offer_id IN (SELECT value FROM json_each(?)) ORDER BY updated_at, rowid`),
++ members: db.prepare('SELECT * FROM deal_offers WHERE merged_into = ? ORDER BY merged_at'),
++ lastSnapshot: db.prepare('SELECT * FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id DESC LIMIT 1'),
++ };
++
++ const get = (id) => (id ? q.byId.get(String(id)) : null);
++
++ /** By id (dof_…) or slug. */
++ function find(idOrSlug) {
++ const s = String(idOrSlug || '');
++ return s.startsWith('dof_') ? get(s) : q.bySlug.get(s) || null;
++ }
++
++ function mustFind(idOrSlug) {
++ const o = find(idOrSlug);
++ if (!o) throw new ApiError(404, 'offer.not_found', 'No such offer');
++ return o;
++ }
++
++ /** The canonical offer a (possibly merged) offer resolves to. */
++ function root(offer) {
++ let cur = offer;
++ for (let i = 0; cur && cur.merged_into && i < 50; i++) cur = get(cur.merged_into);
++ return cur;
++ }
++
++ const groupIds = (rootId) => q.group.all(rootId).map((r) => r.id);
++ const J = (list) => JSON.stringify(list);
++
++ /** Each subject's effective vote in a group: their most recent row (0 = removed). */
++ function effectiveVotes(ids) {
++ const bySubject = new Map();
++ for (const v of q.votes.all(J(ids))) bySubject.set(v.subject, v);
++ return bySubject;
++ }
++
++ function tally(ids) {
++ let up = 0, down = 0, upW = 0, downW = 0;
++ for (const v of effectiveVotes(ids).values()) {
++ if (v.value === 1) { up++; upW += v.weight; } else if (v.value === -1) { down++; downW += v.weight; }
++ }
++ const r4 = (x) => Math.round(x * 10000) / 10000;
++ return { up, down, upWeight: r4(upW), downWeight: r4(downW) };
++ }
++
++ return {
++ get,
++ find,
++ mustFind,
++ root,
++ groupIds,
++ observations: (ids) => q.observations.all(J(ids)),
++ latestObservation: (ids) => q.latest.get(J(ids)) || null,
++ sources: (ids) => q.sources.all(J(ids)),
++ effectiveVotes,
++ tally,
++ mergedMembers: (id) => q.members.all(id),
++ lastSnapshot: (id) => q.lastSnapshot.get(id) || null,
++ };
++}
++
++module.exports = { createReads };
ddiff --git a/server/domain/util.js b/server/domain/util.js
nnew file mode 100644
iindex 0000000..9755161
---- /dev/null
++++ b/server/domain/util.js
@@@ -0,0 +1,138 @@
++'use strict';
++
++/**
++ * Small pure helpers: ids, slugs, URL normalisation, stated-value parsing. Nothing here guesses:
++ * a value that is missing or malformed is null (or a 422), never a default.
++ */
++const { ids } = require('openvibe-contracts');
++
++/** A refusal with a stable problem code (e.g. 404 'offer.not_found'). */
++class ApiError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.name = 'ApiError';
++ this.status = status;
++ this.code = code;
++ this.extra = extra || null;
++ }
++}
++
++const newId = (prefix) => `${prefix}_${ids.ulid()}`;
++
++const CONDITIONS = ['new', 'used', 'refurbished', 'damaged'];
++const AVAILABILITY = ['in_stock', 'out_of_stock', 'preorder', 'discontinued', 'limited', 'sold_out', 'online_only', 'in_store_only'];
++const AVAILABILITY_LABEL = {
++ in_stock: 'In stock', out_of_stock: 'Out of stock', preorder: 'Pre-order', discontinued: 'Discontinued',
++ limited: 'Limited availability', sold_out: 'Sold out', online_only: 'Online only', in_store_only: 'In store only',
++};
++/** schema.org terms (as OpenVibe.Sources passes them through) → our enums; anything else → null. */
++const SCHEMA_AVAILABILITY = {
++ InStock: 'in_stock', OutOfStock: 'out_of_stock', PreOrder: 'preorder', PreSale: 'preorder', Discontinued: 'discontinued',
++ LimitedAvailability: 'limited', SoldOut: 'sold_out', OnlineOnly: 'online_only', InStoreOnly: 'in_store_only',
++};
++const SCHEMA_CONDITION = { NewCondition: 'new', UsedCondition: 'used', RefurbishedCondition: 'refurbished', DamagedCondition: 'damaged' };
++
++// Tracking and affiliate parameters are not part of an offer's identity.
++const TRACKING = /^(utm_[a-z0-9_]+|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|yclid|_hsenc|_hsmi|ref|ref_|tag|aff|affid|affiliate|affiliate_id|irclickid|clickid)$/i;
++
++/** http(s) URL with the fragment and tracking parameters removed, host lower-cased, params sorted. */
++function normalizeUrl(input) {
++ if (input == null || input === '') return null;
++ let u;
++ try { u = new URL(String(input).trim()); } catch { return null; }
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
++ if (u.username || u.password) return null;
++ u.hash = '';
++ u.hostname = u.hostname.toLowerCase();
++ const keep = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
++ u.search = '';
++ for (const [k, v] of keep) u.searchParams.append(k, v);
++ let s = u.toString();
++ if (u.pathname.length > 1 && s.endsWith('/') && !u.search) s = s.slice(0, -1);
++ return s.length <= 2048 ? s : null;
++}
++
++/** The identity used to find duplicates: normalised URL without scheme and without "www.". */
++function urlKey(normalized) {
++ if (!normalized) return null;
++ return normalized.replace(/^https?:\/\//, '').replace(/^www\./, '');
++}
++
++function domainOf(normalized) {
++ try { return new URL(normalized).hostname.replace(/^www\./, ''); } catch { return null; }
++}
++
++function slugify(text, suffixFrom) {
++ const base = String(text || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
++ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/g, '') || 'deal';
++ const suffix = String(suffixFrom || '').replace(/^[a-z]+_/, '').slice(-6).toLowerCase();
++ return suffix ? `${base}-${suffix}` : base;
++}
++
++/**
++ * A decimal amount as stated: '12', '12.5', '1,299.99'. The digits stay as the person or source
++ * gave them (no rounding, no added decimals); only thousands commas and surrounding space go.
++ * → { text, num } | null (blank) ; throws ApiError 422 on anything else (negative, words, symbols).
++ */
++function parseAmount(v, field) {
++ if (v == null || v === '') return null;
++ if (typeof v === 'number') {
++ if (!Number.isFinite(v) || v < 0) throw new ApiError(422, 'request.invalid', `${field} must be a non-negative number`);
++ return { text: String(v), num: v };
++ }
++ const s = String(v).trim().replace(/,(?=\d{3}(\D|$))/g, '');
++ if (s === '') return null;
++ if (!/^\d{1,9}(\.\d{1,4})?$/.test(s)) throw new ApiError(422, 'request.invalid', `${field} must be a plain amount like 19.99 (no currency symbols)`);
++ return { text: s.replace(/^0+(?=\d)/, ''), num: Number(s) };
++}
++
++function parseCurrency(v, field = 'currency') {
++ if (v == null || v === '') return null;
++ const s = String(v).trim().toUpperCase();
++ if (!/^[A-Z]{3}$/.test(s)) throw new ApiError(422, 'request.invalid', `${field} must be an ISO 4217 code such as USD or EUR`);
++ return s;
++}
++
++function parseEnum(v, allowed, field) {
++ if (v == null || v === '') return null;
++ const s = String(v).trim().toLowerCase();
++ if (!allowed.includes(s)) throw new ApiError(422, 'request.invalid', `${field} must be one of ${allowed.join(', ')}`);
++ return s;
++}
++
++/** An instant stated by a person or source (ISO string or epoch ms) → ms; blank → null. */
++function parseInstant(v, field) {
++ if (v == null || v === '') return null;
++ const t = typeof v === 'number' ? v : Date.parse(String(v));
++ if (!Number.isFinite(t)) throw new ApiError(422, 'request.invalid', `${field} must be a date`);
++ return t;
++}
++
++function text(v, { field, min = 0, max = 1000, required = false } = {}) {
++ const s = v == null ? '' : String(v).replace(/\s+/g, ' ').trim();
++ if (!s) {
++ if (required) throw new ApiError(422, 'request.invalid', `${field} is required`);
++ return null;
++ }
++ if (s.length < min) throw new ApiError(422, 'request.invalid', `${field} must be at least ${min} characters`);
++ return s.slice(0, max);
++}
++
++/** Multi-line text (descriptions): keeps line breaks, trims, caps. */
++function longText(v, max = 4000) {
++ if (v == null) return null;
++ const s = String(v).replace(/\r\n?/g, '\n').replace(/[ \t]+\n/g, '\n').trim();
++ return s ? s.slice(0, max) : null;
++}
++
++/** Lower-case word tokens (letters and digits of any script). */
++function tokens(s) {
++ return String(s || '').toLowerCase().normalize('NFKC').split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0);
++}
++
++const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
++
++module.exports = {
++ ApiError, newId, CONDITIONS, AVAILABILITY, AVAILABILITY_LABEL, SCHEMA_AVAILABILITY, SCHEMA_CONDITION,
++ normalizeUrl, urlKey, domainOf, slugify, parseAmount, parseCurrency, parseEnum, parseInstant, text, longText, tokens, iso,
++};
ddiff --git a/server/domain/votes.js b/server/domain/votes.js
nnew file mode 100644
iindex 0000000..f591f93
---- /dev/null
++++ b/server/domain/votes.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++/**
++ * Votes — server-authoritative, one row per (offer, subject), +1 / −1, and 0 for "removed" (the row
++ * is kept so a removal is history too). A person's effective vote on a merged group is their most
++ * recent row in it, so a voter who voted on two offers that were later merged counts once.
++ *
++ * Abuse controls (all server-side, all configurable, see README):
++ * rate limits per subject per hour, per IP hash per hour, per (subject, offer) changes per hour
++ * own offers a person cannot vote on a deal they submitted (any listing in the group)
++ * new accounts weight DEALS_NEW_ACCOUNT_WEIGHT (default 0.25) until there is evidence the account
++ * is at least DEALS_NEW_ACCOUNT_DAYS old: the time embedded in its usr_ ULID (an
++ * upper bound on the account's age, since subjects were minted when identity moved
++ * to Network) or when Deals first saw it sign in, whichever is older. The weight is
++ * stored with the vote, so hotness is recomputable from rows.
++ * vote rings flags for moderators (never automatic removals):
++ * shared_ip ≥ DEALS_RING_IP_MIN distinct voters on one offer from one IP hash
++ * covote two voters who voted the same way on ≥ DEALS_RING_COVOTE_MIN offers
++ * within DEALS_RING_WINDOW_MIN minutes of each other each time
++ */
++const crypto = require('crypto');
++const { ApiError } = require('./util');
++
++const DAY = 24 * 3600 * 1000;
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++
++/** The millisecond time embedded in a usr_<ULID> subject, or null. */
++function subjectTime(subject) {
++ const m = /^usr_([0-9A-HJKMNP-TV-Z]{26})$/.exec(String(subject || ''));
++ if (!m) return null;
++ let t = 0;
++ for (const c of m[1].slice(0, 10)) t = t * 32 + CROCKFORD.indexOf(c);
++ return t;
++}
++
++function createVotes({ config, store, reads, hotness, limits, flags, people, outbox }) {
++ const { db } = store;
++ const q = {
++ upsert: db.prepare(`INSERT INTO deal_votes (offer_id, subject, value, weight, ip_hash, via, created_at, updated_at)
++ VALUES (@offer_id, @subject, @value, @weight, @ip_hash, @via, @now, @at)
++ ON CONFLICT (offer_id, subject) DO UPDATE SET value = excluded.value, weight = excluded.weight,
++ ip_hash = COALESCE(excluded.ip_hash, deal_votes.ip_hash), via = excluded.via, updated_at = excluded.updated_at`),
++ lastInGroup: db.prepare('SELECT MAX(updated_at) AS t FROM deal_votes WHERE subject = ? AND offer_id IN (SELECT value FROM json_each(?))'),
++ sameIp: db.prepare(`SELECT DISTINCT subject FROM deal_votes WHERE ip_hash = ? AND value <> 0 AND offer_id IN (SELECT value FROM json_each(?)) ORDER BY subject`),
++ covote: db.prepare(`SELECT b.subject AS other, COUNT(DISTINCT a.offer_id) AS n
++ FROM deal_votes a JOIN deal_votes b
++ ON b.offer_id = a.offer_id AND b.subject <> a.subject AND b.value = a.value AND a.value <> 0
++ AND ABS(a.updated_at - b.updated_at) <= @win
++ WHERE a.subject = @s GROUP BY b.subject HAVING n >= @min ORDER BY b.subject`),
++ };
++
++ function weightFor(subject, now = store.now()) {
++ const minted = subjectTime(subject);
++ const seen = people.firstSeen(subject);
++ const evidence = Math.max(minted != null ? now - minted : 0, seen != null ? now - seen : 0);
++ return evidence >= config.abuse.newAccountDays * DAY ? 1 : config.abuse.newAccountWeight;
++ }
++
++ function detectRings(root, ids, subject, ipHash) {
++ const raised = [];
++ if (ipHash) {
++ const subjects = q.sameIp.all(ipHash, JSON.stringify(ids)).map((r) => r.subject);
++ if (subjects.length >= config.abuse.ringIpMin) {
++ raised.push(flags.raise({
++ kind: 'vote_ring', offerId: root.id, key: `ring:ip:${root.id}:${ipHash}`, reason: 'shared_ip',
++ details: { signal: 'shared_ip', ip_hash: ipHash, voters: subjects },
++ }));
++ }
++ }
++ const partners = q.covote.all({ s: subject, win: config.abuse.ringWindowMs, min: config.abuse.ringCovoteMin });
++ if (partners.length) {
++ const voters = [subject, ...partners.map((p) => p.other)].sort();
++ const key = `ring:covote:${crypto.createHash('sha256').update(voters.join(',')).digest('hex').slice(0, 20)}`;
++ raised.push(flags.raise({
++ kind: 'vote_ring', offerId: root.id, key, reason: 'covote',
++ details: { signal: 'covote', voters, shared_offers: Object.fromEntries(partners.map((p) => [p.other, p.n])), window_minutes: config.abuse.ringWindowMs / 60000 },
++ }));
++ }
++ return raised;
++ }
++
++ /** value: 1 | -1 (set) or 0 (remove). */
++ function apply(viewer, idOrSlug, value, { ip = null, traceparent } = {}) {
++ const subject = viewer && viewer.subject;
++ if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to vote');
++ if (![1, -1, 0].includes(value)) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
++ const listing = reads.mustFind(idOrSlug);
++ const root = reads.root(listing);
++ if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed by moderators');
++ const ids = reads.groupIds(root.id);
++ if (ids.some((id) => { const o = reads.get(id); return o && o.submitted_by === subject; })) {
++ throw new ApiError(403, 'vote.own_offer', 'You cannot vote on a deal you posted');
++ }
++ const ipHash = limits.ipHash(ip);
++ return store.tx(() => {
++ const current = reads.effectiveVotes(ids).get(subject);
++ const previous = current ? current.value : 0;
++ if (previous === value) return { changed: false, value, previous, tally: reads.tally(ids), root };
++ limits.check('vote', subject, config.abuse.voteSubjectPerHour, 3600 * 1000, 'vote.rate_limited');
++ if (ipHash) limits.check('vote_ip', ipHash, config.abuse.voteIpPerHour, 3600 * 1000, 'vote.rate_limited');
++ limits.check('vote_offer', `${subject}:${root.id}`, config.abuse.voteOfferChangesPerHour, 3600 * 1000, 'vote.rate_limited');
++ const now = store.now();
++ const last = q.lastInGroup.get(subject, JSON.stringify(ids)).t;
++ const weight = weightFor(subject, now);
++ // updated_at strictly after the person's previous row in the group: "most recent" is exact.
++ q.upsert.run({ offer_id: root.id, subject, value, weight, ip_hash: ipHash, via: viewer.kind === 'service' ? 'service' : 'user', now, at: last != null && last >= now ? last + 1 : now });
++ limits.hit('vote', subject);
++ if (ipHash) limits.hit('vote_ip', ipHash);
++ limits.hit('vote_offer', `${subject}:${root.id}`);
++ const snap = hotness.snapshot(root.id, 'vote', now);
++ const tally = reads.tally(ids);
++ outbox.emit({
++ event_type: 'deals.vote.changed',
++ version: 1,
++ source: 'deals',
++ actor: { type: 'user', id: subject },
++ subject: { type: 'offer', id: root.id },
++ visibility: 'internal',
++ payload: {
++ offer_id: root.id, listing_id: listing.id, value, previous, weight,
++ votes: { up: tally.up, down: tally.down, up_weight: tally.upWeight, down_weight: tally.downWeight },
++ hotness: { formula: snap.formula, hot: snap.hot, score: snap.score },
++ },
++ }, { traceparent });
++ const rings = value !== 0 ? detectRings(root, ids, subject, ipHash) : [];
++ return { changed: true, value, previous, weight, tally, hotness: snap, rings, root };
++ });
++ }
++
++ function mine(subject, root) {
++ if (!subject || !root) return 0;
++ const v = reads.effectiveVotes(reads.groupIds(root.id)).get(subject);
++ return v ? v.value : 0;
++ }
++
++ return {
++ set: (viewer, id, value, opts) => apply(viewer, id, Number(value), opts),
++ remove: (viewer, id, opts) => apply(viewer, id, 0, opts),
++ mine,
++ weightFor,
++ };
++}
++
++module.exports = { createVotes, subjectTime };
ddiff --git a/server/domain/watches.js b/server/domain/watches.js
nnew file mode 100644
iindex 0000000..0b40713
---- /dev/null
++++ b/server/domain/watches.js
@@@ -0,0 +1,147 @@
++'use strict';
++
++/**
++ * Watches and saved searches.
++ *
++ * keyword every word of `query` appears in the offer (title, description, product, store)
++ * product the offer is for this product
++ * price_below product or keyword, AND the observation states a price in `currency` below
++ * `max_price`. An observation without a stated price (or in another currency) never
++ * matches: an unknown price is not a low price.
++ * search a saved search (notify = 0): listed on /watches, never notifies
++ *
++ * Matching runs inside the transaction that records an observation. The notification key is
++ * (watch_id, observation_id) — the primary key of watch_notifications — so replaying an import,
++ * re-running the matcher or retrying a request can never produce a second deals.watch.matched for
++ * the same observation. On top of that, a keyword/product watch notifies once per offer, and a
++ * price-below watch again for the same offer only when the price is lower than the last one it
++ * notified about. Observations that are already stale when recorded, offers that are not active,
++ * and a person's own submissions/observations do not notify.
++ *
++ * Deals never emails: deals.watch.matched is an internal event for OpenVibe.Network's notification
++ * consumer (future work).
++ */
++const { ApiError, newId, text, tokens, parseAmount, parseCurrency, iso } = require('./util');
++
++const KINDS = ['keyword', 'product', 'price_below', 'search'];
++
++function createWatches({ config, store, reads, catalog, publication, outbox }) {
++ const { db } = store;
++ const q = {
++ get: db.prepare('SELECT * FROM deal_watches WHERE id = ?'),
++ listFor: db.prepare('SELECT * FROM deal_watches WHERE subject = ? AND deleted_at IS NULL ORDER BY created_at DESC'),
++ countFor: db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ? AND deleted_at IS NULL'),
++ insert: db.prepare(`INSERT INTO deal_watches (id, subject, kind, query, product_id, max_price, max_price_num, currency, notify, label, created_at)
++ VALUES (@id, @subject, @kind, @query, @product_id, @max_price, @max_price_num, @currency, @notify, @label, @now)`),
++ remove: db.prepare('UPDATE deal_watches SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),
++ active: db.prepare("SELECT * FROM deal_watches WHERE notify = 1 AND deleted_at IS NULL AND kind <> 'search'"),
++ claim: db.prepare(`INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, price_num, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)`),
++ setEvent: db.prepare('UPDATE watch_notifications SET event_id = ? WHERE watch_id = ? AND observation_id = ?'),
++ priorForGroup: db.prepare(`SELECT price_num, currency FROM watch_notifications
++ WHERE watch_id = ? AND offer_id IN (SELECT value FROM json_each(?)) AND event_id IS NOT NULL`),
++ notifications: db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE watch_id = ? AND event_id IS NOT NULL'),
++ };
++
++ function create(subject, input) {
++ if (!subject) throw new ApiError(401, 'auth.required', 'Sign in to save watches');
++ const kind = String(input.kind || '');
++ if (!KINDS.includes(kind)) throw new ApiError(422, 'request.invalid', `kind must be one of ${KINDS.join(', ')}`);
++ if (q.countFor.get(subject).n >= config.abuse.watchesPerSubject) throw new ApiError(429, 'watch.limit', `At most ${config.abuse.watchesPerSubject} watches and saved searches`);
++ const query = text(input.query, { field: 'query', max: 200 });
++ let product = null;
++ if (input.product) {
++ product = catalog.productBySlug(input.product) || catalog.product(input.product);
++ if (!product) throw new ApiError(404, 'product.not_found', 'No such product');
++ }
++ const row = { id: newId('dwt'), subject, kind, query: null, product_id: null, max_price: null, max_price_num: null, currency: null, notify: kind === 'search' ? 0 : 1, label: text(input.label, { max: 120 }), now: store.now() };
++ if (kind === 'keyword' || kind === 'search') {
++ if (!query || tokens(query).length === 0) throw new ApiError(422, 'request.invalid', 'query needs at least one word');
++ row.query = query;
++ } else if (kind === 'product') {
++ if (!product) throw new ApiError(422, 'request.invalid', 'product is required');
++ row.product_id = product.id;
++ } else {
++ const amount = parseAmount(input.max_price, 'max_price');
++ const currency = parseCurrency(input.currency);
++ if (!amount || !currency) throw new ApiError(422, 'request.invalid', 'max_price and currency are required');
++ if (!product && !(query && tokens(query).length)) throw new ApiError(422, 'request.invalid', 'a price watch needs a product or keywords');
++ Object.assign(row, { product_id: product ? product.id : null, query: product ? null : query, max_price: amount.text, max_price_num: amount.num, currency });
++ }
++ q.insert.run(row);
++ return q.get.get(row.id);
++ }
++
++ function remove(subject, id, { staff = false } = {}) {
++ const w = q.get.get(String(id || ''));
++ if (!w || w.deleted_at || (w.subject !== subject && !staff)) throw new ApiError(404, 'watch.not_found', 'No such watch');
++ q.remove.run(store.now(), w.id);
++ return { id: w.id, deleted: true };
++ }
++
++ function offerText(v) {
++ return new Set(tokens([v.root.title, v.root.description, v.product && v.product.name, v.product && v.product.brand, v.store && v.store.domain, v.store && v.store.name].filter(Boolean).join(' ')));
++ }
++
++ function matches(w, v, obs, words) {
++ const productOk = w.product_id ? v.root.product_id === w.product_id : true;
++ const keywordOk = w.query ? tokens(w.query).every((t) => words.has(t)) : true;
++ if (w.kind === 'keyword') return keywordOk;
++ if (w.kind === 'product') return productOk;
++ if (w.kind === 'price_below') {
++ if (obs.price_num == null || obs.currency !== w.currency) return false;
++ return productOk && keywordOk && obs.price_num < w.max_price_num;
++ }
++ return false;
++ }
++
++ /** Inside the observation's transaction. Returns the envelopes emitted. */
++ function onObservation(obs) {
++ const offer = reads.get(obs.offer_id);
++ const v = publication.offerView(offer);
++ if (v.root.status !== 'active') return [];
++ if (store.now() - obs.observed_at > config.freshnessMs) return [];
++ const words = offerText(v);
++ const out = [];
++ for (const w of q.active.all()) {
++ if (obs.observed_by && obs.observed_by === w.subject) continue;
++ if (v.root.submitted_by && v.root.submitted_by === w.subject && obs.origin === 'community') continue;
++ if (!matches(w, v, obs, words)) continue;
++ const prior = q.priorForGroup.all(w.id, JSON.stringify(v.ids));
++ if (w.kind !== 'price_below' && prior.length) continue;
++ if (w.kind === 'price_below' && prior.some((p) => p.currency === obs.currency && p.price_num != null && p.price_num <= obs.price_num)) continue;
++ // The uniqueness: at most one row, hence one event, per (watch, observation).
++ if (q.claim.run(w.id, obs.id, v.root.id, obs.price_num, obs.currency, store.now()).changes === 0) continue;
++ const env = outbox.emit({
++ event_type: 'deals.watch.matched',
++ version: 1,
++ source: 'deals',
++ actor: { type: 'service', id: 'deals' },
++ subject: { type: 'watch', id: w.id },
++ visibility: 'internal',
++ payload: {
++ watch_id: w.id, recipient: w.subject, kind: w.kind, query: w.query, product_id: w.product_id,
++ max_price: w.max_price, currency: w.currency,
++ offer_id: v.root.id, offer_url: publication.abs(publication.offerPath(v.root)), title: v.root.title,
++ observation: { id: obs.id, observed_at: iso(obs.observed_at), price: obs.price, currency: obs.currency, availability: obs.availability },
++ },
++ });
++ q.setEvent.run(env.event_id, w.id, obs.id);
++ out.push(env);
++ }
++ return out;
++ }
++
++ function dto(w) {
++ const product = w.product_id ? catalog.product(w.product_id) : null;
++ return {
++ id: w.id, kind: w.kind, query: w.query, label: w.label, notify: Boolean(w.notify),
++ product: product ? { id: product.id, slug: product.slug, name: product.name, url: publication.abs(publication.productPath(product)) } : null,
++ max_price: w.max_price, currency: w.currency, created_at: iso(w.created_at),
++ notifications: q.notifications.get(w.id).n,
++ };
++ }
++
++ return { create, remove, onObservation, list: (subject) => q.listFor.all(subject), get: (id) => q.get.get(id), dto, KINDS };
++}
++
++module.exports = { createWatches, KINDS };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..86c4075
---- /dev/null
++++ b/server/events/outbox.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++/**
++ * Deals → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
++ *
++ * deals.offer.created|updated|expired offer lifecycle (payload: canonical URL, status, the latest
++ * observation with its observed_at and freshness)
++ * deals.vote.changed a person's effective vote changed (payload: counts, not identities)
++ * deals.watch.matched a watch matched a new observation (internal; Network's
++ * notification consumer is future work; Deals never emails)
++ * deals.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
++ * indexEvent), consumed by Search's '*.index_document.*'
++ * subscription
++ *
++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
++ * if its change committed. The relay publishes with Deals' service token (events.event.publish,
++ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise
++ * rows wait in event_outbox and /api/ready reports the relay as off.
++ */
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++
++function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
++ const enabled = Boolean(config.events.url && config.oauth.clientSecret);
++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
++ if (fetchImpl) clientOpts.fetch = fetchImpl;
++ if (enabled) {
++ clientOpts.tokenProvider = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ } else {
++ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };
++ }
++ const events = createEventsClient(createClient(clientOpts), { source: 'deals' });
++ let lastError = null;
++ const outbox = createOutbox(db, {
++ events,
++ intervalMs: config.events.intervalMs,
++ now,
++ onError: (err) => {
++ const msg = err && err.message;
++ if (msg !== lastError) log.warn('[Deals] event publish failed (will retry):', msg);
++ lastError = msg;
++ },
++ });
++ outbox.ensureSchema();
++
++ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
++ function emit(envelope, { traceparent } = {}) {
++ return outbox.enqueue(envelope, { traceparent });
++ }
++
++ return {
++ emit,
++ outbox,
++ enabled,
++ start() { if (enabled) outbox.start(); },
++ stop: () => outbox.stop(),
++ kick() { if (enabled) outbox.kick(); },
++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
++ };
++}
++
++module.exports = { createDealsOutbox };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..47d486d
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,195 @@
++'use strict';
++
++/**
++ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.deals, one
++ * capability per route, acting for the person in X-OV-Subject) and for browsers or apps with a
++ * Network user JWT (judged by the domain: signed in, submitter, moderator). Errors are problem+json.
++ *
++ * GET /offers?sort=hot|new&page= public
++ * GET /offers/:id public (id or slug; a merged id answers with its canonical offer)
++ * GET /offers/:id/hotness public: the latest snapshot, its inputs and a recomputation
++ * POST /offers deals.offer.submit (X-OV-Origin: ai → text held for review)
++ * PATCH /offers/:id deals.offer.update title, description, category, expires_at, product
++ * POST /offers/:id/observations deals.offer.update { price, currency, shipping, shipping_note, condition, availability, observed_at? (services) }
++ * POST /offers/:id/expire deals.offer.expire
++ * PUT /offers/:id/vote deals.vote.set { value: 1 | -1 }
++ * DELETE /offers/:id/vote deals.vote.remove
++ * POST /offers/:id/flags deals.flag.create { kind, reason, duplicate_of? }
++ * POST /offers/:id/merge deals.offer.merge { into, reason } (moderators)
++ * POST /offers/:id/unmerge deals.offer.merge { reason } (moderators)
++ * POST /offers/:id/disable|enable|review deals.offer.moderate (moderators)
++ * GET /flags?status=open deals.offer.moderate (moderators)
++ * POST /flags/:id/resolve deals.offer.moderate { status: resolved|dismissed, resolution }
++ * GET /products/:slug public
++ * POST /products/resolve deals.product.resolve { name, brand, gtin, mpn, sku, url, create? }
++ * GET /stores/:domain public
++ * GET /watches deals.watch.read the acting person's watches
++ * POST /watches deals.watch.create { kind, query, product, max_price, currency, label }
++ * DELETE /watches/:id deals.watch.delete
++ */
++const express = require('express');
++const { run, jsonBody, privateNoStore, ApiError } = require('./errors');
++const { guard } = require('../auth/viewer');
++const { iso } = require('../domain/util');
++
++function cors(origins) {
++ const allowed = new Set(origins);
++ return (req, res, next) => {
++ const origin = req.get('origin');
++ if (origin && allowed.has(origin)) {
++ res.set('Access-Control-Allow-Origin', origin);
++ res.vary('Origin');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
++ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
++ res.set('Access-Control-Max-Age', '600');
++ }
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ next();
++ };
++}
++
++function createApi(ctx) {
++ const { config, reads, catalog, publication, listings, offers, votes, watches, flags, hotness, viewers, access } = ctx;
++ const router = express.Router();
++ router.use(cors(config.apiCorsOrigins));
++ router.use(viewers.middleware());
++ router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
++
++ const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });
++ const offerOut = (offer) => ({ offer: publication.offerDto(publication.offerView(offer)) });
++ const needSubject = (req) => { if (!req.viewer.subject) throw new ApiError(req.viewer.kind === 'service' ? 400 : 401, req.viewer.kind === 'service' ? 'subject.required' : 'auth.required', req.viewer.kind === 'service' ? 'X-OV-Subject must name the person this action is for' : 'Sign in first'); };
++
++ // ── offers ──────────────────────────────────────────────
++
++ router.get('/offers', run((req) => {
++ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
++ const per = 25;
++ const rows = req.query.sort === 'new' ? listings.newest(per, (page - 1) * per) : listings.hot(per, (page - 1) * per);
++ return { sort: req.query.sort === 'new' ? 'new' : 'hot', page, total: listings.countActive(), offers: rows.map((o) => publication.offerDto(publication.offerView(o))) };
++ }));
++
++ router.get('/offers/:id', run((req) => offerOut(reads.mustFind(req.params.id))));
++
++ router.get('/offers/:id/hotness', run((req) => {
++ const root = reads.root(reads.mustFind(req.params.id));
++ const snap = reads.lastSnapshot(root.id);
++ return {
++ offer_id: root.id,
++ formula: 'hot@1: score = upWeight − downWeight; hot = round6(score / (ageHours + 2)^1.5), ageHours = (t − firstSeenAt) / 3600000',
++ snapshot: snap ? { ...snap, computed_at: iso(snap.computed_at), first_seen_at: iso(snap.first_seen_at) } : null,
++ recomputed: snap ? hotness.recompute(snap) : null,
++ };
++ }));
++
++ router.post('/offers', guard('deals.offer.submit'), jsonBody, run((req) => {
++ needSubject(req);
++ const out = offers.submit(req.viewer, req.body || {}, opts(req));
++ return offerOut(out.offer);
++ }, 201));
++
++ router.patch('/offers/:id', guard('deals.offer.update'), jsonBody, run((req) => offerOut(offers.update(req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
++
++ router.post('/offers/:id/observations', guard('deals.offer.update'), jsonBody, run((req) => {
++ needSubject(req);
++ const out = offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));
++ return { ...offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };
++ }, 201));
++
++ router.post('/offers/:id/expire', guard('deals.offer.expire'), jsonBody, run((req) => offerOut(offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));
++
++ router.put('/offers/:id/vote', guard('deals.vote.set'), jsonBody, run((req) => {
++ needSubject(req);
++ const value = Number((req.body || {}).value);
++ if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
++ const r = votes.set(req.viewer, req.params.id, value, opts(req));
++ return { offer_id: r.root.id, value: r.value, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
++ }));
++
++ router.delete('/offers/:id/vote', guard('deals.vote.remove'), run((req) => {
++ needSubject(req);
++ const r = votes.remove(req.viewer, req.params.id, opts(req));
++ return { offer_id: r.root.id, value: 0, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
++ }));
++
++ router.post('/offers/:id/flags', guard('deals.flag.create'), jsonBody, run((req) => {
++ needSubject(req);
++ const r = flags.report(req.viewer, req.params.id, req.body || {}, opts(req));
++ return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };
++ }, (out) => (out.created ? 201 : 200)));
++
++ router.post('/offers/:id/merge', guard('deals.offer.merge'), jsonBody, run((req) => {
++ const b = req.body || {};
++ const r = offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));
++ return { ...offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };
++ }));
++
++ router.post('/offers/:id/unmerge', guard('deals.offer.merge'), jsonBody, run((req) => {
++ const r = offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));
++ return { ...offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };
++ }));
++
++ for (const action of ['disable', 'enable', 'review']) {
++ router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), jsonBody, run((req) => offerOut(offers[action](req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
++ }
++
++ router.get('/flags', guard('deals.offer.moderate'), run((req) => {
++ if (!access.isModerator(req.viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
++ const status = ['open', 'resolved', 'dismissed'].includes(req.query.status) ? req.query.status : 'open';
++ return { flags: flags.list({ status }).map(flags.dto) };
++ }));
++
++ router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {
++ const r = flags.resolve(req.viewer, req.params.id, req.body || {});
++ return { flag: flags.dto(r.flag), changed: r.changed };
++ }));
++
++ // ── products and stores ─────────────────────────────────
++
++ router.get('/products/:slug', run((req) => {
++ const p = catalog.productBySlug(req.params.slug) || catalog.product(req.params.slug);
++ if (!p) throw new ApiError(404, 'product.not_found', 'No such product');
++ const pv = publication.productView(p);
++ return {
++ product: { id: p.id, slug: p.slug, name: p.name, brand: p.brand, category: p.category, url: publication.abs(publication.productPath(p)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
++ offers: pv.offers.map((v) => publication.offerDto(v)),
++ };
++ }));
++
++ router.post('/products/resolve', guard('deals.product.resolve'), jsonBody, run((req) => {
++ const b = req.body || {};
++ const actor = req.viewer.subject || req.viewer.service || null;
++ if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
++ const create = b.create !== false;
++ const r = ctx.store.tx(() => {
++ const out = catalog.resolve(b, { source: req.viewer.kind === 'service' ? 'import' : 'community', actor, create });
++ if (out.created) ctx.indexing.indexProduct(out.product);
++ return out;
++ });
++ if (!r.product) throw new ApiError(404, 'product.not_found', 'No product has these aliases');
++ return { product: { id: r.product.id, slug: r.product.slug, name: r.product.name, url: publication.abs(publication.productPath(r.product)) }, created: r.created, conflicts: r.conflicts };
++ }, (out) => (out.created ? 201 : 200)));
++
++ router.get('/stores/:domain', run((req) => {
++ const st = catalog.storeByDomain(req.params.domain);
++ if (!st) throw new ApiError(404, 'store.not_found', 'No such store');
++ return { store: { id: st.id, domain: st.domain, name: st.name, url: publication.abs(publication.storePath(st)) }, offers: listings.byStore(st.id, 50, 0).map((o) => publication.offerDto(publication.offerView(o))) };
++ }));
++
++ // ── watches ─────────────────────────────────────────────
++
++ router.get('/watches', guard('deals.watch.read'), run((req) => { needSubject(req); return { watches: watches.list(req.viewer.subject).map(watches.dto) }; }));
++
++ router.post('/watches', guard('deals.watch.create'), jsonBody, run((req) => {
++ needSubject(req);
++ return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };
++ }, 201));
++
++ router.delete('/watches/:id', guard('deals.watch.delete'), run((req) => { needSubject(req); return watches.remove(req.viewer.subject, req.params.id); }));
++
++ router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ return router;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..4dcb538
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,82 @@
++'use strict';
++
++/**
++ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):
++ *
++ * GET /robots.txt sitemap location + explicit automated-consumer policy
++ * GET /llms.txt orientation for language models
++ * GET /sitemap.xml sitemap index over the sections below
++ * GET /sitemaps/offers.xml offers the gate calls indexable RIGHT NOW (fresh observation, not
++ * expired, reviewed where review is needed); lastmod = the latest
++ * observation or edit, never "now"
++ * GET /sitemaps/products.xml product pages with at least one fresh offer
++ *
++ * Built from the database on every request, never from the viewer. Stale, expired, disabled,
++ * merged and unreviewed offers never appear.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const sharedSeo = require('openvibe-shared/seo');
++
++function createDiscoveryRoutes({ config, store, publication, listings }) {
++ const router = express.Router();
++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
++ const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
++
++ function offerEntries() {
++ return listings.indexable().map((o) => {
++ const v = publication.offerView(o);
++ return { loc: abs(publication.offerPath(o)), lastmod: v.latest ? Math.max(v.latest.observed_at, o.updated_at) : o.updated_at, decision: v.decision };
++ });
++ }
++ function productEntries() {
++ return listings.products().map((p) => {
++ const pv = publication.productView(p);
++ return { loc: abs(publication.productPath(p)), lastmod: pv.freshestObservedAt || undefined, decision: pv.decision };
++ });
++ }
++
++ router.get('/robots.txt', (_req, res) => {
++ const body = [
++ '# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read',
++ '# public deal, product and store pages, feeds and sitemaps. Prices are observations with times;',
++ '# read the "as of" time and the JSON twin (<deal URL>.json) rather than assuming a price is current.',
++ '# Forms, sign-in, moderation and the API are not for crawling. Pages decide their own',
++ '# indexability (meta robots / X-Robots-Tag): stale or expired deals are noindex. A Disallow is not a noindex.',
++ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/submit', '/watches', '/mod', '/search', '/auth/', '/api/', '/internal/'] }),
++ ].join('\n');
++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
++ });
++
++ router.get('/llms.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
++ name: 'OpenVibe.Deals',
++ summary: 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.',
++ details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'Hot deals', url: abs('/') }, { title: 'New deals', url: abs('/new') }, { title: 'Sitemap', url: abs('/sitemap.xml') },
++ ] },
++ { title: 'Feeds', links: [{ title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') }] },
++ { title: 'Data', links: [
++ { title: 'Deal JSON', url: abs('/new'), note: 'append .json to any deal URL (/d/<slug>.json)' },
++ { title: 'Product JSON', url: abs('/sitemaps/products.xml'), note: 'append .json to any product URL (/p/<slug>.json)' },
++ ] },
++ ],
++ }));
++ });
++
++ router.get('/sitemap.xml', (_req, res) => {
++ const o = offerEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod);
++ const p = productEntries().filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
++ const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
++ xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
++ });
++
++ router.get('/sitemaps/offers.xml', (_req, res) => xml(res, seo.sitemap(offerEntries()).files[0]));
++ router.get('/sitemaps/products.xml', (_req, res) => xml(res, seo.sitemap(productEntries()).files[0]));
++
++ return router;
++}
++
++module.exports = { createDiscoveryRoutes };
ddiff --git a/server/http/errors.js b/server/http/errors.js
nnew file mode 100644
iindex 0000000..87b501f
---- /dev/null
++++ b/server/http/errors.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
++ * and the small request helpers every router shares.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { ApiError } = require('../domain/util');
++
++/** Wrap a JSON handler: its return value is the body; errors become problems. */
++function run(fn, status = 200) {
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (out === undefined || res.headersSent) return;
++ res.status(typeof status === 'function' ? status(out) : status).json(out);
++ } catch (err) {
++ if (res.headersSent) return;
++ if (err instanceof ApiError) {
++ if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
++ return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
++ }
++ console.error('[Deals API]', err && err.stack ? err.stack : err);
++ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ }
++ };
++}
++
++const jsonParser = express.json({ limit: '64kb' });
++/** JSON body parser whose syntax errors are problems too. */
++function jsonBody(req, res, next) {
++ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
++}
++
++/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
++function privateNoStore(res) {
++ res.set('Cache-Control', 'private, no-store');
++ res.vary('Cookie');
++ res.vary('Authorization');
++}
++
++module.exports = { ApiError, run, jsonBody, privateNoStore };
ddiff --git a/server/http/internal.js b/server/http/internal.js
nnew file mode 100644
iindex 0000000..bcd48ba
---- /dev/null
++++ b/server/http/internal.js
@@@ -0,0 +1,49 @@
++'use strict';
++
++/**
++ * Events consumer: POST /internal/events, the endpoint of Deals' OpenVibe.Events subscription to
++ * `sources.item.*` (filter: payload.category = deals). Signed with X-OpenVibe-Signature
++ * (HMAC-SHA256 of the raw body under DEALS_EVENTS_SECRET; several comma-separated secrets allow
++ * rotation), recorded exactly once per event_id in the inbox (idempotency_receipts).
++ *
++ * Source item events carry no prices, so a delivery only wakes the importer, which reads the change
++ * feed from its cursor: a lost event delays an import, a repeated one changes nothing.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
++
++const CONSUMER = 'deals-sources';
++const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
++
++function createInternalRoutes({ config, store, importer }) {
++ const router = express.Router();
++ const inbox = createInbox(store.db, { now: store.now });
++ inbox.ensureSchema();
++
++ router.post('/internal/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
++ const secrets = config.events.webhookSecrets;
++ if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ const sig = req.get('x-openvibe-signature');
++ if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'deals.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
++ let body;
++ try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
++ const event = body && body.event;
++ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
++ return http.sendProblem(res, 400, 'deals.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
++ }
++ const r = inbox.once(CONSUMER, event.event_id, () => {
++ if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';
++ const category = event.payload && event.payload.category;
++ if (category !== 'deals') return 'ignored';
++ return 'import_scheduled';
++ });
++ if (!r.duplicate && r.result === 'import_scheduled') importer.kick();
++ res.status(200).json({ event_id: event.event_id, duplicate: Boolean(r.duplicate), outcome: r.duplicate ? null : r.result });
++ });
++
++ return router;
++}
++
++module.exports = { createInternalRoutes, CONSUMER };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..ad9b79f
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,425 @@
++'use strict';
++
++/**
++ * Public, server-rendered routes — useful without JavaScript (every action is a plain form):
++ *
++ * GET / hot deals (hot@1 snapshots) GET /new newest deals
++ * GET /d/:slug an offer: latest observation "as of", freshness, price history,
++ * sources, votes, discussion (…/:slug.json: the same facts as data)
++ * POST /d/:slug/vote value=up|down|remove POST /d/:slug/observe
++ * POST /d/:slug/flag POST /d/:slug/expire POST /d/:slug/comments
++ * GET /p/:slug a product: price comparison across offers, each with its time (.json)
++ * GET /s/:domain a store's deals
++ * GET /search?q= search (noindex)
++ * GET|POST /submit submit a deal
++ * GET|POST /watches watches and saved searches; POST /watches/:id/delete
++ * GET /mod moderators: flags (incl. vote rings), reviews, possible duplicates
++ * POST /mod/offers/:slug/(merge|unmerge|disable|enable|review|expire), /mod/flags/:id/(resolve|dismiss)
++ * GET /feed.xml /atom.xml /feed.json newest active deals
++ *
++ * Caching: only pages rendered for an ANONYMOUS visitor are `public, max-age=60`; everything else
++ * is `private, no-store`. Pages vary on Cookie and Authorization.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
++const { renderPage } = require('../render/layout');
++const views = require('../render/views');
++const { csrfToken, checkCsrf } = require('../auth/forms');
++const { ApiError, iso } = require('../domain/util');
++
++const PER_PAGE = 25;
++
++function createPages(ctx) {
++ const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers } = ctx;
++ const router = express.Router();
++ router.use(viewers.middleware({ services: false }));
++ const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath };
++ const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
++
++ // ── helpers ─────────────────────────────────────────────
++
++ function cacheHeaders(req, res, { cacheable, robots, status }) {
++ res.vary('Cookie');
++ res.vary('Authorization');
++ if (cacheable && req.viewer.kind === 'anonymous' && status === 200) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
++ else res.set('Cache-Control', 'private, no-store');
++ if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
++ }
++
++ function send(req, res, status, page, { cacheable = false } = {}) {
++ cacheHeaders(req, res, { cacheable, robots: page.decision.robots, status });
++ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
++ }
++
++ function sendJson(req, res, status, body, decision, { cacheable = true } = {}) {
++ cacheHeaders(req, res, { cacheable, robots: decision ? seo.xRobotsTag(decision) : 'noindex', status });
++ res.status(status).json(body);
++ }
++
++ /** A decision for a page that is not an offer (collections, forms, messages). */
++ function pageDecision(path, { indexable = true, empty = false, query = [] } = {}) {
++ return seo.evaluate({
++ state: indexable ? 'published' : 'draft', visibility: indexable ? 'public' : 'private',
++ canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query }), wordCount: 0, noindex: empty,
++ }, { policy: { minWords: 0 }, now: store.now() });
++ }
++
++ function messagePage(req, res, status, heading, text, action) {
++ send(req, res, status, { title: heading, decision: pageDecision(req.path, { indexable: false }), body: views.message({ heading, text, action }) });
++ }
++ const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'Hot deals' });
++
++ const pageNumber = (req) => { const n = parseInt(req.query.page, 10); return Number.isInteger(n) && n > 0 ? n : 1; };
++ const signedIn = (req) => req.viewer.kind === 'user' && Boolean(req.viewer.subject);
++ const csrf = (req) => csrfToken(config, req.viewer);
++
++ /** Form POST guard: signed in, valid form token. → true when the handler may continue. */
++ function formGuard(req, res, back) {
++ if (!signedIn(req)) { res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`); return false; }
++ if (!checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
++ messagePage(req, res, 403, 'This form expired', 'Reload the page and try again.', { href: back, label: 'Back' });
++ return false;
++ }
++ return true;
++ }
++
++ /** Run a form action; ApiErrors become a readable page, success redirects back. */
++ async function act(req, res, back, fn) {
++ if (!formGuard(req, res, back)) return;
++ try {
++ const target = await fn();
++ res.redirect(303, typeof target === 'string' ? target : back);
++ } catch (err) {
++ if (!(err instanceof ApiError)) throw err;
++ if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
++ messagePage(req, res, err.status, 'That did not work', err.message, err.extra && err.extra.existing ? { href: err.extra.existing.url, label: 'The existing deal' } : { href: back, label: 'Back' });
++ }
++ }
++
++ // ── lists ───────────────────────────────────────────────
++
++ function listPage(req, res, kind) {
++ const page = pageNumber(req);
++ const path = kind === 'hot' ? '/' : '/new';
++ const total = listings.countActive();
++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return notFound(req, res);
++ const rows = kind === 'hot' ? listings.hot(PER_PAGE, pager.offset) : listings.newest(PER_PAGE, pager.offset);
++ const list = rows.map((o) => publication.offerView(o));
++ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
++ send(req, res, 200, {
++ title: kind === 'hot' ? 'Hot deals' : 'New deals',
++ description: 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
++ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
++ canonical,
++ feeds: [{ type: 'rss', href: '/feed.xml', title: 'New deals (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New deals (Atom)' }, { type: 'json', href: '/feed.json', title: 'New deals (JSON Feed)' }],
++ prev: pager.prev ? pager.prev.href : null,
++ next: pager.next ? pager.next.href : null,
++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Deals', url: publication.abs('/'), potentialAction: { '@type': 'SearchAction', target: `${publication.abs('/search')}?q={q}`, 'query-input': 'required name=q' } }],
++ body: views.offerList({
++ heading: kind === 'hot' ? 'Hot deals' : 'New deals',
++ intro: kind === 'hot' ? 'Ranked by community votes over time (hotness formula hot@1). Every price shows when it was observed.' : 'The newest deals first. Every price shows when it was observed.',
++ views: list, pager, urls, tabs: views.tabs(kind),
++ empty: 'No deals have been posted yet. Submit the first one.',
++ }),
++ }, { cacheable: true });
++ }
++
++ router.get('/', wrap(async (req, res) => listPage(req, res, 'hot')));
++ router.get('/new', wrap(async (req, res) => listPage(req, res, 'new')));
++
++ // ── offers ──────────────────────────────────────────────
++
++ async function readComments(offer, label, req) {
++ if (!community.enabled) return { state: 'unavailable' };
++ try {
++ const threadId = await community.threadFor(offer, label, req.ov);
++ if (!threadId) return { state: 'unavailable' };
++ const data = await community.readThread(threadId, { ctx: req.ov });
++ return { state: 'ok', comments: data.comments || [] };
++ } catch (err) {
++ console.warn(`[Deals] comments for ${offer.id} unavailable: ${err.message}`);
++ return { state: 'unavailable' };
++ }
++ }
++
++ async function renderOffer(req, res, offer, { json = false } = {}) {
++ const v = publication.offerView(offer);
++ const r = v.root;
++ const dto = publication.offerDto(v);
++ if (r.status === 'disabled') {
++ if (json) return sendJson(req, res, 410, { id: r.id, slug: r.slug, status: 'disabled', reason: r.disabled_reason }, v.decision, { cacheable: false });
++ return messagePage(req, res, 410, 'This deal was removed', `Moderators removed this deal${r.disabled_reason ? `: ${r.disabled_reason}` : ''}.`, { href: '/', label: 'Hot deals' });
++ }
++ if (json) return sendJson(req, res, 200, { offer: dto }, v.decision);
++ const c = r.status === 'disabled' ? { state: 'off' } : await readComments(r, r.title, req);
++ const mergedComments = [];
++ for (const m of v.members.slice(0, 3)) {
++ const tid = community.enabled ? community.knownThread(m) : null;
++ if (!tid) continue;
++ try { const data = await community.readThread(tid, { ctx: req.ov }); mergedComments.push({ title: m.title, comments: { state: 'ok', comments: data.comments || [] } }); } catch { mergedComments.push({ title: m.title, comments: { state: 'unavailable' } }); }
++ }
++ const isMod = access.isStaff(req.viewer);
++ const canonical = publication.abs(publication.offerPath(r));
++ send(req, res, 200, {
++ title: r.title,
++ description: `${views.priceText(v.latest)}${v.latest ? ` as of ${views.when(v.latest.observed_at)}` : ''}${v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${r.description ? ` ${r.description.slice(0, 120)}` : ''}`,
++ decision: v.decision,
++ canonical,
++ type: 'website',
++ jsonLd: [publication.offerJsonLd(v), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: publication.abs(publication.storePath(v.store)) }] : []), { name: r.title, url: canonical }])],
++ body: views.offerPage({
++ v, dto, viewer: req.viewer, csrf: csrf(req), myVote: signedIn(req) ? votes.mine(req.viewer.subject, r) : 0,
++ comments: c, mergedComments, canEdit: access.canEdit(req.viewer, r), isMod,
++ log: isMod ? offers.moderationLog(r) : null, flags: isMod ? flags.forOffer(v.ids).map(flags.dto) : null, urls,
++ notice: req.query.done ? { text: { vote: 'Your vote is recorded.', observe: 'Thanks — your observation is recorded with the time you saw it.', flag: 'Thanks — moderators will look at your report.', expire: 'Marked expired.', comment: 'Comment posted.', moderated: 'Done.' }[req.query.done] || 'Done.' } : null,
++ }),
++ }, { cacheable: true });
++ }
++
++ function offerFor(req, res) {
++ const slug = String(req.params.slug || '');
++ const json = slug.endsWith('.json');
++ const offer = reads.find(json ? slug.slice(0, -5) : slug);
++ if (!offer) { notFound(req, res); return null; }
++ if (offer.merged_into) {
++ const root = reads.root(offer);
++ res.set('Cache-Control', 'public, max-age=300');
++ res.redirect(301, `${publication.offerPath(root)}${json ? '.json' : ''}`);
++ return null;
++ }
++ return { offer, json };
++ }
++
++ router.get('/d/:slug', wrap(async (req, res) => {
++ const f = offerFor(req, res);
++ if (f) await renderOffer(req, res, f.offer, { json: f.json });
++ }));
++
++ const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
++ const ip = (req) => req.ip || null;
++
++ router.post('/d/:slug/vote', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ const value = { up: 1, down: -1, remove: 0 }[String(req.body.value || '')];
++ if (value === undefined) throw new ApiError(422, 'request.invalid', 'Choose hot, cold or remove');
++ if (value === 0) votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
++ else votes.set(req.viewer, req.params.slug, value, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
++ return `${back(req)}?done=vote`;
++ })));
++
++ router.post('/d/:slug/observe', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
++ return `${back(req)}?done=observe#history`;
++ })));
++
++ router.post('/d/:slug/flag', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });
++ return `${back(req)}?done=flag`;
++ })));
++
++ router.post('/d/:slug/expire', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });
++ return `${back(req)}?done=expire`;
++ })));
++
++ router.post('/d/:slug/comments', form, wrap(async (req, res) => act(req, res, back(req), async () => {
++ const root = reads.root(reads.mustFind(req.params.slug));
++ if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
++ const message = String(req.body.message || '').trim().slice(0, 4000);
++ if (!message) return `${back(req)}#comments`;
++ if (!community.enabled) throw new ApiError(503, 'comments.unavailable', 'Comments are not available right now');
++ try {
++ const threadId = await community.threadFor(root, root.title, req.ov);
++ await community.comment(threadId, req.viewer.subject, { message }, req.ov);
++ } catch (err) {
++ throw new ApiError(err.status && err.status < 500 ? err.status : 503, 'comments.unavailable', `Community did not accept the comment: ${err.message}`);
++ }
++ return `${back(req)}?done=comment#comments`;
++ })));
++
++ // ── products and stores ─────────────────────────────────
++
++ router.get('/p/:slug', wrap(async (req, res) => {
++ const slug = String(req.params.slug || '');
++ const json = slug.endsWith('.json');
++ const product = catalog.productBySlug(json ? slug.slice(0, -5) : slug);
++ if (!product) return notFound(req, res);
++ const pv = publication.productView(product);
++ if (json) {
++ return sendJson(req, res, 200, {
++ product: { id: product.id, slug: product.slug, name: product.name, brand: product.brand, category: product.category, url: publication.abs(publication.productPath(product)), aliases: pv.aliases.map((a) => ({ kind: a.kind, value: a.value })) },
++ offers: pv.offers.map((v) => { const d = publication.offerDto(v); return { id: d.id, slug: d.slug, url: d.url, title: d.title, status: d.status, store: d.store, latest_observation: d.latest_observation, freshness: d.freshness }; }),
++ indexability: { indexable: pv.decision.indexable, robots: pv.decision.robots, reasons: pv.decision.reasons },
++ }, pv.decision);
++ }
++ const canonical = publication.abs(publication.productPath(product));
++ send(req, res, 200, {
++ title: `${product.name} — price comparison`,
++ description: `Offers for ${product.name}, each with the time its price was observed.`,
++ decision: pv.decision, canonical,
++ jsonLd: [publication.productJsonLd(pv), seo.structuredData.breadcrumbs([{ name: 'Deals', url: publication.abs('/') }, { name: product.name, url: canonical }])],
++ body: views.productPage({ pv, urls }),
++ }, { cacheable: true });
++ }));
++
++ router.get('/s/:domain', wrap(async (req, res) => {
++ const st = catalog.storeByDomain(req.params.domain);
++ if (!st) return notFound(req, res);
++ const path = publication.storePath(st);
++ const total = listings.countStore(st.id);
++ const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return notFound(req, res);
++ const list = listings.byStore(st.id, PER_PAGE, pager.offset).map((o) => publication.offerView(o));
++ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
++ send(req, res, 200, {
++ title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
++ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
++ body: views.storePage({ st, views: list, pager, urls }),
++ }, { cacheable: true });
++ }));
++
++ // ── search ──────────────────────────────────────────────
++
++ router.get('/search', wrap(async (req, res) => {
++ const q = String(req.query.q || '').slice(0, 200);
++ const page = pageNumber(req);
++ const r = q ? listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };
++ const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;
++ send(req, res, 200, {
++ title: q ? `Search: ${q}` : 'Search deals',
++ decision: pageDecision('/search', { indexable: false }),
++ body: views.searchPage({ q, results: r.rows.map((o) => publication.offerView(o)), pager, urls, csrf: csrf(req), signedIn: signedIn(req) }),
++ });
++ }));
++
++ // ── submit ──────────────────────────────────────────────
++
++ router.get('/submit', wrap(async (req, res) => {
++ if (!signedIn(req)) {
++ return send(req, res, 200, { title: 'Submit a deal', decision: pageDecision('/submit', { indexable: false }), body: views.message({ heading: 'Submit a deal', text: 'Sign in with your OpenVibe account to submit a deal.', action: { href: '/auth/login?next=%2Fsubmit', label: 'Sign in' } }) });
++ }
++ send(req, res, 200, { title: 'Submit a deal', decision: pageDecision('/submit', { indexable: false }), body: views.submitForm({ csrf: csrf(req) }) });
++ }));
++
++ router.post('/submit', form, wrap(async (req, res) => {
++ if (!formGuard(req, res, '/submit')) return;
++ const b = req.body || {};
++ const input = {
++ url: b.url, title: b.title, description: b.description, price: b.price, currency: b.currency, shipping: b.shipping,
++ shipping_note: b.shipping_note, condition: b.condition, availability: b.availability, store_name: b.store_name,
++ // datetime-local has no zone: it is read as UTC, and the form says so on the page it renders.
++ expires_at: b.expires_at ? `${b.expires_at}${/Z|[+-]\d\d:\d\d$/.test(b.expires_at) ? '' : 'Z'}` : null,
++ product: b.product_name || b.product_gtin ? { name: b.product_name, gtin: b.product_gtin } : null,
++ };
++ try {
++ const { offer } = offers.submit(req.viewer, input, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
++ res.redirect(303, publication.offerPath(offer));
++ } catch (err) {
++ if (!(err instanceof ApiError)) throw err;
++ if (err.code === 'offer.duplicate') return res.redirect(303, `${new URL(err.extra.existing.url).pathname}?duplicate=1`);
++ send(req, res, err.status, { title: 'Submit a deal', decision: pageDecision('/submit', { indexable: false }), body: views.submitForm({ csrf: csrf(req), values: b, error: err.message }) });
++ }
++ }));
++
++ // ── watches ─────────────────────────────────────────────
++
++ function watchesPage(req, res, status = 200, { error, notice } = {}) {
++ const list = watches.list(req.viewer.subject).map(watches.dto);
++ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });
++ }
++
++ router.get('/watches', wrap(async (req, res) => {
++ if (!signedIn(req)) return send(req, res, 200, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.message({ heading: 'Watches', text: 'Sign in to watch keywords, products and prices, and to save searches.', action: { href: '/auth/login?next=%2Fwatches', label: 'Sign in' } }) });
++ watchesPage(req, res);
++ }));
++
++ router.post('/watches', form, wrap(async (req, res) => {
++ if (!formGuard(req, res, '/watches')) return;
++ try {
++ const b = req.body || {};
++ watches.create(req.viewer.subject, { kind: b.kind, query: b.query, product: b.product || null, max_price: b.max_price, currency: b.currency, label: b.label });
++ res.redirect(303, '/watches');
++ } catch (err) {
++ if (!(err instanceof ApiError)) throw err;
++ watchesPage(req, res, err.status, { error: err.message });
++ }
++ }));
++
++ router.post('/watches/:id/delete', form, wrap(async (req, res) => act(req, res, '/watches', () => {
++ watches.remove(req.viewer.subject, req.params.id);
++ return '/watches';
++ })));
++
++ // ── moderation ──────────────────────────────────────────
++
++ function modGuard(req, res) {
++ if (!signedIn(req)) { res.redirect(303, '/auth/login?next=%2Fmod'); return false; }
++ if (!access.isStaff(req.viewer)) { messagePage(req, res, 403, 'Moderators only', 'This page is for OpenVibe moderators.'); return false; }
++ return true;
++ }
++
++ router.get('/mod', wrap(async (req, res) => {
++ if (!modGuard(req, res)) return;
++ const dups = listings.possibleDuplicates().map((d) => ({ a: reads.get(d.a), b: reads.get(d.b) }));
++ send(req, res, 200, {
++ title: 'Moderation', decision: pageDecision('/mod', { indexable: false }),
++ body: views.modPage({ csrf: csrf(req), flags: flags.list({ status: 'open' }).map(flags.dto), pending: listings.pendingReview(), duplicates: dups, urls }),
++ });
++ }));
++
++ router.post('/mod/offers/:slug/:action', form, wrap(async (req, res) => {
++ if (!modGuard(req, res)) return;
++ const tp = { traceparent: req.ov && req.ov.traceparent };
++ await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {
++ const b = req.body || {};
++ switch (req.params.action) {
++ case 'merge': { const out = offers.merge(req.viewer, req.params.slug, b.into, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
++ case 'unmerge': { const out = offers.unmerge(req.viewer, req.params.slug, { reason: b.reason }, tp); return `${publication.offerPath(out.offer)}?done=moderated`; }
++ case 'disable': offers.disable(req.viewer, req.params.slug, { reason: b.reason }, tp); return '/mod';
++ case 'enable': offers.enable(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
++ case 'review': offers.review(req.viewer, req.params.slug, { note: b.note }, tp); break;
++ case 'expire': offers.expire(req.viewer, req.params.slug, { reason: b.reason }, tp); break;
++ default: throw new ApiError(404, 'route.not_found', 'No such action');
++ }
++ return `/d/${encodeURIComponent(reads.root(reads.mustFind(req.params.slug)).slug)}?done=moderated`;
++ });
++ }));
++
++ router.post('/mod/flags/:id/:action', form, wrap(async (req, res) => {
++ if (!modGuard(req, res)) return;
++ await act(req, res, '/mod', () => {
++ const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
++ if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
++ flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution });
++ return '/mod';
++ });
++ }));
++
++ // ── feeds (never viewer-dependent) ──────────────────────
++
++ function feedItems() {
++ return listings.newest(50, 0).map((o) => {
++ const v = publication.offerView(o);
++ const price = `${views.priceText(v.latest)}${v.latest ? ` as of ${iso(v.latest.observed_at)}` : ''}`;
++ return {
++ id: `deals:offer:${o.id}`, url: publication.abs(publication.offerPath(o)), title: o.title,
++ summary: `${price}${v.store ? ` at ${v.store.name || v.store.domain}` : ''}.${o.description ? ` ${o.description.slice(0, 300)}` : ''}`,
++ published: o.created_at, updated: Math.max(o.updated_at, v.latest ? v.latest.observed_at : 0), decision: v.decision,
++ };
++ });
++ }
++ const channel = () => ({ title: 'OpenVibe.Deals — new deals', link: publication.abs('/new'), description: 'New deals, each with its source and the time its price was observed.', language: 'en' });
++
++ router.get('/feed.xml', (_req, res) => res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.rssFeed({ ...channel(), feedUrl: publication.abs('/feed.xml') }, feedItems())));
++ router.get('/atom.xml', (_req, res) => {
++ const items = feedItems();
++ res.type('application/atom+xml').set('Cache-Control', 'public, max-age=300').send(seo.atomFeed({ ...channel(), feedUrl: publication.abs('/atom.xml'), ...(items.length ? {} : { updated: store.now() }) }, items));
++ });
++ router.get('/feed.json', (_req, res) => res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed({ ...channel(), feedUrl: publication.abs('/feed.json') }, feedItems()))));
++
++ return { router, notFound, messagePage };
++}
++
++module.exports = { createPages };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..03a3f60
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/**
++ * OpenVibe.Deals — process entry. `node server/index.js`
++ * Listens on PORT (4840) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the
++ * client secret are set) and the worker (stated expiry, hotness, freshness re-indexing, Sources import).
++ */
++const { createApp } = require('./app');
++
++const { app, ctx } = createApp();
++const { config } = ctx;
++
++const server = app.listen(config.port, config.host, () => {
++ console.log(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
++ console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.enabled ? 'on' : 'off'}`);
++});
++server.keepAliveTimeout = 65_000;
++ctx.outbox.start();
++ctx.worker.start();
++
++function shutdown(signal) {
++ console.log(`[Deals] ${signal}: closing`);
++ ctx.worker.stop();
++ server.close(async () => {
++ try { await ctx.outbox.stop(); } catch { /* best effort */ }
++ try { ctx.store.close(); } catch { /* already closed */ }
++ process.exit(0);
++ });
++ setTimeout(() => process.exit(0), 5000).unref();
++}
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++process.on('SIGINT', () => shutdown('SIGINT'));
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..a44cec8
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
++ *
++ * db required a real query on Deals' SQLite (the nine charter tables answer)
++ * network_jwks optional the Network signing key has loaded; without it pages and feeds still
++ * serve, but nobody can sign in and service tokens are refused (503)
++ * events_relay optional the outbox relay is configured and has no rejected rows; when it is
++ * off, events wait in event_outbox (Search and subscribers lag)
++ * sources_import optional the Sources importer is configured and its last pull succeeded
++ * worker optional the last worker tick (expiry, hotness, re-indexing) succeeded
++ * abuse_keys optional DEALS_IP_HASH_SECRET is set (otherwise IP hashes reset on restart)
++ *
++ * Request metrics come from openvibe-shared/metrics in app.js.
++ */
++const { createReadiness } = require('openvibe-shared/ready');
++const { CHARTER_TABLES } = require('./db');
++
++function createDealsReadiness({ store, auth, outbox, importer, worker, limits, release = null }) {
++ const { db } = store;
++ return createReadiness({
++ service: 'deals',
++ release,
++ checks: [
++ {
++ name: 'db', required: true,
++ check: () => {
++ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
++ const missing = CHARTER_TABLES.filter((t) => !names.has(t));
++ return missing.length ? `missing ${missing.join(', ')}` : true;
++ },
++ },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ if (auth.client.publicKey) return true;
++ auth.ensureKey().catch(() => {});
++ return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
++ },
++ },
++ {
++ name: 'events_relay', required: false,
++ check: () => {
++ const s = outbox.status();
++ if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
++ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
++ return { ok: true, detail: { pending: s.pending } };
++ },
++ },
++ {
++ name: 'sources_import', required: false,
++ check: () => {
++ const s = importer.status();
++ if (!s.enabled) return 'import off (DEALS_IMPORT=off, or OV_OAUTH_CLIENT_SECRET / OV_SOURCES_INTERNAL_URL unset)';
++ if (s.last_error) return `last pull failed: ${s.last_error}`;
++ return { ok: true, detail: { cursor: s.cursor, last_run_at: s.last_run_at } };
++ },
++ },
++ {
++ name: 'worker', required: false,
++ check: () => {
++ const s = worker.status();
++ if (!s.enabled) return 'worker off (DEALS_WORKER=off): stated expiries, hotness ticks and freshness re-indexing wait';
++ if (s.last_error) return `last tick failed: ${s.last_error}`;
++ return { ok: true, detail: { last_tick_at: s.last_tick_at } };
++ },
++ },
++ {
++ name: 'abuse_keys', required: false,
++ check: () => (limits.stableIpHash ? true : 'DEALS_IP_HASH_SECRET unset: IP hashes (vote limits, ring detection) reset on every restart'),
++ },
++ ],
++ });
++}
++
++module.exports = { createDealsReadiness };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..34c8d7e
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through this and is complete without JavaScript:
++ * - <head>: title, description, canonical and robots from the indexability gate's decision
++ * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),
++ * Open Graph/Twitter, JSON-LD, feed links, the shared app icon and critical canvas
++ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a
++ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const seo = require('openvibe-publishing/seo');
++const { escapeHtml: esc } = require('openvibe-publishing/ssr');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Deals';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++/**
++ * o: title, description, decision (required), canonical, type ('website'|'article'), image,
++ * jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, prev, next, bodyClass
++ */
++function renderPage(o) {
++ if (!o.decision) throw new TypeError('renderPage needs the gate decision');
++ const head = seo.metaTags({
++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
++ description: o.description || 'Deals submitted and voted on by the community, with source, price and freshness always shown.',
++ decision: o.decision,
++ canonical: o.canonical,
++ type: o.type || 'website',
++ siteName: SITE_NAME,
++ image: o.image || undefined,
++ prev: o.prev || undefined,
++ next: o.next || undefined,
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ });
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
++ const loginNext = encodeURIComponent(o.path || '/');
++ const nav = {
++ service: 'deals',
++ apiBase: NETWORK_URL,
++ links: [
++ { label: 'Hot', href: '/' },
++ { label: 'New', href: '/new' },
++ { label: 'Submit', href: '/submit', icon: 'fa-plus' },
++ { label: 'Watches', href: '/watches', icon: 'fa-bell' },
++ ],
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${loginNext}`,
++ };
++ const account = signedIn
++ ? `${viewer.staff ? '<a href="/mod">Moderation</a> · ' : ''}<a href="/watches">Watches</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ return `<!DOCTYPE html>
++<html lang="${esc(o.lang || 'en')}">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++${head}
++${seo.feedLinks(o.feeds || [])}
++${appIcon.headTags({ site: 'deals' })}
++<link rel="stylesheet" href="${asset('css/deals.css')}">
++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
++</head>
++<body class="${esc(o.bodyClass || '')}">
++<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Hot', href: '/' }, { label: 'New', href: '/new' }, { label: 'Submit', href: '/submit' }, { label: 'Watches', href: '/watches' }] })}
++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
++<main id="main" class="page">
++${o.body || ''}
++</main>
++${chrome.footer({ service: 'deals', variant: 'full' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, assetVersion, SITE_NAME, NETWORK_URL };
ddiff --git a/server/render/views.js b/server/render/views.js
nnew file mode 100644
iindex 0000000..44dfdc0
---- /dev/null
++++ b/server/render/views.js
@@@ -0,0 +1,285 @@
++'use strict';
++
++/**
++ * Page bodies. Every value goes through openvibe-publishing/ssr's auto-escaping `html` template;
++ * only this file's own markup is raw(). Pages work without JavaScript: every action is a form.
++ *
++ * Wording rules: a price is always "as of <time>"; a stale one says it may no longer be available;
++ * an unknown one says "not stated". Nothing here calls a price "current".
++ */
++const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');
++const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/util');
++
++const pad = (n) => String(n).padStart(2, '0');
++function when(ms) {
++ if (ms == null) return '';
++ const d = new Date(ms);
++ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
++}
++function ago(ms, now) {
++ const s = Math.max(0, Math.round((now - ms) / 1000));
++ if (s < 90) return 'just now';
++ const m = Math.round(s / 60);
++ if (m < 90) return `${m} minutes ago`;
++ const h = Math.round(m / 60);
++ if (h < 36) return `${h} hours ago`;
++ return `${Math.round(h / 24)} days ago`;
++}
++/** Only http(s) links are ever rendered as links (source URLs come from other services). */
++const safeHref = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
++const timeEl = (ms) => (ms == null ? '' : html`<time datetime="${new Date(ms).toISOString()}">${when(ms)}</time>`);
++
++function priceText(o) {
++ if (!o || o.price == null) return 'Price not stated';
++ return o.currency ? `${o.price} ${o.currency}` : `${o.price} (currency not stated)`;
++}
++function shippingText(o) {
++ if (!o) return '';
++ if (o.shipping != null) return `+ ${o.shipping} ${o.currency || ''} shipping`.trim();
++ return o.shipping_note || '';
++}
++
++function freshnessBadge(f, now) {
++ if (f.state === 'fresh') return html`<span class="badge fresh" data-freshness="fresh">Fresh · observed ${ago(Date.parse(f.observed_at), now)}</span>`;
++ if (f.state === 'stale') return html`<span class="badge stale" data-freshness="stale">Stale · last observed ${ago(Date.parse(f.observed_at), now)} — this may no longer be available</span>`;
++ return html`<span class="badge stale" data-freshness="unobserved">No observation</span>`;
++}
++
++function statusBadge(root) {
++ if (root.status === 'expired') return html`<span class="badge expired">Expired</span>`;
++ if (root.status === 'disabled') return html`<span class="badge expired">Removed</span>`;
++ return '';
++}
++
++const csrfField = (csrf) => html`<input type="hidden" name="csrf" value="${csrf}">`;
++
++/** The one-line "latest observation" block used on cards and pages. */
++function latestLine(v) {
++ const o = v.latest;
++ return html`<p class="price-line"><strong class="price" data-price="${o && o.price != null ? o.price : ''}">${priceText(o)}</strong>
++${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : ''}
++${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.availability]}</span>` : ''}
++${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;
++}
++
++function offerCard(v, urls) {
++ const r = v.root;
++ const score = v.tally.up - v.tally.down;
++ return html`<li class="deal-item">
++<div class="score" title="${v.tally.up} up, ${v.tally.down} down">${score > 0 ? `+${score}` : score}</div>
++<div class="deal-body">
++<h2 class="deal-title"><a href="${urls.offerPath(r)}">${r.title}</a> ${statusBadge(r)}</h2>
++${latestLine(v)}
++<p class="meta">${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a> · ` : ''}${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a> · ` : ''}posted ${timeEl(r.created_at)}${r.origin === 'import' ? ' · imported from a source' : ''}</p>
++</div></li>`;
++}
++
++function offerList({ heading, intro, views, pager, urls, tabs, empty }) {
++ return html`${tabs ? raw(tabs) : ''}
++<h1>${heading}</h1>
++${intro ? html`<p class="lede">${intro}</p>` : ''}
++${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">${empty || 'Nothing here yet.'}</p>`}
++${raw(pager ? paginationHtml(pager) : '')}`;
++}
++
++function tabs(active) {
++ const t = (href, label, key) => (key === active ? html`<span aria-current="page">${label}</span>` : html`<a href="${href}">${label}</a>`);
++ return html`<nav class="tabs" aria-label="Sort">${t('/', 'Hot', 'hot')} ${t('/new', 'New', 'new')} <form class="inline search" action="/search" method="get" role="search"><label class="sr" for="q">Search deals</label><input id="q" name="q" type="search" placeholder="Search deals"><button type="submit">Search</button></form></nav>`.toString();
++}
++
++function observationTable(v, urls) {
++ const rows = v.observations.map((o) => html`<tr${o.source_removed_at ? raw(' class="removed"') : ''}>
++<td>${timeEl(o.observed_at)}</td>
++<td>${o.price != null ? `${o.price} ${o.currency || '(currency not stated)'}` : html`<span class="meta">not stated</span>`}</td>
++<td>${o.shipping != null ? `${o.shipping} ${o.currency || ''}` : o.shipping_note || html`<span class="meta">not stated</span>`}</td>
++<td>${o.condition || html`<span class="meta">not stated</span>`}</td>
++<td>${o.availability ? AVAILABILITY_LABEL[o.availability] : html`<span class="meta">not stated</span>`}</td>
++<td>${sourceLabel(o)}${o.offer_id !== v.root.id ? html` <span class="meta">(merged listing)</span>` : ''}${o.source_removed_at ? html` <span class="meta">(source removed)</span>` : ''}</td>
++</tr>`);
++ return html`<table class="obs"><caption>Every observation, newest first. A price holds only as of its time.</caption>
++<thead><tr><th scope="col">Observed</th><th scope="col">Price</th><th scope="col">Shipping</th><th scope="col">Condition</th><th scope="col">Availability</th><th scope="col">Source</th></tr></thead>
++<tbody>${rows}</tbody></table>`;
++}
++
++function sourceLabel(o) {
++ if (o.source_kind === 'sources_item') return html`${o.source_key || 'source'}${safeHref(o.source_url) ? html` · <a href="${safeHref(o.source_url)}" rel="nofollow noopener">item</a>` : ''}`;
++ if (o.origin === 'community' && o.source_kind === 'submission') return 'the submission';
++ return 'a member';
++}
++
++function comments(c) {
++ if (!c || c.state === 'off') return '';
++ if (c.state === 'unavailable') return html`<p class="notice">Comments are unavailable right now.</p>`;
++ const list = (c.comments || []).filter((x) => !x.deleted);
++ return html`${list.length ? html`<ol class="comment-list">${list.map((x) => html`<li class="comment"><p class="meta">${x.display_name || 'A member'} · ${x.created_at ? timeEl(Date.parse(x.created_at)) : ''}</p><p>${x.message}</p></li>`)}</ol>` : html`<p class="empty">No comments yet.</p>`}`;
++}
++
++function offerPage({ v, dto, viewer, csrf, myVote, comments: c, mergedComments, canEdit, isMod, notice, log, flags, urls, sourceLinks }) {
++ const r = v.root;
++ const signedIn = viewer.kind === 'user' && viewer.subject;
++ const own = Boolean(signedIn && [r, ...v.members].some((o) => o.submitted_by === viewer.subject));
++ const crumbs = [{ name: 'Deals', url: '/' }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: urls.storePath(v.store) }] : []), { name: r.title }];
++ const voteForm = signedIn && r.status !== 'disabled' ? (own ? html`<p class="meta">You posted this deal.</p>` : html`<form class="inline vote" method="post" action="${urls.offerPath(r)}/vote">${csrfField(csrf)}
++<button name="value" value="up"${myVote === 1 ? raw(' aria-pressed="true"') : ''}>▲ Hot</button>
++<button name="value" value="down"${myVote === -1 ? raw(' aria-pressed="true"') : ''}>▼ Cold</button>
++${myVote ? html`<button name="value" value="remove">Remove my vote</button>` : ''}</form>`) : html`<p class="meta"><a href="/auth/login?next=${encodeURIComponent(urls.offerPath(r))}">Sign in</a> to vote, report a price or comment.</p>`;
++ return html`${raw(breadcrumbsHtml(crumbs))}
++${notice ? html`<p class="notice${notice.error ? ' error' : ''}" role="status">${notice.text}</p>` : ''}
++<article class="deal" data-offer="${r.id}">
++<h1>${r.title} ${statusBadge(r)}</h1>
++${r.status === 'expired' ? html`<p class="notice">This deal expired${r.expired_at ? html` on ${timeEl(r.expired_at)}` : ''}${r.expired_reason === 'stated_expiry' ? ' (its stated end date passed)' : ''}.</p>` : ''}
++<section class="latest" aria-label="Latest observation">
++${latestLine(v)}
++${v.freshness.state === 'stale' ? html`<p class="notice stale-notice">This price was last observed ${timeEl(v.latest.observed_at)}. Deals shows it as history, not as the price now. If you see the deal, report what the page says below.</p>` : ''}
++<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a></p>
++${r.expires_at && r.status === 'active' ? html`<p class="meta">Stated end: ${timeEl(r.expires_at)}</p>` : html`<p class="meta">End date: not stated</p>`}
++</section>
++<section class="votes" aria-label="Votes">
++<p><strong>${v.tally.up - v.tally.down > 0 ? '+' : ''}${v.tally.up - v.tally.down}</strong> <span class="meta">(${v.tally.up} hot, ${v.tally.down} cold${v.hotness ? html` · hotness ${v.hotness.hot} (${v.hotness.formula}, computed ${timeEl(v.hotness.computed_at)})` : ''})</span></p>
++${voteForm}
++</section>
++${r.description ? html`<section class="description">${raw(renderMarkdown(r.description, { rel: 'nofollow ugc noopener' }))}</section>` : ''}
++${r.ai_summary ? html`<section class="disclosure"><p><strong>AI-assisted summary</strong>${r.review_state === 'reviewed' ? ' (reviewed by a person)' : ' (not yet reviewed by a person)'}</p><p>${r.ai_summary}</p></section>` : ''}
++${r.review_state === 'pending' && r.text_origin === 'imported' ? html`<p class="disclosure">This deal was imported from a source and has not been reviewed by a person yet.</p>` : ''}
++<dl class="facts">
++<dt>Store</dt><dd>${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a>` : 'not stated'}</dd>
++<dt>Product</dt><dd>${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a>` : 'not linked'}</dd>
++<dt>Posted</dt><dd>${timeEl(r.created_at)} ${r.origin === 'import' ? '(imported)' : '(by a member)'}</dd>
++</dl>
++<h2 id="history">Price history</h2>
++${observationTable(v, urls)}
++<h2 id="sources">Sources</h2>
++<ul class="sources">${v.sources.map((s) => html`<li>${s.kind === 'sources_item' ? html`OpenVibe.Sources item <code>${s.ref_id}</code> from <code>${s.source_key || 'a source'}</code>${safeHref(s.url) ? html` — <a href="${safeHref(s.url)}" rel="nofollow noopener">original</a>` : ''}${s.retrieved_at ? html`, retrieved ${timeEl(s.retrieved_at)}` : ''}${s.license_note ? html` · ${s.license_note}` : ''}` : s.kind === 'submission' ? html`Submitted by a member ${timeEl(s.created_at)}` : html`Observations reported by a member`}${s.removed_at ? html` <span class="badge expired">removed: ${s.removed_reason}</span>` : ''}${s.offer_id !== r.id ? html` <span class="meta">(merged listing)</span>` : ''}</li>`)}</ul>
++${v.members.length ? html`<h2>Merged listings</h2><ul>${v.members.map((m) => html`<li>${m.title} <span class="meta">(<code>${m.slug}</code>, merged ${timeEl(m.merged_at)})</span></li>`)}</ul>` : ''}
++${signedIn && r.status !== 'disabled' ? html`<details class="card"><summary>Report what the deal page says now</summary>
++<form method="post" action="${urls.offerPath(r)}/observe">${csrfField(csrf)}
++<label for="o-price">Price</label><input id="o-price" name="price" inputmode="decimal" placeholder="19.99">
++<label for="o-currency">Currency</label><input id="o-currency" name="currency" maxlength="3" placeholder="USD">
++<label for="o-shipping">Shipping</label><input id="o-shipping" name="shipping" inputmode="decimal">
++<label for="o-avail">Availability</label><select id="o-avail" name="availability"><option value="">not stated</option>${AVAILABILITY.map((a) => html`<option value="${a}">${AVAILABILITY_LABEL[a]}</option>`)}</select>
++<label for="o-cond">Condition</label><select id="o-cond" name="condition"><option value="">not stated</option>${CONDITIONS.map((c2) => html`<option>${c2}</option>`)}</select>
++<button type="submit">Record observation</button></form></details>
++<details class="card"><summary>Report a problem</summary>
++<form method="post" action="${urls.offerPath(r)}/flag">${csrfField(csrf)}
++<label for="f-kind">Problem</label><select id="f-kind" name="kind"><option value="expired">Expired</option><option value="price_wrong">Price is wrong</option><option value="duplicate">Duplicate</option><option value="spam">Spam</option><option value="other">Other</option></select>
++<label for="f-dup">Duplicate of (link or slug, for duplicates)</label><input id="f-dup" name="duplicate_of">
++<label for="f-reason">Details</label><input id="f-reason" name="reason" maxlength="500">
++<button type="submit">Send report</button></form></details>` : ''}
++${canEdit && r.status === 'active' ? html`<form method="post" action="${urls.offerPath(r)}/expire">${csrfField(csrf)}<button type="submit">Mark expired</button></form>` : ''}
++${isMod ? html`<details class="card mod"><summary>Moderation</summary>
++<form method="post" action="/mod/offers/${r.slug}/merge">${csrfField(csrf)}<label for="m-into">Merge this listing into (slug or id)</label><input id="m-into" name="into" required><label for="m-r">Reason</label><input id="m-r" name="reason"><button type="submit">Merge</button></form>
++${r.status !== 'disabled' ? html`<form method="post" action="/mod/offers/${r.slug}/disable">${csrfField(csrf)}<label for="d-r">Disable (reason)</label><input id="d-r" name="reason" required minlength="3"><button class="danger" type="submit">Disable</button></form>` : html`<form method="post" action="/mod/offers/${r.slug}/enable">${csrfField(csrf)}<button type="submit">Enable</button></form>`}
++${r.status === 'expired' ? html`<form method="post" action="/mod/offers/${r.slug}/enable">${csrfField(csrf)}<button type="submit">Re-activate</button></form>` : ''}
++${r.review_state === 'pending' ? html`<form method="post" action="/mod/offers/${r.slug}/review">${csrfField(csrf)}<button type="submit">Mark text reviewed</button></form>` : ''}
++${v.members.map((m) => html`<form method="post" action="/mod/offers/${m.slug}/unmerge">${csrfField(csrf)}<button type="submit">Unmerge ${m.slug}</button></form>`)}
++${flags && flags.length ? html`<h3>Flags</h3><ul>${flags.map((f) => html`<li>${f.kind} (${f.origin}, ${f.status})${f.reason ? `: ${f.reason}` : ''}</li>`)}</ul>` : ''}
++${log && log.length ? html`<h3>Moderation log</h3><ol>${log.map((l) => html`<li>${timeEl(l.at)} ${l.action} ${l.offer_id} ${l.target_id ? `→ ${l.target_id}` : ''} by ${l.actor}${l.reason ? `: ${l.reason}` : ''}</li>`)}</ol>` : ''}
++</details>` : ''}
++<section class="comments" id="comments"><h2>Discussion</h2>
++${comments(c)}
++${c && c.state === 'ok' && signedIn ? html`<form method="post" action="${urls.offerPath(r)}/comments">${csrfField(csrf)}<label for="c-msg">Comment</label><textarea id="c-msg" name="message" rows="3" maxlength="4000" required></textarea><button type="submit">Comment</button></form>` : ''}
++${(mergedComments || []).map((mc) => html`<h3>Discussion from the merged listing “${mc.title}”</h3>${comments(mc.comments)}`)}
++</section>
++<p class="meta"><a href="${urls.offerPath(r)}.json">This deal as JSON</a> · indexability: ${dto.indexability.robots}${dto.indexability.reasons.length ? ` (${dto.indexability.reasons.map((x) => x.code).join(', ')})` : ''}${sourceLinks || ''}</p>
++</article>`;
++}
++
++function productPage({ pv, urls }) {
++ const p = pv.product;
++ const rows = pv.offers.map((v) => html`<tr>
++<td><a href="${urls.offerPath(v.root)}">${v.root.title}</a> ${statusBadge(v.root)}</td>
++<td>${v.store ? v.store.name || v.store.domain : 'not stated'}</td>
++<td data-price="${v.latest && v.latest.price != null ? v.latest.price : ''}">${priceText(v.latest)}${v.latest && shippingText(v.latest) ? html` <span class="meta">${shippingText(v.latest)}</span>` : ''}</td>
++<td>${v.latest ? timeEl(v.latest.observed_at) : html`<span class="meta">never observed</span>`}</td>
++<td>${freshnessBadge(v.freshness, v.now)}</td>
++</tr>`);
++ return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: p.name }]))}
++<h1>${p.name}</h1>
++${p.brand ? html`<p class="lede">${p.brand}</p>` : ''}
++${p.description ? html`<p>${p.description}</p>` : ''}
++<h2>Price comparison</h2>
++${pv.offers.length ? html`<table class="compare"><caption>Each price is the latest observation of that offer, with its time. Fresh prices with a stated amount come first; stale, unknown and expired ones follow and are never ranked as if they were current.</caption>
++<thead><tr><th scope="col">Offer</th><th scope="col">Store</th><th scope="col">Latest price</th><th scope="col">Observed</th><th scope="col">Freshness</th></tr></thead><tbody>${rows}</tbody></table>` : html`<p class="empty">No offers for this product yet.</p>`}
++${pv.aliases.length ? html`<h2>Also known as</h2><ul class="aliases">${pv.aliases.map((a) => html`<li>${a.kind}: <code>${a.value}</code></li>`)}</ul>` : ''}
++<p class="meta"><a href="${urls.productPath(p)}.json">This product as JSON</a></p>`;
++}
++
++function submitForm({ csrf, values = {}, error }) {
++ const val = (k) => (values[k] == null ? '' : values[k]);
++ return html`<h1>Submit a deal</h1>
++<p class="lede">Link to the offer and say what you see on the page now. Leave anything you did not see empty — Deals never fills in a price, currency, shipping or availability for you.</p>
++${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
++<form method="post" action="/submit" class="card">${csrfField(csrf)}
++<label for="s-url">Link to the offer</label><input id="s-url" name="url" type="url" required value="${val('url')}">
++<label for="s-title">Title</label><input id="s-title" name="title" required minlength="3" maxlength="200" value="${val('title')}">
++<label for="s-price">Price you see now</label><input id="s-price" name="price" inputmode="decimal" placeholder="19.99" value="${val('price')}">
++<label for="s-currency">Currency (ISO code)</label><input id="s-currency" name="currency" maxlength="3" placeholder="USD" value="${val('currency')}">
++<label for="s-ship">Shipping cost</label><input id="s-ship" name="shipping" inputmode="decimal" value="${val('shipping')}">
++<label for="s-shipnote">Shipping note</label><input id="s-shipnote" name="shipping_note" maxlength="200" placeholder="free over 35" value="${val('shipping_note')}">
++<label for="s-cond">Condition</label><select id="s-cond" name="condition"><option value="">not stated</option>${CONDITIONS.map((c) => html`<option${val('condition') === c ? raw(' selected') : ''}>${c}</option>`)}</select>
++<label for="s-avail">Availability</label><select id="s-avail" name="availability"><option value="">not stated</option>${AVAILABILITY.map((a) => html`<option value="${a}"${val('availability') === a ? raw(' selected') : ''}>${AVAILABILITY_LABEL[a]}</option>`)}</select>
++<label for="s-exp">Ends, in UTC (only if the store states it)</label><input id="s-exp" name="expires_at" type="datetime-local" value="${val('expires_at')}">
++<label for="s-store">Store name (optional)</label><input id="s-store" name="store_name" maxlength="120" value="${val('store_name')}">
++<label for="s-prod">Product name (optional, links price comparison)</label><input id="s-prod" name="product_name" maxlength="200" value="${val('product_name')}">
++<label for="s-gtin">Product barcode / GTIN (optional)</label><input id="s-gtin" name="product_gtin" inputmode="numeric" value="${val('product_gtin')}">
++<label for="s-desc">Description (Markdown)</label><textarea id="s-desc" name="description" rows="5" maxlength="4000">${val('description')}</textarea>
++<button type="submit">Submit deal</button>
++</form>`;
++}
++
++function watchesPage({ csrf, watches, error, notice, urls }) {
++ return html`<h1>Watches and saved searches</h1>
++<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
++${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
++${notice ? html`<p class="notice" role="status">${notice}</p>` : ''}
++${watches.length ? html`<ul class="watches">${watches.map((w) => html`<li><strong>${w.kind === 'search' ? 'Saved search' : w.kind === 'price_below' ? 'Price below' : w.kind === 'product' ? 'Product' : 'Keywords'}</strong>:
++${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.product.url}">${w.product.name}</a>` : ''}${w.max_price ? html` under ${w.max_price} ${w.currency}` : ''}
++${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
++<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
++<form method="post" action="/watches" class="card">${csrfField(csrf)}
++<h2>Add</h2>
++<label for="w-kind">Kind</label><select id="w-kind" name="kind"><option value="keyword">Keywords (notify)</option><option value="price_below">Price below (notify)</option><option value="product">Product (notify)</option><option value="search">Saved search (no notifications)</option></select>
++<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200">
++<label for="w-p">Product (slug, for product and price watches)</label><input id="w-p" name="product">
++<label for="w-max">Price below</label><input id="w-max" name="max_price" inputmode="decimal">
++<label for="w-cur">Currency</label><input id="w-cur" name="currency" maxlength="3" placeholder="USD">
++<button type="submit">Save</button>
++</form>`;
++}
++
++function searchPage({ q, results, pager, urls, csrf, signedIn }) {
++ return html`<h1>Search deals</h1>
++<form action="/search" method="get" role="search"><label for="sq">Words</label><input id="sq" name="q" type="search" value="${q || ''}"><button type="submit">Search</button></form>
++${q && signedIn ? html`<form method="post" action="/watches" class="inline">${csrfField(csrf)}<input type="hidden" name="kind" value="search"><input type="hidden" name="query" value="${q}"><button type="submit">Save this search</button></form>
++<form method="post" action="/watches" class="inline">${csrfField(csrf)}<input type="hidden" name="kind" value="keyword"><input type="hidden" name="query" value="${q}"><button type="submit">Watch these words</button></form>` : ''}
++${q ? (results.length ? html`<ol class="deal-list">${results.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No active deals match.</p>`) : ''}
++${raw(pager ? paginationHtml(pager) : '')}`;
++}
++
++function modPage({ csrf, flags, pending, duplicates, urls }) {
++ return html`<h1>Moderation</h1>
++<h2>Open flags</h2>
++${flags.length ? html`<ul class="flags">${flags.map((f) => html`<li><strong>${f.kind}</strong> (${f.origin}) ${f.offer ? html`<a href="${urls.offerPath(f.offer)}">${f.offer.title}</a>` : ''}${f.reason ? `: ${f.reason}` : ''}
++${f.kind === 'vote_ring' ? html` <span class="meta">${f.details.signal}: ${(f.details.voters || []).join(', ')}</span>` : ''}
++<form class="inline" method="post" action="/mod/flags/${f.id}/resolve">${csrfField(csrf)}<button type="submit">Resolved</button></form>
++<form class="inline" method="post" action="/mod/flags/${f.id}/dismiss">${csrfField(csrf)}<button type="submit">Dismiss</button></form></li>`)}</ul>` : html`<p class="empty">No open flags.</p>`}
++<h2>Waiting for a person's review (noindex until reviewed)</h2>
++${pending.length ? html`<ul>${pending.map((o) => html`<li><a href="${urls.offerPath(o)}">${o.title}</a> (${o.text_origin}) <form class="inline" method="post" action="/mod/offers/${o.slug}/review">${csrfField(csrf)}<button type="submit">Mark reviewed</button></form></li>`)}</ul>` : html`<p class="empty">Nothing to review.</p>`}
++<h2>Possible duplicates</h2>
++${duplicates.length ? html`<ul>${duplicates.map((d) => html`<li><a href="${urls.offerPath(d.a)}">${d.a.title}</a> and <a href="${urls.offerPath(d.b)}">${d.b.title}</a>
++<form class="inline" method="post" action="/mod/offers/${d.b.slug}/merge">${csrfField(csrf)}<input type="hidden" name="into" value="${d.a.slug}"><button type="submit">Merge the newer into the older</button></form></li>`)}</ul>` : html`<p class="empty">None found.</p>`}`;
++}
++
++function storePage({ st, views, pager, urls }) {
++ return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: st.name || st.domain }]))}
++<h1>${st.name || st.domain}</h1>
++<p class="lede">Deals at <code>${st.domain}</code>.</p>
++${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No deals from this store.</p>`}
++${raw(pager ? paginationHtml(pager) : '')}`;
++}
++
++function message({ heading, text, action }) {
++ return html`<h1>${heading}</h1><p>${text}</p>${action ? html`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
++}
++
++module.exports = { offerList, tabs, offerPage, productPage, submitForm, watchesPage, searchPage, modPage, storePage, message, priceText, when };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..3103509
---- /dev/null
++++ b/server/worker.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * Background work in the Deals process (one timer, DEALS_WORKER_INTERVAL_MS):
++ *
++ * 1. stated expiry offers whose STATED end time has passed become expired (deals.offer.expired)
++ * 2. hotness a hot@1 snapshot of every active offer from the last DEALS_HOT_WINDOW_DAYS,
++ * all at one shared t, so the hot list compares like with like
++ * 3. freshness re-derive every offer's Search document; a price that went stale since the
++ * last tick changes the document (freshness facet, noindex) and only then is
++ * an event emitted (the sequencer makes unchanged documents no-ops)
++ * 4. Sources pull the deals change feed, then re-confirm imported offers going stale
++ * 5. housekeeping prune old rate-limit windows
++ *
++ * Every step is idempotent, so a tick re-run after a crash changes nothing that already happened.
++ */
++function createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log = console }) {
++ let timer = null;
++ let running = false;
++ let lastError = null;
++ let lastTickAt = null;
++
++ async function tick() {
++ if (running) return null;
++ running = true;
++ const summary = {};
++ try {
++ summary.expired = offers.expireDue();
++ summary.snapshots = hotness.tick({ windowDays: config.worker.hotWindowDays });
++ let reindexed = 0;
++ for (const o of listings.indexable()) {
++ store.tx(() => { if (indexing.indexOffer(o)) reindexed++; });
++ }
++ for (const p of listings.products()) store.tx(() => { if (indexing.indexProduct(p)) reindexed++; });
++ summary.reindexed = reindexed;
++ summary.import = await importer.pull();
++ summary.refresh = await importer.refresh();
++ summary.pruned = limits.prune();
++ lastError = null;
++ } catch (err) {
++ lastError = err.message;
++ log.error('[Deals] worker tick failed:', err.stack || err.message);
++ } finally {
++ lastTickAt = store.now();
++ running = false;
++ }
++ return summary;
++ }
++
++ return {
++ tick,
++ start() {
++ if (!config.worker.enabled) return;
++ timer = setInterval(() => { tick().catch(() => {}); }, config.worker.intervalMs);
++ timer.unref();
++ setTimeout(() => { tick().catch(() => {}); }, 2000).unref();
++ },
++ stop() { clearInterval(timer); },
++ status: () => ({ enabled: config.worker.enabled, last_error: lastError, last_tick_at: lastTickAt ? new Date(lastTickAt).toISOString() : null }),
++ };
++}
++
++module.exports = { createWorker };
ddiff --git a/test/abuse.test.js b/test/abuse.test.js
nnew file mode 100644
iindex 0000000..c4b2357
---- /dev/null
++++ b/test/abuse.test.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * Vote abuse controls are server-side: rate limits per subject, per IP hash and per offer, no
++ * voting on your own deal, reduced weight for new accounts, vote-ring flags for moderators.
++ */
++const assert = require('assert');
++const { boot, check, done, HOUR } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot({ env: { DEALS_VOTE_LIMIT_SUBJECT_HOUR: '4', DEALS_VOTE_LIMIT_IP_HOUR: '5', DEALS_VOTE_LIMIT_OFFER_HOUR: '3', DEALS_SUBMIT_LIMIT_DAY: '12', DEALS_RING_IP_MIN: '3', DEALS_RING_COVOTE_MIN: '3' } });
++ const poster = t.network.addUser('poster');
++ const offers = [];
++ for (let i = 0; i < 8; i++) offers.push(await t.submit(poster, { url: `https://s.example/${i}`, title: `Offer number ${i}`, price: '1', currency: 'USD' }));
++ const vote = (u, o, value, ip) => t.get(`/api/v1/offers/${o.id}/vote`, { method: 'PUT', as: u, json: { value }, ip });
++
++ await check('you cannot vote on a deal you posted', async () => {
++ const r = await vote(poster, offers[0], 1);
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'vote.own_offer');
++ const form = await t.get(`/d/${offers[0].slug}`, { as: poster });
++ assert.match(form.text, /You posted this deal/);
++ });
++
++ await check('per-subject limit: the fifth vote in an hour is 429 with Retry-After; an hour later it works', async () => {
++ const u = t.network.addUser('busy');
++ for (let i = 0; i < 4; i++) assert.strictEqual((await vote(u, offers[i], 1, `198.51.100.${i}`)).status, 200);
++ const r = await vote(u, offers[4], 1, '198.51.100.9');
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.json().code, 'vote.rate_limited');
++ assert.ok(Number(r.headers.get('retry-after')) > 0);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 4);
++ t.clock.advance(HOUR + 1000);
++ assert.strictEqual((await vote(u, offers[4], 1, '198.51.100.9')).status, 200);
++ });
++
++ await check('the same limit applies to the no-JS form (one server-side window)', async () => {
++ const u = t.network.addUser('formy');
++ for (let i = 0; i < 4; i++) await t.get(`/d/${offers[i].slug}/vote`, { as: u, form: { value: 'up' }, ip: '192.0.2.77' });
++ const r = await t.get(`/d/${offers[5].slug}/vote`, { as: u, form: { value: 'up' }, ip: '192.0.2.78' });
++ assert.strictEqual(r.status, 429);
++ assert.match(r.text, /Too many vote actions/);
++ });
++
++ await check('per-IP-hash limit across accounts; the raw IP is never stored', async () => {
++ t.clock.advance(HOUR + 1000);
++ const ip = '203.0.113.50';
++ const codes = [];
++ for (let i = 0; i < 6; i++) codes.push((await vote(t.network.addUser(`ipuser${i}`), offers[6], 1, ip)).status);
++ assert.deepStrictEqual(codes, [200, 200, 200, 200, 200, 429]);
++ const dump = JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_votes').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM rate_events').all()) + JSON.stringify(t.ctx.store.db.prepare('SELECT * FROM deal_flags').all());
++ assert.ok(!dump.includes(ip), 'no raw IP in votes, rate windows or flags');
++ });
++
++ await check('five voters from one IP hash on one offer raise ONE open shared_ip vote-ring flag', async () => {
++ const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'shared_ip'").all();
++ assert.strictEqual(flags.length, 1);
++ assert.strictEqual(flags[0].status, 'open');
++ assert.strictEqual(flags[0].offer_id, offers[6].id);
++ assert.strictEqual(JSON.parse(flags[0].details).voters.length, 5);
++ });
++
++ await check('per-offer change limit: flipping a vote over and over is refused', async () => {
++ t.clock.advance(HOUR + 1000);
++ const u = t.network.addUser('flipper');
++ const codes = [];
++ for (const v of [1, -1, 1, -1]) codes.push((await vote(u, offers[7], v, '192.0.2.10')).status);
++ assert.deepStrictEqual(codes, [200, 200, 200, 429]);
++ const same = await vote(u, offers[7], 1, '192.0.2.10');
++ assert.strictEqual(same.status, 200, 'repeating the current vote is a no-op, not an action');
++ assert.strictEqual(same.json().changed, false);
++ });
++
++ await check('two accounts voting in lockstep on several offers raise a covote vote-ring flag', async () => {
++ t.clock.advance(HOUR + 1000);
++ const a = t.network.addUser('ring_a');
++ const b = t.network.addUser('ring_b');
++ for (let i = 0; i < 3; i++) {
++ assert.strictEqual((await vote(a, offers[i], 1, `10.0.${i}.1`)).status, 200);
++ t.clock.advance(60 * 1000);
++ assert.strictEqual((await vote(b, offers[i], 1, `10.1.${i}.1`)).status, 200);
++ }
++ const flags = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE kind = 'vote_ring' AND reason = 'covote' AND status = 'open'").all();
++ assert.strictEqual(flags.length, 1);
++ const d = JSON.parse(flags[0].details);
++ assert.deepStrictEqual(d.voters, [a.subject, b.subject].sort());
++ assert.strictEqual(flags[0].origin, 'system');
++ });
++
++ await check('vote-ring flags are for moderators: /mod and GET /api/v1/flags; nobody else sees them', async () => {
++ const anon = await t.get('/mod');
++ assert.strictEqual(anon.status, 303);
++ const user = await t.get('/mod', { as: poster });
++ assert.strictEqual(user.status, 403);
++ const page = await t.get('/mod', { as: t.mod });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /vote_ring/);
++ assert.match(page.text, /covote/);
++ const denied = await t.get('/api/v1/flags', { as: poster });
++ assert.strictEqual(denied.status, 403);
++ const api = await t.get('/api/v1/flags', { as: t.mod });
++ assert.strictEqual(api.status, 200);
++ assert.ok(api.json().flags.some((f) => f.kind === 'vote_ring'));
++ const svc = await t.get('/api/v1/flags', { as: t.network.serviceToken('ai', ['deals.offer.submit']) });
++ assert.strictEqual(svc.status, 403);
++ });
++
++ await check('a moderator resolves a flag (audited); flags never change votes by themselves', async () => {
++ const f = t.ctx.store.db.prepare("SELECT * FROM deal_flags WHERE reason = 'shared_ip'").get();
++ const votesBefore = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;
++ const r = await t.get(`/mod/flags/${f.id}/resolve`, { as: t.mod, form: { resolution: 'checked' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT status FROM deal_flags WHERE id = ?').get(f.id).status, 'resolved');
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n, votesBefore);
++ assert.ok(t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'flag.resolve'").get());
++ });
++
++ await check('a person\'s repeated report updates their open flag instead of adding another', async () => {
++ const u = t.network.addUser('reporter');
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(`/d/${offers[1].slug}/flag`, { as: u, form: { kind: 'expired', reason: `try ${i}` } })).status, 303);
++ const rows = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE reporter = ?').all(u.subject);
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].reason, 'try 2');
++ });
++
++ await check('submission limit per person per day', async () => {
++ const u = t.network.addUser('spammer');
++ const codes = [];
++ for (let i = 0; i < 13; i++) codes.push((await t.get('/submit', { as: u, form: { url: `https://spam.example/${i}`, title: `Spam ${i}` } })).status);
++ assert.strictEqual(codes.filter((c) => c === 303).length, 12);
++ assert.strictEqual(codes[12], 429);
++ });
++
++ await check('forms without a valid form token are refused (CSRF)', async () => {
++ const u = t.network.addUser('csrf');
++ const r = await t.get(`/d/${offers[3].slug}/vote`, { as: u, form: { value: 'up', csrf: 'forged' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes WHERE subject = ?').get(u.subject).n, 0);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..2b21603
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,158 @@
++'use strict';
++/**
++ * /api/v1: one capability per route for service tokens, X-OV-Subject for the acting person,
++ * problem+json errors with request ids, AI-assisted text held for review, and every event and
++ * Search document valid against the released contracts.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));
++ const svc = (caps, client = 'ai') => t.network.serviceToken(client, caps);
++ let offer;
++
++ await check('a service token without the route\'s capability is refused (problem+json, legacy error field, request id)', async () => {
++ const r = await t.get('/api/v1/offers', { as: svc(['deals.vote.set']), headers: { 'X-OV-Subject': alice.subject, 'X-OpenVibe-Request-Id': 'req-abc-123' }, json: { url: 'https://a.example/', title: 'Hello' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ const body = r.json();
++ assert.strictEqual(body.code, 'capability.denied');
++ assert.ok(body.error, 'legacy error field');
++ assert.strictEqual(r.headers.get('x-openvibe-request-id'), 'req-abc-123');
++ });
++
++ await check('a bad service token is refused, never downgraded to anonymous', async () => {
++ const forged = t.network.signService({ sub: 'svc:ai', aud: ['openvibe.other'], cap: ['deals.offer.submit'] });
++ const r = await t.get('/api/v1/offers', { as: forged, json: { url: 'https://a.example/', title: 'Hello' } });
++ assert.strictEqual(r.status, 401);
++ });
++
++ await check('a service must name the person it acts for', async () => {
++ const r = await t.get('/api/v1/offers', { as: svc(['deals.offer.submit']), json: { url: 'https://a.example/', title: 'Hello' } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.json().code, 'subject.required');
++ });
++
++ await check('submit via API for a person; the answer is the full offer with its first observation', async () => {
++ const r = await t.get('/api/v1/offers', { as: svc(['deals.offer.submit'], 'live'), headers: { 'X-OV-Subject': alice.subject }, json: { url: 'https://apps.example/game', title: 'Game bundle', price: '9.99', currency: 'usd', availability: 'online_only' } });
++ assert.strictEqual(r.status, 201, r.text);
++ offer = r.json().offer;
++ assert.strictEqual(offer.submitted_by, alice.subject);
++ assert.strictEqual(offer.latest_observation.currency, 'USD');
++ assert.strictEqual(offer.latest_observation.availability, 'online_only');
++ assert.strictEqual(offer.observations.length, 1);
++ const dup = await t.get('/api/v1/offers', { as: alice, json: { url: 'https://apps.example/game?utm_medium=x', title: 'Again' } });
++ assert.strictEqual(dup.status, 409);
++ assert.strictEqual(dup.json().code, 'offer.duplicate');
++ assert.strictEqual(dup.json().existing.id, offer.id);
++ });
++
++ await check('AI-assisted text (X-OV-Origin: ai) is disclosed and noindex until a person reviews it', async () => {
++ const ai = svc(['deals.offer.update']);
++ const denied = await t.get(`/api/v1/offers/${offer.id}`, { method: 'PATCH', as: ai, headers: { 'X-OV-Subject': bob.subject, 'X-OV-Origin': 'ai' }, json: { ai_summary: 'x' } });
++ assert.strictEqual(denied.status, 403, 'bob did not submit it');
++ const r = await t.get(`/api/v1/offers/${offer.id}`, { method: 'PATCH', as: ai, headers: { 'X-OV-Subject': alice.subject, 'X-OV-Origin': 'ai' }, json: { ai_summary: 'A bundle of five indie games.' } });
++ assert.strictEqual(r.status, 200, r.text);
++ const o = r.json().offer;
++ assert.strictEqual(o.review_state, 'pending');
++ assert.strictEqual(o.indexability.indexable, false);
++ const page = await t.get(`/d/${o.slug}`);
++ assert.match(page.text, /AI-assisted summary<\/strong> \(not yet reviewed by a person\)/);
++ const human = await t.get(`/api/v1/offers/${offer.id}`, { method: 'PATCH', as: alice, json: { ai_summary: 'fake' } });
++ assert.strictEqual(human.status, 422, 'only OpenVibe.AI writes ai_summary');
++ const svcReview = await t.get(`/api/v1/offers/${offer.id}/review`, { as: svc(['deals.offer.moderate'], 'mod-bot'), json: {} });
++ assert.strictEqual(svcReview.status, 403, 'a review is recorded for a person, not a service');
++ const rev = await t.get(`/api/v1/offers/${offer.id}/review`, { as: t.mod, json: {} });
++ assert.strictEqual(rev.status, 200);
++ assert.strictEqual(rev.json().offer.review_state, 'reviewed');
++ assert.strictEqual(rev.json().offer.indexability.indexable, true);
++ });
++
++ await check('votes over the API: set, change, remove; server-computed counts', async () => {
++ const put = await t.get(`/api/v1/offers/${offer.slug}/vote`, { method: 'PUT', as: svc(['deals.vote.set'], 'live'), headers: { 'X-OV-Subject': bob.subject }, json: { value: 1 } });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.deepStrictEqual(put.json().votes.up, 1);
++ const bad = await t.get(`/api/v1/offers/${offer.slug}/vote`, { method: 'PUT', as: bob, json: { value: 5 } });
++ assert.strictEqual(bad.status, 422);
++ const del = await t.get(`/api/v1/offers/${offer.slug}/vote`, { method: 'DELETE', as: bob });
++ assert.strictEqual(del.status, 200);
++ assert.deepStrictEqual([del.json().votes.up, del.json().previous, del.json().changed], [0, 1, true]);
++ const noCap = await t.get(`/api/v1/offers/${offer.slug}/vote`, { method: 'DELETE', as: svc(['deals.vote.set'], 'live'), headers: { 'X-OV-Subject': bob.subject } });
++ assert.strictEqual(noCap.status, 403, 'removing needs deals.vote.remove');
++ });
++
++ await check('products/resolve finds by alias, creates only when asked, reports alias conflicts', async () => {
++ const tok = svc(['deals.product.resolve'], 'reviews');
++ const a = await t.get('/api/v1/products/resolve', { as: tok, json: { name: 'Pixel Watch 3', gtin: '0840244706326', mpn: 'GA05785' } });
++ assert.strictEqual(a.status, 201);
++ const again = await t.get('/api/v1/products/resolve', { as: tok, json: { gtin: '840244706326' } });
++ assert.deepStrictEqual([again.status, again.json().product.id, again.json().created], [200, a.json().product.id, false]);
++ const miss = await t.get('/api/v1/products/resolve', { as: tok, json: { mpn: 'NOPE', create: false } });
++ assert.strictEqual(miss.status, 404);
++ const other = await t.get('/api/v1/products/resolve', { as: tok, json: { name: 'Pixel Watch 3 LTE', mpn: 'GA05785X' } });
++ const conflict = await t.get('/api/v1/products/resolve', { as: tok, json: { name: 'Pixel Watch 3 LTE', gtin: '0840244706326' } });
++ assert.strictEqual(conflict.json().product.id, a.json().product.id, 'the gtin wins, it already names a product');
++ assert.ok(other.json().product.id !== a.json().product.id);
++ const pub = await t.get(`/api/v1/products/${a.json().product.slug}`);
++ assert.strictEqual(pub.status, 200);
++ });
++
++ await check('merge via API needs deals.offer.merge; a moderator grant is the decision for a service', async () => {
++ const second = (await t.get('/api/v1/offers', { as: bob, json: { url: 'https://apps.example/game-bundle', title: 'Game bundle (same)' } })).json().offer;
++ const no = await t.get(`/api/v1/offers/${second.id}/merge`, { as: svc(['deals.offer.moderate'], 'mod-bot'), json: { into: offer.id } });
++ assert.strictEqual(no.status, 403);
++ const user = await t.get(`/api/v1/offers/${second.id}/merge`, { as: bob, json: { into: offer.id } });
++ assert.strictEqual(user.status, 403);
++ const yes = await t.get(`/api/v1/offers/${second.id}/merge`, { as: svc(['deals.offer.merge'], 'mod-bot'), json: { into: offer.id, reason: 'same bundle' } });
++ assert.strictEqual(yes.status, 200, yes.text);
++ assert.deepStrictEqual(yes.json().offer.merged_from.map((m) => m.id), [second.id]);
++ const merged = await t.get(`/api/v1/offers/${second.id}`);
++ assert.strictEqual(merged.json().offer.id, offer.id, 'a merged id answers with its canonical offer');
++ });
++
++ await check('flags via API: one open report per person and offer', async () => {
++ const tok = svc(['deals.flag.create'], 'live');
++ const a = await t.get(`/api/v1/offers/${offer.id}/flags`, { as: tok, headers: { 'X-OV-Subject': bob.subject }, json: { kind: 'price_wrong', reason: 'now 12.99' } });
++ assert.strictEqual(a.status, 201);
++ const b = await t.get(`/api/v1/offers/${offer.id}/flags`, { as: tok, headers: { 'X-OV-Subject': bob.subject }, json: { kind: 'price_wrong' } });
++ assert.deepStrictEqual([b.status, b.json().created], [200, false]);
++ const bad = await t.get(`/api/v1/offers/${offer.id}/flags`, { as: tok, headers: { 'X-OV-Subject': bob.subject }, json: { kind: 'hate' } });
++ assert.strictEqual(bad.status, 422);
++ });
++
++ await check('API responses are private and noindex; unknown routes are problem 404', async () => {
++ const r = await t.get(`/api/v1/offers/${offer.id}`);
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');
++ const nf = await t.get('/api/v1/nope');
++ assert.strictEqual(nf.status, 404);
++ assert.strictEqual(nf.json().code, 'route.not_found');
++ assert.strictEqual((await t.get('/api/v1/offers/dof_01K5NOPE0000000000000000000')).json().code, 'offer.not_found');
++ });
++
++ await check('every event validates as events.event-envelope@1; every Search document as search.index-document@1', async () => {
++ const all = t.events();
++ assert.ok(all.length > 10);
++ const types = new Set();
++ for (const e of all) {
++ types.add(e.event_type);
++ const v = contracts.validate('events.event-envelope@1', e);
++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(e.source, 'deals');
++ if (e.event_type === 'deals.index_document.upserted') {
++ const d = contracts.validate('search.index-document@1', { ...e.payload });
++ assert.ok(d.valid, JSON.stringify(d.errors));
++ assert.strictEqual(e.visibility, 'internal');
++ }
++ }
++ for (const x of ['deals.offer.created', 'deals.offer.updated', 'deals.vote.changed', 'deals.index_document.upserted', 'deals.index_document.deleted']) assert.ok(types.has(x), x);
++ const votes = all.filter((e) => e.event_type === 'deals.vote.changed');
++ assert.ok(votes.every((e) => e.visibility === 'internal'), 'who voted is not public');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..41bd5f4
---- /dev/null
++++ b/test/contracts.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * The proposals the lead releases in the next openvibe-contracts version are valid against the
++ * released schemas, match what the code enforces and emits, and do not collide with released ids.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { check, done } = require('./helpers/boot');
++const { PROPOSED } = require('../server/auth/capabilities');
++
++const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
++const SRC = path.join(__dirname, '..', 'server');
++
++function sources(dir) {
++ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? sources(path.join(dir, e.name)) : e.name.endsWith('.js') ? [fs.readFileSync(path.join(dir, e.name), 'utf8')] : []));
++}
++
++(async () => {
++ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.json'));
++ const caps = files.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
++ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
++ const code = sources(SRC).join('\n');
++
++ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by deals', async () => {
++ for (const c of caps) {
++ const v = contracts.validate('capabilities.capability@1', c);
++ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(c.owner, 'deals');
++ assert.ok(c.id.split('.').length >= 3);
++ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);
++ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'deals', `${c.id} collides with a released capability`);
++ }
++ });
++
++ await check('the charter capabilities are all there', async () => {
++ for (const id of ['deals.offer.submit', 'deals.offer.update', 'deals.offer.expire', 'deals.vote.set', 'deals.vote.remove', 'deals.watch.create', 'deals.watch.delete', 'deals.product.resolve']) assert.ok(PROPOSED.has(id), id);
++ });
++
++ await check('the proposals are exactly the capabilities the code enforces', async () => {
++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
++ const guarded = new Set([...code.matchAll(/guard\('([a-z.]+)'\)/g)].map((m) => m[1]));
++ assert.deepStrictEqual([...guarded].sort(), [...PROPOSED].sort());
++ });
++
++ await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {
++ const v = contracts.validate('registry.service-manifest@1', manifest);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.strictEqual(manifest.id, 'deals');
++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);
++ });
++
++ await check('every event type the code emits is declared, and the charter events are all emitted', async () => {
++ const emitted = new Set([...code.matchAll(/'(deals\.[a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]).filter((e) => /^deals\.(offer|vote|watch)\.(created|updated|expired|changed|matched)$/.test(e)));
++ for (const e of emitted) assert.ok(manifest.eventsProduced.includes(e), e);
++ for (const e of ['deals.offer.created', 'deals.offer.updated', 'deals.offer.expired', 'deals.vote.changed', 'deals.watch.matched']) assert.ok(emitted.has(e), `${e} is never emitted`);
++ });
++
++ done();
++})();
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
nnew file mode 100644
iindex 0000000..7d8a56d
---- /dev/null
++++ b/test/freshness.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Prices and availability are timestamped and never silently treated as current.
++ */
++const assert = require('assert');
++const { boot, check, done, jsonLd, robotsOf, HOUR } = require('./helpers/boot');
++const { sourceItem } = require('./helpers/mocks');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ let offer;
++
++ await check('a fresh observation is shown "as of" its time, indexable, with the price in JSON-LD', async () => {
++ offer = await t.submit(alice, { url: 'https://shop.example/headphones', title: 'Studio headphones', price: '49.99', currency: 'USD', availability: 'in_stock' });
++ const page = await t.get(`/d/${offer.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /as of <time datetime="2026-09-22T12:00:00.000Z">2026-09-22 12:00 UTC<\/time>/);
++ assert.match(page.text, /data-freshness="fresh"/);
++ assert.strictEqual(robotsOf(page.text), 'index, follow');
++ const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
++ assert.strictEqual(ld.offers[0].price, '49.99');
++ assert.strictEqual(ld.offers[0].priceCurrency, 'USD');
++ assert.strictEqual(ld.offers[0].availability, 'https://schema.org/InStock');
++ const json = await t.offerJson(offer.slug);
++ assert.strictEqual(json.freshness.state, 'fresh');
++ assert.strictEqual(json.latest_observation.observed_at, '2026-09-22T12:00:00.000Z');
++ assert.ok(!('current_price' in json) && !('price' in json), 'the offer has no "current price" field, only observations');
++ });
++
++ await check('past the freshness window the same price is stale: labelled, noindex (stale_price), out of JSON-LD and sitemaps', async () => {
++ t.clock.advance(49 * HOUR);
++ const page = await t.get(`/d/${offer.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /data-freshness="stale"/);
++ assert.doesNotMatch(page.text, /data-freshness="fresh"/);
++ assert.match(page.text, /may no longer be available/);
++ assert.match(page.text, /as of <time datetime="2026-09-22T12:00:00.000Z">/);
++ assert.strictEqual(robotsOf(page.text), 'noindex, follow');
++ assert.strictEqual(page.headers.get('x-robots-tag'), 'noindex, follow');
++ const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
++ const o = ld.offers[0];
++ assert.ok(!('price' in o) && !('priceCurrency' in o) && !('availability' in o), `stale JSON-LD must not state a price: ${JSON.stringify(o)}`);
++ const json = await t.offerJson(offer.slug);
++ assert.strictEqual(json.freshness.state, 'stale');
++ assert.deepStrictEqual(json.indexability.reasons.map((r) => r.code), ['stale_price']);
++ assert.strictEqual(json.latest_observation.price, '49.99', 'history is kept, with its time');
++ const sm = await t.get('/sitemaps/offers.xml');
++ assert.ok(!sm.text.includes(offer.slug));
++ const list = await t.get('/');
++ assert.match(list.text, /data-freshness="stale"/);
++ });
++
++ await check('nothing on the site calls a price "current"', async () => {
++ for (const p of [`/d/${offer.slug}`, '/', '/new', `/d/${offer.slug}.json`, '/feed.xml', '/llms.txt']) {
++ const r = await t.get(p);
++ assert.doesNotMatch(r.text, /current price|price now:/i, p);
++ }
++ });
++
++ await check('the worker re-indexes a price that went stale: one new Search document (noindex), then nothing', async () => {
++ const before = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);
++ await t.ctx.worker.tick();
++ const after = t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id);
++ assert.strictEqual(after.length, before.length + 1);
++ const doc = after[after.length - 1].payload;
++ assert.strictEqual(doc.facets.freshness, 'stale');
++ assert.strictEqual(doc.indexability.decision, 'noindex');
++ assert.ok(doc.revision > before[before.length - 1].payload.revision);
++ await t.ctx.worker.tick();
++ assert.strictEqual(t.events('deals.index_document.upserted').filter((e) => e.payload.id === offer.id).length, after.length);
++ });
++
++ await check('a new observation makes it fresh again, and the history keeps both, newest first', async () => {
++ const r = await t.get(`/d/${offer.slug}/observe`, { as: bob, form: { price: '44.00', currency: 'USD', availability: 'limited' } });
++ assert.strictEqual(r.status, 303);
++ const json = await t.offerJson(offer.slug);
++ assert.strictEqual(json.freshness.state, 'fresh');
++ assert.strictEqual(json.latest_observation.price, '44.00');
++ assert.deepStrictEqual(json.observations.map((o) => o.price), ['44.00', '49.99']);
++ assert.strictEqual(json.observations[0].observed_by, bob.subject);
++ const page = await t.get(`/d/${offer.slug}`);
++ assert.strictEqual(robotsOf(page.text), 'index, follow');
++ assert.strictEqual(jsonLd(page.text).find((x) => x['@type'] === 'Product').offers[0].price, '44.00');
++ });
++
++ await check('an imported observation is dated by when the source saw it: old retrieval → stale from the start', async () => {
++ t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ1', title: 'Old TV offer', url: 'https://tv.example/old', fields: { price: '300', currency: 'EUR' }, retrievedAt: t.clock.now() - 72 * HOUR }));
++ await t.ctx.importer.pull();
++ const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://tv.example/old'").get();
++ const json = await t.offerJson(o.slug);
++ assert.strictEqual(json.latest_observation.observed_at, new Date(t.clock.now() - 72 * HOUR).toISOString());
++ assert.strictEqual(json.freshness.state, 'stale');
++ assert.ok(json.indexability.reasons.some((r) => r.code === 'stale_price'));
++ });
++
++ await check('an offer can never be fresh without an observation: freshness is derived only from observed_at', async () => {
++ const v = t.ctx.publication.freshness(null, t.clock.now());
++ assert.strictEqual(v.state, 'unobserved');
++ const d = t.ctx.publication.decideOffer({ slug: 'x', status: 'active', review_state: 'not_required', text_origin: 'human', expires_at: null }, null, t.clock.now());
++ assert.ok(d.codes.includes('stale_price'));
++ assert.strictEqual(d.indexable, false);
++ });
++
++ await check('an observation dated in the future is refused', async () => {
++ const token = t.network.serviceToken('ai', ['deals.offer.update']);
++ const r = await t.get(`/api/v1/offers/${offer.id}/observations`, { as: token, headers: { 'X-OV-Subject': bob.subject }, json: { price: '1', currency: 'USD', observed_at: new Date(t.clock.now() + 2 * HOUR).toISOString() } });
++ assert.strictEqual(r.status, 422);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..d780d05
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * Boots Deals on a temp database with a controllable clock and mocks of its neighbours, and returns
++ * a small HTTP client. Every test file gets its own instance.
++ *
++ * const t = await boot(); // t.get(path, { as: user | token, form, json, method, headers })
++ * t.clock.advance(ms) // the app's clock (freshness, hotness, limits, gate)
++ * t.events('deals.watch.matched') // envelopes in event_outbox
++ * t.submit(user, { url, title, price, … }) // a deal through the no-JS form, → offer row
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { startNetwork, startCommunity, startSources } = require('./mocks');
++
++const HOUR = 3600 * 1000;
++
++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
++ let t = start;
++ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork();
++ const community = await startCommunity({ network });
++ const sources = await startSources({ network });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deals-test-'));
++ const dbPath = path.join(dir, 'deals.db');
++ const clock = opts.clock || makeClock();
++ const mod = network.addUser('moddy', { display_name: 'Mod', role: 'global_mod' });
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.deals', TRUST_PROXY: '1',
++ DEALS_DB_PATH: dbPath,
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'deals', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
++ OV_COMMUNITY_URL: 'https://openvibe.community', OV_COMMUNITY_INTERNAL_URL: community.url,
++ OV_SOURCES_INTERNAL_URL: sources.url,
++ DEALS_WORKER: 'off', DEALS_FORM_SECRET: 'test-form-secret', DEALS_IP_HASH_SECRET: 'test-ip-secret',
++ DEALS_EVENTS_SECRET: 'whsec-test',
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
++
++ let server = null;
++ let built = null;
++ async function start() {
++ const config = configLib.load(env);
++ built = createApp({ config, now: clock.now, log: quiet });
++ await built.ctx.auth.ensureKey();
++ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ t.base = `http://127.0.0.1:${server.address().port}`;
++ t.app = built.app;
++ t.ctx = built.ctx;
++ }
++ async function stop() {
++ if (server) await new Promise((r) => server.close(r));
++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
++ server = null; built = null;
++ }
++
++ /** as: a network user ({ subject, … }) → ov_token cookie; a string → Bearer token. ip → X-Forwarded-For. */
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as && typeof o.as === 'object') headers.cookie = `ov_token=${network.userToken(o.as)}`;
++ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
++ if (o.ip) headers['x-forwarded-for'] = o.ip;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) {
++ const f = { ...o.form };
++ if (o.as && typeof o.as === 'object' && f.csrf === undefined) f.csrf = t.csrf(o.as);
++ body = new URLSearchParams(f).toString();
++ headers['content-type'] = 'application/x-www-form-urlencoded';
++ }
++ const res = await fetch(t.base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
++ const text = await res.text();
++ return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
++ }
++
++ /** Rows of event_outbox as parsed envelopes. */
++ function events(type = null) {
++ return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
++ .filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
++ }
++
++ /** Submit through the no-JS form; → the offer row. */
++ async function submit(user, fields, { ip } = {}) {
++ const r = await get('/submit', { as: user, form: fields, ip });
++ if (r.status !== 303) throw new Error(`submit answered ${r.status}: ${r.text.slice(0, 400)}`);
++ const slug = decodeURIComponent(r.headers.get('location').replace(/^\/d\//, '').replace(/\?.*$/, ''));
++ return t.ctx.reads.find(slug);
++ }
++
++ function offerJson(slug) { return get(`/d/${slug}.json`).then((r) => r.json().offer); }
++
++ const t = {
++ network, community, sources, clock, dbPath, mod, get, events, submit, offerJson, HOUR,
++ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.DEALS_FORM_SECRET }, user),
++ async restart() { await stop(); await start(); },
++ async close() { await stop(); await network.close(); await community.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
++ };
++ await start();
++ return t;
++}
++
++/** JSON-LD blocks of an HTML page. */
++function jsonLd(htmlText) {
++ return [...htmlText.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++}
++
++const robotsOf = (htmlText) => (/<meta name="robots" content="([^"]+)"/.exec(htmlText) || [])[1];
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done, makeClock, jsonLd, robotsOf, HOUR };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..6a050e7
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,193 @@
++'use strict';
++/**
++ * In-process stand-ins for Deals' neighbours, with a real RS256 key pair:
++ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as
++ * capabilities), /internal/identity/resolve-batch (needs identity.subject.resolve)
++ * Community /api/v1/comments/threads/resolve, GET thread, POST comment, PUT visibility
++ * Sources /api/v1/items?category=deals&after= and /api/v1/items/:id (needs sources.item.read)
++ * userToken()/serviceToken() mint the tokens browsers and services present to Deals.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ handler(req, raw, json, res);
++ });
++ });
++ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));
++ });
++}
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const directory = new Map(); // subject → { username, display_name }
++ const grants = [];
++ let issuer = null;
++ const srv = await listen((req, raw, json) => {
++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (req.url === '/oauth/token' && req.method === 'POST') {
++ let body = {};
++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
++ else { try { body = JSON.parse(raw); } catch { /* */ } }
++ grants.push(body);
++ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'client_credentials') {
++ let scope = body.scope;
++ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
++ const cap = String(scope || '').split(/\s+/).filter(Boolean);
++ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++ }
++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const token = String(req.headers.authorization || '').slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok || !(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied' });
++ const b = JSON.parse(raw || '{}');
++ const results = {};
++ for (const s of b.subject_ids || []) {
++ const u = directory.get(s);
++ results[s] = u ? { subject: { type: 'user', id: s }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++ function signService({ sub, aud, cap, ns }) {
++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
++ }
++ /** extra.mintedAt: the time embedded in the usr_ ULID (default: long ago, i.e. an established account). */
++ function addUser(username, extra = {}) {
++ const subject = ids.newId('user', extra.mintedAt != null ? extra.mintedAt : Date.parse('2025-01-01T00:00:00Z'));
++ const u = { subject, username, display_name: extra.display_name || username[0].toUpperCase() + username.slice(1), role: extra.role || 'user' };
++ directory.set(subject, u);
++ return u;
++ }
++ function userToken(u) {
++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
++ }
++ function serviceToken(client, cap) {
++ return signService({ sub: `svc:${client}`, aud: ['openvibe.deals'], cap });
++ }
++ return { ...srv, publicPem, grants, directory, addUser, userToken, serviceToken, signService };
++}
++
++async function startCommunity({ network }) {
++ const threads = new Map(); // id → { ref, visibility, comments: [] }
++ const byRef = new Map();
++ const calls = [];
++ let down = false;
++ let seq = 0;
++ const srv = await listen((req, raw, json) => {
++ calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, auth: Boolean(req.headers.authorization) });
++ if (down) return json(503, { code: 'down' });
++ const auth = () => {
++ const token = String(req.headers.authorization || '').slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.community' });
++ return v.ok ? v.claims : null;
++ };
++ if (req.url === '/api/v1/comments/threads/resolve' && req.method === 'POST') {
++ const c = auth();
++ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });
++ const { ref } = JSON.parse(raw);
++ const key = `${ref.service}:${ref.type}:${ref.id}`;
++ if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });
++ const id = ++seq;
++ threads.set(id, { ref, visibility: 'public', comments: [] });
++ byRef.set(key, id);
++ return json(201, { thread: { id, visibility: 'public' }, created: true });
++ }
++ let m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)(\?.*)?$/);
++ if (m && req.method === 'GET') {
++ const t = threads.get(Number(m[1]));
++ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });
++ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility, comment_count: t.comments.length }, comments: t.comments, next_cursor: null });
++ }
++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/comments$/);
++ if (m && req.method === 'POST') {
++ const c = auth();
++ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });
++ const t = threads.get(Number(m[1]));
++ const body = JSON.parse(raw);
++ const who = network.directory.get(req.headers['x-ov-subject']);
++ const comment = { id: t.comments.length + 1, origin: 'user', display_name: who ? who.display_name : 'Someone', message: body.message, deleted: false, created_at: new Date().toISOString() };
++ t.comments.push(comment);
++ return json(201, { comment });
++ }
++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
++ if (m && req.method === 'PUT') {
++ const c = auth();
++ if (!c || !(c.cap || []).includes('community.comment.moderate')) return json(403, { code: 'capability.denied' });
++ threads.get(Number(m[1])).visibility = JSON.parse(raw).visibility;
++ return json(200, { ok: true });
++ }
++ return json(404, { code: 'route.not_found' });
++ });
++ return { ...srv, threads, calls, setDown: (v) => { down = v; } };
++}
++
++async function startSources({ network }) {
++ const items = []; // full sources.item@1 views with change_seq
++ let down = false;
++ const calls = [];
++ const srv = await listen((req, raw, json) => {
++ calls.push(req.url);
++ if (down) return json(503, { code: 'down' });
++ const token = String(req.headers.authorization || '').slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.sources' });
++ if (!v.ok || !(v.claims.cap || []).includes('sources.item.read')) return json(403, { code: 'capability.denied' });
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/items') {
++ const after = Number(u.searchParams.get('after') || 0);
++ const limit = Number(u.searchParams.get('limit') || 100);
++ const cat = u.searchParams.get('category');
++ const rows = items.filter((i) => i.change_seq > after && (!cat || i.category === cat)).sort((a, b) => a.change_seq - b.change_seq);
++ const page = rows.slice(0, limit);
++ return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: {} });
++ }
++ const m = u.pathname.match(/^\/api\/v1\/items\/(itm_[0-9A-Z]+)$/);
++ if (m) {
++ const it = items.find((i) => i.id === m[1]);
++ return it ? json(200, { item: it, source: { key: it.source_key, status: 'healthy', stale: false } }) : json(404, { code: 'sources.not_found' });
++ }
++ return json(404, { code: 'route.not_found' });
++ });
++ let seq = 0;
++ /** Add or replace an item (a new change_seq each time, like Sources). */
++ function put(item) {
++ const i = items.findIndex((x) => x.id === item.id);
++ const full = { change_seq: ++seq, ...item };
++ full.change_seq = seq;
++ if (i >= 0) items[i] = full; else items.push(full);
++ return full;
++ }
++ return { ...srv, items, calls, put, setDown: (v) => { down = v; } };
++}
++
++/** A sources.item@1 view as OpenVibe.Sources serves it. */
++function sourceItem({ id, kind = 'offer', title = 'Something', url = 'https://shop.example/p/1', fields = {}, retrievedAt, revision = 1, removed = null, sourceKey = 'shop-jsonld', summary = null }) {
++ return {
++ id, source_key: sourceKey, category: 'deals', kind, identity: url, canonical_url: url, title, summary, authors: [],
++ published_at: null, source_updated_at: null, fields, revision,
++ provenance: {
++ retrieved_at: new Date(retrievedAt).toISOString(), first_seen_at: new Date(retrievedAt).toISOString(),
++ content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'jsonld@1', fetch_run_id: null,
++ license_note: 'facts only', terms_note: 'checked', entered_by: null,
++ },
++ removed,
++ };
++}
++
++module.exports = { startNetwork, startCommunity, startSources, sourceItem, listen };
ddiff --git a/test/hotness.test.js b/test/hotness.test.js
nnew file mode 100644
iindex 0000000..ea274e6
---- /dev/null
++++ b/test/hotness.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * Hotness is server-computed with one documented, deterministic formula (hot@1), from stored
++ * rows only, and every snapshot can be recomputed from its own inputs.
++ */
++const assert = require('assert');
++const { boot, check, done, HOUR } = require('./helpers/boot');
++const { formula } = require('../server/domain/hotness');
++
++(async () => {
++ await check('hot@1 is a pure function with fixed rounding (worked examples from the README)', async () => {
++ assert.deepStrictEqual(formula({ upWeight: 3, downWeight: 1, firstSeenAt: 0, t: 10 * HOUR }), { formula: 'hot@1', score: 2, hot: 0.048113, ageHours: 10 });
++ assert.deepStrictEqual(formula({ upWeight: 2, downWeight: 0, firstSeenAt: 0, t: 0 }), { formula: 'hot@1', score: 2, hot: 0.707107, ageHours: 0 });
++ assert.strictEqual(formula({ upWeight: 0.25, downWeight: 1, firstSeenAt: 0, t: 2 * HOUR }).hot, -0.09375);
++ assert.strictEqual(formula({ upWeight: 1, downWeight: 0, firstSeenAt: 5 * HOUR, t: 0 }).ageHours, 0, 'a clock skew never makes an offer younger than new');
++ for (let i = 0; i < 50; i++) assert.deepStrictEqual(formula({ upWeight: 7.75, downWeight: 2.25, firstSeenAt: 1000, t: 1000 + 37 * HOUR }), formula({ upWeight: 7.75, downWeight: 2.25, firstSeenAt: 1000, t: 1000 + 37 * HOUR }));
++ assert.throws(() => formula({ upWeight: NaN, downWeight: 0, firstSeenAt: 0, t: 0 }));
++ });
++
++ const t = await boot();
++ const users = Array.from({ length: 6 }, (_, i) => t.network.addUser(`u${i}`));
++ const poster = t.network.addUser('poster');
++
++ await check('the same votes in a different order give identical snapshots', async () => {
++ const X = await t.submit(poster, { url: 'https://a.example/1', title: 'Offer X', price: '10', currency: 'USD' });
++ const Y = await t.submit(poster, { url: 'https://a.example/2', title: 'Offer Y', price: '10', currency: 'USD' });
++ t.clock.advance(3 * HOUR);
++ const plan = [[users[0], 'up'], [users[1], 'up'], [users[2], 'down'], [users[3], 'up']];
++ for (const [u, v] of plan) await t.get(`/d/${X.slug}/vote`, { as: u, form: { value: v } });
++ for (const [u, v] of [...plan].reverse()) await t.get(`/d/${Y.slug}/vote`, { as: u, form: { value: v } });
++ const sx = t.ctx.reads.lastSnapshot(X.id);
++ const sy = t.ctx.reads.lastSnapshot(Y.id);
++ for (const k of ['formula', 'up_count', 'down_count', 'up_weight', 'down_weight', 'score', 'hot']) assert.strictEqual(sx[k], sy[k], k);
++ assert.strictEqual(sx.score, 2);
++ assert.strictEqual(sx.hot, formula({ upWeight: 3, downWeight: 1, firstSeenAt: X.created_at, t: sx.computed_at }).hot);
++ });
++
++ await check('every stored snapshot recomputes to itself (GET /api/v1/offers/:id/hotness)', async () => {
++ const rows = t.ctx.store.db.prepare('SELECT * FROM deal_hotness_snapshots').all();
++ assert.ok(rows.length >= 6);
++ for (const s of rows) assert.strictEqual(t.ctx.hotness.recompute(s).hot, s.hot, `snapshot ${s.id}`);
++ const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
++ const r = (await t.get(`/api/v1/offers/${X.slug}/hotness`)).json();
++ assert.strictEqual(r.recomputed.hot, r.snapshot.hot);
++ assert.match(r.formula, /hot@1/);
++ });
++
++ await check('clients cannot set weight, score or hotness: only value is read', async () => {
++ const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
++ const before = t.ctx.reads.lastSnapshot(X.id);
++ const r = await t.get(`/api/v1/offers/${X.id}/vote`, { method: 'PUT', as: users[4], json: { value: 1, weight: 100, score: 999, hot: 999 } });
++ assert.strictEqual(r.status, 200, r.text);
++ const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ? AND offer_id = ?').get(users[4].subject, X.id);
++ assert.strictEqual(row.weight, 1);
++ const after = t.ctx.reads.lastSnapshot(X.id);
++ assert.strictEqual(after.up_weight, before.up_weight + 1);
++ });
++
++ await check('accounts new to OpenVibe count 0.25 until there is evidence they are a week old', async () => {
++ const X = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Offer X'").get();
++ const fresh = t.network.addUser('newbie', { mintedAt: t.clock.now() - 2 * 24 * HOUR });
++ await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'up' } });
++ const row = t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject);
++ assert.strictEqual(row.weight, 0.25);
++ const snap = t.ctx.reads.lastSnapshot(X.id);
++ assert.strictEqual(snap.up_count, 5);
++ assert.strictEqual(snap.up_weight, 4.25);
++ // Six days later the subject id proves ≥ 7 days: a new vote carries weight 1.
++ t.clock.advance(6 * 24 * HOUR);
++ await t.get(`/d/${X.slug}/vote`, { as: fresh, form: { value: 'down' } });
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT weight FROM deal_votes WHERE subject = ?').get(fresh.subject).weight, 1);
++ });
++
++ await check('the worker snapshots every active offer at one shared t; the hot list follows those snapshots', async () => {
++ const Z = await t.submit(poster, { url: 'https://a.example/3', title: 'Offer Z', price: '10', currency: 'USD' });
++ for (const u of users.slice(0, 5)) await t.get(`/d/${Z.slug}/vote`, { as: u, form: { value: 'up' } });
++ t.clock.advance(HOUR);
++ await t.ctx.worker.tick();
++ const ticks = t.ctx.store.db.prepare("SELECT offer_id, computed_at, hot FROM deal_hotness_snapshots WHERE reason = 'tick'").all();
++ assert.strictEqual(new Set(ticks.map((x) => x.computed_at)).size, 1);
++ assert.strictEqual(ticks.length, 3);
++ const list = await t.get('/api/v1/offers?sort=hot');
++ const order = list.json().offers.map((o) => o.title);
++ assert.strictEqual(order[0], 'Offer Z');
++ const hots = list.json().offers.map((o) => o.hotness.hot);
++ assert.deepStrictEqual(hots, [...hots].sort((a, b) => b - a));
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..1d67df5
---- /dev/null
++++ b/test/import.test.js
@@@ -0,0 +1,158 @@
++'use strict';
++/**
++ * Imports from OpenVibe.Sources (deals category): observations dated by retrieval, idempotent
++ * replays, dedupe by link, removal, re-confirmation, the signed event wake-up and failure states.
++ */
++const assert = require('assert');
++const { boot, check, done, robotsOf, HOUR } = require('./helpers/boot');
++const { sourceItem } = require('./helpers/mocks');
++const { signDelivery } = require('openvibe-sdk/events');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const byUrl = (u) => t.ctx.store.db.prepare('SELECT * FROM deal_offers WHERE url = ?').get(u);
++ const obsOf = (id) => t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at, rowid').all(id);
++ const T0 = t.clock.now();
++
++ await check('offer, product and article items become offers; values exactly as stated; observed_at = retrieved_at', async () => {
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1', title: 'Espresso machine', url: 'https://coffee.example/espresso', retrievedAt: T0 - HOUR,
++ fields: { price: '249.00', currency: 'EUR', availability: 'InStock', condition: 'RefurbishedCondition', valid_until: '2026-10-01', seller: 'Coffee Shop' } }));
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA2', kind: 'product', title: 'Acme Kettle 2000', url: 'https://kettles.example/acme-2000', retrievedAt: T0,
++ fields: { brand: 'Acme', gtin: '4006381333931', offers: [
++ { price: '39.99', currency: 'USD', availability: 'InStock', url: 'https://kettles.example/acme-2000?utm_source=feed', seller: 'Kettles' },
++ { price: null, currency: null, availability: 'OutOfStock', url: 'https://market.example/acme-2000', seller: 'Market' },
++ ] } }));
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3', kind: 'article', title: 'Weekend sale on garden tools', url: 'https://news.example/garden', retrievedAt: T0 }));
++ const s = await t.ctx.importer.pull();
++ assert.deepStrictEqual(s.outcomes, { created: 4 }, JSON.stringify(s));
++ assert.strictEqual(s.items, 3);
++
++ const esp = byUrl('https://coffee.example/espresso');
++ assert.strictEqual(esp.origin, 'import');
++ assert.strictEqual(esp.review_state, 'pending');
++ assert.strictEqual(esp.expires_at, Date.parse('2026-10-01'));
++ const o = obsOf(esp.id)[0];
++ assert.deepStrictEqual([o.price, o.currency, o.availability, o.condition, o.observed_at, o.origin], ['249.00', 'EUR', 'in_stock', 'refurbished', T0 - HOUR, 'import']);
++ assert.strictEqual(t.ctx.catalog.store(esp.store_id).name, 'Coffee Shop');
++
++ const k1 = byUrl('https://kettles.example/acme-2000');
++ const k2 = byUrl('https://market.example/acme-2000');
++ assert.ok(k1 && k2 && k1.product_id && k1.product_id === k2.product_id, 'one product, two offers (tracking parameter stripped)');
++ assert.strictEqual(obsOf(k2.id)[0].price, null);
++ assert.strictEqual(obsOf(k2.id)[0].availability, 'out_of_stock');
++ const product = t.ctx.catalog.product(k1.product_id);
++ assert.strictEqual(product.brand, 'Acme', 'the brand the source stated');
++ const p = (await t.get(`/p/${product.slug}.json`)).json();
++ assert.strictEqual(p.offers.length, 2);
++ assert.ok(p.product.aliases.some((a) => a.kind === 'gtin'));
++ });
++
++ await check('imported text is noindex until a person reviews it; review is recorded and re-indexes', async () => {
++ const esp = byUrl('https://coffee.example/espresso');
++ const page = await t.get(`/d/${esp.slug}`);
++ assert.strictEqual(robotsOf(page.text), 'noindex, follow');
++ assert.match(page.text, /imported from a source and has not been reviewed/);
++ const json = await t.offerJson(esp.slug);
++ const reason = json.indexability.reasons.find((r) => r.code === 'noindex_requested');
++ assert.match(reason.detail, /review_pending/);
++ const r = await t.get(`/mod/offers/${esp.slug}/review`, { as: t.mod, form: {} });
++ assert.strictEqual(r.status, 303);
++ const after = await t.offerJson(esp.slug);
++ assert.strictEqual(after.review_state, 'reviewed');
++ assert.strictEqual(after.indexability.indexable, true);
++ const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === esp.id).pop().payload;
++ assert.strictEqual(doc.indexability.decision, 'index');
++ assert.strictEqual(doc.authorship, 'imported');
++ assert.deepStrictEqual(doc.provenance.map((x) => [x.service, x.type, x.id]), [['sources', 'item', 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1']]);
++ });
++
++ await check('replaying the feed changes nothing (same revision, same retrieval)', async () => {
++ const count = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n;
++ const events = t.events().length;
++ t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
++ const s = await t.ctx.importer.pull();
++ assert.deepStrictEqual(s.outcomes, { unchanged: 4 });
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations').get().n, count);
++ assert.strictEqual(t.events().length, events);
++ });
++
++ await check('a new revision is a new observation; the history keeps the old price with its time', async () => {
++ t.clock.advance(2 * HOUR);
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA1', title: 'Espresso machine', url: 'https://coffee.example/espresso', retrievedAt: t.clock.now(), revision: 2,
++ fields: { price: '229.00', currency: 'EUR', availability: 'LimitedAvailability', seller: 'Coffee Shop' } }));
++ const s = await t.ctx.importer.pull();
++ assert.deepStrictEqual(s.outcomes, { updated: 1 });
++ const esp = byUrl('https://coffee.example/espresso');
++ assert.deepStrictEqual(obsOf(esp.id).map((o) => [o.price, o.source_revision]), [['249.00', 1], ['229.00', 2]]);
++ assert.strictEqual((await t.offerJson(esp.slug)).latest_observation.price, '229.00');
++ });
++
++ await check('the same link from a person or another item attaches to the existing offer', async () => {
++ const dup = await t.get('/submit', { as: alice, form: { url: 'https://coffee.example/espresso?utm_campaign=x#top', title: 'Espresso machine!', price: '229', currency: 'EUR' } });
++ assert.strictEqual(dup.status, 303);
++ assert.match(dup.headers.get('location'), /\?duplicate=1$/);
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAA4', sourceKey: 'other-feed', kind: 'article', title: 'Espresso deal', url: 'http://www.coffee.example/espresso', retrievedAt: t.clock.now() }));
++ const s = await t.ctx.importer.pull();
++ assert.deepStrictEqual(s.outcomes, { attached: 1 });
++ const esp = byUrl('https://coffee.example/espresso');
++ const json = await t.offerJson(esp.slug);
++ assert.deepStrictEqual(json.sources.map((x) => x.key).sort(), ['other-feed', 'shop-jsonld']);
++ });
++
++ await check('an imported offer is re-confirmed when Sources fetched the unchanged item again', async () => {
++ t.clock.advance(30 * HOUR);
++ const it = t.sources.items.find((i) => i.id === 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3');
++ it.provenance = { ...it.provenance, retrieved_at: new Date(t.clock.now() - HOUR).toISOString() };
++ const r = await t.ctx.importer.refresh();
++ assert.ok(r.outcomes.refreshed >= 1, JSON.stringify(r));
++ const garden = byUrl('https://news.example/garden');
++ const obs = obsOf(garden.id);
++ assert.strictEqual(obs.length, 2);
++ assert.strictEqual(obs[1].origin, 'import_refresh');
++ assert.strictEqual(obs[1].observed_at, t.clock.now() - HOUR);
++ });
++
++ await check('a removed item removes its source; an imported offer with no source left is disabled (410, Search tombstone)', async () => {
++ t.sources.put({ ...t.sources.items.find((i) => i.id === 'itm_01K5AAAAAAAAAAAAAAAAAAAAA3'), removed: { at: new Date(t.clock.now()).toISOString(), reason: 'licence withdrawn' } });
++ const s = await t.ctx.importer.pull();
++ assert.deepStrictEqual(s.outcomes, { removed: 1 });
++ const garden = byUrl('https://news.example/garden');
++ assert.strictEqual(garden.status, 'disabled');
++ assert.match(garden.disabled_reason, /licence withdrawn/);
++ assert.strictEqual((await t.get(`/d/${garden.slug}`)).status, 410);
++ assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === garden.id));
++ assert.ok(!(await t.get('/feed.xml')).text.includes(garden.slug));
++ });
++
++ await check('the signed event wake-up: bad signature 401, deals items scheduled once, others ignored', async () => {
++ const env = (id, category) => ({ event: { event_id: id, event_type: 'sources.item.updated', source: 'sources', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'sources' }, subject: { type: 'item', id: 'itm_x' }, visibility: 'internal', payload: { category } }, seq: 1 });
++ const post = (body, sig) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig } });
++ const good = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA1', 'deals'));
++ assert.strictEqual((await post(good, 'sha256=00')).status, 401);
++ const r1 = await post(good, signDelivery(good, 'whsec-test'));
++ assert.deepStrictEqual([r1.status, r1.json().outcome, r1.json().duplicate], [200, 'import_scheduled', false]);
++ const r2 = await post(good, signDelivery(good, 'whsec-test'));
++ assert.strictEqual(r2.json().duplicate, true);
++ const news = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA2', 'news'));
++ assert.strictEqual((await post(news, signDelivery(news, 'whsec-test'))).json().outcome, 'ignored');
++ });
++
++ await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {
++ const cursor = t.ctx.importer.cursor();
++ const offers = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n;
++ t.sources.setDown(true);
++ await t.ctx.importer.pull();
++ assert.strictEqual(t.ctx.importer.cursor(), cursor);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, offers);
++ const ready = (await t.get('/api/ready')).json();
++ assert.strictEqual(ready.checks.sources_import.status, 'fail');
++ assert.match(ready.checks.sources_import.reason || JSON.stringify(ready.checks.sources_import), /Sources answered 503/);
++ t.sources.setDown(false);
++ await t.ctx.importer.pull();
++ assert.strictEqual((await t.get('/api/ready')).json().checks.sources_import.status, 'ok');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/merge.test.js b/test/merge.test.js
nnew file mode 100644
iindex 0000000..9a56195
---- /dev/null
++++ b/test/merge.test.js
@@@ -0,0 +1,120 @@
++'use strict';
++/**
++ * Duplicate offers merge without losing votes or history; a voter who voted on both counts once;
++ * the merge is audited and reversible.
++ */
++const assert = require('assert');
++const { boot, check, done, HOUR } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const [s1, s2, v1, v2, v3, v4, eve] = ['s1', 's2', 'v1', 'v2', 'v3', 'v4', 'eve'].map((n) => t.network.addUser(n));
++ const vote = (u, slug, value) => t.get(`/d/${slug}/vote`, { as: u, form: { value } });
++ let A, B, beforeB;
++
++ await check('two listings of one deal, each with its own votes and observations', async () => {
++ A = await t.submit(s1, { url: 'https://shop.example/tv-55', title: '55" TV deal', price: '399', currency: 'USD' });
++ t.clock.advance(10 * 60 * 1000);
++ B = await t.submit(s2, { url: 'https://shop.example/tv-55-inch?color=black', title: '55 inch TV at Shop', price: '399.00', currency: 'USD' });
++ await t.get(`/d/${B.slug}/observe`, { as: v3, form: { price: '389', currency: 'USD' } });
++ for (const [u, s, v] of [[v1, A, 'up'], [v2, A, 'up'], [v1, B, 'up'], [v3, B, 'down']]) {
++ t.clock.advance(60 * 1000);
++ assert.strictEqual((await vote(u, s.slug, v)).status, 303);
++ }
++ const a = await t.offerJson(A.slug);
++ beforeB = await t.offerJson(B.slug);
++ assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 0]);
++ assert.deepStrictEqual([beforeB.votes.up, beforeB.votes.down], [1, 1]);
++ assert.strictEqual(beforeB.observations.length, 2);
++ });
++
++ await check('only moderators merge', async () => {
++ const r = await t.get(`/mod/offers/${B.slug}/merge`, { as: eve, form: { into: A.slug } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(t.ctx.reads.get(B.id).merged_into, null);
++ });
++
++ await check('merge B into A: the voter who voted on both counts once, every observation and source is kept', async () => {
++ const r = await t.get(`/mod/offers/${B.slug}/merge`, { as: t.mod, form: { into: A.slug, reason: 'same TV, same store' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const a = await t.offerJson(A.slug);
++ assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 1], 'v1 (both), v2 up; v3 down');
++ assert.strictEqual(a.observations.length, 3);
++ assert.deepStrictEqual(a.observations.map((o) => o.price), ['389', '399.00', '399']);
++ assert.strictEqual(a.latest_observation.price, '389', 'the latest observation of the group');
++ assert.strictEqual(a.sources.length, 3);
++ assert.deepStrictEqual(a.merged_from.map((m) => m.slug), [B.slug]);
++ const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_votes').get().n;
++ assert.strictEqual(rows, 4, 'no vote row was deleted, moved or copied');
++ });
++
++ await check('the merged listing redirects to the canonical one (page and JSON), and Search drops it', async () => {
++ const page = await t.get(`/d/${B.slug}`);
++ assert.strictEqual(page.status, 301);
++ assert.strictEqual(page.headers.get('location'), `/d/${A.slug}`);
++ const json = await t.get(`/d/${B.slug}.json`);
++ assert.strictEqual(json.headers.get('location'), `/d/${A.slug}.json`);
++ const tomb = t.events('deals.index_document.deleted').filter((e) => e.payload.id === B.id);
++ assert.strictEqual(tomb.length, 1);
++ const upd = t.events('deals.offer.updated').filter((e) => e.payload.merged_offer_id === B.id);
++ assert.strictEqual(upd.length, 1);
++ assert.strictEqual(upd[0].subject.id, A.id);
++ });
++
++ await check('the merge is audited with the tallies before and after', async () => {
++ const log = t.ctx.store.db.prepare("SELECT * FROM moderation_log WHERE action = 'merge'").all();
++ assert.strictEqual(log.length, 1);
++ assert.strictEqual(log[0].offer_id, B.id);
++ assert.strictEqual(log[0].target_id, A.id);
++ assert.strictEqual(log[0].actor, t.mod.subject);
++ assert.strictEqual(log[0].reason, 'same TV, same store');
++ const before = JSON.parse(log[0].before);
++ assert.deepStrictEqual([before.target.up, before.duplicate.up, before.duplicate.down], [2, 1, 1]);
++ assert.deepStrictEqual([JSON.parse(log[0].after).target.up, JSON.parse(log[0].after).target.down], [2, 1]);
++ });
++
++ await check('a vote cast through the merged listing lands on the canonical offer; changing a vote counts once', async () => {
++ t.clock.advance(60 * 1000);
++ assert.strictEqual((await vote(v4, B.slug, 'up')).status, 303);
++ const row = t.ctx.store.db.prepare('SELECT offer_id FROM deal_votes WHERE subject = ?').get(v4.subject);
++ assert.strictEqual(row.offer_id, A.id);
++ t.clock.advance(60 * 1000);
++ await vote(v1, A.slug, 'down');
++ const a = await t.offerJson(A.slug);
++ assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 2], 'v2, v4 up; v1 (now down, once), v3 down');
++ assert.strictEqual(t.ctx.votes.mine(v1.subject, t.ctx.reads.get(A.id)), -1);
++ });
++
++ await check('merging the canonical offer into its own duplicate is refused (no cycles)', async () => {
++ const r = await t.get(`/api/v1/offers/${A.id}/merge`, { as: t.mod, json: { into: B.id } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json().code, 'offer.merge_cycle');
++ });
++
++ await check('unmerge restores B exactly as it was (its own votes and observations); A keeps what was cast on it', async () => {
++ const r = await t.get(`/mod/offers/${B.slug}/unmerge`, { as: t.mod, form: { reason: 'different model' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const b = await t.offerJson(B.slug);
++ assert.deepStrictEqual(b.votes, beforeB.votes);
++ assert.deepStrictEqual(b.observations.map((o) => o.id), beforeB.observations.map((o) => o.id));
++ assert.deepStrictEqual(b.sources.map((s) => s.id), beforeB.sources.map((s) => s.id));
++ assert.strictEqual(b.merged_from.length, 0);
++ const a = await t.offerJson(A.slug);
++ assert.deepStrictEqual([a.votes.up, a.votes.down], [2, 1], 'A: v2, v4 up, v1 down (cast on A)');
++ assert.strictEqual(a.observations.length, 1);
++ assert.strictEqual((await t.get(`/d/${B.slug}`)).status, 200);
++ const log = t.ctx.store.db.prepare("SELECT action FROM moderation_log WHERE action IN ('merge','unmerge') ORDER BY id").all().map((x) => x.action);
++ assert.deepStrictEqual(log, ['merge', 'unmerge']);
++ const up = t.events('deals.index_document.upserted').filter((e) => e.payload.id === B.id).pop();
++ assert.ok(up, 'B is back in Search');
++ });
++
++ await check('hotness after merge and unmerge is computed from the group (snapshots record the reason)', async () => {
++ const reasons = t.ctx.store.db.prepare('SELECT reason FROM deal_hotness_snapshots WHERE offer_id = ? ORDER BY id').all(A.id).map((x) => x.reason);
++ assert.ok(reasons.includes('merge') && reasons.includes('unmerge'));
++ t.clock.advance(HOUR);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/missing-price.test.js b/test/missing-price.test.js
nnew file mode 100644
iindex 0000000..d1d9b7f
---- /dev/null
++++ b/test/missing-price.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * "Never fabricate missing prices": an unknown price stays unknown in the page, the JSON, JSON-LD,
++ * the price comparison, feeds and Search.
++ */
++const assert = require('assert');
++const { boot, check, done, jsonLd, HOUR } = require('./helpers/boot');
++const { sourceItem } = require('./helpers/mocks');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ let noPrice;
++
++ await check('a deal submitted without a price says "Price not stated"; JSON has null; JSON-LD Offer has no price at all', async () => {
++ noPrice = await t.submit(alice, { url: 'https://store.example/lamp', title: 'Desk lamp clearance', product_name: 'Acme desk lamp', product_gtin: '0012345678905' });
++ const page = await t.get(`/d/${noPrice.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Price not stated/);
++ assert.match(page.text, /data-price=""/);
++ const json = await t.offerJson(noPrice.slug);
++ assert.strictEqual(json.latest_observation.price, null);
++ assert.strictEqual(json.latest_observation.currency, null);
++ assert.strictEqual(json.latest_observation.availability, null);
++ const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
++ assert.strictEqual(ld.offers.length, 1);
++ const o = ld.offers[0];
++ assert.strictEqual(o['@type'], 'Offer');
++ for (const k of ['price', 'priceCurrency', 'availability', 'itemCondition', 'lowPrice', 'highPrice']) assert.ok(!(k in o), `JSON-LD must omit ${k}: ${JSON.stringify(o)}`);
++ assert.doesNotMatch(JSON.stringify(ld), /"price":\s*"?0/);
++ });
++
++ await check('a price without a currency is refused (422), never completed with an assumed currency', async () => {
++ const r = await t.get('/submit', { as: alice, form: { url: 'https://store.example/x', title: 'Currency test', price: '10' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /needs its currency/);
++ assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE url = 'https://store.example/x'").get().n, 0);
++ const bad = await t.get('/submit', { as: alice, form: { url: 'https://store.example/y', title: 'Symbol test', price: '$10', currency: 'USD' } });
++ assert.strictEqual(bad.status, 422);
++ });
++
++ await check('the price comparison ranks fresh stated prices first; unknown and stale never sort as cheap', async () => {
++ const product = t.ctx.catalog.product(noPrice.product_id);
++ // An older, cheaper offer that goes stale, then a fresh one with a stated price.
++ const stale = await t.submit(alice, { url: 'https://cheap.example/lamp', title: 'Desk lamp at Cheap', price: '5.00', currency: 'USD', product_name: 'Acme desk lamp' });
++ assert.strictEqual(stale.product_id, product.id, 'product resolved by its name alias');
++ t.clock.advance(50 * HOUR);
++ const fresh = await t.submit(alice, { url: 'https://fair.example/lamp', title: 'Desk lamp at Fair', price: '30.00', currency: 'USD', product_gtin: '12345678905' });
++ assert.strictEqual(fresh.product_id, product.id, 'product resolved by GTIN (normalised)');
++ // noPrice is stale too by now; refresh it with an availability-only observation (still no price).
++ const obs = await t.get(`/d/${noPrice.slug}/observe`, { as: alice, form: { availability: 'in_stock' } });
++ assert.strictEqual(obs.status, 303);
++ const r = await t.get(`/p/${product.slug}.json`);
++ const order = r.json().offers.map((o) => [o.slug, o.latest_observation.price, o.freshness.state]);
++ assert.deepStrictEqual(order.map((x) => x[0]), [fresh.slug, noPrice.slug, stale.slug], JSON.stringify(order));
++ assert.deepStrictEqual(order.map((x) => x[1]), ['30.00', null, '5.00']);
++ assert.deepStrictEqual(order.map((x) => x[2]), ['fresh', 'fresh', 'stale']);
++ const page = await t.get(`/p/${product.slug}`);
++ assert.match(page.text, /Price not stated/);
++ const ld = jsonLd(page.text).find((x) => x['@type'] === 'Product');
++ assert.strictEqual(ld.gtin, '0012345678905');
++ const prices = ld.offers.map((o) => o.price);
++ assert.deepStrictEqual(prices, ['30.00', undefined, undefined], 'only the fresh stated price reaches structured data');
++ });
++
++ await check('a price in an imported headline is not parsed into a price', async () => {
++ t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ2', kind: 'article', title: 'Acme blender for $19 + free shipping', url: 'https://deals.example/blender', retrievedAt: t.clock.now() }));
++ await t.ctx.importer.pull();
++ const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/blender'").get();
++ const json = await t.offerJson(o.slug);
++ assert.strictEqual(json.latest_observation.price, null);
++ assert.strictEqual(json.latest_observation.currency, null);
++ });
++
++ await check('a malformed imported price stays null (never repaired)', async () => {
++ t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZZ3', title: 'Weird price', url: 'https://deals.example/weird', fields: { price: 'call us', currency: 'USD', availability: 'SomethingElse' }, retrievedAt: t.clock.now() }));
++ await t.ctx.importer.pull();
++ const o = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE url = 'https://deals.example/weird'").get();
++ const json = await t.offerJson(o.slug);
++ assert.strictEqual(json.latest_observation.price, null);
++ assert.strictEqual(json.latest_observation.availability, null, 'an unknown availability term is not guessed');
++ });
++
++ await check('feeds and the Search document say "not stated", never 0', async () => {
++ const feed = await t.get('/feed.json');
++ const item = feed.json().items.find((i) => i.url.endsWith(noPrice.slug));
++ assert.match(item.summary, /Price not stated/);
++ const doc = t.events('deals.index_document.upserted').filter((e) => e.payload.id === noPrice.id).pop().payload;
++ assert.match(doc.body, /price not stated/);
++ assert.strictEqual(doc.facets.currency, undefined);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..12f75b3
---- /dev/null
++++ b/test/pages.test.js
@@@ -0,0 +1,192 @@
++'use strict';
++/**
++ * Server-rendered pages useful without JavaScript; expiry and moderation reach pages, feeds,
++ * sitemaps and Search; discovery artifacts; caching; operations endpoints. No seed deals.
++ */
++const assert = require('assert');
++const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));
++ let deal, timed;
++
++ await check('the site starts empty: no seed deals, an honest empty page that is noindex', async () => {
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get().n, 0);
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /No deals have been posted yet/);
++ assert.strictEqual(robotsOf(r.text), 'noindex, follow');
++ assert.match(r.headers.get('cache-control'), /public, max-age=60/);
++ });
++
++ await check('/submit: sign-in first; then a plain form with a form token; a forged token is refused', async () => {
++ const anon = await t.get('/submit');
++ assert.match(anon.text, /Sign in with your OpenVibe account/);
++ const page = await t.get('/submit', { as: alice });
++ assert.match(page.text, /<form method="post" action="\/submit"/);
++ assert.match(page.text, /name="csrf" value="[^"]+"/);
++ assert.match(page.headers.get('cache-control'), /private, no-store/);
++ const forged = await t.get('/submit', { as: alice, form: { url: 'https://x.example/', title: 'Nope', csrf: 'x' } });
++ assert.strictEqual(forged.status, 403);
++ const anonPost = await t.get('/submit', { form: { url: 'https://x.example/', title: 'Nope' } });
++ assert.strictEqual(anonPost.status, 303);
++ assert.match(anonPost.headers.get('location'), /^\/auth\/login/);
++ });
++
++ await check('a submitted deal page: price as of its time, history, sources, store, votes, JSON twin, JSON-LD, breadcrumbs', async () => {
++ deal = await t.submit(alice, { url: 'https://gadgets.example/drone?ref=abc', title: 'Mini drone with camera', price: '79.00', currency: 'USD', shipping: '4.99', condition: 'new', availability: 'in_stock', description: 'Folding **mini** drone.', store_name: 'Gadgets Inc' });
++ assert.strictEqual(deal.url, 'https://gadgets.example/drone', 'tracking parameter stripped');
++ const r = await t.get(`/d/${deal.slug}`);
++ assert.strictEqual(r.status, 200);
++ for (const re of [/Mini drone with camera/, /79.00 USD/, /\+ 4.99 USD shipping/, /Price history/, /Submitted by a member/, /Gadgets Inc/, /<strong>mini<\/strong>/, /\.json"/, /Sign in<\/a> to vote/]) assert.match(r.text, re);
++ assert.match(r.text, /rel="nofollow noopener ugc"/);
++ const types = jsonLd(r.text).map((x) => x['@type']);
++ assert.deepStrictEqual(types, ['Product', 'BreadcrumbList']);
++ const ld = jsonLd(r.text)[0].offers[0];
++ assert.deepStrictEqual([ld.price, ld.priceCurrency, ld.itemCondition, ld.seller.name], ['79.00', 'USD', 'https://schema.org/NewCondition', 'Gadgets Inc']);
++ assert.strictEqual(ld.shippingDetails.shippingRate.value, '4.99');
++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe.deals\/d\//);
++ const json = await t.get(`/d/${deal.slug}.json`);
++ assert.strictEqual(json.status, 200);
++ assert.strictEqual(json.json().offer.store.name, 'Gadgets Inc');
++ });
++
++ await check('vote and comment with forms only; comments are Community\'s thread, referenced', async () => {
++ const v = await t.get(`/d/${deal.slug}/vote`, { as: bob, form: { value: 'up' } });
++ assert.strictEqual(v.status, 303);
++ const c = await t.get(`/d/${deal.slug}/comments`, { as: bob, form: { message: 'Bought one, works.' } });
++ assert.strictEqual(c.status, 303);
++ const r = await t.get(`/d/${deal.slug}`, { as: bob });
++ assert.match(r.text, /aria-pressed="true">▲ Hot/);
++ assert.match(r.text, /Bought one, works./);
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++ const thread = [...t.community.threads.values()][0];
++ assert.deepStrictEqual(thread.ref, { service: 'deals', type: 'offer', id: deal.id, label: 'Mini drone with camera' });
++ const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('deal_offer_discussion_refs')").all().map((x) => x.name).join(',');
++ assert.doesNotMatch(cols, /message|body|text/);
++ t.community.setDown(true);
++ const down = await t.get(`/d/${deal.slug}`);
++ assert.match(down.text, /Comments are unavailable right now/);
++ t.community.setDown(false);
++ });
++
++ await check('product, store and search pages', async () => {
++ const p = await t.submit(alice, { url: 'https://gadgets.example/drone-pro', title: 'Drone Pro', price: '199', currency: 'USD', product_name: 'SkyCam Drone Pro' });
++ const prod = t.ctx.catalog.product(p.product_id);
++ const pr = await t.get(`/p/${prod.slug}`);
++ assert.strictEqual(pr.status, 200);
++ assert.match(pr.text, /Price comparison/);
++ assert.match(pr.text, /199 USD/);
++ const st = await t.get('/s/gadgets.example');
++ assert.strictEqual(st.status, 200);
++ assert.match(st.text, /Mini drone with camera/);
++ const s = await t.get('/search?q=drone+camera');
++ assert.strictEqual(s.status, 200);
++ assert.match(s.text, /Mini drone with camera/);
++ assert.doesNotMatch(s.text, /Drone Pro<\/a>/);
++ assert.strictEqual(robotsOf(s.text), 'noindex, nofollow');
++ assert.strictEqual((await t.get('/p/nope')).status, 404);
++ });
++
++ await check('the submitter marks it expired: badge, noindex (expired), deals.offer.expired, out of lists and sitemaps', async () => {
++ const other = await t.get(`/d/${deal.slug}/expire`, { as: bob, form: {} });
++ assert.strictEqual(other.status, 403, 'only the submitter or a moderator');
++ const r = await t.get(`/d/${deal.slug}/expire`, { as: alice, form: {} });
++ assert.strictEqual(r.status, 303);
++ const page = await t.get(`/d/${deal.slug}`);
++ assert.match(page.text, /This deal expired/);
++ assert.strictEqual(robotsOf(page.text), 'noindex, follow');
++ const e = t.events('deals.offer.expired');
++ assert.strictEqual(e.length, 1);
++ assert.strictEqual(e[0].payload.reason, 'submitter');
++ assert.ok(!(await t.get('/')).text.includes(deal.slug));
++ assert.ok(!(await t.get('/sitemaps/offers.xml')).text.includes(deal.slug));
++ });
++
++ await check('a stated end time expires the deal on the worker tick; an unknown end never expires by itself', async () => {
++ timed = await t.submit(alice, { url: 'https://gadgets.example/flash', title: 'Flash sale earbuds', price: '15', currency: 'USD', expires_at: '2026-09-22T14:00' });
++ const open = await t.submit(alice, { url: 'https://gadgets.example/open', title: 'Open-ended cable deal', price: '3', currency: 'USD' });
++ assert.match((await t.get(`/d/${open.slug}`)).text, /End date: not stated/);
++ t.clock.advance(3 * HOUR);
++ await t.ctx.worker.tick();
++ assert.strictEqual(t.ctx.reads.get(timed.id).status, 'expired');
++ assert.strictEqual(t.ctx.reads.get(timed.id).expired_reason, 'stated_expiry');
++ assert.strictEqual(t.ctx.reads.get(open.id).status, 'active');
++ t.clock.advance(30 * 24 * HOUR);
++ await t.ctx.worker.tick();
++ assert.strictEqual(t.ctx.reads.get(open.id).status, 'active', 'stale, but never assumed expired');
++ });
++
++ await check('moderators disable a deal: 410, out of feeds, sitemaps and Search; enable brings it back', async () => {
++ const target = t.ctx.store.db.prepare("SELECT * FROM deal_offers WHERE title = 'Drone Pro'").get();
++ await t.get(`/d/${target.slug}`);
++ const thread = [...t.community.threads.values()].find((x) => x.ref.id === target.id);
++ assert.ok(thread, 'the page resolved its Community thread');
++ const r = await t.get(`/mod/offers/${target.slug}/disable`, { as: t.mod, form: { reason: 'misleading' } });
++ assert.strictEqual(r.status, 303);
++ await new Promise((ok) => setTimeout(ok, 100));
++ assert.strictEqual(thread.visibility, 'hidden', 'the disabled deal\'s discussion is hidden too');
++ assert.strictEqual((await t.get(`/d/${target.slug}`)).status, 410);
++ assert.strictEqual((await t.get(`/d/${target.slug}.json`)).status, 410);
++ assert.ok(!(await t.get('/feed.xml')).text.includes(target.slug));
++ assert.ok(t.events('deals.index_document.deleted').some((e) => e.payload.id === target.id));
++ const en = await t.get(`/mod/offers/${target.slug}/enable`, { as: t.mod, form: {} });
++ assert.strictEqual(en.status, 303);
++ assert.strictEqual((await t.get(`/d/${target.slug}`)).status, 200);
++ await new Promise((ok) => setTimeout(ok, 100));
++ assert.strictEqual(thread.visibility, 'public');
++ const log = t.ctx.store.db.prepare('SELECT action FROM moderation_log WHERE offer_id = ? ORDER BY id').all(target.id).map((x) => x.action);
++ assert.deepStrictEqual(log, ['disable', 'enable']);
++ });
++
++ await check('feeds (RSS, Atom, JSON Feed) carry "as of" times and stable ids', async () => {
++ const fresh = await t.submit(bob, { url: 'https://books.example/novel', title: 'Hardcover novel', price: '12.50', currency: 'GBP' });
++ const rss = await t.get('/feed.xml');
++ assert.match(rss.headers.get('content-type'), /rss\+xml/);
++ assert.match(rss.text, new RegExp(`<guid isPermaLink="false">deals:offer:${fresh.id}</guid>`));
++ assert.match(rss.text, /12.50 GBP as of 2026-/);
++ const atom = await t.get('/atom.xml');
++ assert.match(atom.text, /<feed xmlns="http:\/\/www.w3.org\/2005\/Atom">/);
++ const jf = (await t.get('/feed.json')).json();
++ assert.ok(jf.items.some((i) => i.id === `deals:offer:${fresh.id}`));
++ });
++
++ await check('robots.txt, llms.txt and the sitemap index; sitemaps list only indexable deals', async () => {
++ const robots = await t.get('/robots.txt');
++ assert.match(robots.text, /Sitemap: https:\/\/openvibe.deals\/sitemap.xml/);
++ assert.match(robots.text, /Disallow: \/api\//);
++ assert.match(robots.text, /as of/);
++ const llms = await t.get('/llms.txt');
++ assert.match(llms.text, /OpenVibe.Deals/);
++ assert.match(llms.text, /never 0/);
++ const idx = await t.get('/sitemap.xml');
++ assert.match(idx.text, /sitemaps\/offers.xml/);
++ assert.match(idx.text, /sitemaps\/products.xml/);
++ const offers = await t.get('/sitemaps/offers.xml');
++ const fresh = t.ctx.store.db.prepare("SELECT slug FROM deal_offers WHERE title = 'Hardcover novel'").get().slug;
++ assert.ok(offers.text.includes(fresh));
++ assert.ok(!offers.text.includes(timed.slug), 'expired');
++ assert.ok(!offers.text.includes(deal.slug), 'expired');
++ });
++
++ await check('operations: health, readiness, release.json, metrics (loopback), legal pages', async () => {
++ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-deals');
++ const ready = await t.get('/api/ready');
++ assert.strictEqual(ready.status, 200);
++ const rj = ready.json();
++ assert.strictEqual(rj.checks.db.status, 'ok');
++ assert.ok(rj.checks.events_relay, 'relay state reported');
++ const rel = await t.get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json().service, 'deals');
++ const m = await t.get('/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.match(m.text, /http_requests_total|openvibe_/);
++ assert.strictEqual((await t.get('/terms')).status, 200);
++ assert.strictEqual((await t.get('/nope/nope')).status, 404);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..f0e3540
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
++ * databases and in-process mocks of OpenVibe.Network, Community and Sources; none needs the
++ * network or a running site.
++ *
++ * npm test # everything
++ * npm test -- schedule # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
++ if (process.env.VERBOSE) console.log(r.output);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/watches.test.js b/test/watches.test.js
nnew file mode 100644
iindex 0000000..6226f75
---- /dev/null
++++ b/test/watches.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * Watches never notify twice for one observation; unknown prices never match a price watch;
++ * saved searches never notify. Notifications are deals.watch.matched events (no email).
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done, HOUR } = require('./helpers/boot');
++const { sourceItem } = require('./helpers/mocks');
++
++(async () => {
++ const t = await boot();
++ const [watcher, poster, other] = ['watcher', 'poster', 'other'].map((n) => t.network.addUser(n));
++ const matched = (watchId) => t.events('deals.watch.matched').filter((e) => !watchId || e.payload.watch_id === watchId);
++ const w = {};
++ let offer;
++
++ await check('watches and a saved search are created through the no-JS form', async () => {
++ for (const [key, form] of [
++ ['keyword', { kind: 'keyword', query: 'Noise cancelling headphones' }],
++ ['price', { kind: 'price_below', query: 'headphones', max_price: '40', currency: 'USD' }],
++ ['saved', { kind: 'search', query: 'headphones' }],
++ ]) {
++ const r = await t.get('/watches', { as: watcher, form });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ }
++ const list = t.ctx.watches.list(watcher.subject);
++ for (const x of list) w[x.kind === 'price_below' ? 'price' : x.kind === 'search' ? 'saved' : 'keyword'] = x.id;
++ assert.strictEqual(list.length, 3);
++ const page = await t.get('/watches', { as: watcher });
++ assert.match(page.text, /Saved search/);
++ assert.match(page.text, /under 40 USD/);
++ const bad = await t.get('/watches', { as: watcher, form: { kind: 'price_below', query: 'x', max_price: '10' } });
++ assert.strictEqual(bad.status, 422, 'a price watch needs its currency');
++ });
++
++ await check('a matching new offer notifies the keyword watch once; the price watch stays quiet above its limit', async () => {
++ offer = await t.submit(poster, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones NC700', price: '49.99', currency: 'USD' });
++ assert.strictEqual(matched(w.keyword).length, 1);
++ assert.strictEqual(matched(w.price).length, 0);
++ assert.strictEqual(matched(w.saved).length, 0);
++ const e = matched(w.keyword)[0];
++ assert.strictEqual(e.payload.recipient, watcher.subject);
++ assert.strictEqual(e.visibility, 'internal');
++ assert.strictEqual(e.payload.observation.price, '49.99');
++ const v = contracts.validate('events.event-envelope@1', e);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ });
++
++ await check('a lower observation notifies the price watch; the keyword watch does not repeat for the same offer', async () => {
++ await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35', currency: 'USD' } });
++ assert.strictEqual(matched(w.price).length, 1);
++ assert.strictEqual(matched(w.keyword).length, 1);
++ });
++
++ await check('re-running the matcher on the same observation emits nothing (one notification per observation)', async () => {
++ const obs = t.ctx.store.db.prepare('SELECT * FROM deal_price_observations WHERE offer_id = ? ORDER BY observed_at DESC, rowid DESC LIMIT 1').get(offer.id);
++ const before = matched().length;
++ for (let i = 0; i < 3; i++) t.ctx.store.tx(() => t.ctx.watches.onObservation(obs));
++ assert.strictEqual(matched().length, before);
++ const rows = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM watch_notifications WHERE observation_id = ?').get(obs.id).n;
++ assert.strictEqual(rows, 1);
++ // The primary key is the guarantee, not just the code path.
++ const dup = t.ctx.store.db.prepare('INSERT OR IGNORE INTO watch_notifications (watch_id, observation_id, offer_id, created_at) VALUES (?, ?, ?, ?)').run(w.price, obs.id, offer.id, 0);
++ assert.strictEqual(dup.changes, 0);
++ });
++
++ await check('the same price again does not re-notify; a lower one does', async () => {
++ t.clock.advance(HOUR);
++ await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '35.00', currency: 'USD' } });
++ assert.strictEqual(matched(w.price).length, 1);
++ await t.get(`/d/${offer.slug}/observe`, { as: other, form: { price: '29.90', currency: 'USD' } });
++ assert.strictEqual(matched(w.price).length, 2);
++ });
++
++ await check('an unknown price, another currency, or the watcher\'s own report never match a price watch', async () => {
++ const before = matched(w.price).length;
++ const o2 = await t.submit(poster, { url: 'https://audio.example/cheap', title: 'Cheap headphones' });
++ await t.get(`/d/${o2.slug}/observe`, { as: other, form: { availability: 'in_stock' } });
++ await t.get(`/d/${o2.slug}/observe`, { as: other, form: { price: '10', currency: 'EUR' } });
++ await t.get(`/d/${o2.slug}/observe`, { as: watcher, form: { price: '9', currency: 'USD' } });
++ assert.strictEqual(matched(w.price).length, before);
++ });
++
++ await check('an imported observation replayed from Sources notifies once', async () => {
++ t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW1', title: 'Wireless headphones deal', url: 'https://audio.example/wh', fields: { price: '19.99', currency: 'USD' }, retrievedAt: t.clock.now() }));
++ const before = matched(w.price).length;
++ await t.ctx.importer.pull();
++ assert.strictEqual(matched(w.price).length, before + 1);
++ // Replay the whole feed from the start: same items, same revision, same retrieval → nothing.
++ t.ctx.store.db.prepare("UPDATE import_state SET value = '0'").run();
++ await t.ctx.importer.pull();
++ assert.strictEqual(matched(w.price).length, before + 1);
++ const obsCount = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM deal_price_observations o JOIN deal_offers f ON f.id = o.offer_id WHERE f.url = 'https://audio.example/wh'").get().n;
++ assert.strictEqual(obsCount, 1);
++ });
++
++ await check('an observation that is already stale when imported does not notify', async () => {
++ const before = matched().length;
++ t.sources.put(sourceItem({ id: 'itm_01K5ZZZZZZZZZZZZZZZZZZZZW2', title: 'Headphones from last week', url: 'https://audio.example/old', fields: { price: '5', currency: 'USD' }, retrievedAt: t.clock.now() - 5 * 24 * HOUR }));
++ await t.ctx.importer.pull();
++ assert.strictEqual(matched().length, before);
++ });
++
++ await check('a deleted watch stops; a saved search never notified', async () => {
++ const r = await t.get(`/watches/${w.keyword}/delete`, { as: watcher, form: {} });
++ assert.strictEqual(r.status, 303);
++ const before = matched(w.keyword).length;
++ await t.submit(poster, { url: 'https://audio.example/nc800', title: 'Noise cancelling headphones NC800', price: '20', currency: 'USD' });
++ assert.strictEqual(matched(w.keyword).length, before);
++ assert.strictEqual(matched(w.saved).length, 0);
++ const cannot = await t.get(`/watches/${w.price}/delete`, { as: other, form: {} });
++ assert.strictEqual(cannot.status, 404, 'someone else\'s watch is not found');
++ });
++
++ await check('API: watches are listed for the acting person only, with the deals.watch.* capabilities', async () => {
++ const token = t.network.serviceToken('network', ['deals.watch.read']);
++ const r = await t.get('/api/v1/watches', { as: token, headers: { 'X-OV-Subject': watcher.subject } });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json().watches.map((x) => x.kind).sort(), ['price_below', 'search']);
++ const denied = await t.get('/api/v1/watches', { as: token, method: 'POST', headers: { 'X-OV-Subject': watcher.subject }, json: { kind: 'keyword', query: 'x' } });
++ assert.strictEqual(denied.status, 403);
++ });
++
++ await t.close();
++ done();
++})();