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
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -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"++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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; } }nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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'));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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);++})();nnew file mode 100644@@@ -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();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,1562 @@
@@ -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"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,420 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,297 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,253 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,401 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,425 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,285 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -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);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -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();
+})();