openvibers/openvibe.trade Commits
OpenVibe.Trade service (Wave 19): informational only per ADR-025 — instruments and aliases with deterministic resolution, immutable timestamped observations (no observation, no number), per-source freshness with stale-since, SEC filings from OpenVibe.Sources, private watchlists, alert rules idempotent per triggering observation or document, reviewed context (AI drafts need a person), SSR pages, feeds, sitemaps, API v1, events through the SDK outbox; route-inventory test for ADR-025
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-trade",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-trade",++ "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-trade",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Trade — informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.",++ "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", "trade", "watchlists", "filings", "informational"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Trade",++ "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,55 @@++/* OpenVibe.Trade — readable tables, 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: 16px/1.6 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: 1040px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.disclaimer { max-width: 1040px; margin: 8px auto 0; padding: 8px 16px; border: 1px solid var(--warning, #f59e0b); border-radius: 8px; font-size: .95rem; background: var(--bg-card, #131c2e); }++.disclaimer-foot { margin: 24px auto; }++.page { max-width: 1040px; margin: 0 auto; padding: 16px 16px 48px; min-height: 60vh; }++h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .5rem; }++h1 .name { font-weight: 400; color: var(--text-secondary, #96a7c2); font-size: 1.3rem; }++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }++.lede { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }++.meta, .muted, .empty { color: var(--text-muted, #7386a3); font-size: .92rem; }++.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; }++.badge.stale { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); font-weight: 600; }++.badge.fresh { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }++table.data { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: .95rem; }++table.data caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding-bottom: 4px; }++table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }++table.data td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }++tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }++.docs { list-style: none; padding: 0; }++.docs li { padding: 8px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.docs li:target, tr:target { outline: 2px solid var(--accent, #3b82f6); }++.context-body { border-left: 3px solid var(--border-light, #2c3d5c); padding-left: 14px; }++.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 12px; }++.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); }++.revisions { list-style: none; padding: 0; }++.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: 16px 0; }++label { display: block; margin: 8px 0 4px; font-weight: 600; font-size: .95rem; }++fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 10px 0; }++fieldset label { font-weight: 400; }++input, textarea, select { font: inherit; color: var(--text-primary); background: var(--bg-input, #0d1424); border: 1px solid var(--border-light, #2c3d5c); border-radius: 6px; padding: 6px 8px; max-width: 100%; }++textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }++input[type=checkbox], input[type=radio] { width: auto; }++.search input[type=search] { width: min(420px, 100%); }++button { display: inline-block; margin-top: 8px; font: inherit; font-weight: 600; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 7px 14px; cursor: pointer; }++button.danger { background: var(--danger, #ef4444); }++form.inline { display: inline; }++form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }++.inline-form { margin: 10px 0; }++.inline-form label { display: inline-block; margin-right: 6px; }++@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }++@media (max-width: 700px) {++ h1 { font-size: 1.5rem; }++ table.data { display: block; overflow-x: auto; }++}nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++++/**++ * OpenVibe.Trade — 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 (http/pages.js) /, /resolve, /i/:symbol(.json), /sources++ * Private (http/private.js) /watchlists, watchlist and alert forms++ * Editor (http/editor.js) /editor/…++ * Discovery (http/discovery.js) robots, llms, sitemaps, feeds++ * API (http/api.js) /api/v1/…++ * Webhook (events/webhook.js) POST /internal/events (signed OpenVibe.Events deliveries)++ * Machine /api/health, /api/ready, /release.json, /metrics++ *++ * Information only (ADR-025): no route takes an order, holds value, runs escrow, lists goods++ * between users or gives personal advice. test/route-inventory.test.js enforces it.++ */++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 { createTradeOutbox } = require('./events/outbox');++const { createWebhook } = require('./events/webhook');++const { createUrls } = require('./domain/urls');++const { createInstruments } = require('./domain/instruments');++const { createFreshness } = require('./domain/freshness');++const { createObservations } = require('./domain/observations');++const { createDocuments } = require('./domain/documents');++const { createAlerts } = require('./domain/alerts');++const { createWatchlists } = require('./domain/watchlists');++const { createContext } = require('./domain/context');++const { createIndexing } = require('./domain/indexing');++const { createReading } = require('./domain/reading');++const { createSync } = require('./domain/sync');++const { createSourcesClient } = require('./clients/sources');++const { createCommon } = require('./http/common');++const { createPages } = require('./http/pages');++const { createPrivate } = require('./http/private');++const { createEditor } = require('./http/editor');++const { createDiscovery } = require('./http/discovery');++const { createApi } = require('./http/api');++const { define } = require('./http/routes');++const { createTradeReadiness } = 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, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), 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 });++++ // One context object: domain modules reach each other through it at call time.++ const ctx = { config, store, log };++ ctx.urls = createUrls(config);++ ctx.outbox = createTradeOutbox({ db: store.db, config, fetchImpl, now: store.now, log });++ ctx.instruments = createInstruments({ store });++ ctx.freshness = createFreshness({ store, config, outbox: ctx.outbox });++ ctx.alerts = createAlerts({ store, config, ctx });++ ctx.observations = createObservations({ store, ctx });++ ctx.documents = createDocuments({ store, ctx });++ ctx.watchlists = createWatchlists({ store, config, ctx });++ ctx.context = createContext({ store, config, ctx });++ ctx.indexing = createIndexing({ store, config, ctx });++ ctx.reading = createReading({ store, ctx });++ ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl });++ ctx.sync = createSync({ store, config, ctx, sources: ctx.sources, log });++ ctx.auth = opts.auth || createAuthClient(config);++ ctx.viewers = createViewerResolver({ auth: ctx.auth, config });++ ctx.common = createCommon({ config, store });++ ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, log });++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'trade', root: path.join(__dirname, '..') });++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'trade', 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 ───────────────────────────────────++ const machine = express.Router();++ define(machine, 'get', '/api/health', 'health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-trade', version: VERSION }));++ define(machine, 'get', '/release.json', 'releaseInfo', release.handler);++ const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release });++ define(machine, 'get', '/api/ready', 'readiness', readiness.handler);++ app.use(machine);++++ // ── Signed OpenVibe.Events deliveries (before any body parser) ──++ app.use(createWebhook({ store, config, sync: ctx.sync, log }).router);++++ // ── 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, ctx.auth));++ {++ const legal = require('openvibe-shared/legal');++ const legalRouter = express.Router();++ define(legalRouter, 'get', legal.PATHS, 'legalPage', legal.handler({ id: 'trade', service: 'trade', host: 'openvibe.trade', name: 'OpenVibe.Trade', profile: 'information' }));++ app.use(legalRouter);++ }++++ // ── 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');++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));++++ // ── Pages (the viewer is resolved for browsers only; service tokens are not identities here) ──++ app.use(ctx.viewers.middleware({ services: false }));++ app.use(createDiscovery(ctx));++ app.use(['/watchlists', '/alerts', '/editor'], rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));++ app.use(createPrivate(ctx));++ app.use(createEditor(ctx));++ app.use(createPages(ctx));++ app.use(function pageNotFound(req, res) { ctx.common.notFound(req, res); });++++ // eslint-disable-next-line no-unused-vars++ app.use(function errorHandler(err, req, res, _next) {++ log.error('[Trade]', 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,47 @@++'use strict';++++/**++ * Capability checks for service tokens (audience openvibe.trade), including the ids Trade++ * introduces before the contracts library knows them.++ *++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its++ * manifests yet. Trade's 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: watchlists and alert rules by++ * ownership, editing by the editor list (domain/access.js). A service token is judged by its++ * capability AND, for private data, by the person it acts for (X-OV-Subject).++ *++ * There is deliberately no capability for anything that moves value: ADR-025 (information only).++ */++const { capabilities } = require('openvibe-contracts');++++const CAPABILITIES = Object.freeze({++ INSTRUMENT_RESOLVE: 'trade.instrument.resolve',++ INSTRUMENT_MANAGE: 'trade.instrument.manage',++ OBSERVATION_WRITE: 'trade.observation.write',++ CONTEXT_READ: 'trade.context.read',++ CONTEXT_PROPOSE: 'trade.context.propose',++ WATCHLIST_READ: 'trade.watchlist.read',++ WATCHLIST_CREATE: 'trade.watchlist.create',++ WATCHLIST_UPDATE: 'trade.watchlist.update',++ WATCHLIST_DELETE: 'trade.watchlist.delete',++ ALERT_READ: 'trade.alert.read',++ ALERT_CREATE: 'trade.alert.create',++ ALERT_DELETE: 'trade.alert.delete',++});++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 editor and comment forms (CSRF). The session cookie is++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under++ * TRADE_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(`trade-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.trade — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)++//++// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with++// Trade's client id: registered in the Network as OAuth client `trade`, redirect++// https://openvibe.trade/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.trade):++// 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(`[Trade] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);++ return client.publicKey;++ }++ } catch (err) {++ console.warn(`[Trade 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('[Trade 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('[Trade 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('[Trade 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,106 @@++'use strict';++++/**++ * Who is calling, resolved once per request into `req.viewer`:++ *++ * { kind: 'anonymous', subject: null, editor: false }++ * { kind: 'user', subject: 'usr_…'|null, editor, user, token }++ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the++ * token's subject_id claim. Editors: role admin, or a subject in TRADE_EDITORS.++ * 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.trade.++ * X-OV-Subject names the person it acts for (watchlists and alert rules need one);++ * X-OV-Origin: ai marks AI output (OpenVibe.AI's trade.summarize_market_context), which is++ * never attributed to a person and stays a draft 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 PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.trade';++++class ViewerError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, editor: false, origin: 'user' });++++function createViewerResolver({ auth, config }) {++ const editors = new Set(config.editors || []);++++ 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', editor: 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;++ const editor = claims.role === 'admin' || Boolean(subject && editors.has(subject));++ return { kind: 'user', subject, editor, 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 function resolveViewer(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 ownership or the editor list in the domain layer.++ */++function guard(cap) {++ return function capabilityGuard(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 };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * OpenVibe.Sources, read with Trade's service token (audience openvibe.sources):++ * sources.item.read GET /api/v1/items?category=trade&after=<change_seq>&include_removed=1++ * sources.source.read GET /api/v1/sources (health and staleness of every source)++ * Off until OV_OAUTH_CLIENT_SECRET is set. A failure is an error for the caller to record, never an++ * empty answer.++ */++const { serviceAuth } = require('openvibe-contracts');++++function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {++ const enabled = Boolean(config.oauth.clientSecret && config.sources.internalUrl);++ 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 sources.source.read', fetchImpl,++ }) : null;++++ async function get(path) {++ if (!enabled) throw new Error('Sources client disabled (OV_OAUTH_CLIENT_SECRET unset)');++ const res = await fetchImpl(`${config.sources.internalUrl}${path}`, {++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },++ signal: AbortSignal.timeout(15_000),++ });++ 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})` : ''} for ${path.split('?')[0]}`);++ err.status = res.status;++ throw err;++ }++ return data;++ }++++ return {++ enabled,++ items: ({ after = 0, limit = 200 } = {}) => get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),++ sources: () => get('/api/v1/sources'),++ };++}++++module.exports = { createSourcesClient };nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * OpenVibe.Trade configuration. Every value comes from the environment (production:++ * /etc/openvibe/trade.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 list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4860);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.trade' : `http://localhost:${port}`));++++ return {++ service: 'trade',++ 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.TRADE_DB_PATH || './data/trade.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 || 'trade',++ 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.TRADE_FORM_SECRET || '',++++ // Editors: Network subjects (usr_…) who may create instruments, write context and review++ // AI drafts. Network admins are editors too.++ editors: list(env.TRADE_EDITORS),++++ // OpenVibe.Sources: the trade category (filings, market data feeds). Read with a service++ // token (sources.item.read, sources.source.read). Off until the client secret is set.++ sources: {++ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720'),++ category: 'trade',++ syncIntervalMs: int(env.TRADE_SYNC_INTERVAL_MS, 60_000),++ pageLimit: Math.min(Math.max(int(env.TRADE_SYNC_PAGE_LIMIT, 200), 1), 500),++ maxPagesPerRun: Math.max(int(env.TRADE_SYNC_MAX_PAGES, 20), 1),++ },++++ // Freshness. A source without its own window (Sources' stale_after_sec) uses this one.++ freshness: {++ defaultStaleAfterSec: Math.max(int(env.TRADE_DEFAULT_STALE_AFTER_SEC, 3 * 3600), 60),++ // How old a monetary observation may be before the page is noindex (stale_price).++ priceMaxAgeMs: Math.max(int(env.TRADE_PRICE_MAX_AGE_SEC, 24 * 3600), 60) * 1000,++ },++++ // Indexability policy for instrument pages (openvibe-publishing/seo gate). Financial pages++ // are a sensitive category: nothing is indexable before a person-reviewed context exists.++ gate: { minWords: Math.max(int(env.TRADE_GATE_MIN_WORDS, 60), 0) },++++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // Signed webhook deliveries (sources.* topics) wake the Sources sync early.++ webhookSecret: env.TRADE_EVENTS_WEBHOOK_SECRET || '',++ },++++ worker: {++ enabled: env.TRADE_WORKER !== 'off',++ freshnessIntervalMs: int(env.TRADE_FRESHNESS_INTERVAL_MS, 60_000),++ },++++ // 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'),++++ limits: {++ watchlistsPerSubject: 20,++ itemsPerWatchlist: 200,++ alertRulesPerSubject: 100,++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,222 @@++'use strict';++++/**++ * Trade's own SQLite database: created on boot, idempotently. Nothing here is shared with another++ * service.++ *++ * The nine charter tables (roadmap §15.13):++ *++ * trade_instruments Trade-owned symbol, name, kind, exchange, CIK, status, which context++ * revision is published++ * trade_instrument_aliases Trade-owned tickers, CIKs and names that resolve to an instrument++ * trade_watchlists Trade-owned private, one owner subject (usr_…)++ * trade_watchlist_items Trade-owned instruments on a watchlist++ * trade_market_observations Trade-owned value, unit, currency, observed_at, source, retrieved_at;++ * immutable (triggers abort UPDATE and DELETE)++ * trade_source_documents Trade-owned filings and other documents from OpenVibe.Sources items++ * trade_alert_rules Trade-owned threshold | filing_type | new_document, private per owner++ * trade_alert_deliveries Trade-owned one row per (rule, triggering observation or document):++ * the UNIQUE key is what makes alerts idempotent++ * trade_context_revisions package openvibe-publishing/revisions, prefix trade_context++ * (immutable; editor-written or reviewed AI drafts)++ *++ * Also here: trade_context_drafts / trade_context_revision_purges / trade_context_reviews (package++ * companions), trade_index_revisions (Search document sequencer), trade_source_status (freshness++ * per source), trade_sync_state (the Sources cursor), event_outbox (SDK outbox) and++ * idempotency_receipts (SDK inbox for signed webhook deliveries).++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const { createRevisionStore } = require('openvibe-publishing/revisions');++const { createReviewLog } = require('openvibe-publishing/authorship');++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS trade_instruments (++ id TEXT PRIMARY KEY, -- ins_<ULID>++ symbol TEXT NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol++ name TEXT NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),++ exchange TEXT, -- as an editor recorded it; NULL = not recorded++ cik TEXT UNIQUE, -- SEC Central Index Key, 10 digits zero-padded++ currency TEXT, -- ISO 4217 trading currency, when recorded++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),++ context_published_revision INTEGER, -- trade_context_revisions.number readers see++ context_published_at INTEGER,++ created_by TEXT, -- usr_… or svc:…++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++++CREATE TABLE IF NOT EXISTS trade_instrument_aliases (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),++ kind TEXT NOT NULL CHECK (kind IN ('ticker','cik','name')),++ value TEXT NOT NULL, -- as entered++ normalized TEXT NOT NULL, -- the resolution key (see domain/instruments.js)++ added_by TEXT,++ created_at INTEGER NOT NULL,++ UNIQUE (instrument_id, kind, normalized)++);++-- A ticker or CIK names exactly one instrument; a name may be shared (resolution is then ambiguous).++CREATE UNIQUE INDEX IF NOT EXISTS trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');++CREATE INDEX IF NOT EXISTS trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);++++CREATE TABLE IF NOT EXISTS trade_watchlists (++ id TEXT PRIMARY KEY, -- wl_<ULID>++ owner_subject TEXT NOT NULL, -- usr_…; never shown to anyone else++ name TEXT NOT NULL,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ UNIQUE (owner_subject, name)++);++++CREATE TABLE IF NOT EXISTS trade_watchlist_items (++ watchlist_id TEXT NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,++ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),++ note TEXT,++ added_at INTEGER NOT NULL,++ PRIMARY KEY (watchlist_id, instrument_id)++);++++CREATE TABLE IF NOT EXISTS trade_market_observations (++ id TEXT PRIMARY KEY, -- obs_<ULID>++ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),++ metric TEXT NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues++ value TEXT NOT NULL, -- the decimal exactly as the source stated it++ value_num REAL NOT NULL, -- the same value, for comparisons only++ unit TEXT NOT NULL, -- USD, shares, USD/share, pure, …++ currency TEXT, -- ISO 4217 when the value is monetary++ period TEXT, -- e.g. 2026-Q2, when the source states one++ observed_at INTEGER NOT NULL, -- when the value was true, per the source++ source_key TEXT NOT NULL, -- OpenVibe.Sources source key++ source_item_id TEXT, -- itm_… when it came from a Sources item++ source_url TEXT,++ source_ref TEXT NOT NULL, -- the source's own identity of this datum++ retrieved_at INTEGER NOT NULL, -- when the source was fetched++ max_age_sec INTEGER, -- the value is stale after this long (NULL: only the source's freshness)++ recorded_by TEXT NOT NULL, -- svc:… (a feed) or usr_… (never inferred)++ recorded_at INTEGER NOT NULL,++ UNIQUE (source_key, source_ref)++);++CREATE INDEX IF NOT EXISTS trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);++CREATE TRIGGER IF NOT EXISTS trade_obs_no_update BEFORE UPDATE ON trade_market_observations++ BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;++CREATE TRIGGER IF NOT EXISTS trade_obs_no_delete BEFORE DELETE ON trade_market_observations++ BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;++++CREATE TABLE IF NOT EXISTS trade_source_documents (++ id TEXT PRIMARY KEY, -- doc_<ULID>++ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),++ source_key TEXT NOT NULL,++ source_item_id TEXT NOT NULL UNIQUE, -- itm_…: Sources owns the item++ source_revision INTEGER NOT NULL,++ kind TEXT NOT NULL CHECK (kind IN ('filing','article','record')),++ form_type TEXT, -- 10-K, 8-K, … when the source states it++ title TEXT,++ filer_name TEXT,++ cik TEXT,++ accession TEXT,++ url TEXT,++ published_at INTEGER, -- the source's own date; NULL if it gave none++ source_updated_at INTEGER,++ retrieved_at INTEGER NOT NULL,++ first_seen_at INTEGER NOT NULL,++ license_note TEXT,++ terms_note TEXT,++ removed_at INTEGER,++ removed_reason TEXT,++ recorded_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);++++CREATE TABLE IF NOT EXISTS trade_alert_rules (++ id TEXT PRIMARY KEY, -- alr_<ULID>++ owner_subject TEXT NOT NULL,++ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),++ kind TEXT NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),++ metric TEXT,++ operator TEXT CHECK (operator IN ('above','below')),++ threshold TEXT,++ threshold_num REAL,++ unit TEXT,++ currency TEXT,++ form_types TEXT, -- JSON array (filing_type rules)++ armed INTEGER NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm++ last_observed_at INTEGER, -- newest observation a threshold rule evaluated++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),++ CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)++);++CREATE INDEX IF NOT EXISTS trade_rules_owner ON trade_alert_rules (owner_subject, status);++CREATE INDEX IF NOT EXISTS trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);++++CREATE TABLE IF NOT EXISTS trade_alert_deliveries (++ id TEXT PRIMARY KEY, -- ald_<ULID>++ rule_id TEXT NOT NULL REFERENCES trade_alert_rules(id),++ owner_subject TEXT NOT NULL,++ trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('observation','document')),++ trigger_id TEXT NOT NULL,++ event_id TEXT, -- the trade.alert.triggered envelope++ summary TEXT NOT NULL, -- JSON snapshot of what triggered it++ created_at INTEGER NOT NULL,++ UNIQUE (rule_id, trigger_kind, trigger_id)++);++CREATE INDEX IF NOT EXISTS trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);++++-- Freshness of each source Trade shows data from (not a charter table; a projection of Sources'++-- health plus Trade's own clock).++CREATE TABLE IF NOT EXISTS trade_source_status (++ source_key TEXT PRIMARY KEY,++ name TEXT,++ upstream_status TEXT, -- Sources' health status, NULL = never reported++ last_success_at INTEGER, -- last successful fetch (Sources, or the newest retrieval Trade recorded)++ stale_after_sec INTEGER,++ reported_at INTEGER, -- when Sources last told us about it++ stale INTEGER NOT NULL DEFAULT 1, -- as of the last evaluation++ stale_since INTEGER,++ evaluated_at INTEGER,++ terms_note TEXT,++ license_note TEXT++);++++CREATE TABLE IF NOT EXISTS trade_sync_state (++ name TEXT PRIMARY KEY,++ cursor INTEGER NOT NULL DEFAULT 0,++ last_ok_at INTEGER,++ last_error TEXT,++ last_error_at INTEGER,++ counts TEXT NOT NULL DEFAULT '{}'++);++`;++++/**++ * Open (or create) the database and the package stores on it.++ * opts.now — injectable clock (epoch ms) shared by everything, so tests 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,++ revisions: createRevisionStore(db, { prefix: 'trade_context', now }),++ reviews: createReviewLog(db, { prefix: 'trade_context', now }),++ sequencer: createIndexSequencer(db, { prefix: 'trade', now }),++ tx: (fn) => db.transaction(fn)(),++ close: () => db.close(),++ };++}++++const CHARTER_TABLES = ['trade_instruments', 'trade_instrument_aliases', 'trade_watchlists', 'trade_watchlist_items',++ 'trade_market_observations', 'trade_source_documents', 'trade_alert_rules', 'trade_alert_deliveries', 'trade_context_revisions'];++++module.exports = { openStore, CHARTER_TABLES };nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++++/**++ * Alert rules and their deliveries. Rules are private: one owner (usr_…), never shown to anyone++ * else, never indexed, never in a feed.++ *++ * threshold metric + above|below + threshold + unit (+ currency). Fires on a CROSSING: when++ * a new observation (newer than the last one the rule evaluated, matching metric,++ * unit and currency exactly — there is no conversion) meets the condition while++ * the rule is armed; it re-arms when a newer observation no longer meets it.++ * Older observations arriving late are recorded but never trigger.++ * filing_type a newly seen document of the instrument whose form type is in the list++ * new_document any newly seen document of the instrument++ *++ * Idempotency: a delivery is the row (rule, trigger kind, trigger id) under a UNIQUE key, written++ * in the same transaction as the observation or document that triggered it and as its++ * `trade.alert.triggered` event. Re-processing the same observation or document (a replayed++ * sync, a retried request, a second worker) inserts nothing and emits nothing. No email is sent:++ * the event (visibility subject, subject = the owner) is for Notifications and the owner's own++ * realtime stream. Following ADR-020, the payload is data (rule, instrument, trigger with its++ * timestamps and source), never rendered notification text: Network renders the notification.++ */++const { ApiError } = require('../http/errors');++const { newId, iso, parseDecimal, invalid, str, json } = require('./util');++const { formType } = require('./mapping');++++const KINDS = ['threshold', 'filing_type', 'new_document'];++const DISCLAIMER = 'Information only — not investment advice; no trading here.';++++function createAlerts({ store, config, ctx }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM trade_alert_rules WHERE id = ?'),++ insert: db.prepare(`INSERT INTO trade_alert_rules (id, owner_subject, instrument_id, kind, metric, operator, threshold, threshold_num, unit, currency, form_types, created_at, updated_at)++ VALUES (@id, @owner, @instrument_id, @kind, @metric, @operator, @threshold, @threshold_num, @unit, @currency, @form_types, @now, @now)`),++ countOwner: db.prepare("SELECT COUNT(*) AS n FROM trade_alert_rules WHERE owner_subject = ? AND status = 'active'"),++ forOwner: db.prepare("SELECT r.*, i.symbol, i.name AS instrument_name FROM trade_alert_rules r JOIN trade_instruments i ON i.id = r.instrument_id WHERE r.owner_subject = ? AND r.status = 'active' ORDER BY r.created_at DESC"),++ remove: db.prepare("UPDATE trade_alert_rules SET status = 'deleted', updated_at = ? WHERE id = ? AND status = 'active'"),++ thresholds: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind = 'threshold' AND metric = ? AND unit = ?"),++ docRules: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind IN ('filing_type','new_document')"),++ setState: db.prepare('UPDATE trade_alert_rules SET armed = @armed, last_observed_at = @observed, updated_at = @now WHERE id = @id'),++ deliver: db.prepare(`INSERT OR IGNORE INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)++ VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now)`),++ setEvent: db.prepare('UPDATE trade_alert_deliveries SET event_id = ? WHERE id = ?'),++ deliveries: db.prepare(`SELECT d.*, r.kind AS rule_kind FROM trade_alert_deliveries d JOIN trade_alert_rules r ON r.id = d.rule_id++ WHERE d.owner_subject = ? ORDER BY d.created_at DESC, d.id DESC LIMIT ?`),++ countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_alert_deliveries WHERE rule_id = ?'),++ };++++ function ruleDto(r, instrument) {++ const ins = instrument || ctx.instruments.get(r.instrument_id);++ return {++ id: r.id, kind: r.kind, status: r.status,++ instrument: ins ? { id: ins.id, symbol: ins.symbol, name: ins.name } : { id: r.instrument_id },++ metric: r.metric, operator: r.operator, threshold: r.threshold, unit: r.unit, currency: r.currency,++ form_types: r.form_types ? json(r.form_types, []) : null,++ armed: r.kind === 'threshold' ? Boolean(r.armed) : null,++ created_at: iso(r.created_at), updated_at: iso(r.updated_at),++ };++ }++++ function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {++ const id = newId('ald', store.now());++ const info = q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });++ if (!info.changes) return null; // already delivered for this (rule, trigger)++ const env = ctx.outbox.emit({++ event_type: 'trade.alert.triggered', actor: { type: 'service', id: 'trade' }, visibility: 'subject', priority: 'important',++ subject: { type: 'user', id: rule.owner_subject },++ payload: {++ delivery_id: id,++ rule: ruleDto(rule, instrument),++ instrument: { id: instrument.id, symbol: instrument.symbol, name: instrument.name, url: ctx.urls.instrument(instrument) },++ trigger: { kind, id: trigger.id, ...summary },++ disclaimer: DISCLAIMER,++ },++ }, { traceparent });++ q.setEvent.run(env.event_id, id);++ return { id, event_id: env.event_id };++ }++++ const api = {++ KINDS,++ DISCLAIMER,++ get: (id) => q.byId.get(id) || null,++ dto: ruleDto,++++ forOwner(subject) { return q.forOwner.all(subject).map((r) => ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })); },++++ deliveries(subject, limit = 50) {++ return q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200)).map((d) => ({++ id: d.id, rule_id: d.rule_id, rule_kind: d.rule_kind, trigger: { kind: d.trigger_kind, id: d.trigger_id, ...json(d.summary, {}) },++ event_id: d.event_id, created_at: iso(d.created_at),++ }));++ },++ deliveryCount: (ruleId) => q.countFor.get(ruleId).n,++++ create(subject, instrument, input) {++ if (!subject) throw new ApiError(403, 'subject.required', 'Alert rules belong to a person');++ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');++ const kind = String(input.kind || '');++ if (!KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);++ if (q.countOwner.get(subject).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);++ const row = { id: newId('alr', store.now()), owner: subject, instrument_id: instrument.id, kind, metric: null, operator: null, threshold: null, threshold_num: null, unit: null, currency: null, form_types: null, now: store.now() };++ if (kind === 'threshold') {++ row.metric = str(input.metric, 80, 'metric', { required: true });++ if (!['above', 'below'].includes(input.operator)) throw invalid('operator must be above or below');++ row.operator = input.operator;++ const t = parseDecimal(input.threshold);++ if (!t) throw invalid('threshold must be a finite decimal');++ row.threshold = t.text; row.threshold_num = t.num;++ row.unit = str(input.unit, 20, 'unit', { required: true });++ const c = input.currency == null || input.currency === '' ? null : String(input.currency).trim().toUpperCase();++ if (c && !/^[A-Z]{3}$/.test(c)) throw invalid('currency must be an ISO 4217 code like USD');++ row.currency = c;++ } else if (kind === 'filing_type') {++ const raw = Array.isArray(input.form_types) ? input.form_types : String(input.form_types || '').split(',');++ const list = [...new Set(raw.map((x) => formType(String(x))).filter(Boolean))];++ if (!list.length || list.length > 10) throw invalid('form_types must list 1–10 form types, e.g. 10-K, 10-Q, 8-K');++ row.form_types = JSON.stringify(list);++ }++ q.insert.run(row);++ return q.byId.get(row.id);++ },++++ remove(subject, id) {++ const r = q.byId.get(id);++ // Someone else's rule is "not found": its existence is not disclosed.++ if (!r || r.owner_subject !== subject || r.status !== 'active') throw new ApiError(404, 'alert.not_found', 'No such alert rule');++ q.remove.run(store.now(), id);++ return true;++ },++++ /** Inside the transaction that recorded `obs`. */++ onObservation(obs, instrument, opts = {}) {++ const out = [];++ for (const rule of q.thresholds.all(instrument.id, obs.metric, obs.unit)) {++ if (rule.currency && rule.currency !== obs.currency) continue;++ if (rule.created_at > obs.recorded_at) continue;++ if (rule.last_observed_at != null && obs.observed_at <= rule.last_observed_at) continue; // late arrival++ const meets = rule.operator === 'above' ? obs.value_num > rule.threshold_num : obs.value_num < rule.threshold_num;++ let armed = rule.armed;++ if (meets && armed) {++ const d = deliver(rule, instrument, 'observation', obs, {++ metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency,++ observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), source_key: obs.source_key, source_url: obs.source_url,++ }, opts);++ if (d) out.push(d);++ armed = 0;++ } else if (!meets) {++ armed = 1;++ }++ q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });++ }++ return out;++ },++++ /** Inside the transaction that first recorded `doc`. */++ onDocument(doc, instrument, opts = {}) {++ const out = [];++ for (const rule of q.docRules.all(instrument.id)) {++ if (rule.kind === 'filing_type') {++ const types = json(rule.form_types, []);++ if (!doc.form_type || !types.includes(doc.form_type)) continue;++ }++ const d = deliver(rule, instrument, 'document', doc, {++ form_type: doc.form_type, title: doc.title, url: doc.url, published_at: iso(doc.published_at),++ retrieved_at: iso(doc.retrieved_at), source_key: doc.source_key,++ }, opts);++ if (d) out.push(d);++ }++ return out;++ },++ };++ return api;++}++++module.exports = { createAlerts, DISCLAIMER };nnew file mode 100644@@@ -0,0 +1,291 @@++'use strict';++++/**++ * Context: the short, sourced explanation shown on an instrument page, as immutable revisions++ * (openvibe-publishing/revisions, prefix trade_context → trade_context_revisions) that cite++ * Trade's own documents and observations.++ *++ * editor-written a person on the editor list (TRADE_EDITORS or a Network admin) writes it;++ * authorship human; publishable at once++ * AI draft OpenVibe.AI's trade.summarize_market_context output, delivered by the AI++ * service (X-OV-Origin: ai). Authorship ai with the workflow run, never++ * attributed to a person, stored as a draft: it cannot be published — and the++ * page stays noindex — until a person records an approving review++ * (openvibe-publishing/authorship). Always labelled on the page and in JSON.++ *++ * Every revision records what it cites ({ kind: document|observation, id }) and as_of: the newest++ * observation or document time among the citations (never the time of writing). Text that reads as++ * personalised advice or a recommendation to trade is refused (ADR-025): Trade is information only.++ */++const authorship = require('openvibe-publishing/authorship');++const ssr = require('openvibe-publishing/ssr');++const { ApiError } = require('../http/errors');++const { iso, invalid } = require('./util');++++const WORKFLOW = 'trade.summarize_market_context';++const ADVICE_RE = /\b(you should (buy|sell|hold|invest|short)|we recommend|i recommend|recommend(s|ed)? (buying|selling|holding)|strong (buy|sell)|(buy|sell|hold|outperform|underperform) rating|price target|guaranteed (return|profit)s?)\b/i;++const MAX_BODY = 20000;++++function createContext({ store, config, ctx }) {++ const { db } = store;++ const editors = new Set(config.editors || []);++ const setPublished = db.prepare('UPDATE trade_instruments SET context_published_revision = ?, context_published_at = ?, updated_at = ? WHERE id = ?');++++ function refuseAdvice(text) {++ if (ADVICE_RE.test(text)) {++ throw new ApiError(422, 'context.advice_refused', 'Trade publishes information only: context cannot recommend buying, selling or holding, or give price targets (ADR-025)');++ }++ }++++ /** Validate cited refs: they must be this instrument's documents (not removed) or observations. */++ function resolveCites(instrument, cites) {++ if (cites == null) return [];++ if (!Array.isArray(cites) || cites.length > 50) throw invalid('cites must be a list of at most 50 { kind, id }');++ const seen = new Set();++ const out = [];++ for (const c of cites) {++ const kind = c && c.kind;++ const id = c && String(c.id || '');++ if (kind === 'document') {++ const d = ctx.documents.get(id);++ if (!d || d.instrument_id !== instrument.id || d.removed_at) throw invalid(`document ${id} is not a current document of ${instrument.symbol}`, 'context.bad_citation');++ } else if (kind === 'observation') {++ const o = ctx.observations.get(id);++ if (!o || o.instrument_id !== instrument.id) throw invalid(`observation ${id} is not an observation of ${instrument.symbol}`, 'context.bad_citation');++ } else {++ throw invalid('each citation is { kind: "document" | "observation", id }', 'context.bad_citation');++ }++ const key = `${kind}:${id}`;++ if (!seen.has(key)) { seen.add(key); out.push({ kind, id }); }++ }++ return out;++ }++++ function asOf(cites) {++ let t = null;++ for (const c of cites) {++ let v = null;++ if (c.kind === 'observation') { const o = ctx.observations.get(c.id); v = o && o.observed_at; }++ else { const d = ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }++ if (v != null && (t == null || v > t)) t = v;++ }++ return iso(t);++ }++++ function isEditorSubject(viewer) {++ if (viewer.kind === 'user') return Boolean(viewer.editor && viewer.subject);++ if (viewer.kind === 'service') return Boolean(viewer.origin !== 'ai' && viewer.subject && editors.has(viewer.subject));++ return false;++ }++++ function create(instrument, { content, fields, rec, author, message, expectedRevision }) {++ const head = store.revisions.headNumber(instrument.id);++ const expected = expectedRevision == null ? head : Number(expectedRevision);++ if (!Number.isInteger(expected) || expected < 0) throw invalid('expected_revision must be a revision number');++ return store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true }).revision;++ }++++ /** AI output → markdown. Citations are indices into input_sources (what the AI was given). */++ function fromAiOutput(instrument, body) {++ const wf = body.workflow || {};++ if (wf.id !== WORKFLOW) throw invalid(`AI context must come from the ${WORKFLOW} workflow`, 'context.wrong_workflow');++ const runId = wf.run_id || wf.runId;++ if (!runId || typeof runId !== 'string') throw invalid('workflow.run_id is required', 'authorship.workflow_required');++ const out = body.output;++ if (!out || typeof out !== 'object' || typeof out.summary !== 'string' || !out.summary.trim()) throw invalid('output.summary is required');++ const sources = Array.isArray(body.input_sources) ? body.input_sources : [];++ const refs = sources.map((s) => {++ const t = s && s.source_type;++ const kind = t === 'trade.document' ? 'document' : t === 'trade.observation' ? 'observation' : null;++ if (!kind) throw invalid('input_sources entries are { source_type: trade.document | trade.observation, source_id }', 'context.bad_citation');++ return { kind, id: String(s.source_id || '') };++ });++ const refOf = (i) => {++ if (!Number.isInteger(i) || i < 0 || i >= refs.length) throw invalid(`citation ${i} is not one of the ${refs.length} input sources`, 'context.bad_citation');++ return refs[i];++ };++ const all = [];++ const cite = (list) => (Array.isArray(list) ? list.map((i) => { const r = refOf(i); all.push(r); return `[${i + 1}]`; }).join('') : '');++ const lines = [String(out.summary).trim().slice(0, 3000)];++ cite(out.citations);++ const obs = Array.isArray(out.observations) ? out.observations.slice(0, 30) : [];++ if (obs.length) {++ lines.push('', '**What the sources state**', '');++ for (const o of obs) {++ const when = typeof o.observed_at === 'string' && o.observed_at ? ` (as of ${o.observed_at.slice(0, 40)})` : ' (the source states no time)';++ lines.push(`- ${String(o.text || '').slice(0, 600)}${when} ${cite(o.citations)}`.trimEnd());++ }++ }++ const gaps = Array.isArray(out.gaps) ? out.gaps.slice(0, 20).map((g) => String(g).slice(0, 300)) : [];++ if (gaps.length) {++ lines.push('', '**Not covered by the sources**', '');++ for (const g of gaps) lines.push(`- ${g}`);++ }++ if (refs.length) {++ lines.push('', '**Sources given to the model**', '');++ refs.forEach((r, i) => lines.push(`${i + 1}. ${r.kind} ${r.id}`));++ }++ const rec = authorship.record({++ mode: 'ai',++ workflow: { id: WORKFLOW, runId, ...(wf.version != null ? { version: wf.version } : {}), ...(wf.model ? { model: String(wf.model) } : {}) },++ stubProvider: body.stub_provider === true,++ source: { label: `${instrument.symbol} documents and observations on OpenVibe.Trade` },++ });++ resolveCites(instrument, refs); // every source the model was given must exist++ return { content: lines.join('\n'), cites: resolveCites(instrument, all), rec, gaps };++ }++++ const api = {++ WORKFLOW,++ isEditorSubject,++++ head: (instrument) => store.revisions.head(instrument.id),++ revision: (instrument, n) => store.revisions.get(instrument.id, n),++ revisions: (instrument, opts) => store.revisions.list(instrument.id, opts),++++ /**++ * A new context revision. Editors: { body, cites?, expected_revision?, publish? }.++ * OpenVibe.AI (service, X-OV-Origin: ai): { workflow: { id, run_id, version?, model? },++ * input_sources: [{ source_type, source_id }], output: { summary, observations, gaps, citations },++ * stub_provider? } — always a draft.++ */++ propose(viewer, instrument, body = {}) {++ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');++ if (viewer.kind === 'service' && viewer.origin === 'ai') {++ const { content, cites, rec, gaps } = fromAiOutput(instrument, body);++ refuseAdvice(content);++ const fields = { as_of: asOf(cites), cites, origin: 'ai', gaps };++ const revision = store.tx(() => create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));++ return { revision, published: false };++ }++ if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors write context');++ const text = typeof body.body === 'string' ? body.body.replace(/\r\n/g, '\n').trim() : '';++ if (!text) throw invalid('body is required');++ if (text.length > MAX_BODY) throw invalid(`body is longer than ${MAX_BODY} characters`);++ refuseAdvice(text);++ const cites = resolveCites(instrument, body.cites);++ const rec = authorship.record({ mode: 'human', authors: [viewer.subject] });++ const fields = { as_of: asOf(cites), cites, origin: 'editor' };++ return store.tx(() => {++ const revision = create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });++ if (body.publish === true || body.publish === 'on' || body.publish === '1') {++ api.publish(instrument, revision.number);++ return { revision, published: true };++ }++ return { revision, published: false };++ });++ },++++ /** A person's review. Approving an AI draft publishes it. */++ review(viewer, instrument, number, { decision, note } = {}) {++ if (viewer.kind !== 'user' || !viewer.editor || !viewer.subject) throw new ApiError(403, 'review.forbidden', 'Only a person on the editor list can review context');++ const rev = store.revisions.get(instrument.id, Number(number));++ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');++ return store.tx(() => {++ const review = store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });++ let published = false;++ if (decision === 'approved') { api.publish(instrument, rev.number); published = true; }++ return { review, published };++ });++ },++++ publish(instrument, number) {++ const rev = store.revisions.get(instrument.id, Number(number));++ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');++ const rec = rev.meta.authorship;++ const ok = authorship.canPublish(rec, store.reviews.latest(instrument.id, rev.number));++ if (!ok.ok) throw new ApiError(409, 'context.review_required', 'AI-generated context needs an approving review by a person before it is published');++ return store.tx(() => {++ setPublished.run(rev.number, store.now(), store.now(), instrument.id);++ ctx.indexing.refresh(ctx.instruments.get(instrument.id));++ return rev;++ });++ },++++ retract(viewer, instrument) {++ if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors retract context');++ return store.tx(() => {++ setPublished.run(null, null, store.now(), instrument.id);++ ctx.indexing.refresh(ctx.instruments.get(instrument.id));++ return true;++ });++ },++++ /** The published revision's view, or null. */++ published(instrument) {++ if (!instrument.context_published_revision) return null;++ const rev = store.revisions.get(instrument.id, instrument.context_published_revision);++ return rev ? api.view(instrument, rev) : null;++ },++++ /** Revisions after the published one (drafts awaiting review or publication). */++ pending(instrument) {++ const published = instrument.context_published_revision || 0;++ return store.revisions.list(instrument.id, { limit: 20 }).filter((r) => r.number > published).map((r) => api.view(instrument, r));++ },++++ view(instrument, rev) {++ const rec = rev.meta.authorship || null;++ const review = store.reviews.latest(instrument.id, rev.number);++ const cites = (rev.fields.cites || []).map((c) => {++ if (c.kind === 'observation') {++ const o = ctx.observations.get(c.id);++ return o ? { kind: c.kind, id: c.id, observation: ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };++ }++ const d = ctx.documents.get(c.id);++ return d && !d.removed_at ? { kind: c.kind, id: c.id, document: ctx.documents.dto(d) } : { kind: c.kind, id: c.id, missing: true };++ });++ return {++ revision: rev.number,++ published: instrument.context_published_revision === rev.number,++ published_at: instrument.context_published_revision === rev.number ? iso(instrument.context_published_at) : null,++ written_at: rev.createdAt,++ as_of: rev.fields.as_of || null,++ origin: rev.fields.origin || null,++ body_markdown: rev.content,++ body_html: ssr.renderMarkdown(rev.content),++ text: ssr.markdownToText(rev.content),++ cites,++ authorship: rec,++ disclosure: rec ? authorship.disclosure(rec, review) : null,++ review: review ? { decision: review.decision, reviewed_at: review.reviewedAt, reviewer: review.reviewer } : null,++ needs_review: rec ? !authorship.canPublish(rec, review).ok : false,++ };++ },++++ /** The input OpenVibe.AI's trade.summarize_market_context expects, numbered sources included. */++ input(instrument) {++ const sources = [];++ for (const o of ctx.observations.latest(instrument)) {++ const dto = ctx.observations.dto(o);++ sources.push({++ source_type: 'trade.observation', source_id: o.id, url: o.source_url || undefined,++ title: `${o.metric} of ${instrument.symbol}`,++ observed_at: dto.observed_at, retrieved_at: dto.retrieved_at,++ snippet: `${o.metric} = ${o.value} ${o.unit}${o.currency && o.currency !== o.unit ? ` (${o.currency})` : ''}${o.period ? `, period ${o.period}` : ''}`,++ provenance: { source_key: o.source_key, stale: dto.freshness.stale, stale_since: dto.freshness.stale_since },++ });++ }++ for (const d of ctx.documents.forInstrument(instrument, { limit: 20 })) {++ const fresh = ctx.freshness.view(d.source_key);++ sources.push({++ source_type: 'trade.document', source_id: d.id, url: d.url || undefined, title: d.title || undefined,++ published_at: iso(d.published_at) || undefined, retrieved_at: iso(d.retrieved_at),++ snippet: [d.form_type, d.filer_name, d.accession].filter(Boolean).join(' · ') || undefined,++ provenance: { source_key: d.source_key, stale: fresh.stale, stale_since: fresh.stale_since },++ });++ }++ const clean = sources.map((s) => JSON.parse(JSON.stringify(s)));++ const windows = [...new Set(clean.map((s) => ctx.freshness.view(s.provenance.source_key).stale_after_sec))];++ const hours = Math.max(1, Math.ceil((windows.length ? Math.min(...windows) : config.freshness.defaultStaleAfterSec) / 3600));++ return {++ workflow: WORKFLOW,++ input: { instrument: { symbol: instrument.symbol, name: instrument.name, ...(instrument.exchange ? { exchange: instrument.exchange } : {}) }, stale_after_hours: hours, sources: clean },++ deliver_to: { method: 'POST', path: `/api/v1/instruments/${encodeURIComponent(instrument.symbol)}/context`, headers: { 'X-OV-Origin': 'ai' } },++ };++ },++ };++ return api;++}++++module.exports = { createContext, WORKFLOW, ADVICE_RE };nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++++/**++ * Source documents (filings and other documents) linked from OpenVibe.Sources items. Sources owns++ * the item; Trade keeps the metadata it shows (form type, filer, dates, URL, provenance) keyed by++ * the item id, follows the item's revisions, and hides the document when Sources removes the item++ * (takedown or licence), keeping the row as the record that it happened.++ *++ * A newly seen document is what new_document and filing_type alert rules trigger on (once per rule++ * and document: alerts.onDocument). Item revisions update the row and never trigger again.++ */++const { newId, iso } = require('./util');++++function createDocuments({ store, ctx }) {++ const { db } = store;++ const q = {++ byItem: db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?'),++ byId: db.prepare('SELECT * FROM trade_source_documents WHERE id = ?'),++ insert: db.prepare(`INSERT INTO trade_source_documents (id, instrument_id, source_key, source_item_id, source_revision, kind, form_type, title, filer_name,++ cik, accession, url, published_at, source_updated_at, retrieved_at, first_seen_at, license_note, terms_note, recorded_at, updated_at)++ VALUES (@id, @instrument_id, @source_key, @source_item_id, @source_revision, @kind, @form_type, @title, @filer_name,++ @cik, @accession, @url, @published_at, @source_updated_at, @retrieved_at, @first_seen_at, @license_note, @terms_note, @now, @now)`),++ revise: db.prepare(`UPDATE trade_source_documents SET source_revision = @source_revision, form_type = @form_type, title = @title, filer_name = @filer_name,++ url = @url, published_at = @published_at, source_updated_at = @source_updated_at, retrieved_at = @retrieved_at,++ license_note = @license_note, terms_note = @terms_note, updated_at = @now WHERE id = @id`),++ touch: db.prepare('UPDATE trade_source_documents SET retrieved_at = @retrieved_at, updated_at = @now WHERE id = @id AND retrieved_at < @retrieved_at'),++ remove: db.prepare('UPDATE trade_source_documents SET removed_at = @at, removed_reason = @reason, updated_at = @now WHERE id = @id AND removed_at IS NULL'),++ forInstrument: db.prepare(`SELECT * FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL++ ORDER BY COALESCE(published_at, first_seen_at) DESC, id DESC LIMIT ? OFFSET ?`),++ countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL'),++ recent: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id++ WHERE d.removed_at IS NULL AND i.status = 'active' ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT ?`),++ };++++ const api = {++ get: (id) => q.byId.get(id) || null,++ byItem: (itemId) => q.byItem.get(itemId) || null,++ forInstrument: (instrument, { limit = 50, offset = 0 } = {}) => q.forInstrument.all(instrument.id, limit, offset),++ count: (instrument) => q.countFor.get(instrument.id).n,++ recent: (limit = 50) => q.recent.all(limit),++++ /** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */++ upsert(doc, instrument, { traceparent } = {}) {++ return store.tx(() => {++ const existing = q.byItem.get(doc.source_item_id);++ if (existing) {++ if (existing.instrument_id !== instrument.id) return { document: existing, created: false, changed: false };++ if (doc.source_revision > existing.source_revision) {++ q.revise.run({ ...doc, id: existing.id, now: store.now() });++ } else {++ q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });++ }++ ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);++ return { document: q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };++ }++ const id = newId('doc', store.now());++ q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });++ const row = q.byId.get(id);++ ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);++ ctx.alerts.onDocument(row, instrument, { traceparent });++ return { document: row, created: true, changed: true };++ });++ },++++ /** Sources removed the item: hide the document (the row stays as the record). */++ remove(itemId, { at, reason }) {++ const row = q.byItem.get(itemId);++ if (!row) return false;++ return q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() }).changes > 0;++ },++++ dto(d) {++ return {++ id: d.id, kind: d.kind, form_type: d.form_type, title: d.title, filer_name: d.filer_name, cik: d.cik, accession: d.accession, url: d.url,++ published_at: iso(d.published_at), source_updated_at: iso(d.source_updated_at), retrieved_at: iso(d.retrieved_at), first_seen_at: iso(d.first_seen_at),++ source: { key: d.source_key, item_id: d.source_item_id, revision: d.source_revision, license_note: d.license_note, terms_note: d.terms_note },++ };++ },++ };++ return api;++}++++module.exports = { createDocuments };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++++/**++ * Freshness per source: is what Trade shows from a source still current?++ *++ * A source is FRESH at time `now` only when its last successful fetch is known and younger than its++ * staleness window (Sources' stale_after_sec, else TRADE_DEFAULT_STALE_AFTER_SEC). Everything++ * else is stale:++ * - the last success is older than the window → stale since last success + window++ * - Sources never reported a success → stale since Trade first saw the source++ * - Trade has never heard of the source at all → freshness unknown (shown as stale)++ * Sources' own health status (failing, disabled, …) is shown next to it, but the verdict is++ * computed with Trade's clock, so an outage of Sources or of the sync makes data go stale on its own++ * rather than look current forever. Nothing is ever replaced by an invented value: a stale value is++ * shown with its timestamps and a "stale since" label, and a missing one is shown as missing.++ *++ * `trade.source.stale` and `trade.source.recovered` are emitted only on a transition (evaluate()),++ * so a replay or a second tick changes nothing.++ */++const { iso } = require('./util');++++function createFreshness({ store, config, outbox }) {++ const { db } = store;++ const def = config.freshness.defaultStaleAfterSec;++ const q = {++ get: db.prepare('SELECT * FROM trade_source_status WHERE source_key = ?'),++ all: db.prepare('SELECT * FROM trade_source_status ORDER BY source_key'),++ ensure: db.prepare(`INSERT OR IGNORE INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL)`),++ report: db.prepare(`UPDATE trade_source_status SET name = COALESCE(@name, name), upstream_status = @status,++ last_success_at = CASE WHEN @last IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,++ stale_after_sec = COALESCE(@window, stale_after_sec), reported_at = @now,++ terms_note = COALESCE(@terms, terms_note), license_note = COALESCE(@license, license_note)++ WHERE source_key = @key`),++ retrieval: db.prepare(`UPDATE trade_source_status SET last_success_at = @at WHERE source_key = @key AND (last_success_at IS NULL OR last_success_at < @at)`),++ mark: db.prepare('UPDATE trade_source_status SET stale = @stale, stale_since = @since, evaluated_at = @now WHERE source_key = @key'),++ };++++ /** Pure: the verdict for one status row at `now`. row may be null (unknown source). */++ function verdict(row, now) {++ if (!row) return { known: false, stale: true, staleSince: null, window: def, lastSuccessAt: null };++ const window = row.stale_after_sec || def;++ if (row.last_success_at == null) return { known: true, stale: true, staleSince: row.stale_since, window, lastSuccessAt: null };++ const until = row.last_success_at + window * 1000;++ return { known: true, stale: now > until, staleSince: now > until ? until : null, window, lastSuccessAt: row.last_success_at };++ }++++ function view(key, now = store.now()) {++ const row = q.get.get(key) || null;++ const v = verdict(row, now);++ return {++ key,++ name: row ? row.name : null,++ known: v.known,++ status: !v.known ? 'unknown' : v.stale ? 'stale' : 'fresh',++ stale: v.stale,++ stale_since: iso(v.staleSince),++ last_success_at: iso(v.lastSuccessAt),++ stale_after_sec: v.window,++ upstream_status: row ? row.upstream_status : null,++ reported_at: row ? iso(row.reported_at) : null,++ terms_note: row ? row.terms_note : null,++ license_note: row ? row.license_note : null,++ };++ }++++ function ensure(key) { q.ensure.run(key, store.now()); }++++ /** Emit on a transition only. Inside the caller's transaction (or its own). */++ function evaluate(key) {++ return store.tx(() => {++ const row = q.get.get(key);++ if (!row) return null;++ const v = verdict(row, store.now());++ const first = row.evaluated_at == null;++ const was = Boolean(row.stale);++ const since = v.stale ? (v.staleSince != null ? v.staleSince : (row.stale_since || store.now())) : null;++ q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });++ // First sight: a fresh source is not a recovery; a stale one is reported once.++ if (first ? !v.stale : v.stale === was) return null;++ const envelope = outbox.emit({++ event_type: v.stale ? 'trade.source.stale' : 'trade.source.recovered',++ actor: { type: 'service', id: 'trade' }, visibility: 'public', priority: 'low',++ subject: { type: 'source', id: key },++ payload: {++ source_key: key, stale: v.stale, stale_since: iso(since), last_success_at: iso(v.lastSuccessAt),++ stale_after_sec: v.window, upstream_status: row.upstream_status || null, evaluated_at: iso(store.now()),++ },++ });++ return envelope;++ });++ }++++ return {++ verdict,++ view,++ ensure,++ evaluate,++ evaluateAll() { return q.all.all().map((r) => evaluate(r.source_key)).filter(Boolean); },++ all(now = store.now()) { return q.all.all().map((r) => view(r.source_key, now)); },++++ /** What Sources says about a source (its health block). Evaluates the transition. */++ report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {++ return store.tx(() => {++ ensure(key);++ q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });++ return evaluate(key);++ });++ },++++ /** A datum retrieved at `at` proves a successful fetch at that time. */++ noteRetrieval(key, at) {++ ensure(key);++ q.retrieval.run({ key, at });++ return evaluate(key);++ },++ };++}++++module.exports = { createFreshness };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * Indexability and OpenVibe.Search for instrument pages (openvibe-publishing/seo gate and++ * index-hooks). The only public, indexable objects Trade has are instrument pages; watchlists,++ * alert rules and deliveries are private and never reach this module.++ *++ * Gate facts for an instrument page:++ * state active → published, archived → unpublished++ * sensitive always (financial information): unreviewed_sensitive until a person-reviewed++ * context is published (human-written, or an AI draft a person approved)++ * authorship the published context's record (an unreviewed AI draft is never published anyway)++ * text the published context (policy minWords, TRADE_GATE_MIN_WORDS)++ * price the newest monetary observation's observed_at: older than TRADE_PRICE_MAX_AGE_SEC++ * → stale_price (noindex), so a crawler never indexes an old number as current++ *++ * Search gets a document only while the page is listable; otherwise a tombstone (and nothing at all++ * for a page that was never indexed). The sequencer raises the document revision only when the++ * document changes, so replays and worker ticks send nothing new.++ */++const seo = require('openvibe-publishing/seo');++const hooks = require('openvibe-publishing/index-hooks');++const authorship = require('openvibe-publishing/authorship');++const ssr = require('openvibe-publishing/ssr');++++const OWNER = 'trade';++++function createIndexing({ store, config, ctx }) {++ const { db } = store;++ const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL++ ORDER BY observed_at DESC LIMIT 1`);++++ function publishedContext(instrument) {++ if (!instrument.context_published_revision) return null;++ return store.revisions.get(instrument.id, instrument.context_published_revision);++ }++++ function decide(instrument, now = store.now()) {++ const rev = publishedContext(instrument);++ const rec = rev ? rev.meta.authorship : null;++ const review = rev ? store.reviews.latest(instrument.id, rev.number) : null;++ const reviewedContext = Boolean(rec && authorship.canPublish(rec, review).ok);++ const facts = {++ state: instrument.status === 'active' ? 'published' : 'unpublished',++ visibility: 'public',++ canonicalUrl: ctx.urls.instrument(instrument),++ text: rev ? ssr.markdownToText(rev.content) : '',++ sensitive: true,++ sensitiveReviewed: reviewedContext,++ };++ if (rec) Object.assign(facts, authorship.gateFacts(rec, review));++ const m = latestMonetary.get(instrument.id);++ if (m) facts.price = { observedAt: m.observed_at };++ return seo.evaluate(facts, { policy: { minWords: config.gate.minWords, priceMaxAgeMs: config.freshness.priceMaxAgeMs }, now });++ }++++ function document(instrument, decision) {++ const rev = publishedContext(instrument);++ const identity = { owner: OWNER, type: 'instrument', id: instrument.id, revision: 0 };++ if (!decision.listable || instrument.status !== 'active') return hooks.tombstone(identity);++ const rec = rev ? rev.meta.authorship : null;++ const aliases = ctx.instruments.aliases(instrument).map((a) => a.value);++ return hooks.buildIndexDocument({++ ...identity,++ state: 'published',++ visibility: 'public',++ canonicalUrl: ctx.urls.instrument(instrument),++ title: `${instrument.symbol} — ${instrument.name}`,++ summary: rev ? ssr.markdownToText(rev.content, 300) : null,++ body: [rev ? ssr.markdownToText(rev.content) : '', `Also known as: ${aliases.join(', ')}`].join('\n\n'),++ facets: { kind: instrument.kind, symbol: instrument.symbol, ...(instrument.exchange ? { exchange: instrument.exchange } : {}) },++ authorship: rec,++ provenance: (rev && rev.fields.cites || []).slice(0, 40).map((c) => ({ service: OWNER, type: c.kind, id: c.id })),++ decision,++ publishedAt: instrument.context_published_at,++ updatedAt: rev ? rev.createdAt : null,++ language: 'en',++ });++ }++++ /** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */++ function refresh(instrument, { traceparent } = {}) {++ if (!instrument) return null;++ const decision = decide(instrument);++ const doc = document(instrument, decision);++ const prev = store.sequencer.current(OWNER, 'instrument', instrument.id);++ if (doc.deleted && prev == null) return null;++ const stamped = store.sequencer.stamp(doc);++ if (prev != null && stamped.revision === prev) return null;++ return ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ }++++ return {++ OWNER,++ decide,++ refresh,++ refreshAll: () => store.tx(() => ctx.instruments.active().map((i) => refresh(i)).filter(Boolean)),++ };++}++++module.exports = { createIndexing, OWNER };nnew file mode 100644@@@ -0,0 +1,211 @@++'use strict';++++/**++ * Instruments and their aliases, with deterministic resolution.++ *++ * Normalisation (the key an alias is stored and looked up under):++ * ticker upper case, trimmed; A–Z, 0–9, '.', '-' only, 1–16 characters (BRK.B, RDS-A)++ * cik digits only ("CIK" prefix allowed), zero-padded to 10 (320193 → 0000320193)++ * name NFKD without diacritics, upper case, '&' → AND, dots and apostrophes removed (S.A. → SA),++ * other punctuation → space, whitespace++ * collapsed, a trailing SEC state marker (/DE/) dropped, then trailing corporate-form++ * words (INC, CORP, CO, LTD, PLC, LLC, …, and a dangling AND from "& Co") dropped++ *++ * resolve(query, { kind }) tries, in this fixed order, and stops at the first rule that matches:++ * 1. CIK when the query is all digits (optionally "CIK…"): the instrument's CIK or a cik alias++ * 2. ticker the instrument's symbol, then a ticker alias++ * 3. name name aliases; one instrument → resolved, several → ambiguous (candidates listed,++ * none chosen), none → not_found++ * There is no fuzzy or prefix matching and no ranking: the same database answers the same query the++ * same way. A ticker or CIK names exactly one instrument (a unique index enforces it).++ */++const { ApiError } = require('../http/errors');++const { newId, invalid, str } = require('./util');++++const KINDS = ['equity', 'fund', 'index', 'currency', 'commodity', 'crypto', 'other'];++const SYMBOL_RE = /^[A-Z0-9][A-Z0-9.-]{0,15}$/;++const CORPORATE_WORDS = new Set(['INC', 'INCORPORATED', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'PLC', 'LLC', 'LP', 'LLP', 'SA', 'AG', 'NV', 'SE', 'THE']);++++function normSymbol(v) {++ if (v == null) return null;++ const s = String(v).trim().toUpperCase();++ return SYMBOL_RE.test(s) ? s : null;++}++++function normCik(v) {++ if (v == null) return null;++ const s = String(v).trim().toUpperCase().replace(/^CIK/, '');++ return /^\d{1,10}$/.test(s) ? s.padStart(10, '0') : null;++}++++function normName(v) {++ if (v == null) return null;++ let s = String(v).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toUpperCase();++ s = s.replace(/&/g, ' AND ').replace(/\s*\/[A-Z]{2}\/\s*$/, ' ');++ s = s.replace(/[.'’]/g, '').replace(/[^A-Z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();++ const words = s.split(' ').filter(Boolean);++ while (words.length > 1 && (CORPORATE_WORDS.has(words[words.length - 1]) || words[words.length - 1] === 'AND')) words.pop();++ while (words.length > 1 && words[0] === 'THE') words.shift();++ const out = words.join(' ');++ return out || null;++}++++const NORMALIZE = { ticker: normSymbol, cik: normCik, name: normName };++++function createInstruments({ store }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM trade_instruments WHERE id = ?'),++ bySymbol: db.prepare('SELECT * FROM trade_instruments WHERE symbol = ?'),++ byCik: db.prepare('SELECT * FROM trade_instruments WHERE cik = ?'),++ alias: db.prepare('SELECT DISTINCT i.* FROM trade_instrument_aliases a JOIN trade_instruments i ON i.id = a.instrument_id WHERE a.kind = ? AND a.normalized = ? ORDER BY i.symbol'),++ aliasesOf: db.prepare('SELECT kind, value, normalized, created_at FROM trade_instrument_aliases WHERE instrument_id = ? ORDER BY kind, normalized'),++ insert: db.prepare(`INSERT INTO trade_instruments (id, symbol, name, kind, exchange, cik, currency, status, created_by, created_at, updated_at)++ VALUES (@id, @symbol, @name, @kind, @exchange, @cik, @currency, 'active', @by, @now, @now)`),++ insertAlias: db.prepare(`INSERT OR IGNORE INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)++ VALUES (?, ?, ?, ?, ?, ?)`),++ count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),++ page: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol LIMIT ? OFFSET ?"),++ active: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol"),++ };++++ function fields(input, { partial = false } = {}) {++ const out = {};++ if (!partial || input.name !== undefined) out.name = str(input.name, 200, 'name', { required: !partial });++ if (!partial || input.kind !== undefined) {++ const kind = input.kind == null || input.kind === '' ? (partial ? undefined : 'equity') : String(input.kind);++ if (kind !== undefined && !KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);++ if (kind !== undefined) out.kind = kind;++ }++ if (!partial || input.exchange !== undefined) out.exchange = str(input.exchange, 40, 'exchange');++ if (!partial || input.cik !== undefined) {++ if (input.cik == null || input.cik === '') out.cik = null;++ else {++ out.cik = normCik(input.cik);++ if (!out.cik) throw invalid('cik must be up to 10 digits');++ }++ }++ if (!partial || input.currency !== undefined) {++ const c = input.currency == null || input.currency === '' ? null : String(input.currency).trim().toUpperCase();++ if (c && !/^[A-Z]{3}$/.test(c)) throw invalid('currency must be an ISO 4217 code like USD');++ out.currency = c;++ }++ return out;++ }++++ function conflictGuard(fn) {++ try { return fn(); } catch (err) {++ if (err && /UNIQUE constraint failed/.test(err.message)) {++ if (/trade_instruments\.symbol/.test(err.message)) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');++ if (/trade_instruments\.cik/.test(err.message)) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');++ throw new ApiError(409, 'alias.taken', 'This ticker or CIK already names another instrument');++ }++ throw err;++ }++ }++++ const api = {++ KINDS,++ normSymbol, normCik, normName,++++ get: (id) => q.byId.get(id) || null,++ bySymbol: (symbol) => { const s = normSymbol(symbol); return s ? q.bySymbol.get(s) || null : null; },++ aliases: (instrument) => q.aliasesOf.all(instrument.id),++ active: () => q.active.all(),++ page({ limit = 50, offset = 0 } = {}) {++ return { total: q.count.get().n, instruments: q.page.all(limit, offset) };++ },++++ /** input: { symbol, name, kind?, exchange?, cik?, currency? } → the new row. Aliases for the symbol, CIK and name are added. */++ create(input, by) {++ const symbol = normSymbol(input.symbol);++ if (!symbol) throw invalid('symbol must be 1–16 characters of A–Z, 0–9, "." or "-"');++ const f = fields(input);++ const id = newId('ins', store.now());++ return conflictGuard(() => store.tx(() => {++ // A ticker alias of another instrument must not be shadowed by a new symbol.++ const holder = q.alias.all('ticker', symbol).find((i) => i.symbol !== symbol);++ if (holder) throw new ApiError(409, 'alias.taken', `${symbol} is already an alias of ${holder.symbol}`);++ q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });++ q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());++ if (f.cik) q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());++ const n = normName(f.name);++ if (n) q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());++ return q.byId.get(id);++ }));++ },++++ update(instrument, input) {++ const f = fields(input, { partial: true });++ if (input.status !== undefined) {++ if (!['active', 'archived'].includes(input.status)) throw invalid('status must be active or archived');++ f.status = input.status;++ }++ const keys = Object.keys(f);++ if (!keys.length) return instrument;++ return conflictGuard(() => store.tx(() => {++ db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });++ if (f.cik) q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());++ if (f.name) { const n = normName(f.name); if (n) q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }++ return q.byId.get(instrument.id);++ }));++ },++++ addAlias(instrument, kind, value, by) {++ if (!NORMALIZE[kind]) throw invalid('alias kind must be ticker, cik or name');++ const raw = str(value, 200, 'alias', { required: true });++ const n = NORMALIZE[kind](raw);++ if (!n) throw invalid(`not a valid ${kind}`);++ if (kind === 'ticker') {++ const owner = q.bySymbol.get(n);++ if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the symbol of ${owner.symbol}`);++ }++ if (kind === 'cik') {++ const owner = q.byCik.get(n);++ if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the CIK of ${owner.symbol}`);++ }++ conflictGuard(() => q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));++ return { kind, value: raw, normalized: n };++ },++++ /**++ * → { query, status: 'resolved'|'ambiguous'|'not_found', match: { kind, value } | null,++ * instrument | null, candidates: [] }++ */++ resolve(query, { kind = null } = {}) {++ const raw = String(query == null ? '' : query).trim().slice(0, 200);++ const out = (status, match, instrument, candidates = []) => ({ query: raw, status, match, instrument: instrument || null, candidates });++ if (!raw) return out('not_found', null, null);++ if (kind && !NORMALIZE[kind]) throw invalid('kind must be ticker, cik or name');++++ if (!kind || kind === 'cik') {++ const cik = /^(CIK)?\d{1,10}$/i.test(raw) ? normCik(raw) : null;++ if (cik) {++ const hit = q.byCik.get(cik) || q.alias.all('cik', cik)[0];++ if (hit) return out('resolved', { kind: 'cik', value: cik }, hit);++ }++ }++ if (!kind || kind === 'ticker') {++ const sym = normSymbol(raw);++ if (sym) {++ const hit = q.bySymbol.get(sym);++ if (hit) return out('resolved', { kind: 'symbol', value: sym }, hit);++ const alias = q.alias.all('ticker', sym)[0];++ if (alias) return out('resolved', { kind: 'ticker', value: sym }, alias);++ }++ }++ if (!kind || kind === 'name') {++ const n = normName(raw);++ if (n) {++ const hits = q.alias.all('name', n);++ if (hits.length === 1) return out('resolved', { kind: 'name', value: n }, hits[0]);++ if (hits.length > 1) return out('ambiguous', { kind: 'name', value: n }, null, hits);++ }++ }++ return out('not_found', null, null);++ },++ };++ return api;++}++++module.exports = { createInstruments, normSymbol, normCik, normName, KINDS };nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++++/**++ * From an OpenVibe.Sources item (sources.item@1, category trade) to what Trade records. Pure and++ * deterministic; it reads only what the item states and never fills a gap.++ *++ * observation the item states a value: fields { metric, value, unit, observed_at } plus a++ * symbol/ticker or CIK (currency, period, max_age_sec optional). An item without its++ * own observation time is skipped — the retrieval time is NOT an observation time.++ * document an SEC EDGAR filing: the canonical URL is under++ * sec.gov/Archives/edgar/data/<CIK>/<accession>/ (the xbrlrss feed's index links),++ * so the CIK and accession number come from the URL; the form type is the item's++ * summary when it looks like a form type (the feed's <description>, e.g. "10-Q"),++ * else fields.form_type; the filer is the item title.++ * Any other item with fields.cik or fields.symbol/ticker is a document too++ * (kind 'record' for record items, else 'article').++ * skip everything else, with a reason (counted in the sync state, never guessed at).++ */++const { parseTime, parseDecimal } = require('./util');++++const SEC_PATH_RE = /^https?:\/\/(?:www\.)?sec\.gov\/Archives\/edgar\/data\/(\d{1,10})\/(\d{18})(?:\/|$)/i;++const FORM_RE = /^[A-Z0-9][A-Z0-9 \-/.]{0,19}$/;++++function formType(v) {++ if (typeof v !== 'string') return null;++ const s = v.trim().toUpperCase();++ return FORM_RE.test(s) && /\d|^[A-Z]{1,6}(\/A)?$/.test(s) ? s : null;++}++++function accessionOf(digits) {++ return `${digits.slice(0, 10)}-${digits.slice(10, 12)}-${digits.slice(12)}`;++}++++function filerName(title) {++ if (typeof title !== 'string') return null;++ const s = title.replace(/\s*\(\d{10}\)\s*(\((Filer|Subject|Reporting|Filed by)[^)]*\))?\s*$/i, '').trim();++ return s || null;++}++++/** → { type: 'observation', keys, observation } | { type: 'document', keys, document } | { type: 'skip', reason } */++function mapItem(item) {++ if (!item || typeof item !== 'object' || !item.id) return { type: 'skip', reason: 'malformed_item' };++ const f = item.fields && typeof item.fields === 'object' ? item.fields : {};++ const keys = {++ cik: f.cik != null ? String(f.cik) : null,++ symbol: f.symbol != null ? String(f.symbol) : (f.ticker != null ? String(f.ticker) : null),++ };++ const prov = item.provenance || {};++ const retrievedAt = parseTime(prov.retrieved_at);++ if (retrievedAt == null) return { type: 'skip', reason: 'no_retrieval_time' };++++ if (item.kind === 'observation' || (f.metric != null && f.value != null)) {++ if (!keys.cik && !keys.symbol) return { type: 'skip', reason: 'no_instrument_key' };++ if (f.metric == null || f.unit == null || !parseDecimal(f.value)) return { type: 'skip', reason: 'incomplete_observation' };++ if (parseTime(f.observed_at) == null) return { type: 'skip', reason: 'no_observation_time' };++ return {++ type: 'observation', keys,++ observation: {++ metric: f.metric, value: f.value, unit: f.unit, currency: f.currency == null ? null : f.currency,++ period: f.period == null ? null : String(f.period), observed_at: f.observed_at, retrieved_at: prov.retrieved_at,++ source_key: item.source_key, source_item_id: item.id, source_url: item.canonical_url || null,++ source_ref: `${item.id}#r${item.revision}:${f.metric}`, max_age_sec: f.max_age_sec == null ? null : f.max_age_sec,++ },++ };++ }++++ const base = {++ source_key: item.source_key, source_item_id: item.id, source_revision: item.revision,++ title: item.title || null, url: item.canonical_url || null,++ published_at: parseTime(item.published_at), source_updated_at: parseTime(item.source_updated_at),++ retrieved_at: retrievedAt, first_seen_at: parseTime(prov.first_seen_at) || retrievedAt,++ license_note: prov.license_note || null, terms_note: prov.terms_note || null,++ };++ const sec = item.canonical_url ? SEC_PATH_RE.exec(item.canonical_url) : null;++ if (sec) {++ const cik = sec[1].padStart(10, '0');++ return {++ type: 'document', keys: { cik, symbol: keys.symbol },++ document: { ...base, kind: 'filing', form_type: formType(item.summary) || formType(f.form_type), filer_name: filerName(item.title), cik, accession: accessionOf(sec[2]) },++ };++ }++ if (keys.cik || keys.symbol) {++ return {++ type: 'document', keys,++ document: { ...base, kind: item.kind === 'record' ? 'record' : 'article', form_type: formType(f.form_type), filer_name: null, cik: keys.cik ? keys.cik.replace(/^CIK/i, '').padStart(10, '0') : null, accession: null },++ };++ }++ return { type: 'skip', reason: 'no_instrument_key' };++}++++module.exports = { mapItem, formType, filerName, accessionOf };nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++++/**++ * Market observations: one value a source stated for an instrument at a time.++ *++ * Every row carries value (the decimal exactly as stated), unit, currency (when monetary),++ * observed_at (when the value was true, per the source), the source (key, Sources item, URL, the++ * source's own reference) and retrieved_at (when the source was fetched). None of these is ever++ * filled in: a datum without an observation time or a retrieval time is refused, and an instrument++ * without observations shows no number at all.++ *++ * Rows are immutable (SQLite triggers). The same (source_key, source_ref) recorded twice is one++ * observation (a replay answers created: false); the same reference with a DIFFERENT value is a++ * 409 — a source correcting itself sends a new reference, and both stay on record.++ *++ * Recording runs in one transaction with: trade.observation.created, the source's freshness++ * (a retrieval proves a successful fetch), alert evaluation (alerts.onObservation) and the++ * instrument's Search document (the gate's stale_price rule looks at monetary observations).++ */++const { ApiError } = require('../http/errors');++const { newId, iso, parseTime, parseDecimal, invalid, str, httpUrl } = require('./util');++++const METRIC_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,79}$/;++const UNIT_RE = /^[A-Za-z][A-Za-z0-9/%._-]{0,19}$/;++const SOURCE_KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;++const FUTURE_SKEW_MS = 5 * 60 * 1000;++++function createObservations({ store, ctx }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM trade_market_observations WHERE id = ?'),++ byRef: db.prepare('SELECT * FROM trade_market_observations WHERE source_key = ? AND source_ref = ?'),++ insert: db.prepare(`INSERT INTO trade_market_observations (id, instrument_id, metric, value, value_num, unit, currency, period, observed_at,++ source_key, source_item_id, source_url, source_ref, retrieved_at, max_age_sec, recorded_by, recorded_at)++ VALUES (@id, @instrument_id, @metric, @value, @value_num, @unit, @currency, @period, @observed_at,++ @source_key, @source_item_id, @source_url, @source_ref, @retrieved_at, @max_age_sec, @recorded_by, @recorded_at)`),++ latest: db.prepare(`SELECT * FROM (++ SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, IFNULL(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn++ FROM trade_market_observations o WHERE instrument_id = ?)++ WHERE rn = 1 ORDER BY metric, unit`),++ history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ? AND (@metric IS NULL OR metric = @metric)++ AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),++ count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),++ };++++ function validate(input, instrument) {++ const metric = str(input.metric, 80, 'metric', { required: true });++ if (!METRIC_RE.test(metric)) throw invalid('metric must start with a letter and use letters, digits, "_", ".", ":" or "-"');++ const value = parseDecimal(input.value);++ if (!value) throw invalid('value must be a finite decimal as the source stated it');++ const unit = str(input.unit, 20, 'unit', { required: true });++ if (!UNIT_RE.test(unit)) throw invalid('unit is malformed');++ const currency = input.currency == null || input.currency === '' ? null : String(input.currency).trim();++ if (currency && !/^[A-Z]{3}$/.test(currency)) throw invalid('currency must be an ISO 4217 code like USD');++ const observedAt = parseTime(input.observed_at);++ if (observedAt == null) throw invalid('observed_at must be the date-time (with zone) the source states for this value', 'observation.time_required');++ const retrievedAt = parseTime(input.retrieved_at);++ if (retrievedAt == null) throw invalid('retrieved_at must be the date-time (with zone) the source was fetched', 'observation.time_required');++ const limit = store.now() + FUTURE_SKEW_MS;++ if (observedAt > limit || retrievedAt > limit) throw invalid('observed_at and retrieved_at cannot be in the future');++ const sourceKey = str(input.source_key, 64, 'source_key', { required: true });++ if (!SOURCE_KEY_RE.test(sourceKey)) throw invalid('source_key must be an OpenVibe.Sources source key');++ const sourceRef = str(input.source_ref, 300, 'source_ref', { required: true });++ const sourceItemId = input.source_item_id == null || input.source_item_id === '' ? null : String(input.source_item_id);++ if (sourceItemId && !/^itm_[0-9A-HJKMNP-TV-Z]{26}$/.test(sourceItemId)) throw invalid('source_item_id must be a Sources item id (itm_…)');++ let maxAge = null;++ if (input.max_age_sec != null && input.max_age_sec !== '') {++ maxAge = Number(input.max_age_sec);++ if (!Number.isInteger(maxAge) || maxAge < 60 || maxAge > 10 * 365 * 86400) throw invalid('max_age_sec must be an integer between 60 and ten years');++ }++ return {++ instrument_id: instrument.id, metric, value: value.text, value_num: value.num, unit, currency,++ period: str(input.period, 40, 'period'), observed_at: observedAt, source_key: sourceKey, source_item_id: sourceItemId,++ source_url: httpUrl(input.source_url, 'source_url'), source_ref: sourceRef, retrieved_at: retrievedAt, max_age_sec: maxAge,++ };++ }++++ const same = (a, b) => a.instrument_id === b.instrument_id && a.metric === b.metric && a.value === b.value && a.unit === b.unit++ && (a.currency || null) === (b.currency || null) && a.observed_at === b.observed_at;++++ const api = {++ get: (id) => q.byId.get(id) || null,++ count: (instrument) => q.count.get(instrument.id).n,++ latest: (instrument) => q.latest.all(instrument.id),++ history(instrument, { metric = null, before = null, limit = 50 } = {}) {++ const b = parseTime(before);++ return q.history.all(instrument.id, { metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });++ },++++ /**++ * → { observation, created }. recordedBy: 'svc:…' or 'usr_…' (who recorded it here).++ */++ record(input, instrument, { recordedBy, traceparent } = {}) {++ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');++ if (!recordedBy) throw new TypeError('recordedBy is required');++ const row = validate(input || {}, instrument);++ return store.tx(() => {++ const existing = q.byRef.get(row.source_key, row.source_ref);++ if (existing) {++ if (same(existing, row)) return { observation: existing, created: false };++ throw new ApiError(409, 'observation.conflict', 'This source reference already recorded a different value; a correction needs its own source_ref');++ }++ const id = newId('obs', store.now());++ q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });++ const obs = q.byId.get(id);++ ctx.outbox.emit({++ event_type: 'trade.observation.created', actor: actorOf(recordedBy), visibility: 'public', priority: 'low',++ subject: { type: 'observation', id },++ payload: {++ instrument: { id: instrument.id, symbol: instrument.symbol },++ ...api.dto(obs, { withFreshness: false }),++ },++ }, { traceparent });++ ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);++ ctx.alerts.onObservation(obs, instrument, { traceparent });++ ctx.indexing.refresh(instrument, { traceparent });++ return { observation: obs, created: true };++ });++ },++++ /** Is this observation stale at `now`? Its source's freshness, and its own max age. */++ staleness(obs, now = store.now()) {++ const src = ctx.freshness.view(obs.source_key, now);++ const own = obs.max_age_sec ? obs.observed_at + obs.max_age_sec * 1000 : null;++ const ownStale = own != null && now > own;++ const candidates = [];++ if (src.stale && src.stale_since) candidates.push(Date.parse(src.stale_since));++ if (ownStale) candidates.push(own);++ return {++ stale: src.stale || ownStale,++ stale_since: candidates.length ? iso(Math.min(...candidates)) : null,++ reason: ownStale ? 'value_older_than_max_age' : !src.known ? 'source_unknown' : src.stale ? 'source_stale' : null,++ source: src,++ };++ },++++ dto(obs, { withFreshness = true, now = store.now() } = {}) {++ const out = {++ id: obs.id, metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency, period: obs.period,++ observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), recorded_at: iso(obs.recorded_at),++ max_age_sec: obs.max_age_sec,++ source: { key: obs.source_key, item_id: obs.source_item_id, url: obs.source_url, ref: obs.source_ref },++ };++ if (withFreshness) {++ const s = api.staleness(obs, now);++ out.freshness = { stale: s.stale, stale_since: s.stale_since, reason: s.reason, source_status: s.source.status, source_last_success_at: s.source.last_success_at };++ }++ return out;++ },++ };++ return api;++}++++function actorOf(by) {++ const s = String(by || '');++ if (s.startsWith('svc:')) return { type: 'service', id: s.slice(4) };++ if (/^usr_/.test(s)) return { type: 'user', id: s };++ return { type: 'service', id: 'trade' };++}++++module.exports = { createObservations, actorOf };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * Read models shared by the pages, the JSON twins and the API, so the HTML and the++ * machine-readable representation always describe the same data (§32.5: no divergence).++ */++const seo = require('openvibe-publishing/seo');++const { iso } = require('./util');++const { DISCLAIMER } = require('./alerts');++++function createReading({ store, ctx }) {++ function instrumentDto(i) {++ return {++ id: i.id, symbol: i.symbol, name: i.name, kind: i.kind, exchange: i.exchange, cik: i.cik, currency: i.currency,++ status: i.status, url: ctx.urls.instrument(i), created_at: iso(i.created_at), updated_at: iso(i.updated_at),++ };++ }++++ /** Everything the instrument page shows. docsPage: { limit, offset }. */++ function instrument(i, { limit = 25, offset = 0 } = {}) {++ const now = store.now();++ const observations = ctx.observations.latest(i).map((o) => ctx.observations.dto(o, { now }));++ const docs = ctx.documents.forInstrument(i, { limit, offset }).map((d) => {++ const dto = ctx.documents.dto(d);++ const f = ctx.freshness.view(d.source_key, now);++ dto.freshness = { stale: f.stale, stale_since: f.stale_since, reason: !f.known ? 'source_unknown' : f.stale ? 'source_stale' : null };++ return dto;++ });++ const keys = [...new Set([...observations.map((o) => o.source.key), ...docs.map((d) => d.source.key)])];++ const sources = keys.sort().map((k) => ctx.freshness.view(k, now));++ const decision = ctx.indexing.decide(i, now);++ return {++ instrument: instrumentDto(i),++ aliases: ctx.instruments.aliases(i).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),++ observations,++ documents: docs,++ documents_total: ctx.documents.count(i),++ sources,++ context: ctx.context.published(i),++ indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons },++ disclaimer: DISCLAIMER,++ generated_at: iso(now),++ decision,++ };++ }++++ /** JSON-LD from real fields only: the page, what it is about, breadcrumbs. */++ function jsonLd(i) {++ const url = ctx.urls.instrument(i);++ const about = i.kind === 'equity'++ ? { '@type': 'Corporation', name: i.name, tickerSymbol: i.symbol }++ : { '@type': 'Thing', name: i.name };++ return [++ seo.compact({++ '@context': 'https://schema.org', '@type': 'WebPage', '@id': `${url}#page`, url, name: `${i.symbol} — ${i.name}`,++ about, dateModified: i.context_published_at ? iso(i.context_published_at) : undefined, inLanguage: 'en',++ }),++ seo.structuredData.breadcrumbs([{ name: 'Instruments', url: ctx.urls.abs('/') }, { name: i.symbol, url }]),++ ];++ }++++ return { instrumentDto, instrument, jsonLd };++}++++module.exports = { createReading };nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++++/**++ * Pull the trade category from OpenVibe.Sources and record what it states.++ *++ * 1. GET /api/v1/sources every trade source's health → freshness (report)++ * 2. GET /api/v1/items?category=trade&after=<cursor>&include_removed=1, in change order:++ * removed item → the document is hidden (the row stays)++ * observation item → an observation (domain/observations.js; idempotent per item revision)++ * filing / keyed item → a document (domain/documents.js; new → document alerts)++ * no instrument matches → skipped (counted by reason; nothing is created for it)++ * Each page is applied in one transaction together with the cursor, so a crash replays the++ * page and every effect is idempotent (UNIQUE keys on observations, documents and deliveries).++ * The page's per-source health block → freshness as well.++ *++ * A failed call is recorded in trade_sync_state (last_error) and changes nothing else; freshness++ * then decays on Trade's own clock, so pages show the data as stale instead of pretending.++ */++const { mapItem } = require('./mapping');++const { parseTime, iso, json } = require('./util');++++const CURSOR = 'sources.trade';++++function createSync({ store, config, ctx, sources, log = console }) {++ const { db } = store;++ const q = {++ get: db.prepare('SELECT * FROM trade_sync_state WHERE name = ?'),++ ensure: db.prepare("INSERT OR IGNORE INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}')"),++ advance: db.prepare('UPDATE trade_sync_state SET cursor = @cursor, counts = @counts WHERE name = @name'),++ ok: db.prepare('UPDATE trade_sync_state SET last_ok_at = ?, last_error = NULL WHERE name = ?'),++ fail: db.prepare('UPDATE trade_sync_state SET last_error = ?, last_error_at = ? WHERE name = ?'),++ };++ q.ensure.run(CURSOR);++ let running = null;++++ function reportHealth(key, h, extra = {}) {++ if (!h || typeof h !== 'object') return;++ ctx.freshness.report(key, {++ name: extra.name || null,++ status: h.status || null,++ lastSuccessAt: parseTime(h.last_success_at),++ staleAfterSec: Number.isInteger(h.stale_after_sec) ? h.stale_after_sec : null,++ termsNote: extra.terms_note || null,++ licenseNote: extra.license_note || null,++ });++ }++++ function resolveKeys(keys) {++ if (keys.cik) {++ const r = ctx.instruments.resolve(keys.cik, { kind: 'cik' });++ if (r.status === 'resolved') return r.instrument;++ }++ if (keys.symbol) {++ const r = ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });++ if (r.status === 'resolved') return r.instrument;++ }++ return null;++ }++++ /** Apply one item. → a counter name. Inside the page transaction. */++ function applyItem(item) {++ if (item.removed) return ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';++ const m = mapItem(item);++ if (m.type === 'skip') return `skipped_${m.reason}`;++ const instrument = resolveKeys(m.keys);++ if (!instrument) return 'skipped_no_matching_instrument';++ if (instrument.status !== 'active') return 'skipped_archived_instrument';++ if (m.type === 'observation') {++ try {++ const r = ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });++ return r.created ? 'observations_created' : 'observations_unchanged';++ } catch (err) {++ if (err && (err.status === 409 || err.status === 422)) return `skipped_${String(err.code || 'invalid').replace(/\W+/g, '_')}`;++ throw err;++ }++ }++ const r = ctx.documents.upsert(m.document, instrument);++ return r.created ? 'documents_created' : r.changed ? 'documents_updated' : 'documents_unchanged';++ }++++ async function runOnce() {++ if (!sources.enabled) return { ok: false, skipped: 'disabled' };++ const summary = { pages: 0, counts: {} };++ try {++ const list = await sources.sources();++ for (const s of (list.sources || [])) {++ if (s && s.category === config.sources.category && s.key) reportHealth(s.key, s.health, s);++ }++ for (let page = 0; page < config.sources.maxPagesPerRun; page++) {++ const state = q.get.get(CURSOR);++ const data = await sources.items({ after: state.cursor, limit: config.sources.pageLimit });++ const items = Array.isArray(data.items) ? data.items : [];++ store.tx(() => {++ const counts = json(q.get.get(CURSOR).counts, {});++ for (const item of items) {++ const k = applyItem(item);++ counts[k] = (counts[k] || 0) + 1;++ summary.counts[k] = (summary.counts[k] || 0) + 1;++ }++ for (const [key, h] of Object.entries(data.sources || {})) reportHealth(key, h);++ const next = Number.isInteger(data.next_after) ? data.next_after : state.cursor;++ q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });++ });++ summary.pages++;++ if (!data.more || !items.length) break;++ }++ q.ok.run(store.now(), CURSOR);++ ctx.outbox.kick();++ return { ok: true, ...summary };++ } catch (err) {++ q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);++ log.warn('[Trade] Sources sync failed:', err.message);++ return { ok: false, error: err.message, ...summary };++ }++ }++++ return {++ CURSOR,++ enabled: sources.enabled,++ applyItem,++ /** One run at a time; concurrent callers share it. */++ run() {++ if (!running) running = runOnce().finally(() => { running = null; });++ return running;++ },++ state() {++ const s = q.get.get(CURSOR);++ return { enabled: sources.enabled, cursor: s.cursor, last_ok_at: iso(s.last_ok_at), last_error: s.last_error, last_error_at: iso(s.last_error_at), counts: json(s.counts, {}) };++ },++ };++}++++module.exports = { createSync };nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++/** Canonical paths and absolute URLs (from BASE_URL). One form per page. */++const seo = require('openvibe-publishing/seo');++++function createUrls(config) {++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);++ const path = {++ home: () => '/',++ instrument: (i) => `/i/${encodeURIComponent(i.symbol)}`,++ instrumentJson: (i) => `/i/${encodeURIComponent(i.symbol)}.json`,++ documentsFeed: (i, type) => `/i/${encodeURIComponent(i.symbol)}/documents.${type === 'atom' ? 'atom' : type === 'json' ? 'json' : 'xml'}`,++ sources: () => '/sources',++ watchlists: () => '/watchlists',++ };++ return {++ abs,++ path,++ instrument: (i) => abs(path.instrument(i)),++ documentAnchor: (i, d) => `${abs(path.instrument(i))}#${d.id}`,++ };++}++++module.exports = { createUrls };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/** Small shared helpers: ids, times, decimals, validation errors. Nothing here invents a value. */++const { ids } = require('openvibe-contracts');++const { ApiError } = require('../http/errors');++++const newId = (prefix, now) => `${prefix}_${ids.ulid(now)}`;++++/** epoch ms → ISO string, or null. */++const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());++++/**++ * A date the caller STATES: an ISO 8601 date-time with a zone (…Z or ±hh:mm), or epoch ms as a++ * number. Anything else (including a missing value) is null — never "now".++ */++function parseTime(v) {++ if (v == null || v === '') return null;++ if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? Math.floor(v) : null;++ const s = String(v).trim();++ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/i.test(s)) return null;++ const t = Date.parse(s);++ return Number.isFinite(t) ? t : null;++}++++const DECIMAL_RE = /^-?(\d{1,20})(\.\d{1,12})?([eE][-+]?\d{1,3})?$/;++++/** A decimal as stated: → { text, num } or null. Numbers are kept as their shortest string form. */++function parseDecimal(v) {++ if (typeof v === 'number') {++ if (!Number.isFinite(v)) return null;++ return { text: String(v), num: v };++ }++ if (typeof v !== 'string') return null;++ const s = v.trim();++ if (!DECIMAL_RE.test(s)) return null;++ const num = Number(s);++ return Number.isFinite(num) ? { text: s, num } : null;++}++++const invalid = (detail, code = 'request.invalid') => new ApiError(422, code, detail);++++function str(v, max, name, { required = false } = {}) {++ if (v == null || v === '') {++ if (required) throw invalid(`${name} is required`);++ return null;++ }++ if (typeof v !== 'string') throw invalid(`${name} must be a string`);++ const s = v.trim();++ if (!s && required) throw invalid(`${name} is required`);++ if (s.length > max) throw invalid(`${name} is longer than ${max} characters`);++ return s || null;++}++++function httpUrl(v, name) {++ if (v == null || v === '') return null;++ let u;++ try { u = new URL(String(v)); } catch { throw invalid(`${name} must be an absolute URL`); }++ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw invalid(`${name} must be http(s)`);++ return u.toString();++}++++/** Parse JSON stored by this service; a broken value is an empty default, never a crash. */++function json(text, def) {++ if (text == null) return def;++ try { return JSON.parse(text); } catch { return def; }++}++++module.exports = { newId, iso, parseTime, parseDecimal, invalid, str, httpUrl, json };nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++++/**++ * Watchlists: private per subject (usr_…). Only the owner (or a service acting for the owner with++ * X-OV-Subject and a trade.watchlist.* capability) can read or change one; anyone else gets 404, so++ * a watchlist's existence is never disclosed. Watchlists are never in a sitemap, a feed, Search or++ * an event, and every response that shows one is Cache-Control: private, no-store.++ */++const { ApiError } = require('../http/errors');++const { newId, iso, str } = require('./util');++++function createWatchlists({ store, config, ctx }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM trade_watchlists WHERE id = ?'),++ forOwner: db.prepare('SELECT * FROM trade_watchlists WHERE owner_subject = ? ORDER BY name'),++ count: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists WHERE owner_subject = ?'),++ insert: db.prepare('INSERT INTO trade_watchlists (id, owner_subject, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'),++ rename: db.prepare('UPDATE trade_watchlists SET name = ?, updated_at = ? WHERE id = ?'),++ remove: db.prepare('DELETE FROM trade_watchlists WHERE id = ?'),++ items: db.prepare(`SELECT w.*, i.symbol, i.name, i.kind, i.exchange, i.status FROM trade_watchlist_items w++ JOIN trade_instruments i ON i.id = w.instrument_id WHERE w.watchlist_id = ? ORDER BY i.symbol`),++ itemCount: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?'),++ addItem: db.prepare('INSERT OR IGNORE INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?)'),++ removeItem: db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ? AND instrument_id = ?'),++ touch: db.prepare('UPDATE trade_watchlists SET updated_at = ? WHERE id = ?'),++ };++++ const unique = (fn) => {++ try { return fn(); } catch (err) {++ if (err && /UNIQUE constraint failed: trade_watchlists/.test(err.message)) throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');++ throw err;++ }++ };++++ const api = {++ /** The owner's watchlist, or 404 for anyone else (existence is not disclosed). */++ mustOwn(subject, id) {++ const w = q.byId.get(String(id || ''));++ if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');++ return w;++ },++ forOwner: (subject) => (subject ? q.forOwner.all(subject) : []),++ items: (w) => q.items.all(w.id),++++ create(subject, input) {++ if (!subject) throw new ApiError(403, 'subject.required', 'Watchlists belong to a person');++ const name = str(input.name, 80, 'name', { required: true });++ if (q.count.get(subject).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);++ const id = newId('wl', store.now());++ unique(() => q.insert.run(id, subject, name, store.now(), store.now()));++ return q.byId.get(id);++ },++++ rename(w, input) {++ const name = str(input.name, 80, 'name', { required: true });++ unique(() => q.rename.run(name, store.now(), w.id));++ return q.byId.get(w.id);++ },++++ remove(w) { return store.tx(() => { db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return q.remove.run(w.id).changes > 0; }); },++++ add(w, instrument, note = null) {++ if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');++ if (q.itemCount.get(w.id).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);++ const added = q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now()).changes > 0;++ if (added) q.touch.run(store.now(), w.id);++ return added;++ },++++ drop(w, instrument) {++ if (!instrument) return false;++ const removed = q.removeItem.run(w.id, instrument.id).changes > 0;++ if (removed) q.touch.run(store.now(), w.id);++ return removed;++ },++++ dto(w, { withItems = true } = {}) {++ const out = { id: w.id, name: w.name, created_at: iso(w.created_at), updated_at: iso(w.updated_at) };++ if (withItems) {++ out.items = q.items.all(w.id).map((it) => ({++ instrument: { id: it.instrument_id, symbol: it.symbol, name: it.name, kind: it.kind, exchange: it.exchange, status: it.status, url: ctx.urls.instrument({ symbol: it.symbol }) },++ note: it.note, added_at: iso(it.added_at),++ }));++ }++ return out;++ },++ };++ return api;++}++++module.exports = { createWatchlists };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/**++ * Trade → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * trade.observation.created a market observation was recorded (public data)++ * trade.alert.triggered one delivery of one alert rule (visibility subject: only++ * the rule's owner, e.g. through Notifications); no email++ * trade.source.stale|recovered a source's freshness changed (only on a transition)++ * trade.index_document.upserted|deleted the OpenVibe.Search document or tombstone of an++ * instrument page (index-hooks indexEvent)++ *++ * 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 Trade's 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 createTradeOutbox({ 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: 'trade' });++ 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('[Trade] 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 = { createTradeOutbox };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++/**++ * POST /internal/events — signed OpenVibe.Events webhook deliveries (subscription topic sources.*).++ *++ * The body is verified against X-OpenVibe-Signature (HMAC-SHA256 of the raw body with++ * TRADE_EVENTS_WEBHOOK_SECRET) before anything is read; a bad or missing signature is 401 and the++ * route answers 404 while no secret is configured. Each event is recorded once in the SDK inbox++ * (idempotency_receipts, consumer 'sources'); a webhook is a wake-up, not durable truth: a++ * sources.item.* or sources.fetch.failed event makes the cursor-based sync run now, and the sync++ * reads the items themselves from the Sources API.++ */++const express = require('express');++const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { http } = require('openvibe-contracts');++++function createWebhook({ store, config, sync, log = console }) {++ const router = express.Router();++ const inbox = createInbox(store.db, { now: store.now });++ inbox.ensureSchema();++++ router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {++ if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');++ const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret);++ if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });++ const e = delivery.event;++ if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });++ const r = inbox.once('sources', e.event_id, () => true);++ const wake = !r.duplicate && /^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(e.event_type);++ if (wake) sync.run().catch((err) => log.warn('[Trade] sync after webhook failed:', err.message));++ res.status(200).json({ accepted: true, duplicate: r.duplicate, sync: wake });++ });++++ return { router, inbox };++}++++module.exports = { createWebhook };nnew file mode 100644@@@ -0,0 +1,245 @@++'use strict';++++/**++ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.trade, one++ * capability per route; private data needs X-OV-Subject, the person the service acts for) and for++ * browsers or apps with a Network user JWT (judged by ownership / the editor list). Errors are++ * problem+json. Every response is private, no-store and noindex.++ *++ * Public reads (no capability; services need none either):++ * GET /instruments?limit&offset active instruments++ * GET /instruments/:symbol the instrument page's data (as /i/:symbol.json)++ * GET /instruments/:symbol/observations?metric&before&limit history, each with freshness++ * GET /instruments/:symbol/documents?limit&offset++ * GET /sources freshness of every source++ * Capabilities:++ * GET /instruments/resolve?q=&kind= trade.instrument.resolve++ * POST /instruments trade.instrument.manage (editors)++ * PATCH /instruments/:symbol trade.instrument.manage (editors)++ * POST /instruments/:symbol/aliases trade.instrument.manage (editors)++ * POST /observations trade.observation.write (services only)++ * GET /instruments/:symbol/context trade.context.read (published; ?all=1 drafts for editors/services)++ * GET /instruments/:symbol/context/input trade.context.read (OpenVibe.AI workflow input)++ * POST /instruments/:symbol/context trade.context.propose (AI drafts with X-OV-Origin: ai; editors)++ * POST /instruments/:symbol/context/revisions/:n/review people on the editor list only++ * GET /watchlists trade.watchlist.read++ * POST /watchlists trade.watchlist.create++ * GET /watchlists/:id trade.watchlist.read++ * PATCH /watchlists/:id trade.watchlist.update++ * PUT /watchlists/:id/items/:symbol trade.watchlist.update++ * DELETE /watchlists/:id/items/:symbol trade.watchlist.update++ * DELETE /watchlists/:id trade.watchlist.delete++ * GET /alerts trade.alert.read++ * GET /alerts/deliveries trade.alert.read++ * POST /alerts trade.alert.create++ * DELETE /alerts/:id trade.alert.delete++ *++ * There is no endpoint that takes an order, holds or moves value, or gives personal advice++ * (ADR-025); test/route-inventory.test.js fails the build if one appears.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { run, jsonBody, ApiError, privateNoStore } = require('./errors');++const { guard } = require('../auth/viewer');++const { CAPABILITIES: C } = require('../auth/capabilities');++const { define } = require('./routes');++const { DISCLAIMER } = require('../domain/alerts');++++function cors(origins) {++ const allowed = new Set(origins);++ return function apiCors(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');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ next();++ };++}++++function createApi(ctx) {++ const { config, instruments, observations, documents, freshness, context, watchlists, alerts, reading, viewers, indexing, store } = ctx;++ const router = express.Router();++ router.use(cors(config.apiCorsOrigins));++ router.use(viewers.middleware());++ router.use(function privateApi(req, res, next) { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });++++ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });++++ function mustInstrument(req) {++ const i = instruments.bySymbol(req.params.symbol);++ if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');++ return i;++ }++++ /** The person whose private data this is: the signed-in user, or X-OV-Subject for a service. */++ function owner(req) {++ const v = req.viewer;++ if ((v.kind === 'user' || v.kind === 'service') && v.subject) return v.subject;++ if (v.kind === 'service') throw new ApiError(400, 'subject.required', 'X-OV-Subject names the person this service acts for');++ throw new ApiError(401, 'auth.required', 'Sign in with OpenVibe');++ }++++ function mustEditor(req) {++ const v = req.viewer;++ if (v.kind === 'service') return; // the capability guard already decided++ if (v.kind !== 'user' || !v.editor) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors can do this');++ }++++ const paging = (req, def = 50, max = 200) => ({++ limit: Math.min(Math.max(parseInt(req.query.limit, 10) || def, 1), max),++ offset: Math.max(parseInt(req.query.offset, 10) || 0, 0),++ });++++ // ── Instruments ─────────────────────────────────────────++++ define(router, 'get', '/instruments', 'getInstruments', run((req) => {++ const { limit, offset } = paging(req);++ const page = instruments.page({ limit, offset });++ return { total: page.total, instruments: page.instruments.map(reading.instrumentDto), disclaimer: DISCLAIMER };++ }));++++ define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run((req) => {++ const r = instruments.resolve(req.query.q, { kind: req.query.kind || null });++ return {++ query: r.query, status: r.status, match: r.match,++ instrument: r.instrument ? reading.instrumentDto(r.instrument) : null,++ candidates: r.candidates.map(reading.instrumentDto),++ };++ }));++++ define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {++ mustEditor(req);++ const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;++ const i = instruments.create(req.body || {}, by);++ store.tx(() => indexing.refresh(i, tp(req)));++ return { instrument: reading.instrumentDto(i), aliases: instruments.aliases(i) };++ }, 201));++++ define(router, 'get', '/instruments/:symbol', 'getInstrument', run((req) => {++ const { decision, ...data } = reading.instrument(mustInstrument(req), paging(req, 25, 100));++ return data;++ }));++++ define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {++ mustEditor(req);++ const i = instruments.update(mustInstrument(req), req.body || {});++ store.tx(() => indexing.refresh(i, tp(req)));++ return { instrument: reading.instrumentDto(i) };++ }));++++ define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {++ mustEditor(req);++ const i = mustInstrument(req);++ const b = req.body || {};++ const alias = instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);++ return { alias, aliases: instruments.aliases(i) };++ }, 201));++++ define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run((req) => {++ const i = mustInstrument(req);++ const list = observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });++ return { instrument: reading.instrumentDto(i), observations: list.map((o) => observations.dto(o)), disclaimer: DISCLAIMER };++ }));++++ define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run((req) => {++ const i = mustInstrument(req);++ return { instrument: reading.instrumentDto(i), total: documents.count(i), documents: documents.forInstrument(i, paging(req)).map(documents.dto) };++ }));++++ // ── Observations (first-party feeds) ────────────────────++++ define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), jsonBody, run((req) => {++ if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');++ const b = req.body || {};++ const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);++ if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument (symbol or instrument_id)');++ const out = observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });++ ctx.outbox.kick();++ return { observation: observations.dto(out.observation), created: out.created };++ }, (out) => (out.created ? 201 : 200)));++++ // ── Context ─────────────────────────────────────────────++++ define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run((req) => {++ const i = mustInstrument(req);++ const out = { instrument: reading.instrumentDto(i), published: context.published(i), disclaimer: DISCLAIMER };++ if (req.query.all === '1') {++ const v = req.viewer;++ if (!(v.kind === 'service' || (v.kind === 'user' && v.editor))) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors see drafts');++ out.pending = context.pending(i);++ }++ return out;++ }));++++ define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run((req) => context.input(mustInstrument(req))));++++ define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), jsonBody, run((req) => {++ const i = mustInstrument(req);++ const out = context.propose(req.viewer, i, req.body || {});++ ctx.outbox.kick();++ return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };++ }, 201));++++ define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', jsonBody, run((req) => {++ const i = mustInstrument(req);++ const b = req.body || {};++ const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });++ ctx.outbox.kick();++ return out;++ }, 201));++++ // ── Sources ─────────────────────────────────────────────++++ define(router, 'get', '/sources', 'getSourceFreshness', run(() => ({ sources: freshness.all(), sync: ctx.sync.state() })));++++ // ── Watchlists (private) ────────────────────────────────++++ define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run((req) => ({ watchlists: watchlists.forOwner(owner(req)).map((w) => watchlists.dto(w)) })));++++ define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));++++ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run((req) => ({ watchlist: watchlists.dto(watchlists.mustOwn(owner(req), req.params.id)) })));++++ define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {++ const w = watchlists.mustOwn(owner(req), req.params.id);++ return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };++ }));++++ define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {++ const w = watchlists.mustOwn(owner(req), req.params.id);++ const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);++ return { added, watchlist: watchlists.dto(w) };++ }));++++ define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), run((req) => {++ const w = watchlists.mustOwn(owner(req), req.params.id);++ return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };++ }));++++ define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));++++ // ── Alerts (private) ────────────────────────────────────++++ define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run((req) => ({ rules: alerts.forOwner(owner(req)) })));++++ define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run((req) => ({ deliveries: alerts.deliveries(owner(req), req.query.limit) })));++++ define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), jsonBody, run((req) => {++ const b = req.body || {};++ const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);++ return { rule: alerts.dto(rule) };++ }, 201));++++ define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));++++ router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });++ return router;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * Shared helpers for the HTML routers: caching, the page renderer, form parsing with the CSRF++ * check, notices after a redirect, and the not-found page.++ *++ * Caching (no leaks through shared caches):++ * - public, max-age=60 only pages that are the same for everyone, served to an anonymous viewer++ * - private, no-store everything personal (signed-in views, watchlists, alerts, the editor),++ * every error and every form response++ * Every HTML response varies on Cookie and Authorization.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const { renderPage } = require('../render/layout');++const views = require('../render/views');++const { csrfToken, checkCsrf } = require('../auth/forms');++const { ApiError, asApiError, privateNoStore } = require('./errors');++++const NOTICES = {++ watchlist_created: 'Watchlist created.',++ watchlist_renamed: 'Watchlist renamed.',++ watchlist_deleted: 'Watchlist deleted.',++ item_added: 'Added to your watchlist.',++ item_present: 'It was already on that watchlist.',++ item_removed: 'Removed from your watchlist.',++ alert_created: 'Alert created. You will be notified once per triggering document or observation.',++ alert_deleted: 'Alert deleted.',++ instrument_created: 'Instrument added.',++ instrument_saved: 'Saved.',++ alias_added: 'Alias added.',++ context_saved: 'Context revision saved.',++ context_published: 'Context published.',++ context_rejected: 'Review recorded: rejected.',++ context_retracted: 'Published context retracted.',++};++++function createCommon({ config, store }) {++ const formBody = express.urlencoded({ extended: false, limit: '64kb' });++++ function vary(res) { res.vary('Cookie'); res.vary('Authorization'); }++++ function cacheFor(req, res, { personal = false } = {}) {++ vary(res);++ if (personal || (req.viewer && req.viewer.kind !== 'anonymous')) privateNoStore(res);++ else res.set('Cache-Control', 'public, max-age=60');++ }++++ /** Render a full page. o: status, title, description, decision, canonical, body, jsonLd, feeds, personal */++ function page(req, res, o) {++ const decision = o.decision || seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: o.canonical || null, noindex: true }, { policy: { minWords: 0 } });++ cacheFor(req, res, { personal: o.personal || (o.status && o.status >= 400) });++ res.set('X-Robots-Tag', decision.robots);++ res.status(o.status || 200).type('html').send(renderPage({ ...o, decision, viewer: req.viewer, config, path: req.originalUrl }));++ }++++ function notFound(req, res, message = 'There is nothing at this address.') {++ page(req, res, { status: 404, title: 'Not found', body: views.errorPage({ status: 404, title: 'Not found', message }), personal: true });++ }++++ function failure(req, res, err) {++ const e = asApiError(err);++ if (!e) throw err;++ page(req, res, { status: e.status, title: e.status === 404 ? 'Not found' : 'That did not work', body: views.errorPage({ status: e.status, title: e.status === 404 ? 'Not found' : 'That did not work', message: e.message }), personal: true });++ }++++ const noticeOf = (req) => {++ const n = req.query && typeof req.query.n === 'string' ? req.query.n : null;++ if (n && NOTICES[n]) return { text: NOTICES[n] };++ const e = req.query && typeof req.query.e === 'string' ? req.query.e.slice(0, 300) : null;++ return e ? { text: e, error: true } : null;++ };++++ /** Form middleware: parse, require a signed-in person, check the form token. */++ const signedInForm = [formBody, function requireFormSession(req, res, next) {++ if (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.subject) {++ privateNoStore(res);++ return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.get('referer') ? new URL(req.get('referer'), config.baseUrl).pathname : '/watchlists')}`);++ }++ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) {++ return failure(req, res, new ApiError(403, 'form.token_invalid', 'This form expired. Go back, reload the page and try again.'));++ }++ next();++ }];++++ const loginHref = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}`;++++ /** Redirect after a form, with a notice code or an error message. */++ function after(res, path, { n, e } = {}) {++ privateNoStore(res);++ const url = new URL(path, config.baseUrl);++ if (n) url.searchParams.set('n', n);++ if (e) url.searchParams.set('e', e);++ res.redirect(303, url.pathname + url.search + url.hash);++ }++++ return {++ page, notFound, failure, cacheFor, vary, noticeOf, signedInForm, formBody, loginHref, after,++ csrf: (req) => csrfToken(config, req.viewer),++ now: () => store.now(),++ };++}++++module.exports = { createCommon, NOTICES };nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++++/**++ * Crawl, feed and machine-readability artifacts (roadmap §32.4/§32.5):++ *++ * GET /robots.txt sitemap + explicit automated-consumer policy++ * GET /llms.txt orientation for language models++ * GET /sitemap.xml sitemap index++ * GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:++ * person-reviewed context, no stale price); lastmod = the++ * context's real publication time++ * GET /feed.xml | /atom.xml | /feed.json recent public documents across instruments++ * GET /i/:symbol/documents.xml|.atom|.json one instrument's documents++ *++ * Built from the database on every request, never from the viewer. Watchlists, alert rules,++ * deliveries and the editor never appear in any of them.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const sharedSeo = require('openvibe-shared/seo');++const { define } = require('./routes');++const { DISCLAIMER } = require('../render/layout');++++function createDiscovery(ctx) {++ const { store, instruments, documents, indexing, urls, common } = ctx;++ const router = express.Router();++ const abs = urls.abs;++ const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);++ const listing = (url) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0 } });++++ function feedItem(d, instrument) {++ const url = urls.documentAnchor(instrument, d);++ const when = (ms) => (ms == null ? 'no date stated' : new Date(ms).toISOString());++ return {++ id: `urn:openvibe:trade:document:${d.id}`,++ url,++ title: `${instrument.symbol}: ${d.form_type ? `${d.form_type} — ` : ''}${d.title || d.filer_name || 'document'}`,++ summary: `${d.kind === 'filing' ? 'Filed' : 'Published'} ${when(d.published_at)}; retrieved ${when(d.retrieved_at)} from source ${d.source_key}.${d.url ? ` Original: ${d.url}` : ''} ${DISCLAIMER}`,++ published: d.published_at == null ? null : new Date(d.published_at).toISOString(),++ tags: [instrument.symbol, ...(d.form_type ? [d.form_type] : [])],++ decision: listing(url),++ };++ }++++ function sendFeed(res, type, channel, items) {++ if (type === 'json') {++ return res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed(channel, items)));++ }++ if (type === 'atom') {++ const newest = items.map((i) => i.published).filter(Boolean).sort().pop();++ return xml(res, seo.atomFeed({ ...channel, ...(newest ? {} : { updated: new Date(store.now()).toISOString() }) }, items), 'application/atom+xml');++ }++ return xml(res, seo.rssFeed(channel, items), 'application/rss+xml');++ }++++ define(router, 'get', '/robots.txt', 'robotsTxt', (_req, res) => {++ const body = [++ '# openvibe.trade automated-consumer policy: search engines and AI crawlers are welcome to read',++ '# instrument pages, feeds and sitemaps. Watchlists, alerts, the editor, sign-in and the API are',++ '# private or not for crawling. Pages decide their own indexability (meta robots / X-Robots-Tag).',++ '# Information only: nothing here is investment advice, and there is no trading here.',++ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/watchlists', '/alerts', '/editor', '/resolve', '/auth/', '/api/', '/internal/'] }),++ ].join('\n');++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);++ });++++ define(router, 'get', '/llms.txt', 'llmsTxt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({++ name: 'OpenVibe.Trade',++ summary: 'Informational market context: instruments, timestamped observations with their sources, filings from OpenVibe.Sources, and reviewed context. Information only — not investment advice; no trading here.',++ details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI-written context is labelled and is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',++ sections: [++ { title: 'Start here', links: [++ { title: 'Instruments', url: abs('/'), note: 'all instruments' },++ { title: 'Source freshness', url: abs('/sources') },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ ] },++ { title: 'Feeds', links: [++ { title: 'Recent documents (RSS)', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') },++ { title: 'Per instrument', url: abs('/'), note: '/i/<SYMBOL>/documents.xml, .atom and .json' },++ ] },++ { title: 'Data', links: [{ title: 'Instrument JSON', url: abs('/'), note: 'append .json to an instrument URL (/i/<SYMBOL>.json)' }] },++ ],++ }));++ });++++ define(router, 'get', '/sitemap.xml', 'sitemapIndex', (_req, res) => {++ const times = instruments.active().filter((i) => indexing.decide(i).indexable && i.context_published_at).map((i) => i.context_published_at);++ const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;++ xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));++ });++++ define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', (_req, res) => {++ const entries = instruments.active().map((i) => ({++ loc: urls.instrument(i),++ ...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),++ decision: indexing.decide(i),++ }));++ xml(res, seo.sitemap(entries).files[0]);++ });++++ const siteChannel = (type) => ({++ title: 'Recent documents — OpenVibe.Trade',++ link: abs('/'),++ feedUrl: abs(type === 'atom' ? '/atom.xml' : type === 'json' ? '/feed.json' : '/feed.xml'),++ description: `Filings and documents recorded from OpenVibe.Sources, with their source dates. ${DISCLAIMER}`,++ language: 'en',++ });++ const recentItems = () => documents.recent(50).map((d) => feedItem(d, instruments.get(d.instrument_id)));++ define(router, 'get', '/feed.xml', 'documentsRss', (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), recentItems()));++ define(router, 'get', '/atom.xml', 'documentsAtom', (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), recentItems()));++ define(router, 'get', '/feed.json', 'documentsJsonFeed', (_req, res) => sendFeed(res, 'json', siteChannel('json'), recentItems()));++++ define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', (req, res) => {++ const type = { xml: 'rss', atom: 'atom', json: 'json' }[req.params.format];++ const instrument = instruments.bySymbol(req.params.symbol);++ if (!type || !instrument || instrument.status !== 'active') return common.notFound(req, res);++ if (instrument.symbol !== req.params.symbol) return res.redirect(301, urls.path.documentsFeed(instrument, type));++ const items = documents.forInstrument(instrument, { limit: 50 }).map((d) => feedItem(d, instrument));++ sendFeed(res, type, {++ title: `${instrument.symbol} documents — OpenVibe.Trade`, link: urls.instrument(instrument),++ feedUrl: abs(urls.path.documentsFeed(instrument, type)),++ description: `Filings and documents about ${instrument.name}, with their source dates. ${DISCLAIMER}`, language: 'en',++ }, items);++ });++++ return router;++}++++module.exports = { createDiscovery };nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++++/**++ * The editor: plain HTML forms for people on the editor list (TRADE_EDITORS, Network admins).++ * Private, never cached by shared caches, never indexed.++ *++ * GET /editor add instruments; context waiting for review++ * POST /editor/instruments add an instrument++ * GET /editor/i/:symbol details, aliases, context revisions++ * POST /editor/i/:symbol save details (name, kind, exchange, CIK, currency, status)++ * POST /editor/i/:symbol/aliases add a ticker, CIK or name alias++ * POST /editor/i/:symbol/context write a context revision (optionally publish)++ * POST /editor/i/:symbol/context/:n/review approve (publishes) or reject an AI draft++ * POST /editor/i/:symbol/context/:n/publish publish a revision that needs no review++ * POST /editor/i/:symbol/context/retract remove the published context from the page++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const views = require('../render/views');++const { define } = require('./routes');++const { ApiError, asApiError } = require('./errors');++++function createEditor(ctx) {++ const { instruments, context, documents, observations, common, indexing } = ctx;++ const router = express.Router();++ const decision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });++++ function requireEditor(req, res, next) {++ const v = req.viewer;++ if (v.kind !== 'user' || !v.subject) return common.after(res, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (!v.editor) return common.failure(req, res, new ApiError(403, 'editor.forbidden', 'Only people on the Trade editor list can use the editor.'));++ next();++ }++ const form = [...common.signedInForm, requireEditor];++++ function mustInstrument(req) {++ const i = instruments.bySymbol(req.params.symbol);++ if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');++ return i;++ }++ const editPath = (i) => `/editor/i/${encodeURIComponent(i.symbol)}`;++++ function fail(req, res, path, err) {++ const e = asApiError(err);++ if (!e) throw err;++ common.after(res, path, { e: e.message });++ }++++ define(router, 'get', '/editor', 'showEditor', requireEditor, (req, res) => {++ const all = instruments.active();++ const pending = [];++ for (const i of all) for (const p of context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });++ common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });++ });++++ define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, (req, res) => {++ try {++ const i = instruments.create(req.body, req.viewer.subject);++ ctx.store.tx(() => indexing.refresh(i));++ common.after(res, editPath(i), { n: 'instrument_created' });++ } catch (err) { fail(req, res, '/editor', err); }++ });++++ define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, (req, res) => {++ let i;++ try { i = mustInstrument(req); } catch (err) { return common.failure(req, res, err); }++ const revisions = context.revisions(i, { limit: 30 }).map((r) => context.view(i, r));++ common.page(req, res, {++ title: `Editor · ${i.symbol}`, decision, personal: true,++ body: views.editorInstrument({++ instrument: i, aliases: instruments.aliases(i), revisions, head: context.head(i) ? context.head(i).number : 0,++ documents: documents.forInstrument(i, { limit: 30 }), observations: observations.latest(i),++ csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS,++ }),++ });++ });++++ define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ const updated = instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });++ ctx.store.tx(() => indexing.refresh(updated));++ common.after(res, editPath(updated), { n: 'instrument_saved' });++ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }++ });++++ define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);++ common.after(res, editPath(i), { n: 'alias_added' });++ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }++ });++++ define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];++ const cites = raw.map((s) => { const [kind, id] = String(s).split(':'); return { kind, id }; });++ const out = context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });++ common.after(res, editPath(i), { n: out.published ? 'context_published' : 'context_saved' });++ } catch (err) {++ if (err && err.code === 'revision.conflict') return common.after(res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, { e: 'Someone saved a newer revision while you were writing. Your text was not saved: copy it, reload and try again.' });++ fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err);++ }++ });++++ define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';++ context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });++ common.after(res, editPath(i), { n: decisionValue === 'approved' ? 'context_published' : 'context_rejected' });++ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }++ });++++ define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ context.publish(i, parseInt(req.params.n, 10));++ common.after(res, editPath(i), { n: 'context_published' });++ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }++ });++++ define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, (req, res) => {++ try {++ const i = mustInstrument(req);++ context.retract(req.viewer, i);++ common.after(res, editPath(i), { n: 'context_retracted' });++ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }++ });++++ return router;++}++++module.exports = { createEditor };nnew file mode 100644@@@ -0,0 +1,64 @@++'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');++++/** A refusal with a stable problem code (e.g. 404 'post.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;++ }++}++++/** Publishing package errors carry { status, code } too. */++function asApiError(err) {++ if (err instanceof ApiError) return err;++ if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {++ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;++ return new ApiError(err.status, err.code, err.message, extra);++ }++ if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {++ return new ApiError(422, 'request.invalid', err.message);++ }++ return null;++}++++/** 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;++ const e = asApiError(err);++ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });++ console.error('[Trade 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: '512kb' });++/** 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, asApiError, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * Public pages, server-rendered and complete without JavaScript:++ *++ * GET / instruments, recent documents, feeds++ * GET /resolve?q= deterministic resolution (CIK → ticker → exact name); one match → 302++ * GET /i/:symbol the instrument page: observations with timestamps and sources, context++ * with its disclosure, documents, source freshness ("stale since")++ * GET /i/:symbol.json the same data as JSON++ * GET /sources freshness of every source++ *++ * A lower-case symbol or a ticker alias answers 301 to the canonical /i/:SYMBOL.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const { paginate } = require('openvibe-publishing/ssr');++const views = require('../render/views');++const { define } = require('./routes');++++function createPages(ctx) {++ const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync } = ctx;++ const router = express.Router();++ const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });++++ define(router, 'get', '/', 'showHome', (req, res) => {++ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);++ const per = 50;++ const first = instruments.page({ limit: 0, offset: 0 });++ const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });++ if (pager.outOfRange && first.total) return common.notFound(req, res);++ const page = instruments.page({ limit: pager.limit, offset: pager.offset });++ const recent = documents.recent(10).map((d) => ({ document: documents.dto(d), instrument: instruments.get(d.instrument_id) }));++ const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);++ common.page(req, res, {++ title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),++ feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],++ jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],++ body: views.home({ page, recent, urls, sync: sync.state(), pager }),++ });++ });++++ define(router, 'get', '/resolve', 'showResolve', (req, res) => {++ const result = instruments.resolve(req.query.q);++ if (result.status === 'resolved') {++ common.vary(res);++ res.set('Cache-Control', 'public, max-age=60');++ return res.redirect(302, urls.path.instrument(result.instrument));++ }++ common.page(req, res, { status: result.status === 'not_found' ? 404 : 200, title: 'Find an instrument', body: views.resolvePage({ result, urls }), personal: true });++ });++++ define(router, 'get', '/sources', 'showSources', (req, res) => {++ common.page(req, res, {++ title: 'Source freshness', canonical: urls.abs('/sources'),++ body: views.sourcesPage({ sources: freshness.all(), sync: sync.state() }),++ });++ });++++ define(router, 'get', '/i/:symbol', 'showInstrument', (req, res) => {++ let raw = String(req.params.symbol);++ const asJson = raw.endsWith('.json');++ if (asJson) raw = raw.slice(0, -5);++ let instrument = instruments.bySymbol(raw);++ if (!instrument) {++ const r = instruments.resolve(raw, { kind: 'ticker' });++ instrument = r.status === 'resolved' ? r.instrument : null;++ }++ if (!instrument) return asJson ? res.status(404).set('Cache-Control', 'private, no-store').json({ code: 'instrument.not_found', error: 'No such instrument' }) : common.notFound(req, res, `No instrument has the symbol “${raw}”.`);++ if (instrument.symbol !== raw) {++ common.vary(res);++ res.set('Cache-Control', 'public, max-age=300');++ return res.redirect(301, asJson ? urls.path.instrumentJson(instrument) : urls.path.instrument(instrument) + (req.query.page ? `?page=${encodeURIComponent(req.query.page)}` : ''));++ }++++ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);++ const total = documents.count(instrument);++ const pager = paginate({ page: pageNo, perPage: 25, total, href: (p) => (p === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${p}`) });++ if (pager.outOfRange && total) return common.notFound(req, res);++ const data = reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });++ const { decision } = data;++++ if (asJson) {++ common.cacheFor(req, res);++ res.set('X-Robots-Tag', decision.robots);++ const { decision: _d, ...out } = data;++ return res.json(out);++ }++ const viewer = req.viewer;++ const signedIn = viewer.kind === 'user' && viewer.subject;++ common.page(req, res, {++ title: `${instrument.symbol} — ${instrument.name}`,++ description: `${instrument.name} (${instrument.symbol}): observations with their observation times and sources, filings and source freshness. Information only.`,++ canonical: urls.abs(pageNo === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${pageNo}`),++ decision: pageNo === 1 ? decision : { ...decision, canonical: urls.instrument(instrument) },++ jsonLd: reading.jsonLd(instrument),++ feeds: ['rss', 'atom', 'json'].map((t) => ({ type: t, href: urls.path.documentsFeed(instrument, t), title: `${instrument.symbol} documents` })),++ personal: Boolean(signedIn),++ body: views.instrumentPage({++ ...data, instrument, urls, pager, notice: common.noticeOf(req),++ context: data.context, pending: viewer.editor ? context.pending(instrument) : [],++ viewer, watchlists: signedIn ? watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),++ }),++ });++ });++++ return router;++}++++module.exports = { createPages };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * Private pages and forms: watchlists and alert rules. Only for the signed-in owner; every response++ * is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's++ * watchlist or rule answers "not found". Plain HTML forms with the form token (no JavaScript).++ *++ * GET /watchlists your watchlists, alert rules and recent deliveries++ * POST /watchlists create { name }++ * POST /watchlists/rename { watchlist_id, name }++ * POST /watchlists/delete { watchlist_id }++ * POST /watchlists/item add { watchlist_id, symbol | q, note? }++ * POST /watchlists/item/remove { watchlist_id, symbol }++ * POST /alerts create { symbol, kind, … }++ * POST /alerts/delete { rule_id }++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const views = require('../render/views');++const { define } = require('./routes');++const { asApiError } = require('./errors');++++function createPrivate(ctx) {++ const { instruments, watchlists, alerts, common } = ctx;++ const router = express.Router();++ const privateDecision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });++++ function back(req, res, fallback, err) {++ const e = asApiError(err);++ if (!e) throw err;++ common.after(res, fallback, { e: e.message });++ }++++ function instrumentFrom(body) {++ if (body.symbol) return instruments.bySymbol(body.symbol);++ const r = instruments.resolve(body.q);++ return r.status === 'resolved' ? r.instrument : null;++ }++++ define(router, 'get', '/watchlists', 'showWatchlists', (req, res) => {++ const v = req.viewer;++ const signedIn = v.kind === 'user' && v.subject;++ const lists = signedIn ? watchlists.forOwner(v.subject).map((w) => watchlists.dto(w)) : [];++ common.page(req, res, {++ title: 'Your watchlists', decision: privateDecision, personal: true,++ body: views.watchlistsPage({++ viewer: v, lists, rules: signedIn ? alerts.forOwner(v.subject) : [], deliveries: signedIn ? alerts.deliveries(v.subject, 20) : [],++ csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),++ }),++ });++ });++++ define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, (req, res) => {++ try {++ watchlists.create(req.viewer.subject, { name: req.body.name });++ common.after(res, '/watchlists', { n: 'watchlist_created' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, (req, res) => {++ try {++ watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });++ common.after(res, '/watchlists', { n: 'watchlist_renamed' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, (req, res) => {++ try {++ watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));++ common.after(res, '/watchlists', { n: 'watchlist_deleted' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, (req, res) => {++ try {++ const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ const instrument = instrumentFrom(req.body);++ const added = watchlists.add(w, instrument, req.body.note);++ const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';++ common.after(res, from, { n: added ? 'item_added' : 'item_present' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, (req, res) => {++ try {++ const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);++ watchlists.drop(w, instruments.bySymbol(req.body.symbol));++ common.after(res, '/watchlists', { n: 'item_removed' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, (req, res) => {++ const instrument = instruments.bySymbol(req.body.symbol);++ try {++ alerts.create(req.viewer.subject, instrument, {++ kind: req.body.kind, metric: req.body.metric, operator: req.body.operator, threshold: req.body.threshold,++ unit: req.body.unit, currency: req.body.currency, form_types: req.body.form_types,++ });++ common.after(res, instrument ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists', { n: 'alert_created' });++ } catch (err) {++ const e = asApiError(err);++ if (!e) throw err;++ common.after(res, instrument ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists', { e: e.message });++ }++ });++++ define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, (req, res) => {++ try {++ alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));++ common.after(res, '/watchlists', { n: 'alert_deleted' });++ } catch (err) { back(req, res, '/watchlists', err); }++ });++++ return router;++}++++module.exports = { createPrivate };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * Route definition with an explicit handler name. Every route Trade serves (outside the shared++ * /auth session layer) is registered through define(), so the route inventory test++ * (test/route-inventory.test.js) can read each path AND a meaningful handler name from the live++ * Express stack and fail the build if either carries order, buy/sell, execution, custody, wallet,++ * escrow, checkout or listing semantics (ADR-025).++ */++function named(name, fn) {++ if (typeof name !== 'string' || !/^[a-z][A-Za-z0-9]+$/.test(name)) throw new TypeError(`handler name "${name}" must be camelCase`);++ Object.defineProperty(fn, 'name', { value: name });++ return fn;++}++++/** define(router, 'get', '/path', 'handlerName', ...middleware, handler) */++function define(router, method, path, name, ...handlers) {++ if (!handlers.length) throw new TypeError('a route needs a handler');++ const last = handlers.pop();++ router[method](path, ...handlers, named(name, last));++}++++module.exports = { define, named };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/**++ * OpenVibe.Trade — process entry. `node server/index.js`++ * Listens on PORT (4860) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the++ * client secret are set) and the worker (Sources sync, freshness).++ */++const { createApp } = require('./app');++++const { app, ctx } = createApp();++const { config } = ctx;++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Trade] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; Sources sync ${ctx.sync.enabled ? 'on' : 'off'}; worker ${config.worker.enabled ? 'on' : 'off'}`);++});++server.keepAliveTimeout = 65_000;++ctx.outbox.start();++ctx.worker.start();++++function shutdown(signal) {++ console.log(`[Trade] ${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,69 @@++'use strict';++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready).++ *++ * db required a real query on Trade's SQLite (the nine charter tables answer)++ * network_jwks optional the Network signing key has loaded; without it pages and feeds++ * 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++ * sources_sync optional the Sources sync is configured and its last run succeeded; while it++ * fails, pages keep serving what was recorded and mark it stale++ * freshness optional how many sources are stale right now (information, never hidden)++ */++const { createReadiness } = require('openvibe-shared/ready');++const { CHARTER_TABLES } = require('./db');++++function createTradeReadiness({ store, auth, outbox, sync, freshness, release = null }) {++ const { db } = store;++ return createReadiness({++ service: 'trade',++ 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_sync', required: false,++ check: () => {++ const s = sync.state();++ if (!s.enabled) return 'Sources sync off (OV_OAUTH_CLIENT_SECRET unset): no new filings or observations arrive';++ if (s.last_error) return `last Sources sync failed: ${s.last_error}`;++ if (!s.last_ok_at) return 'Sources sync has not completed yet';++ return { ok: true, detail: { cursor: s.cursor, last_ok_at: s.last_ok_at } };++ },++ },++ {++ name: 'freshness', required: false,++ check: () => {++ const all = freshness.all();++ const stale = all.filter((s) => s.stale).map((s) => s.key);++ return stale.length ? `${stale.length} of ${all.length} sources stale: ${stale.slice(0, 10).join(', ')}` : { ok: true, detail: { sources: all.length } };++ },++ },++ ],++ });++}++++module.exports = { createTradeReadiness };nnew file mode 100644@@@ -0,0 +1,105 @@++'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, JSON-LD from real fields only, feed links, the shared app icon++ * - the persistent disclaimer, above the content on every page:++ * "Information only — not investment advice; no trading here."++ * - 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.Trade';++const DISCLAIMER = 'Information only — not investment advice; no trading here.';++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)}`;++++const NAV_LINKS = [++ { label: 'Instruments', href: '/' },++ { label: 'Sources', href: '/sources' },++ { label: 'Watchlists', href: '/watchlists' },++];++++/**++ * o: title, description, decision (required), canonical, type, jsonLd [], feeds [{ type, href, title }],++ * body (HTML), viewer, config, path, 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} — sourced market information`,++ description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,++ decision: o.decision,++ canonical: o.canonical,++ type: o.type || 'website',++ siteName: SITE_NAME,++ jsonLd: (o.jsonLd || []).filter(Boolean),++ });++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const loginNext = encodeURIComponent(o.path || '/');++ const nav = {++ service: 'trade',++ apiBase: NETWORK_URL,++ links: NAV_LINKS,++ 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.editor ? '<a href="/editor">Editor</a> · ' : ''}<a href="/watchlists">Your watchlists</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="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++${head}++${seo.feedLinks(o.feeds || [])}++${appIcon.headTags({ site: 'trade' })}++<link rel="stylesheet" href="${asset('css/trade.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: NAV_LINKS })}++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>++<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>++<main id="main" class="page">++${o.body || ''}++</main>++<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>++${chrome.footer({ service: 'trade', 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, DISCLAIMER };nnew file mode 100644@@@ -0,0 +1,328 @@++'use strict';++++/**++ * Page bodies (HTML fragments for layout.renderPage). All escaping goes through++ * openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the++ * package's safe Markdown renderer.++ *++ * The honesty rules are visible here:++ * - a number appears only in an observation row, next to its observed_at, retrieved_at and source++ * - no observation → the sentence "no number is shown", never a placeholder value++ * - a stale source or value carries a "stale since …" badge; unknown freshness says so++ * - AI context carries its disclosure; the disclaimer is in the layout on every page++ */++const { html, raw, breadcrumbsHtml, paginationHtml } = require('openvibe-publishing/ssr');++++const ISO = (v) => (v == null ? null : (typeof v === 'number' ? new Date(v).toISOString() : String(v)));++++/** A timestamp as `<time>` in UTC to the minute, or the given fallback text. */++function when(v, fallback = 'not stated by the source') {++ const s = ISO(v);++ if (!s) return html`<span class="muted">${fallback}</span>`;++ const d = new Date(s);++ if (Number.isNaN(d.getTime())) return html`<span class="muted">${fallback}</span>`;++ return html`<time datetime="${d.toISOString()}">${d.toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`;++}++++function staleBadge(f) {++ if (!f) return '';++ if (f.stale) {++ return html`<span class="badge stale">stale</span>${f.stale_since ? html` since ${when(f.stale_since)}` : html` <span class="muted">(${f.reason === 'source_unknown' ? 'freshness unknown' : 'never fetched successfully'})</span>`}`;++ }++ return html`<span class="badge fresh">current</span>`;++}++++function csrfField(token) { return html`<input type="hidden" name="_csrf" value="${token}">`; }++++function notice(n) {++ if (!n) return '';++ return html`<p class="notice ${n.error ? 'error' : ''}" role="status">${n.text}</p>`;++}++++// ── Home ─────────────────────────────────────────────────────────────────────++++function home({ page, recent, urls, sync, pager }) {++ const rows = page.instruments.map((i) => html`<tr>++ <td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>++ <td>${i.name}</td><td>${i.kind}</td><td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>++ </tr>`);++ const docs = recent.map((r) => html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}">${r.instrument.symbol}</a>++ ${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}++ — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`);++ return html`++<h1>Sourced market information</h1>++<p class="lede">Instruments with the observations and filings that sources published about them. Every value shows when it was observed, when it was retrieved and which source stated it. When a source falls behind, its data is marked stale — it is never replaced with a guess.</p>++<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>++<form class="search" method="get" action="/resolve" role="search">++ <label for="q">Find an instrument by ticker, CIK or company name</label>++ <input id="q" name="q" type="search" maxlength="200" required>++ <button type="submit">Find</button>++</form>++<h2>Instruments</h2>++${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Exchange</th></tr></thead><tbody>${rows}</tbody></table>++${raw(paginationHtml(pager))}`++ : html`<p class="empty">No instruments have been added yet.</p>`}++<h2>Recent documents</h2>++${recent.length ? html`<ul class="docs">${docs}</ul>` : html`<p class="empty">No documents have been recorded yet.</p>`}++<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${sync && sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(sync.last_ok_at)}.` : ''}</p>`;++}++++// ── Resolve ──────────────────────────────────────────────────────────────────++++function resolvePage({ result, urls }) {++ const cands = result.candidates.map((i) => html`<li><a href="${urls.path.instrument(i)}">${i.symbol}</a> — ${i.name}</li>`);++ return html`++<h1>Find an instrument</h1>++<form class="search" method="get" action="/resolve" role="search">++ <label for="q">Ticker, CIK or company name</label>++ <input id="q" name="q" type="search" maxlength="200" value="${result.query}" required>++ <button type="submit">Find</button>++</form>++${result.status === 'ambiguous' ? html`<p>“${result.query}” matches several instruments by name. None was chosen for you:</p><ul>${cands}</ul>` : ''}++${result.status === 'not_found' ? html`<p class="empty">No instrument matches “${result.query}” as a CIK, a ticker or an exact company name. Resolution is exact on purpose: no guess is made.</p>` : ''}`;++}++++// ── Instrument ───────────────────────────────────────────────────────────────++++function observationsTable(instrument, observations) {++ if (!observations.length) {++ return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;++ }++ const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">++ <th scope="row">${o.metric}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>++ <td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>++ <td>${when(o.observed_at)}</td>++ <td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>++ <td>${when(o.retrieved_at)}</td>++ <td>${staleBadge(o.freshness)}</td>++ </tr>`);++ return html`<table class="data observations">++<caption>Latest observation per metric. Values are exactly as the source stated them.</caption>++<thead><tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">Observed</th><th scope="col">Source</th><th scope="col">Retrieved</th><th scope="col">Freshness</th></tr></thead>++<tbody>${rows}</tbody></table>`;++}++++function sourcesTable(list, { caption }) {++ if (!list.length) return html`<p class="empty">No sources have reported yet.</p>`;++ const rows = list.map((s) => html`<tr id="source-${s.key}">++ <th scope="row">${s.name || s.key}${s.name && s.name !== s.key ? html` <span class="muted">${s.key}</span>` : ''}</th>++ <td>${s.status === 'fresh' ? html`<span class="badge fresh">current</span>` : s.status === 'unknown' ? html`<span class="badge stale">freshness unknown</span>` : html`<span class="badge stale">stale</span>${s.stale_since ? html` since ${when(s.stale_since)}` : ''}`}</td>++ <td>${when(s.last_success_at, 'never')}</td>++ <td>${s.stale_after_sec ? `${Math.round(s.stale_after_sec / 60)} min` : ''}</td>++ <td>${s.upstream_status || html`<span class="muted">not reported</span>`}</td>++ </tr>`);++ return html`<table class="data sources"><caption>${caption}</caption>++<thead><tr><th scope="col">Source</th><th scope="col">Freshness</th><th scope="col">Last successful fetch</th><th scope="col">Stale after</th><th scope="col">Sources reports</th></tr></thead>++<tbody>${rows}</tbody></table>`;++}++++function citeList(cites, instrument, urls) {++ if (!cites.length) return html`<p class="muted">This context cites no documents or observations.</p>`;++ const items = cites.map((c) => {++ if (c.missing) return html`<li class="muted">${c.kind} ${c.id} is no longer available.</li>`;++ if (c.observation) {++ const o = c.observation;++ return html`<li><a href="${urls.path.instrument(instrument)}#${o.id}">${o.metric} = ${o.value} ${o.unit}</a>, observed ${when(o.observed_at)}, source ${o.source.key}, retrieved ${when(o.retrieved_at)} ${staleBadge(o.freshness)}</li>`;++ }++ const d = c.document;++ return html`<li><a href="${urls.path.instrument(instrument)}#${d.id}">${d.form_type ? `${d.form_type}: ` : ''}${d.title || d.filer_name || d.id}</a>, ${d.published_at ? html`published ${when(d.published_at)}` : 'no publication date stated'}, retrieved ${when(d.retrieved_at)}</li>`;++ });++ return html`<ol class="cites">${items}</ol>`;++}++++function disclosureLine(ctx) {++ if (ctx.disclosure) {++ return html`<p class="disclosure"><strong>${ctx.disclosure.short}.</strong> ${ctx.disclosure.long}${ctx.review && ctx.review.decision === 'approved' ? html` Approved ${when(ctx.review.reviewed_at)}.` : ''}</p>`;++ }++ return html`<p class="disclosure">Written by a Trade editor.</p>`;++}++++function contextSection(instrument, ctx, urls, { pending = [], editor = false } = {}) {++ const pend = editor && pending.length++ ? html`<p class="notice">${pending.length} context revision${pending.length === 1 ? '' : 's'} waiting — <a href="/editor/i/${encodeURIComponent(instrument.symbol)}">review in the editor</a>.</p>` : '';++ if (!ctx) {++ return html`<section class="context" aria-labelledby="context-h"><h2 id="context-h">Context</h2>++<p class="empty">No reviewed context has been published for ${instrument.symbol}. The observations and documents below are shown as their sources stated them.</p>${pend}</section>`;++ }++ return html`<section class="context" aria-labelledby="context-h"><h2 id="context-h">Context</h2>++${disclosureLine(ctx)}++<p class="meta">As of ${ctx.as_of ? when(ctx.as_of) : html`<span class="muted">no dated source cited</span>`} · written ${when(ctx.written_at)} · published ${when(ctx.published_at)} · revision ${ctx.revision}</p>++<div class="context-body">${raw(ctx.body_html)}</div>++<h3>Cited</h3>++${citeList(ctx.cites, instrument, urls)}++<p class="muted">Context describes what sources state. It is not a recommendation to buy, sell or hold anything.</p>++${pend}</section>`;++}++++function documentsSection(instrument, docs, { pager, urls }) {++ const feeds = html`<p class="feeds">Feeds of ${instrument.symbol} documents: <a href="${urls.path.documentsFeed(instrument, 'rss')}">RSS</a> · <a href="${urls.path.documentsFeed(instrument, 'atom')}">Atom</a> · <a href="${urls.path.documentsFeed(instrument, 'json')}">JSON Feed</a></p>`;++ if (!docs.length) return html`<section aria-labelledby="docs-h"><h2 id="docs-h">Documents</h2><p class="empty">No documents from sources have been recorded for ${instrument.symbol}.</p>${feeds}</section>`;++ const items = docs.map((d) => html`<li id="${d.id}" class="doc">++ ${d.form_type ? html`<span class="badge">${d.form_type}</span> ` : ''}${d.url ? html`<a href="${d.url}" rel="nofollow noopener">${d.title || d.filer_name || 'Document'}</a>` : (d.title || 'Document')}++ <div class="meta">${d.kind === 'filing' ? 'Filed' : 'Published'} ${when(d.published_at)} · retrieved ${when(d.retrieved_at)} · source ${d.source.key}${d.freshness && d.freshness.stale ? html` (${staleBadge(d.freshness)}: newer documents may be missing)` : ''}${d.accession ? ` · accession ${d.accession}` : ''}</div>++ </li>`);++ return html`<section aria-labelledby="docs-h"><h2 id="docs-h">Documents</h2><ul class="docs">${items}</ul>++${raw(paginationHtml(pager))}${feeds}</section>`;++}++++function personalTools(instrument, { viewer, watchlists, csrf, loginHref }) {++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) {++ return html`<section class="personal" aria-labelledby="personal-h"><h2 id="personal-h">Watch this instrument</h2>++<p><a href="${loginHref}">Sign in</a> to add ${instrument.symbol} to a private watchlist or to get an alert when a new document or observation arrives.</p></section>`;++ }++ const options = watchlists.map((w) => html`<option value="${w.id}">${w.name}</option>`);++ return html`<section class="personal" aria-labelledby="personal-h"><h2 id="personal-h">Watch this instrument <span class="badge">private</span></h2>++${watchlists.length ? html`<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}++ <input type="hidden" name="symbol" value="${instrument.symbol}">++ <label for="wl">Add to watchlist</label><select id="wl" name="watchlist_id">${options}</select>++ <button type="submit">Add</button></form>` : html`<p><a href="/watchlists">Create a watchlist</a> first.</p>`}++<form method="post" action="/alerts" class="alert-form">${csrfField(csrf)}++ <input type="hidden" name="symbol" value="${instrument.symbol}">++ <fieldset><legend>New alert for ${instrument.symbol}</legend>++ <label><input type="radio" name="kind" value="new_document" checked> Any new document</label>++ <label><input type="radio" name="kind" value="filing_type"> New filing of type(s) <input name="form_types" placeholder="10-K, 10-Q, 8-K" maxlength="120"></label>++ <label><input type="radio" name="kind" value="threshold"> Observation++ <input name="metric" placeholder="metric, e.g. price.close" maxlength="80">++ <select name="operator"><option value="above">above</option><option value="below">below</option></select>++ <input name="threshold" placeholder="value" maxlength="40"> <input name="unit" placeholder="unit, e.g. USD" maxlength="20"></label>++ </fieldset>++ <button type="submit">Create alert</button>++ <p class="muted">Alerts are delivered as OpenVibe notifications, once per triggering observation or document. No email is sent.</p>++</form></section>`;++}++++function instrumentPage(d) {++ const { instrument, aliases, observations, sources, documents, context, urls } = d;++ const crumbs = breadcrumbsHtml([{ name: 'Instruments', url: '/' }, { name: instrument.symbol }]);++ const meta = [instrument.kind, instrument.exchange ? `exchange ${instrument.exchange}` : null, instrument.cik ? `CIK ${instrument.cik}` : null, instrument.currency ? `trades in ${instrument.currency}` : null].filter(Boolean).join(' · ');++ const other = aliases.filter((a) => !(a.kind === 'ticker' && a.normalized === instrument.symbol) && !(a.kind === 'cik' && a.normalized === instrument.cik) && a.kind !== 'name');++ return html`${raw(crumbs)}++<h1>${instrument.symbol} <span class="name">${instrument.name}</span></h1>++<p class="meta">${meta}${other.length ? html` · also ${other.map((a) => a.value).join(', ')}` : ''}${instrument.status !== 'active' ? html` · <span class="badge stale">archived</span>` : ''}</p>++${notice(d.notice)}++<section aria-labelledby="obs-h"><h2 id="obs-h">Observations</h2>${observationsTable(instrument, observations)}</section>++${contextSection(instrument, context, urls, { pending: d.pending, editor: d.viewer && d.viewer.editor })}++${documentsSection(instrument, documents, { pager: d.pager, urls })}++<section aria-labelledby="src-h"><h2 id="src-h">Source freshness</h2>${sourcesTable(sources, { caption: `Sources behind this page. A source is stale when its last successful fetch is older than its window.` })}</section>++${personalTools(instrument, d)}++<p class="muted">Machine-readable: <a href="${urls.path.instrumentJson(instrument)}">${instrument.symbol}.json</a> (the same data with every timestamp and source).</p>`;++}++++// ── Sources ──────────────────────────────────────────────────────────────────++++function sourcesPage({ sources, sync }) {++ return html`<h1>Source freshness</h1>++<p class="lede">Every source Trade shows data from, with its last successful fetch. A stale source's data stays on the pages with its timestamps and a “stale since” label; it is never replaced with an invented value.</p>++${sourcesTable(sources, { caption: 'Sources and their freshness, computed with this site’s clock.' })}++<p class="muted">Synchronisation with OpenVibe.Sources: ${!sync.enabled ? 'not configured' : sync.last_error ? html`failing since ${when(sync.last_error_at)} (${sync.last_error})` : sync.last_ok_at ? html`last succeeded ${when(sync.last_ok_at)}` : 'not run yet'}.</p>`;++}++++// ── Watchlists (private) ─────────────────────────────────────────────────────++++function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, notice: n }) {++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) {++ return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account. <a href="${loginHref}">Sign in</a> to see yours.</p>`;++ }++ const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">++<h2>${w.name}</h2>++${w.items.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>++${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td>++<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}++</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}++<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">++ <label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>++<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">++ <label>Rename <input name="name" maxlength="80" value="${w.name}" required></label> <button type="submit">Rename</button></form>++<form method="post" action="/watchlists/delete" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><button type="submit" class="danger">Delete this watchlist</button></form>++</section>`);++ const ruleRows = rules.map((r) => html`<tr><td><a href="/i/${encodeURIComponent(r.instrument.symbol)}">${r.instrument.symbol}</a></td>++<td>${r.kind === 'threshold' ? `${r.metric} ${r.operator} ${r.threshold} ${r.unit}${r.currency ? ` (${r.currency})` : ''}` : r.kind === 'filing_type' ? `new filing: ${(r.form_types || []).join(', ')}` : 'any new document'}</td>++<td>${when(r.created_at)}</td>++<td><form method="post" action="/alerts/delete" class="inline">${csrfField(csrf)}<input type="hidden" name="rule_id" value="${r.id}"><button type="submit">Delete</button></form></td></tr>`);++ const delRows = deliveries.map((x) => html`<li>${when(x.created_at)} — ${x.trigger.kind === 'observation'++ ? html`${x.trigger.metric} = ${x.trigger.value} ${x.trigger.unit}, observed ${when(x.trigger.observed_at)} (${x.trigger.source_key})`++ : html`${x.trigger.form_type ? `${x.trigger.form_type}: ` : ''}${x.trigger.title || 'document'}, ${x.trigger.published_at ? html`published ${when(x.trigger.published_at)}` : 'no date stated'}`}</li>`);++ return html`<h1>Your watchlists <span class="badge">private</span></h1>++<p class="muted">Only you can see this page. It is never cached by shared caches, indexed or listed anywhere.</p>++${notice(n)}++${listHtml}++<form method="post" action="/watchlists" class="card">${csrfField(csrf)}++ <label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>++ <button type="submit">Create</button></form>++<h2>Your alerts</h2>++${rules.length ? html`<table class="data"><thead><tr><th scope="col">Instrument</th><th scope="col">Condition</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${ruleRows}</tbody></table>`++ : html`<p class="empty">No alerts. Create one from an instrument’s page.</p>`}++<h2>Recent alert deliveries</h2>++${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;++}++++// ── Editor ───────────────────────────────────────────────────────────────────++++function editorHome({ instruments, pending, csrf, notice: n, kinds }) {++ return html`<h1>Editor</h1>${notice(n)}++<form method="post" action="/editor/instruments" class="card">${csrfField(csrf)}++ <h2>Add an instrument</h2>++ <label>Symbol <input name="symbol" maxlength="16" required></label>++ <label>Name <input name="name" maxlength="200" required></label>++ <label>Kind <select name="kind">${kinds.map((k) => html`<option>${k}</option>`)}</select></label>++ <label>Exchange (optional) <input name="exchange" maxlength="40"></label>++ <label>SEC CIK (optional) <input name="cik" maxlength="13"></label>++ <label>Trading currency (optional) <input name="currency" maxlength="3"></label>++ <button type="submit">Add instrument</button></form>++<h2>Context waiting for review</h2>++${pending.length ? html`<ul>${pending.map((p) => html`<li><a href="/editor/i/${encodeURIComponent(p.symbol)}">${p.symbol}</a>: revision ${p.revision} (${p.label})</li>`)}</ul>` : html`<p class="empty">Nothing is waiting.</p>`}++<h2>Instruments</h2>++<ul>${instruments.map((i) => html`<li><a href="/editor/i/${encodeURIComponent(i.symbol)}">${i.symbol}</a> — ${i.name}</li>`)}</ul>`;++}++++function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {++ const revs = revisions.map((r) => html`<li class="card">++<p><strong>Revision ${r.revision}</strong> ${r.published ? html`<span class="badge fresh">published</span>` : ''} ${r.disclosure ? html`<span class="badge">${r.disclosure.short}</span>` : html`<span class="badge">editor</span>`}++ ${r.needs_review ? html`<span class="badge stale">needs a person’s review</span>` : ''} · written ${when(r.written_at)} · as of ${r.as_of ? when(r.as_of) : 'no dated citation'}</p>++<div class="context-body">${raw(r.body_html)}</div>++${r.disclosure ? html`<p class="disclosure">${r.disclosure.long}</p>` : ''}++${!r.published && r.needs_review ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/${r.revision}/review" class="inline-form">${csrfField(csrf)}++ <label>Note <input name="note" maxlength="500"></label>++ <button type="submit" name="decision" value="approved">Approve and publish</button> <button type="submit" name="decision" value="rejected" class="danger">Reject</button></form>` : ''}++${!r.published && !r.needs_review ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/${r.revision}/publish" class="inline-form">${csrfField(csrf)}<button type="submit">Publish this revision</button></form>` : ''}++</li>`);++ const citeOptions = [++ ...observations.map((o) => html`<label><input type="checkbox" name="cite" value="observation:${o.id}"> ${o.metric} = ${o.value} ${o.unit}, observed ${when(o.observed_at)}</label>`),++ ...documents.map((d) => html`<label><input type="checkbox" name="cite" value="document:${d.id}"> ${d.form_type || d.kind}: ${d.title || d.filer_name || d.id}, ${d.published_at ? html`published ${when(d.published_at)}` : 'no date'}</label>`),++ ];++ return html`${raw(breadcrumbsHtml([{ name: 'Editor', url: '/editor' }, { name: instrument.symbol }]))}++<h1>${instrument.symbol} — ${instrument.name}</h1>${notice(n)}++<p><a href="/i/${encodeURIComponent(instrument.symbol)}">Public page</a></p>++<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}" class="card">${csrfField(csrf)}++ <h2>Details</h2>++ <label>Name <input name="name" maxlength="200" value="${instrument.name}" required></label>++ <label>Kind <select name="kind">${kinds.map((k) => html`<option ${k === instrument.kind ? 'selected' : ''}>${k}</option>`)}</select></label>++ <label>Exchange <input name="exchange" maxlength="40" value="${instrument.exchange || ''}"></label>++ <label>SEC CIK <input name="cik" maxlength="13" value="${instrument.cik || ''}"></label>++ <label>Trading currency <input name="currency" maxlength="3" value="${instrument.currency || ''}"></label>++ <label>Status <select name="status"><option ${instrument.status === 'active' ? 'selected' : ''}>active</option><option ${instrument.status === 'archived' ? 'selected' : ''}>archived</option></select></label>++ <button type="submit">Save</button></form>++<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/aliases" class="card">${csrfField(csrf)}++ <h2>Aliases</h2>++ <ul>${aliases.map((a) => html`<li>${a.kind}: ${a.value} <span class="muted">(${a.normalized})</span></li>`)}</ul>++ <label>Kind <select name="kind"><option>ticker</option><option>cik</option><option>name</option></select></label>++ <label>Value <input name="value" maxlength="200" required></label>++ <button type="submit">Add alias</button></form>++<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context" class="card">${csrfField(csrf)}++ <h2>Write context</h2>++ <p class="muted">Describe what the cited sources state. No recommendations, targets or predictions: text that reads as advice is refused.</p>++ <input type="hidden" name="expected_revision" value="${head}">++ <label for="ctx-body">Context (Markdown)</label><textarea id="ctx-body" name="body" rows="10" maxlength="20000" required></textarea>++ <fieldset><legend>Cite</legend>${citeOptions.length ? citeOptions : html`<p class="muted">Nothing to cite yet.</p>`}</fieldset>++ <label><input type="checkbox" name="publish" value="1"> Publish now</label>++ <button type="submit">Save revision</button></form>++${instrument.context_published_revision ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/retract" class="inline-form">${csrfField(csrf)}<button type="submit" class="danger">Retract the published context</button></form>` : ''}++<h2>Context revisions</h2>++${revisions.length ? html`<ol class="revisions">${revs}</ol>` : html`<p class="empty">No context yet.</p>`}`;++}++++function errorPage({ status, title, message }) {++ return html`<h1>${title}</h1><p>${message}</p><p class="muted">Error ${status}. <a href="/">All instruments</a></p>`;++}++++module.exports = { home, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when };nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * Background work in the Trade process (two timers):++ *++ * Sources sync every TRADE_SYNC_INTERVAL_MS (and early when a signed webhook arrives):++ * domain/sync.js pulls the trade category; idempotent per item revision++ * freshness every TRADE_FRESHNESS_INTERVAL_MS: re-evaluate every source on Trade's clock++ * (trade.source.stale / recovered on transitions only) and re-send any instrument++ * Search document whose gate decision changed (e.g. a price observation aged++ * past TRADE_PRICE_MAX_AGE_SEC → noindex)++ */++function createWorker({ config, sync, freshness, indexing, outbox, log = console }) {++ let syncTimer = null;++ let freshTimer = null;++++ function freshnessTick() {++ try {++ const changed = freshness.evaluateAll();++ const docs = indexing.refreshAll();++ if (changed.length || docs.length) outbox.kick();++ return { sources: changed.length, documents: docs.length };++ } catch (err) {++ log.error('[Trade] freshness tick failed:', err.message);++ return null;++ }++ }++++ return {++ freshnessTick,++ start() {++ if (!config.worker.enabled) return;++ if (sync.enabled) {++ syncTimer = setInterval(() => sync.run().catch(() => {}), config.sources.syncIntervalMs);++ syncTimer.unref();++ setTimeout(() => sync.run().catch(() => {}), 2000).unref();++ }++ freshTimer = setInterval(freshnessTick, config.worker.freshnessIntervalMs);++ freshTimer.unref();++ },++ stop() { clearInterval(syncTimer); clearInterval(freshTimer); },++ };++}++++module.exports = { createWorker };nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++/**++ * Alerts are idempotent per triggering observation (and per document): one delivery row and one++ * trade.alert.triggered event per (rule, trigger), whatever is replayed or retried. Threshold rules++ * fire on a crossing; late (older) observations never fire. No email: the event is for the owner.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const MIN = 60e3;++++(async () => {++ const t = await boot();++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const iso = t.iso;++ const obs = (value, minutes, ref) => t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });++ const deliveries = () => t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();++ let ruleId;++++ await check('alice creates a threshold rule through the API (for herself only)', async () => {++ t.clock.advance(-10 * MIN);++ const r = await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '100', unit: 'USD', currency: 'USD' } });++ assert.strictEqual(r.status, 201, r.text);++ ruleId = r.json().rule.id;++ t.clock.set(t.T0 + 60 * MIN);++ });++++ await check('an observation crossing the threshold delivers exactly once, with one event', async () => {++ obs('99.50', 1, 'o1');++ assert.strictEqual(deliveries().length, 0, 'below: nothing');++ const { observation } = obs('101.00', 2, 'o2');++ const d = deliveries();++ assert.strictEqual(d.length, 1);++ assert.strictEqual(d[0].trigger_kind, 'observation');++ assert.strictEqual(d[0].trigger_id, observation.id);++ const ev = t.events('trade.alert.triggered');++ assert.strictEqual(ev.length, 1);++ assert.strictEqual(ev[0].visibility, 'subject');++ assert.deepStrictEqual(ev[0].subject, { type: 'user', id: t.alice.subject });++ assert.strictEqual(ev[0].payload.trigger.value, '101.00');++ assert.strictEqual(ev[0].payload.trigger.observed_at, iso(t.T0 + 2 * MIN));++ assert.ok(ev[0].payload.disclaimer.includes('not investment advice'));++ assert.strictEqual(d[0].event_id, ev[0].event_id);++ });++++ await check('replaying the same observation (same source reference) delivers nothing new', async () => {++ const r = obs('101.00', 2, 'o2');++ assert.strictEqual(r.created, false);++ const api = await t.get('/api/v1/observations', { as: t.network.serviceToken('feed', ['trade.observation.write']), json: { symbol: 'ACME', metric: 'price.close', value: '101.00', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'o2', observed_at: iso(t.T0 + 2 * MIN), retrieved_at: iso(t.T0 + 2 * MIN) } });++ assert.strictEqual(api.status, 200);++ assert.strictEqual(deliveries().length, 1);++ assert.strictEqual(t.events('trade.alert.triggered').length, 1);++ });++++ await check('re-running evaluation for the same observation cannot deliver twice (UNIQUE rule × trigger)', async () => {++ const o = t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();++ t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);++ t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));++ t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));++ assert.strictEqual(deliveries().length, 1);++ assert.strictEqual(t.events('trade.alert.triggered').length, 1);++ });++++ await check('staying above does not repeat; dropping below re-arms; crossing again delivers once more', async () => {++ obs('102.00', 3, 'o3');++ assert.strictEqual(deliveries().length, 1, 'still above: no repeat');++ obs('98.00', 4, 'o4');++ assert.strictEqual(deliveries().length, 1);++ obs('100.50', 5, 'o5');++ assert.strictEqual(deliveries().length, 2);++ assert.strictEqual(t.events('trade.alert.triggered').length, 2);++ });++++ await check('a late observation (older than the last one evaluated) never fires', async () => {++ obs('97.00', 6, 'o6'); // re-arms++ obs('150.00', 1.5, 'late');++ assert.strictEqual(deliveries().length, 2);++ });++++ await check('unit or currency mismatch never fires (no conversion)', async () => {++ t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });++ assert.strictEqual(deliveries().length, 2);++ });++++ await check('document rules: new_document and filing_type fire once per document; revisions of the item never re-fire', async () => {++ const nd = await t.get('/api/v1/alerts', { as: t.bob, json: { symbol: 'ACME', kind: 'new_document' } });++ assert.strictEqual(nd.status, 201);++ const ft = await t.get('/api/v1/alerts', { as: t.bob, json: { symbol: 'ACME', kind: 'filing_type', form_types: ['8-K'] } });++ assert.strictEqual(ft.status, 201);++ const url = 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm';++ const item = t.sources.putItem({ canonical_url: url, title: 'ACME CORP', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });++ await t.ctx.sync.run();++ const bobs = () => deliveries().filter((d) => d.owner_subject === t.bob.subject);++ assert.strictEqual(bobs().length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');++ t.sources.putItem({ id: item.id, canonical_url: url, title: 'ACME CORP (amended title)', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });++ await t.ctx.sync.run();++ t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();++ await t.ctx.sync.run();++ assert.strictEqual(bobs().length, 1, 'item revision and page replay: no second delivery');++ assert.strictEqual((await t.get('/i/ACME.json')).json().documents[0].title, 'ACME CORP (amended title)');++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });++ await t.ctx.sync.run();++ assert.strictEqual(bobs().length, 3, 'an 8-K: new_document + filing_type');++ });++++ await check('rules and deliveries are private: someone else sees none and cannot delete them', async () => {++ const mine = (await t.get('/api/v1/alerts', { as: t.alice })).json().rules;++ assert.strictEqual(mine.length, 1);++ const bobsView = (await t.get('/api/v1/alerts', { as: t.bob })).json().rules;++ assert.ok(bobsView.every((r) => r.id !== ruleId));++ assert.strictEqual((await t.get(`/api/v1/alerts/${ruleId}`, { as: t.bob, method: 'DELETE' })).status, 404);++ const dl = (await t.get('/api/v1/alerts/deliveries', { as: t.alice })).json().deliveries;++ assert.strictEqual(dl.length, 2);++ assert.ok(dl.every((d) => d.trigger.observed_at && d.trigger.source_key));++ assert.strictEqual((await t.get(`/api/v1/alerts/${ruleId}`, { as: t.alice, method: 'DELETE' })).status, 200);++ });++++ await check('a service acting for a person needs the capability and X-OV-Subject', async () => {++ const svc = t.network.serviceToken('notifications', ['trade.alert.read']);++ assert.strictEqual((await t.get('/api/v1/alerts', { as: svc })).status, 400);++ const r = await t.get('/api/v1/alerts', { as: svc, headers: { 'x-ov-subject': t.bob.subject } });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().rules.length, 2);++ assert.strictEqual((await t.get('/api/v1/alerts', { as: svc, method: 'POST', json: { symbol: 'ACME', kind: 'new_document' }, headers: { 'x-ov-subject': t.bob.subject } })).status, 403);++ });++++ await check('no email is ever sent: Trade has no mail client and no mail configuration', async () => {++ const fs = require('fs');++ const path = require('path');++ const files = [];++ (function walk(d) { for (const f of fs.readdirSync(d)) { const p = path.join(d, f); if (fs.statSync(p).isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(path.join(__dirname, '..', 'server'));++ const src = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');++ assert.ok(!/nodemailer|smtp|sendmail|SMTP_/i.test(src));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * Context: OpenVibe.AI's trade.summarize_market_context is a seam — its output is a draft context++ * revision, labelled, never attributed to a person, noindex (the page stays out of sitemaps and++ * Search) until a person on the editor list approves it. Editors write context with plain forms.++ * Text that reads as advice is refused.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const errOf = (r) => new URL(r.headers.get('location'), 'https://x').searchParams.get('e') || '';++const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');++++(async () => {++ const t = await boot();++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const { observation } = t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });++ t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });++ const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);++ let input;++++ await check('the AI workflow input lists numbered sources with observation/retrieval times and staleness', async () => {++ const r = await t.get('/api/v1/instruments/ACME/context/input', { as: ai });++ assert.strictEqual(r.status, 200, r.text);++ input = r.json();++ assert.strictEqual(input.workflow, 'trade.summarize_market_context');++ assert.strictEqual(input.input.instrument.symbol, 'ACME');++ const s = input.input.sources[0];++ assert.strictEqual(s.source_type, 'trade.observation');++ assert.strictEqual(s.source_id, observation.id);++ assert.strictEqual(s.observed_at, t.iso(t.T0 - 60e3));++ assert.strictEqual(s.retrieved_at, t.iso(t.T0));++ assert.strictEqual(s.provenance.stale, false);++ assert.strictEqual(input.input.stale_after_hours, 24);++ });++++ let draftRev;++ await check('AI output becomes a labelled DRAFT: not published, needs review, page not indexable', async () => {++ const r = await t.get('/api/v1/instruments/ACME/context', {++ as: ai, headers: { 'x-ov-origin': 'ai' },++ json: {++ workflow: { id: 'trade.summarize_market_context', run_id: 'run_01JABCDEFGHJKMNPQRSTVWXYZ0', version: 1 },++ input_sources: input.input.sources.map((s) => ({ source_type: s.source_type, source_id: s.source_id })),++ output: { summary: `The latest recorded close for ACME is 10.50 USD. ${WORDS(70)}`, observations: [{ text: 'Close of 10.50 USD', observed_at: t.iso(t.T0 - 60e3), citations: [0] }], stale_sources: [], disclaimer: 'Informational only; not investment advice.', citations: [0], gaps: ['No filings were provided.'] },++ },++ });++ assert.strictEqual(r.status, 201, r.text);++ const rev = r.json().revision;++ draftRev = rev.revision;++ assert.strictEqual(r.json().published, false);++ assert.strictEqual(rev.needs_review, true);++ assert.strictEqual(rev.authorship.mode, 'ai');++ assert.deepStrictEqual(rev.authorship.authors, [], 'never attributed to a person');++ assert.strictEqual(rev.authorship.workflow.id, 'trade.summarize_market_context');++ assert.strictEqual(rev.disclosure.short, 'AI-generated');++ assert.strictEqual(rev.as_of, t.iso(t.T0 - 60e3), 'as_of = newest cited observation time');++ assert.deepStrictEqual(rev.cites, [{ kind: 'observation', id: observation.id, observation: rev.cites[0].observation }]);++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('No reviewed context has been published'));++ assert.ok(!page.text.includes('latest recorded close for ACME'), 'the draft is not shown to readers');++ assert.ok(page.text.includes('<meta name="robots" content="noindex, nofollow">'));++ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));++ assert.strictEqual(t.events('trade.index_document.upserted').length, 0);++ });++++ await check('AI output cannot be published without a person, nor delivered as if by a person', async () => {++ const pub = await t.get(`/editor/i/ACME/context/${draftRev}/publish`, { as: t.editor, form: { _csrf: t.csrf(t.editor) } });++ assert.strictEqual(pub.status, 303);++ assert.ok(/review/i.test(errOf(pub)));++ assert.strictEqual(t.ctx.instruments.bySymbol('ACME').context_published_revision, null);++ const svcReview = await t.get(`/api/v1/instruments/ACME/context/revisions/${draftRev}/review`, { as: ai, json: { decision: 'approved' } });++ assert.strictEqual(svcReview.status, 403, 'a service cannot review');++ const noRun = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context' }, output: { summary: 'x' } } });++ assert.strictEqual(noRun.status, 422);++ const wrongWf = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'blog.draft_post', run_id: 'r' }, output: { summary: 'x' } } });++ assert.strictEqual(wrongWf.status, 422);++ const asUser = await t.get('/api/v1/instruments/ACME/context', { as: ai, json: { body: 'hello' } });++ assert.strictEqual(asUser.status, 403, 'a service without X-OV-Origin: ai and without an editor subject cannot write context');++ const badCite = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context', run_id: 'r2' }, input_sources: [{ source_type: 'trade.observation', source_id: 'obs_NOPE' }], output: { summary: 'x', citations: [0] } } });++ assert.strictEqual(badCite.status, 422);++ });++++ await check('advice is refused, from the AI and from editors alike', async () => {++ const r = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context', run_id: 'r3' }, input_sources: [], output: { summary: 'You should buy ACME now; price target 20.' } } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'context.advice_refused');++ const f = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: 'We recommend holding ACME.', expected_revision: String(draftRev), publish: '1' } });++ assert.strictEqual(f.status, 303);++ assert.ok(/information only/i.test(errOf(f)));++ });++++ await check('a person on the editor list approves the draft: published, disclosed, indexable, in the sitemap and Search', async () => {++ const nonEditor = await t.get(`/editor/i/ACME/context/${draftRev}/review`, { as: t.alice, form: { _csrf: t.csrf(t.alice), decision: 'approved' } });++ assert.strictEqual(nonEditor.status, 403);++ const r = await t.get(`/editor/i/ACME/context/${draftRev}/review`, { as: t.editor, form: { _csrf: t.csrf(t.editor), decision: 'approved', note: 'Checked against the source.' } });++ assert.strictEqual(r.status, 303);++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('latest recorded close for ACME'));++ assert.ok(page.text.includes('AI-generated'), 'the label stays after review');++ assert.ok(page.text.includes('reviewed by a person'));++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), page.text.match(/<meta name="robots"[^>]*>/)[0]);++ const sm = (await t.get('/sitemaps/instruments.xml')).text;++ assert.ok(sm.includes('https://openvibe.trade/i/ACME'));++ const up = t.events('trade.index_document.upserted');++ assert.strictEqual(up.length, 1);++ assert.strictEqual(up[0].payload.authorship, 'ai_generated');++ assert.strictEqual(up[0].payload.visibility, 'public');++ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.ok(ld.find((b) => b['@type'] === 'WebPage').dateModified, 'dateModified is the real publication time');++ });++++ await check('an editor writes human context with a plain form; a stale base is refused without losing the published text', async () => {++ const head = t.ctx.context.head(t.ctx.instruments.bySymbol('ACME')).number;++ const ok = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme filed nothing new this week. ${WORDS(70)}`, expected_revision: String(head), cite: `observation:${observation.id}`, publish: '1' } });++ assert.strictEqual(ok.status, 303);++ assert.ok(/n=context_published/.test(ok.headers.get('location')));++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('Written by a Trade editor.'));++ assert.ok(page.text.includes('Acme filed nothing new this week.'));++ const stale = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: 'Late edit.', expected_revision: String(head) } });++ assert.ok(/newer revision/.test(errOf(stale)));++ assert.ok((await t.get('/i/ACME')).text.includes('Acme filed nothing new this week.'));++ });++++ await check('retracting the context takes the page out of the sitemap and sends a Search tombstone', async () => {++ const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });++ assert.strictEqual(r.status, 303);++ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));++ assert.strictEqual(t.events('trade.index_document.deleted').length, 1);++ });++++ await check('the editor is for editors only; its pages are private and noindex', async () => {++ assert.strictEqual((await t.get('/editor', { as: t.alice })).status, 403);++ assert.strictEqual((await t.get('/editor')).status, 303);++ const e = await t.get('/editor', { as: t.editor });++ assert.strictEqual(e.status, 200);++ assert.ok(/private/.test(e.headers.get('cache-control')));++ assert.strictEqual(e.headers.get('x-robots-tag'), 'noindex, nofollow');++ const add = await t.get('/editor/instruments', { as: t.editor, form: { _csrf: t.csrf(t.editor), symbol: 'nwco', name: 'New Co', kind: 'equity', cik: '' } });++ assert.strictEqual(add.status, 303);++ assert.ok(t.ctx.instruments.bySymbol('NWCO'));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,78 @@++'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 every envelope Trade produces is a++ * valid events.event-envelope@1.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { PROPOSED } = require('../server/auth/capabilities');++++const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');++++(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'));++++ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by trade', 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, 'trade');++ 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 === 'trade', `${c.id} collides with a released capability`);++ }++ });++++ await check('the proposals are exactly the capabilities the code enforces, and cover the charter\'s', async () => {++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());++ for (const id of ['trade.watchlist.create', 'trade.watchlist.update', 'trade.instrument.resolve', 'trade.alert.create', 'trade.alert.delete', 'trade.context.read']) assert.ok(PROPOSED.has(id), id);++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');++ for (const c of caps) for (const route of c.implementedBy) assert.ok(src.includes(`'${route.split(' ')[1].replace('/api/v1', '')}'`), `${c.id}: ${route} is not in http/api.js`);++ });++++ await check('the service manifest proposal is a valid registry.service-manifest@1 and declares every event', async () => {++ const v = contracts.validate('registry.service-manifest@1', manifest);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(manifest.id, 'trade');++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);++ const src = fs.readdirSync(path.join(__dirname, '..', 'server', 'domain')).map((f) => fs.readFileSync(path.join(__dirname, '..', 'server', 'domain', f), 'utf8')).join('\n');++ for (const m of src.matchAll(/event_type: (?:[a-z.]+ \? )?'([a-z_.]+)'(?: : '([a-z_.]+)')?/g)) {++ for (const e of [m[1], m[2]].filter(Boolean)) assert.ok(manifest.eventsProduced.includes(e), e);++ }++ });++++ await check('every envelope Trade actually produces validates as events.event-envelope@1 (and Search documents as search.index-document@1)', async () => {++ const t = await boot();++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });++ await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '1', unit: 'USD' } });++ t.clock.advance(1000);++ t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });++ t.clock.advance(3600e3);++ t.ctx.freshness.evaluateAll();++ const rev = t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });++ t.ctx.context.publish(acme, rev.revision.number);++ const all = t.events();++ const types = new Set(all.map((e) => e.event_type));++ for (const ty of ['trade.observation.created', 'trade.alert.triggered', 'trade.source.stale', 'trade.index_document.upserted']) assert.ok(types.has(ty), `${ty} produced`);++ for (const e of all) {++ const v = contracts.validate('events.event-envelope@1', e);++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);++ assert.ok(manifest.eventsProduced.includes(e.event_type), e.event_type);++ if (e.event_type === 'trade.index_document.upserted') {++ const d = contracts.validate('search.index-document@1', e.payload);++ assert.ok(d.valid, JSON.stringify(d.errors));++ }++ }++ await t.close();++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * Stale or unavailable feeds are shown as stale — with "stale since" — and never replaced with++ * invented values. Driven by an injected clock; transitions emit trade.source.stale|recovered once.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const HOUR = 3600e3;++++(async () => {++ const t = await boot();++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ const iso = t.iso;++++ await check('an observation from a source with a known window is current inside the window', async () => {++ t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });++ t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });++ const json = (await t.get('/i/ACME.json')).json();++ assert.strictEqual(json.observations[0].freshness.stale, false);++ assert.strictEqual(json.sources[0].status, 'fresh');++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('<span class="badge fresh">current</span>'));++ assert.ok(!page.text.includes('badge stale'));++ });++++ await check('after the window, the same value is shown as stale since last success + window (clock advanced, nothing else changed)', async () => {++ t.clock.advance(2 * HOUR);++ const json = (await t.get('/i/ACME.json')).json();++ const o = json.observations[0];++ assert.strictEqual(o.value, '10.50', 'the value is kept, not replaced');++ assert.strictEqual(o.freshness.stale, true);++ assert.strictEqual(o.freshness.stale_since, iso(t.T0 + HOUR));++ assert.strictEqual(o.freshness.reason, 'source_stale');++ assert.strictEqual(json.sources[0].status, 'stale');++ assert.strictEqual(json.sources[0].stale_since, iso(t.T0 + HOUR));++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('<span class="badge stale">stale</span> since <time datetime="2026-09-22T13:00:00.000Z">2026-09-22 13:00 UTC</time>'), 'explicit stale-since label');++ assert.ok(page.text.includes('<data value="10.50">10.50</data>'), 'the old value stays visible with its timestamps');++ assert.ok(/class="is-stale"/.test(page.text));++ });++++ await check('the transition emits trade.source.stale exactly once, and recovery emits trade.source.recovered once', async () => {++ t.ctx.freshness.evaluateAll();++ t.ctx.freshness.evaluateAll();++ const stale = t.events('trade.source.stale');++ assert.strictEqual(stale.length, 1);++ assert.strictEqual(stale[0].payload.stale_since, iso(t.T0 + HOUR));++ t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });++ t.ctx.freshness.evaluateAll();++ assert.strictEqual(t.events('trade.source.recovered').length, 1);++ assert.strictEqual(t.events('trade.source.stale').length, 1);++ const json = (await t.get('/i/ACME.json')).json();++ assert.strictEqual(json.observations[0].value, '10.60');++ assert.strictEqual(json.observations[0].freshness.stale, false);++ });++++ await check('a value older than its own max_age is stale even while its source is fresh', async () => {++ t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });++ const o = (await t.get('/i/ACME.json')).json().observations.find((x) => x.metric === 'eps.diluted');++ assert.strictEqual(o.freshness.stale, true);++ assert.strictEqual(o.freshness.reason, 'value_older_than_max_age');++ assert.strictEqual(o.freshness.stale_since, iso(t.clock.now() - HOUR));++ });++++ await check('a source Trade has never heard from is "freshness unknown", never "current"', async () => {++ const v = t.ctx.freshness.view('nobody-knows');++ assert.strictEqual(v.status, 'unknown');++ assert.strictEqual(v.stale, true);++ });++++ await check('Sources unavailable: the sync records the failure and invents nothing; data decays to stale on Trade\'s clock', async () => {++ const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;++ t.sources.setDown(true);++ const r = await t.ctx.sync.run();++ assert.strictEqual(r.ok, false);++ const state = t.ctx.sync.state();++ assert.ok(state.last_error && /503/.test(state.last_error));++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, before);++ t.clock.advance(5 * HOUR);++ const json = (await t.get('/i/ACME.json')).json();++ assert.ok(json.observations.every((o) => o.freshness.stale), 'everything is stale now');++ assert.ok(json.observations.find((o) => o.metric === 'price.close').value === '10.60', 'last real value kept, labelled stale');++ const sourcesPage = await t.get('/sources');++ assert.ok(/failing since/.test(sourcesPage.text), 'the sources page says the sync is failing');++ const ready = (await t.get('/api/ready')).json();++ assert.ok(JSON.stringify(ready).includes('last Sources sync failed'));++ t.sources.setDown(false);++ });++++ await check('the gate: a monetary observation older than TRADE_PRICE_MAX_AGE_SEC makes the page noindex (stale_price)', async () => {++ const acmeNow = t.ctx.instruments.bySymbol('ACME');++ assert.ok(!t.ctx.indexing.decide(acmeNow).codes.includes('stale_price'), 'observed hours ago: not yet');++ t.clock.advance(24 * HOUR);++ const d = t.ctx.indexing.decide(acmeNow);++ assert.ok(d.codes.includes('stale_price'), d.codes.join(','));++ assert.ok(!d.indexable);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * Boots Trade 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.base, t.get(path, { as: user | token, json, form })++ * t.clock.advance(ms) // the app's clock (freshness, gate, alerts)++ * t.editor, t.alice, t.bob // Network users (the editor is in TRADE_EDITORS)++ * t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)++ * t.events(type) // parsed envelopes in event_outbox++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { startNetwork, startSources } = require('./mocks');++++const T0 = Date.parse('2026-09-22T12:00:00Z');++++function makeClock(start = T0) {++ 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 sources = await startSources({ network });++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-trade-test-'));++ const dbPath = path.join(dir, 'trade.db');++ const clock = opts.clock || makeClock();++ const editor = network.addUser('editor');++ const alice = network.addUser('alice');++ const bob = network.addUser('bob');++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.trade', TRUST_PROXY: '1',++ TRADE_DB_PATH: dbPath,++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'trade', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',++ OV_SOURCES_INTERNAL_URL: sources.url,++ TRADE_EDITORS: editor.subject, TRADE_WORKER: 'off', TRADE_FORM_SECRET: 'test-form-secret',++ TRADE_EVENTS_WEBHOOK_SECRET: 'hook-secret',++ ...(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. */++ 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}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams(o.form).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); } };++ }++++ 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)));++ }++++ const t = {++ network, sources, clock, dbPath, editor, alice, bob, get, events, T0,++ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.TRADE_FORM_SECRET }, user),++ iso: (ms) => new Date(ms).toISOString(),++ instrument(input) { return t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },++ /** Record an observation as a first-party feed would (domain call, recordedBy svc:feed). */++ observe(instrument, input) {++ return t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });++ },++ async restart() { await stop(); await start(); },++ async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ };++ await start();++ return t;++}++++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, T0 };nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++/**++ * In-process stand-ins for Trade's neighbours, with a real RS256 key pair:++ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as++ * capabilities)++ * Sources GET /api/v1/sources and GET /api/v1/items (sources.item@1 shape, change order,++ * include_removed), gated on the capabilities; the test controls items, health and outages++ * userToken()/serviceToken() mint the tokens browsers and services present to Trade.++ */++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 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' });++ }++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++ function signService({ sub, aud, cap }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);++ }++ function addUser(username, extra = {}) {++ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };++ }++ 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.trade'], cap });++ }++ return { ...srv, publicPem, grants, addUser, userToken, serviceToken, signService };++}++++/** A stand-in for OpenVibe.Sources' read API. */++async function startSources({ network }) {++ const items = []; // { ...item, change_seq }++ const sources = new Map(); // key → source view++ const calls = [];++ let seq = 0;++ let down = false;++ const srv = await listen((req, raw, json) => {++ calls.push({ method: req.method, url: 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) return json(401, { code: v.code });++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/api/v1/sources') {++ if (!(v.claims.cap || []).includes('sources.source.read')) return json(403, { code: 'capability.denied' });++ return json(200, { sources: [...sources.values()] });++ }++ if (u.pathname === '/api/v1/items') {++ if (!(v.claims.cap || []).includes('sources.item.read')) return json(403, { code: 'capability.denied' });++ const after = Number(u.searchParams.get('after') || 0);++ const limit = Number(u.searchParams.get('limit') || 100);++ const cat = u.searchParams.get('category');++ const incl = u.searchParams.get('include_removed') === '1';++ const rows = items.filter((i) => i.change_seq > after && (!cat || i.category === cat) && (incl || !i.removed)).sort((a, b) => a.change_seq - b.change_seq);++ const page = rows.slice(0, limit);++ const keys = [...new Set(page.map((i) => i.source_key))];++ const health = {};++ for (const k of keys) if (sources.has(k)) health[k] = sources.get(k).health;++ return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: health });++ }++ return json(404, { code: 'route.not_found' });++ });++++ const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++ return {++ ...srv, calls,++ setDown: (d) => { down = d; },++ /** A source with its health block (as GET /api/v1/sources shows it). */++ setSource(key, { name = key, category = 'trade', status = 'healthy', lastSuccessAt = null, staleAfterSec = 3600, stale = false } = {}) {++ sources.set(key, {++ key, name, category, type: 'rss', terms_note: 'test terms', license_note: null,++ health: { status, stale, last_success_at: lastSuccessAt, stale_after_sec: staleAfterSec },++ });++ },++ /** Add or revise an item; returns it. */++ putItem(input) {++ const existing = items.find((i) => i.id === input.id);++ const base = existing || { id: input.id || `itm_${ids.ulid()}`, revision: 0 };++ const item = {++ id: base.id, source_key: input.source_key || 'sec-xbrl-filings', category: input.category || 'trade', kind: input.kind || 'article',++ identity: input.identity || input.canonical_url || base.id, canonical_url: input.canonical_url || null,++ title: input.title == null ? null : input.title, summary: input.summary == null ? null : input.summary, authors: [],++ published_at: input.published_at || null, source_updated_at: input.source_updated_at || null, fields: input.fields || {},++ revision: existing && input.sameRevision ? existing.revision : base.revision + 1,++ provenance: {++ retrieved_at: input.retrieved_at, first_seen_at: existing ? existing.provenance.first_seen_at : input.retrieved_at,++ content_hash: hash(JSON.stringify(input)), raw_body_hash: null, parser_version: 'feed@1', fetch_run_id: null,++ license_note: null, terms_note: 'test terms', entered_by: null,++ },++ removed: input.removed || null,++ change_seq: ++seq,++ };++ if (existing) items.splice(items.indexOf(existing), 1, item); else items.push(item);++ return item;++ },++ remove(id, { at, reason }) {++ const it = items.find((i) => i.id === id);++ it.removed = { at, reason };++ it.change_seq = ++seq;++ return it;++ },++ };++}++++module.exports = { startNetwork, startSources, listen };nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * No observation → no number shown. Every datum exposes its observation and source timestamps.++ * Observations are immutable and idempotent per source reference.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const feed = t.network.serviceToken('feed', ['trade.observation.write']);++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++++ await check('an instrument without observations shows no number, in HTML and JSON', async () => {++ const page = await t.get('/i/ACME');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('No observations have been recorded for ACME, so no number is shown.'));++ assert.ok(!/<table class="data observations"/.test(page.text), 'no observations table');++ assert.ok(!/<data value=/.test(page.text), 'no <data> value element');++ const json = (await t.get('/i/ACME.json')).json();++ assert.deepStrictEqual(json.observations, []);++ assert.ok(!('price' in json) && !('value' in json.instrument), 'no invented top-level value');++ });++++ await check('an observation without observed_at or retrieved_at is refused, never defaulted to now', async () => {++ const noObserved = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', retrieved_at: t.iso(t.T0) } });++ assert.strictEqual(noObserved.status, 422);++ assert.strictEqual(noObserved.json().code, 'observation.time_required');++ const noRetrieved = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0) } });++ assert.strictEqual(noRetrieved.status, 422);++ const future = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0 + 3600e3), retrieved_at: t.iso(t.T0) } });++ assert.strictEqual(future.status, 422);++ const noValue = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: 'about ten', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0), retrieved_at: t.iso(t.T0) } });++ assert.strictEqual(noValue.status, 422);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, 0);++ });++++ await check('only a service with trade.observation.write records observations; people cannot', async () => {++ const body = { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) };++ assert.strictEqual((await t.get('/api/v1/observations', { as: t.network.serviceToken('other', ['trade.context.read']), json: body })).status, 403);++ assert.strictEqual((await t.get('/api/v1/observations', { as: t.editor, json: body })).status, 403);++ assert.strictEqual((await t.get('/api/v1/observations', { json: body })).status, 403);++ });++++ let obsId;++ await check('a recorded observation shows its value with observed_at, retrieved_at and source', async () => {++ const r = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.50', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', source_url: 'https://example.org/acme/close', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });++ assert.strictEqual(r.status, 201, r.text);++ const o = r.json().observation;++ obsId = o.id;++ assert.strictEqual(o.value, '10.50', 'the decimal exactly as stated');++ assert.strictEqual(o.observed_at, '2026-09-22T11:59:00.000Z');++ assert.strictEqual(o.retrieved_at, '2026-09-22T12:00:00.000Z');++ assert.strictEqual(o.source.key, 'test-feed');++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('<data value="10.50">10.50</data>'));++ assert.ok(page.text.includes('datetime="2026-09-22T11:59:00.000Z"'), 'observed_at shown');++ assert.ok(page.text.includes('datetime="2026-09-22T12:00:00.000Z"'), 'retrieved_at shown');++ assert.ok(page.text.includes('href="https://example.org/acme/close"'));++ const json = (await t.get('/i/ACME.json')).json();++ assert.strictEqual(json.observations.length, 1);++ for (const k of ['observed_at', 'retrieved_at', 'recorded_at']) assert.ok(json.observations[0][k], k);++ assert.ok(json.observations[0].freshness, 'every observation carries its freshness');++ const ev = t.events('trade.observation.created');++ assert.strictEqual(ev.length, 1);++ assert.strictEqual(ev[0].payload.observed_at, '2026-09-22T11:59:00.000Z');++ assert.strictEqual(ev[0].payload.retrieved_at, '2026-09-22T12:00:00.000Z');++ });++++ await check('the same source reference again is a replay (200, no second row or event); a different value is 409', async () => {++ const same = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.50', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });++ assert.strictEqual(same.status, 200);++ assert.strictEqual(same.json().created, false);++ assert.strictEqual(same.json().observation.id, obsId);++ const diff = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '11', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });++ assert.strictEqual(diff.status, 409);++ assert.strictEqual(t.events('trade.observation.created').length, 1);++ });++++ await check('observations are immutable in the database', async () => {++ assert.throws(() => t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);++ assert.throws(() => t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);++ });++++ await check('history keeps every observation, newest first, each with its timestamps', async () => {++ t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });++ const h = (await t.get('/api/v1/instruments/ACME/observations?metric=price.close')).json();++ assert.deepStrictEqual(h.observations.map((o) => o.value), ['10.75', '10.50']);++ const latest = (await t.get('/i/ACME.json')).json().observations;++ assert.strictEqual(latest.length, 1, 'latest per metric');++ assert.strictEqual(latest[0].value, '10.75');++ });++++ await check('JSON-LD carries only real fields (no price, no rating, no invented date)', async () => {++ const page = await t.get('/i/ACME');++ const blocks = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.ok(blocks.length >= 1);++ const flat = JSON.stringify(blocks);++ for (const k of ['price', 'offers', 'aggregateRating', 'ratingValue', 'datePublished']) assert.ok(!flat.includes(`"${k}"`), k);++ const pageLd = blocks.find((b) => b['@type'] === 'WebPage');++ assert.strictEqual(pageLd.about.tickerSymbol, 'ACME');++ assert.ok(!('dateModified' in pageLd), 'no context published → no dateModified');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * Public surfaces are useful without JavaScript and carry the persistent disclaimer; the JSON twin++ * describes the same data as the HTML; feeds, sitemaps, robots and llms.txt behave; machine++ * endpoints answer; the historical marketplace branch is recorded as an open question, not built.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const DISCLAIMER = 'Information only — not investment advice; no trading here.';++++(async () => {++ const t = await boot();++ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });++ t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });++ t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0), staleAfterSec: 2700 });++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: t.iso(t.T0) });++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', retrieved_at: t.iso(t.T0) });++ await t.ctx.sync.run();++++ await check('every public page carries the persistent, plain disclaimer (header and footer)', async () => {++ for (const p of ['/', '/i/ACME', '/sources', '/watchlists', '/resolve?q=nothing', '/does-not-exist']) {++ const r = await t.get(p);++ assert.ok(r.text.split(DISCLAIMER).length - 1 >= 2, `${p}: disclaimer missing`);++ }++ assert.strictEqual((await t.get('/i/ACME.json')).json().disclaimer, DISCLAIMER);++ assert.ok((await t.get('/feed.xml')).text.includes('not investment advice'));++ assert.ok((await t.get('/llms.txt')).text.includes('not investment advice'));++ });++++ await check('the instrument page is complete without JavaScript: observations, documents, freshness, context, feeds', async () => {++ const r = await t.get('/i/ACME');++ assert.strictEqual(r.status, 200);++ const html = r.text.replace(/<script[\s\S]*?<\/script>/g, '');++ for (const s of ['<h1>ACME', 'price.close', '10.50', '10-K', '8-K', 'accession 0001234567-26-000001', 'Source freshness', 'No reviewed context has been published', 'documents.xml', 'Sign in']) assert.ok(html.includes(s), s);++ assert.ok(html.includes('Filed <span class="muted">not stated by the source</span>'), 'a filing without a date says so');++ assert.ok(/<link rel="canonical" href="https:\/\/openvibe.trade\/i\/ACME">/.test(r.text));++ assert.ok(r.text.includes('<link rel="alternate" type="application/rss+xml"'));++ });++++ await check('the JSON twin describes the same data as the HTML (no divergence)', async () => {++ const j = (await t.get('/i/ACME.json')).json();++ const h = (await t.get('/i/ACME')).text;++ for (const o of j.observations) assert.ok(h.includes(`<data value="${o.value}">`) && h.includes(`datetime="${o.observed_at}"`));++ for (const d of j.documents) assert.ok(h.includes(`id="${d.id}"`));++ assert.strictEqual(j.documents.length, 2);++ assert.deepStrictEqual(j.indexability.reasons.map((x) => x.code).includes('unreviewed_sensitive'), true);++ });++++ await check('feeds: RSS, Atom and JSON Feed per instrument and site-wide; undated items get no invented date', async () => {++ const rss = await t.get('/i/ACME/documents.xml');++ assert.strictEqual(rss.status, 200);++ assert.ok(/application\/rss\+xml/.test(rss.headers.get('content-type')));++ assert.strictEqual((rss.text.match(/<item>/g) || []).length, 2);++ assert.strictEqual((rss.text.match(/<pubDate>/g) || []).length, 1, 'only the dated filing has a pubDate');++ assert.ok(rss.text.includes('https://openvibe.trade/i/ACME#doc_'), 'items link back to the canonical page');++ const atom = await t.get('/i/ACME/documents.atom');++ assert.strictEqual((atom.text.match(/<entry>/g) || []).length, 1, 'Atom leaves out the undated entry');++ const jf = (await t.get('/i/ACME/documents.json')).json();++ assert.strictEqual(jf.items.length, 2);++ assert.strictEqual(jf.items.filter((i) => i.date_published).length, 1);++ assert.strictEqual((await t.get('/i/NOPE/documents.xml')).status, 404);++ assert.strictEqual((await t.get('/feed.json')).json().items.length, 2);++ });++++ await check('robots, llms.txt and the sitemap index; no page is indexable before reviewed context', async () => {++ const robots = (await t.get('/robots.txt')).text;++ assert.ok(robots.includes('Sitemap: https://openvibe.trade/sitemap.xml'));++ assert.ok(/automated-consumer policy/.test(robots));++ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.trade/sitemaps/instruments.xml'));++ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('<loc>'), 'no indexable instrument yet');++ assert.ok((await t.get('/llms.txt')).text.startsWith('# OpenVibe.Trade'));++ });++++ await check('resolve: one match redirects to the canonical page; a miss says no guess was made', async () => {++ const r = await t.get('/resolve?q=1234567');++ assert.strictEqual(r.status, 302);++ assert.strictEqual(r.headers.get('location'), '/i/ACME');++ const miss = await t.get('/resolve?q=Acm');++ assert.strictEqual(miss.status, 404);++ assert.ok(miss.text.includes('no guess is made'));++ });++++ await check('machine endpoints: health, readiness (truthful), release, legal pages', async () => {++ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-trade');++ const ready = await t.get('/api/ready');++ assert.strictEqual(ready.status, 200);++ const body = JSON.stringify(ready.json());++ assert.ok(body.includes('relay off'), 'the events relay is reported off in tests');++ assert.strictEqual((await t.get('/release.json')).status, 200);++ const terms = await t.get('/terms');++ assert.strictEqual(terms.status, 200);++ assert.ok(terms.text.includes('OpenVibe.Trade'));++ });++++ await check('the historical marketplace branch is an open question in docs, and nothing of it is implemented', async () => {++ const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'marketplace-open-question.md'), 'utf8');++ assert.ok(/ADR-025/.test(doc) && /unresolved|open question/i.test(doc));++ const tables = t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);++ assert.ok(!tables.some((n) => /order|listing|escrow|wallet|custody|payment|cart/.test(n)), tables.join(','));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * Watchlists never leak: private cache headers, noindex, owner-only (404 for anyone else), never in++ * sitemaps, feeds, robots-allowed paths, Search documents or events. Works without JavaScript++ * through plain forms with a form token.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });++ const form = (user, fields) => ({ as: user, form: { _csrf: t.csrf(user), ...fields } });++ let wlId;++++ await check('anonymous /watchlists is a sign-in prompt, private and noindex', async () => {++ const r = await t.get('/watchlists');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Sign in'));++ assert.ok(/private/.test(r.headers.get('cache-control')) && /no-store/.test(r.headers.get('cache-control')));++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));++ });++++ await check('alice creates a watchlist and adds instruments with plain forms (no JavaScript)', async () => {++ const c = await t.get('/watchlists', form(t.alice, { name: 'Secret plans' }));++ assert.strictEqual(c.status, 303);++ assert.ok(/private, no-store/.test(c.headers.get('cache-control')));++ wlId = t.ctx.watchlists.forOwner(t.alice.subject)[0].id;++ assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, symbol: 'ACME' }))).status, 303);++ assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, q: 'Zeta Holdings' }))).status, 303);++ const page = await t.get('/watchlists', { as: t.alice });++ assert.ok(page.text.includes('Secret plans') && page.text.includes('ACME') && page.text.includes('ZETA'));++ assert.ok(/private/.test(page.headers.get('cache-control')) && /no-store/.test(page.headers.get('cache-control')));++ assert.ok(/Cookie/.test(page.headers.get('vary')));++ assert.strictEqual(page.headers.get('x-robots-tag'), 'noindex, nofollow');++ });++++ await check('a form without the right token changes nothing', async () => {++ const r = await t.get('/watchlists', { as: t.alice, form: { _csrf: 'forged', name: 'Injected' } });++ assert.strictEqual(r.status, 403);++ const r2 = await t.get('/watchlists', { as: t.alice, form: { _csrf: t.csrf(t.bob), name: 'Injected' } });++ assert.strictEqual(r2.status, 403);++ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1);++ });++++ await check('bob cannot see, change or delete alice\'s watchlist (404, existence not disclosed)', async () => {++ const bobPage = await t.get('/watchlists', { as: t.bob });++ assert.ok(!bobPage.text.includes('Secret plans'));++ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob, method: 'PATCH', json: { name: 'x' } })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}/items/ACME`, { as: t.bob, method: 'DELETE' })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob, method: 'DELETE' })).status, 404);++ const del = await t.get('/watchlists/delete', form(t.bob, { watchlist_id: wlId }));++ assert.strictEqual(del.status, 303);++ assert.strictEqual(new URL(del.headers.get('location'), 'https://x').searchParams.get('e'), 'No such watchlist');++ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1, 'still there');++ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');++ });++++ await check('the API answers the owner, privately', async () => {++ const r = await t.get('/api/v1/watchlists', { as: t.alice });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json().watchlists[0].items.map((i) => i.instrument.symbol), ['ACME', 'ZETA']);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');++ });++++ await check('a service needs trade.watchlist.* and X-OV-Subject, and sees only that person\'s lists', async () => {++ const reader = t.network.serviceToken('network', ['trade.watchlist.read']);++ assert.strictEqual((await t.get('/api/v1/watchlists', { as: reader })).status, 400);++ const asBob = await t.get('/api/v1/watchlists', { as: reader, headers: { 'x-ov-subject': t.bob.subject } });++ assert.deepStrictEqual(asBob.json().watchlists, []);++ const asAlice = await t.get('/api/v1/watchlists', { as: reader, headers: { 'x-ov-subject': t.alice.subject } });++ assert.strictEqual(asAlice.json().watchlists.length, 1);++ assert.strictEqual((await t.get('/api/v1/watchlists', { as: reader, method: 'POST', json: { name: 'x' }, headers: { 'x-ov-subject': t.alice.subject } })).status, 403);++ const wrongAud = t.network.signService({ sub: 'svc:network', aud: ['openvibe.blog'], cap: ['trade.watchlist.read'] });++ assert.strictEqual((await t.get('/api/v1/watchlists', { as: wrongAud, headers: { 'x-ov-subject': t.alice.subject } })).status, 401);++ });++++ await check('an instrument page shown to a signed-in person (with her watchlist names) is private; anonymous is public', async () => {++ const mine = await t.get('/i/ACME', { as: t.alice });++ assert.ok(mine.text.includes('Secret plans'), 'her own list offered in the add form');++ assert.ok(/private/.test(mine.headers.get('cache-control')) && /no-store/.test(mine.headers.get('cache-control')));++ const anon = await t.get('/i/ACME');++ assert.ok(!anon.text.includes('Secret plans'));++ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(/Cookie/.test(anon.headers.get('vary')));++ });++++ await check('watchlists never appear in sitemaps, feeds, robots-allowed paths, Search documents or events', async () => {++ const blobs = [];++ for (const p of ['/sitemap.xml', '/sitemaps/instruments.xml', '/feed.xml', '/atom.xml', '/feed.json', '/i/ACME/documents.xml', '/llms.txt', '/i/ACME.json', '/', '/sources']) {++ const r = await t.get(p);++ assert.ok(r.status < 400, `${p} ${r.status}`);++ blobs.push(r.text);++ }++ const all = blobs.join('\n');++ assert.ok(!all.includes('Secret plans'));++ assert.ok(!all.includes(wlId));++ assert.ok(!all.includes(t.alice.subject));++ const robots = (await t.get('/robots.txt')).text;++ for (const p of ['/watchlists', '/alerts', '/editor', '/api/']) assert.ok(robots.includes(`Disallow: ${p}`), p);++ const events = JSON.stringify(t.events());++ assert.ok(!events.includes(wlId) && !events.includes('Secret plans') && !events.includes('watchlist'));++ assert.ok(!t.events().some((e) => e.event_type.includes('watchlist')));++ });++++ await check('removing and deleting work for the owner', async () => {++ assert.strictEqual((await t.get('/watchlists/item/remove', form(t.alice, { watchlist_id: wlId, symbol: 'ZETA' }))).status, 303);++ assert.deepStrictEqual(t.ctx.watchlists.items(t.ctx.watchlists.forOwner(t.alice.subject)[0]).map((i) => i.symbol), ['ACME']);++ assert.strictEqual((await t.get('/watchlists/delete', form(t.alice, { watchlist_id: wlId }))).status, 303);++ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 0);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId).n, 0);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * ADR-025 acceptance: "No Trade endpoint accepts an order, holds value or gives personalised advice++ * (a route-inventory test in Trade's CI)."++ *++ * Walks the LIVE Express stack of the real app (every router, every mount) and fails if any route++ * path, handler name, capability id or event type carries order / buy / sell / execution / custody /++ * wallet / escrow / checkout / listing semantics (or the close cousins: purchase, payment, payout,++ * deposit, withdraw, cart). Every route outside the shared /auth session layer and /metrics must have a named++ * handler (http/routes.js define()), so a new route cannot slip through anonymously. A negative++ * control proves the checker catches a violating route.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const express = require('express');++const { boot, check, done } = require('./helpers/boot');++const { PROPOSED } = require('../server/auth/capabilities');++++const FORBIDDEN = ['order', 'buy', 'sell', 'execut', 'custod', 'wallet', 'escrow', 'checkout', 'listing',++ 'purchas', 'payment', 'payout', 'deposit', 'withdraw', 'cart'];++++/** Words of a path or identifier: split on non-alphanumerics and camelCase boundaries, lower-cased. */++function words(s) {++ return String(s || '')++ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')++ .split(/[^A-Za-z0-9]+/)++ .map((w) => w.toLowerCase())++ .filter(Boolean);++}++function violations(s) {++ return words(s).filter((w) => FORBIDDEN.some((f) => w.startsWith(f)));++}++++function mountOf(layer) {++ if (!layer.regexp || layer.regexp.fast_slash) return '';++ const src = layer.regexp.source;++ return src.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/, '').replace(/\\\//g, '/').replace(/\(\?:\(\[\^\\\/]\+\?\)\)/g, ':param');++}++++/** Every route in the app: { path, methods, handlers: [names] }. */++function inventory(app) {++ const out = [];++ function walk(stack, prefix) {++ for (const layer of stack) {++ if (layer.route) {++ const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];++ for (const p of paths) {++ out.push({++ path: prefix + p,++ methods: Object.keys(layer.route.methods),++ handlers: layer.route.stack.map((l) => l.handle && l.handle.name).filter((n) => n !== undefined),++ final: layer.route.stack.length ? layer.route.stack[layer.route.stack.length - 1].handle.name : '',++ });++ }++ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {++ walk(layer.handle.stack, prefix + mountOf(layer));++ }++ }++ }++ walk(app._router.stack, '');++ return out;++}++++(async () => {++ const t = await boot();++ const routes = inventory(t.app);++++ await check('the walker sees the real routes (pages, API, discovery, private, editor, webhook, machine)', async () => {++ const paths = new Set(routes.map((r) => r.path));++ for (const p of ['/', '/i/:symbol', '/watchlists', '/editor', '/robots.txt', '/sitemap.xml', '/api/v1/instruments', '/api/v1/alerts', '/api/v1/observations', '/internal/events', '/api/ready', '/auth/login']) {++ assert.ok(paths.has(p), `route ${p} not found in the inventory: ${[...paths].join(' ')}`);++ }++ assert.ok(routes.length >= 50, `only ${routes.length} routes found`);++ });++++ await check('no route path carries order/buy/sell/execute/custody/wallet/escrow/checkout/listing semantics', async () => {++ const bad = routes.filter((r) => violations(r.path).length).map((r) => `${r.methods.join(',')} ${r.path} (${violations(r.path).join(', ')})`);++ assert.deepStrictEqual(bad, []);++ });++++ await check('no handler name carries those semantics either', async () => {++ const bad = [];++ for (const r of routes) for (const h of r.handlers) if (violations(h).length) bad.push(`${r.path}: ${h}`);++ assert.deepStrictEqual(bad, []);++ });++++ await check('every route outside /auth has a named final handler (registered through define())', async () => {++ // /auth/* is the shared Network session layer and /metrics the shared metrics endpoint++ // (openvibe-shared, loopback only); both are checked for paths above like everything else.++ const shared = (p) => p.startsWith('/auth') || p === '/metrics';++ const anon = routes.filter((r) => !shared(r.path) && (!r.final || r.final === 'anonymous' || /^bound /.test(r.final)));++ assert.deepStrictEqual(anon.map((r) => r.path), []);++ });++++ await check('no capability id and no produced event type carries those semantics', async () => {++ for (const id of PROPOSED) assert.deepStrictEqual(violations(id), [], id);++ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));++ for (const e of manifest.eventsProduced) assert.deepStrictEqual(violations(e), [], e);++ for (const c of manifest.capabilities) assert.deepStrictEqual(violations(c), [], c);++ });++++ await check('no route answers a value-moving verb on an unknown path (the API 404s everything else)', async () => {++ for (const p of ['/api/v1/orders', '/api/v1/instruments/ANY/buy', '/api/v1/wallet', '/api/v1/checkout', '/api/v1/escrow', '/api/v1/listings']) {++ const r = await t.get(p, { method: 'POST', json: {} });++ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);++ }++ });++++ await check('negative control: the checker flags a violating route and handler', async () => {++ const bad = express();++ const r = express.Router();++ r.post('/orders', function placeOrder(_req, res) { res.end(); });++ r.get('/portfolio', function sellPosition(_req, res) { res.end(); });++ r.post('/custody/:id', function hold(_req, res) { res.end(); });++ bad.use('/api/v1', r);++ const inv = inventory(bad);++ assert.strictEqual(inv.length, 3);++ assert.ok(violations(inv[0].path).length && violations(inv[0].final).length);++ assert.ok(violations(inv[1].final).length);++ assert.ok(violations(inv[2].path).length);++ // …and does not flag innocent words that merely contain the letters.++ assert.deepStrictEqual(violations('/borders/sorted/reorder-free/watchlists'), []);++ });++++ 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 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(30)} ${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,141 @@++'use strict';++/**++ * OpenVibe.Sources → Trade: SEC EDGAR filings become documents of the instrument with that CIK;++ * observation items become observations only with their own observation time; unmatched items are++ * skipped and counted; removals hide documents; replaying pages changes nothing; the signed webhook++ * wakes the sync and is idempotent per event id. Deterministic resolution.++ */++const assert = require('assert');++const { signDelivery } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++const { mapItem } = require('../server/domain/mapping');++++(async () => {++ const t = await boot();++ const apple = t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });++ t.instrument({ symbol: 'ACME', name: 'Acme Corp' });++ t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corp++ const ret = t.iso(t.T0 - 5 * 60e3);++ t.sources.setSource('sec-xbrl-filings', { name: 'SEC EDGAR: latest XBRL filings', lastSuccessAt: ret, staleAfterSec: 2700 });++++ const filing = t.sources.putItem({++ canonical_url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000105/0000320193-26-000105-index.htm',++ title: 'APPLE INC (0000320193) (Filer)', summary: '10-Q', published_at: '2026-09-22T11:30:00.000Z', retrieved_at: ret,++ });++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/789019/000078901926000050/0000789019-26-000050-index.htm', title: 'MICROSOFT CORP', summary: '8-K', published_at: '2026-09-22T11:40:00.000Z', retrieved_at: ret });++ t.sources.putItem({ source_key: 'test-quotes', kind: 'observation', canonical_url: 'https://quotes.example/aapl', title: 'AAPL close', retrieved_at: ret, fields: { symbol: 'AAPL', metric: 'price.close', value: '231.40', unit: 'USD', currency: 'USD', observed_at: '2026-09-21T20:00:00Z' } });++ t.sources.putItem({ source_key: 'test-quotes', kind: 'observation', canonical_url: 'https://quotes.example/aapl2', title: 'AAPL open (no time)', retrieved_at: ret, fields: { symbol: 'AAPL', metric: 'price.open', value: '229.00', unit: 'USD', currency: 'USD' } });++++ await check('mapping: an EDGAR index URL yields CIK, accession, form type and filer; nothing else is guessed', async () => {++ const m = mapItem(filing);++ assert.strictEqual(m.type, 'document');++ assert.strictEqual(m.document.cik, '0000320193');++ assert.strictEqual(m.document.accession, '0000320193-26-000105');++ assert.strictEqual(m.document.form_type, '10-Q');++ assert.strictEqual(m.document.filer_name, 'APPLE INC');++ const noDate = mapItem({ ...filing, id: 'itm_X', published_at: null });++ assert.strictEqual(noDate.document.published_at, null, 'no publication date stays null');++ assert.strictEqual(mapItem({ ...filing, summary: 'APPLE INC quarterly report' }).document.form_type, null, 'not a form type → null');++ assert.strictEqual(mapItem({ ...filing, provenance: { ...filing.provenance, retrieved_at: null } }).type, 'skip');++ });++++ await check('a sync run records matched filings and observations, skips the rest, and advances the cursor', async () => {++ const r = await t.ctx.sync.run();++ assert.strictEqual(r.ok, true, JSON.stringify(r));++ assert.strictEqual(r.counts.documents_created, 1);++ assert.strictEqual(r.counts.observations_created, 1);++ assert.strictEqual(r.counts.skipped_no_matching_instrument, 1, 'MICROSOFT has no instrument here');++ assert.strictEqual(r.counts.skipped_no_observation_time, 1, 'an observation without its own time is not recorded');++ const json = (await t.get('/i/AAPL.json')).json();++ assert.strictEqual(json.documents.length, 1);++ const d = json.documents[0];++ assert.strictEqual(d.form_type, '10-Q');++ assert.strictEqual(d.published_at, '2026-09-22T11:30:00.000Z');++ assert.strictEqual(d.retrieved_at, ret);++ assert.strictEqual(d.source.item_id, filing.id);++ assert.deepStrictEqual(json.observations.map((o) => [o.metric, o.value, o.observed_at]), [['price.close', '231.40', '2026-09-21T20:00:00.000Z']]);++ assert.strictEqual(t.ctx.sync.state().cursor, 4);++ const src = json.sources.find((s) => s.key === 'sec-xbrl-filings');++ assert.strictEqual(src.name, 'SEC EDGAR: latest XBRL filings');++ assert.strictEqual(src.stale_after_sec, 2700);++ });++++ await check('replaying the same Sources pages (cursor reset) creates nothing new', async () => {++ const docs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n;++ const obs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;++ const evts = t.events().length;++ t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();++ const r = await t.ctx.sync.run();++ assert.strictEqual(r.ok, true);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n, docs);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, obs);++ assert.strictEqual(t.events('trade.observation.created').length, 1);++ assert.strictEqual(t.events().length, evts, 'no new events at all');++ });++++ await check('a removed Sources item hides the document (the row stays as the record)', async () => {++ t.sources.remove(filing.id, { at: t.iso(t.T0), reason: 'takedown request' });++ await t.ctx.sync.run();++ assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 0);++ const row = t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);++ assert.strictEqual(row.removed_reason, 'takedown request');++ const feed = await t.get('/i/AAPL/documents.xml');++ assert.ok(!feed.text.includes('10-Q'), 'gone from the feed too');++ });++++ await check('resolution is deterministic: CIK, then ticker, then exact normalised name; several names → ambiguous', async () => {++ const r = (q, k) => t.ctx.instruments.resolve(q, k ? { kind: k } : {});++ assert.strictEqual(r('320193').instrument.symbol, 'AAPL');++ assert.strictEqual(r('CIK0000320193').match.kind, 'cik');++ assert.strictEqual(r('aapl').instrument.symbol, 'AAPL');++ assert.strictEqual(r('Apple Inc').instrument.symbol, 'AAPL');++ assert.strictEqual(r('APPLE, INC.').instrument.symbol, 'AAPL');++ const amb = r('Acme Corporation');++ assert.strictEqual(amb.status, 'ambiguous');++ assert.deepStrictEqual(amb.candidates.map((i) => i.symbol), ['ACME', 'ACMX']);++ assert.strictEqual(amb.instrument, null, 'none is chosen');++ assert.strictEqual(r('Appl').status, 'not_found', 'no prefix or fuzzy matching');++ t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);++ assert.strictEqual(r('APC').match.kind, 'ticker');++ for (let i = 0; i < 3; i++) assert.deepStrictEqual(r('Acme Corporation').candidates.map((x) => x.id), amb.candidates.map((x) => x.id));++ const page = await t.get('/i/APC');++ assert.strictEqual(page.status, 301);++ assert.strictEqual(page.headers.get('location'), '/i/AAPL');++ assert.strictEqual((await t.get('/i/aapl')).headers.get('location'), '/i/AAPL');++ const api = await t.get('/api/v1/instruments/resolve?q=Acme%20Corp', { as: t.network.serviceToken('ai', ['trade.instrument.resolve']) });++ assert.strictEqual(api.json().status, 'ambiguous');++ assert.strictEqual((await t.get('/api/v1/instruments/resolve?q=AAPL', { as: t.network.serviceToken('ai', ['trade.context.read']) })).status, 403);++ });++++ await check('a ticker or CIK names one instrument: collisions are 409', async () => {++ const ed = t.editor;++ assert.strictEqual((await t.get('/api/v1/instruments', { as: { ...ed, role: 'user' }, json: { symbol: 'APC', name: 'Other' } })).status, 409);++ const acme = t.ctx.instruments.bySymbol('ACME');++ assert.throws(() => t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);++ });++++ await check('the signed webhook wakes the sync once per event id; a bad signature is 401', async () => {++ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000106/0000320193-26-000106-index.htm', title: 'APPLE INC', summary: '8-K', published_at: '2026-09-22T12:10:00.000Z', retrieved_at: t.iso(t.T0) });++ const body = JSON.stringify({ event: { event_id: 'evt_01JABCDEFGHJKMNPQRSTVWXYZ0', event_type: 'sources.item.created', payload: {} }, seq: 7 });++ const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': 'sha256=00' } });++ assert.strictEqual(bad.status, 401);++ const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });++ assert.strictEqual(ok.status, 200);++ assert.strictEqual(ok.json().sync, true);++ await t.ctx.sync.run();++ assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 1);++ const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });++ assert.strictEqual(again.json().duplicate, true);++ assert.strictEqual(again.json().sync, false);++ });++++ await check('Trade asks Sources with its own service token for exactly sources.item.read and sources.source.read', async () => {++ const g = t.network.grants.filter((x) => x.audience === 'openvibe.sources');++ assert.ok(g.length >= 1);++ assert.deepStrictEqual(g[0].scope.split(' ').sort(), ['sources.item.read', 'sources.source.read']);++ assert.ok(t.sources.calls.some((c) => /category=trade/.test(c.url) && /include_removed=1/.test(c.url)));++ });++++ 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-trade",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-trade",
+ "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-trade",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Trade — informational market context: instruments and aliases, timestamped market observations with their sources, filings from OpenVibe.Sources, private watchlists and alerts, and reviewed context. Information only: no custody, no order execution, no advice.",
+ "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", "trade", "watchlists", "filings", "informational"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Trade",
+ "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,55 @@
@@ -0,0 +1,55 @@
+/* OpenVibe.Trade — readable tables, 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: 16px/1.6 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: 1040px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.disclaimer { max-width: 1040px; margin: 8px auto 0; padding: 8px 16px; border: 1px solid var(--warning, #f59e0b); border-radius: 8px; font-size: .95rem; background: var(--bg-card, #131c2e); }
+.disclaimer-foot { margin: 24px auto; }
+.page { max-width: 1040px; margin: 0 auto; padding: 16px 16px 48px; min-height: 60vh; }
+h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .5rem; }
+h1 .name { font-weight: 400; color: var(--text-secondary, #96a7c2); font-size: 1.3rem; }
+h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
+.lede { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }
+.meta, .muted, .empty { color: var(--text-muted, #7386a3); font-size: .92rem; }
+.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; }
+.badge.stale { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); font-weight: 600; }
+.badge.fresh { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
+table.data { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: .95rem; }
+table.data caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding-bottom: 4px; }
+table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }
+table.data td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
+tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }
+.docs { list-style: none; padding: 0; }
+.docs li { padding: 8px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.docs li:target, tr:target { outline: 2px solid var(--accent, #3b82f6); }
+.context-body { border-left: 3px solid var(--border-light, #2c3d5c); padding-left: 14px; }
+.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 12px; }
+.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); }
+.revisions { list-style: none; padding: 0; }
+.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: 16px 0; }
+label { display: block; margin: 8px 0 4px; font-weight: 600; font-size: .95rem; }
+fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 10px 0; }
+fieldset label { font-weight: 400; }
+input, textarea, select { font: inherit; color: var(--text-primary); background: var(--bg-input, #0d1424); border: 1px solid var(--border-light, #2c3d5c); border-radius: 6px; padding: 6px 8px; max-width: 100%; }
+textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; }
+input[type=checkbox], input[type=radio] { width: auto; }
+.search input[type=search] { width: min(420px, 100%); }
+button { display: inline-block; margin-top: 8px; font: inherit; font-weight: 600; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 7px 14px; cursor: pointer; }
+button.danger { background: var(--danger, #ef4444); }
+form.inline { display: inline; }
+form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
+.inline-form { margin: 10px 0; }
+.inline-form label { display: inline-block; margin-right: 6px; }
+@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
+@media (max-width: 700px) {
+ h1 { font-size: 1.5rem; }
+ table.data { display: block; overflow-x: auto; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+
+/**
+ * OpenVibe.Trade — 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 (http/pages.js) /, /resolve, /i/:symbol(.json), /sources
+ * Private (http/private.js) /watchlists, watchlist and alert forms
+ * Editor (http/editor.js) /editor/…
+ * Discovery (http/discovery.js) robots, llms, sitemaps, feeds
+ * API (http/api.js) /api/v1/…
+ * Webhook (events/webhook.js) POST /internal/events (signed OpenVibe.Events deliveries)
+ * Machine /api/health, /api/ready, /release.json, /metrics
+ *
+ * Information only (ADR-025): no route takes an order, holds value, runs escrow, lists goods
+ * between users or gives personal advice. test/route-inventory.test.js enforces it.
+ */
+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 { createTradeOutbox } = require('./events/outbox');
+const { createWebhook } = require('./events/webhook');
+const { createUrls } = require('./domain/urls');
+const { createInstruments } = require('./domain/instruments');
+const { createFreshness } = require('./domain/freshness');
+const { createObservations } = require('./domain/observations');
+const { createDocuments } = require('./domain/documents');
+const { createAlerts } = require('./domain/alerts');
+const { createWatchlists } = require('./domain/watchlists');
+const { createContext } = require('./domain/context');
+const { createIndexing } = require('./domain/indexing');
+const { createReading } = require('./domain/reading');
+const { createSync } = require('./domain/sync');
+const { createSourcesClient } = require('./clients/sources');
+const { createCommon } = require('./http/common');
+const { createPages } = require('./http/pages');
+const { createPrivate } = require('./http/private');
+const { createEditor } = require('./http/editor');
+const { createDiscovery } = require('./http/discovery');
+const { createApi } = require('./http/api');
+const { define } = require('./http/routes');
+const { createTradeReadiness } = 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, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), 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 });
+
+ // One context object: domain modules reach each other through it at call time.
+ const ctx = { config, store, log };
+ ctx.urls = createUrls(config);
+ ctx.outbox = createTradeOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
+ ctx.instruments = createInstruments({ store });
+ ctx.freshness = createFreshness({ store, config, outbox: ctx.outbox });
+ ctx.alerts = createAlerts({ store, config, ctx });
+ ctx.observations = createObservations({ store, ctx });
+ ctx.documents = createDocuments({ store, ctx });
+ ctx.watchlists = createWatchlists({ store, config, ctx });
+ ctx.context = createContext({ store, config, ctx });
+ ctx.indexing = createIndexing({ store, config, ctx });
+ ctx.reading = createReading({ store, ctx });
+ ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl });
+ ctx.sync = createSync({ store, config, ctx, sources: ctx.sources, log });
+ ctx.auth = opts.auth || createAuthClient(config);
+ ctx.viewers = createViewerResolver({ auth: ctx.auth, config });
+ ctx.common = createCommon({ config, store });
+ ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, log });
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'trade', root: path.join(__dirname, '..') });
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'trade', 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 ───────────────────────────────────
+ const machine = express.Router();
+ define(machine, 'get', '/api/health', 'health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-trade', version: VERSION }));
+ define(machine, 'get', '/release.json', 'releaseInfo', release.handler);
+ const readiness = createTradeReadiness({ store, auth: ctx.auth, outbox: ctx.outbox, sync: ctx.sync, freshness: ctx.freshness, release: release.release });
+ define(machine, 'get', '/api/ready', 'readiness', readiness.handler);
+ app.use(machine);
+
+ // ── Signed OpenVibe.Events deliveries (before any body parser) ──
+ app.use(createWebhook({ store, config, sync: ctx.sync, log }).router);
+
+ // ── 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, ctx.auth));
+ {
+ const legal = require('openvibe-shared/legal');
+ const legalRouter = express.Router();
+ define(legalRouter, 'get', legal.PATHS, 'legalPage', legal.handler({ id: 'trade', service: 'trade', host: 'openvibe.trade', name: 'OpenVibe.Trade', profile: 'information' }));
+ app.use(legalRouter);
+ }
+
+ // ── 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');
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
+
+ // ── Pages (the viewer is resolved for browsers only; service tokens are not identities here) ──
+ app.use(ctx.viewers.middleware({ services: false }));
+ app.use(createDiscovery(ctx));
+ app.use(['/watchlists', '/alerts', '/editor'], rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPrivate(ctx));
+ app.use(createEditor(ctx));
+ app.use(createPages(ctx));
+ app.use(function pageNotFound(req, res) { ctx.common.notFound(req, res); });
+
+ // eslint-disable-next-line no-unused-vars
+ app.use(function errorHandler(err, req, res, _next) {
+ log.error('[Trade]', 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,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * Capability checks for service tokens (audience openvibe.trade), including the ids Trade
+ * introduces before the contracts library knows them.
+ *
+ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
+ * manifests yet. Trade's 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: watchlists and alert rules by
+ * ownership, editing by the editor list (domain/access.js). A service token is judged by its
+ * capability AND, for private data, by the person it acts for (X-OV-Subject).
+ *
+ * There is deliberately no capability for anything that moves value: ADR-025 (information only).
+ */
+const { capabilities } = require('openvibe-contracts');
+
+const CAPABILITIES = Object.freeze({
+ INSTRUMENT_RESOLVE: 'trade.instrument.resolve',
+ INSTRUMENT_MANAGE: 'trade.instrument.manage',
+ OBSERVATION_WRITE: 'trade.observation.write',
+ CONTEXT_READ: 'trade.context.read',
+ CONTEXT_PROPOSE: 'trade.context.propose',
+ WATCHLIST_READ: 'trade.watchlist.read',
+ WATCHLIST_CREATE: 'trade.watchlist.create',
+ WATCHLIST_UPDATE: 'trade.watchlist.update',
+ WATCHLIST_DELETE: 'trade.watchlist.delete',
+ ALERT_READ: 'trade.alert.read',
+ ALERT_CREATE: 'trade.alert.create',
+ ALERT_DELETE: 'trade.alert.delete',
+});
+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 editor and comment forms (CSRF). The session cookie is
+ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
+ * TRADE_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(`trade-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.trade — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
+//
+// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
+// Trade's client id: registered in the Network as OAuth client `trade`, redirect
+// https://openvibe.trade/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.trade):
+// 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(`[Trade] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
+ return client.publicKey;
+ }
+ } catch (err) {
+ console.warn(`[Trade 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('[Trade 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('[Trade 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('[Trade 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,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * Who is calling, resolved once per request into `req.viewer`:
+ *
+ * { kind: 'anonymous', subject: null, editor: false }
+ * { kind: 'user', subject: 'usr_…'|null, editor, user, token }
+ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the
+ * token's subject_id claim. Editors: role admin, or a subject in TRADE_EDITORS.
+ * 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.trade.
+ * X-OV-Subject names the person it acts for (watchlists and alert rules need one);
+ * X-OV-Origin: ai marks AI output (OpenVibe.AI's trade.summarize_market_context), which is
+ * never attributed to a person and stays a draft 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 PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.trade';
+
+class ViewerError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, editor: false, origin: 'user' });
+
+function createViewerResolver({ auth, config }) {
+ const editors = new Set(config.editors || []);
+
+ 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', editor: 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;
+ const editor = claims.role === 'admin' || Boolean(subject && editors.has(subject));
+ return { kind: 'user', subject, editor, 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 function resolveViewer(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 ownership or the editor list in the domain layer.
+ */
+function guard(cap) {
+ return function capabilityGuard(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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * OpenVibe.Sources, read with Trade's service token (audience openvibe.sources):
+ * sources.item.read GET /api/v1/items?category=trade&after=<change_seq>&include_removed=1
+ * sources.source.read GET /api/v1/sources (health and staleness of every source)
+ * Off until OV_OAUTH_CLIENT_SECRET is set. A failure is an error for the caller to record, never an
+ * empty answer.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {
+ const enabled = Boolean(config.oauth.clientSecret && config.sources.internalUrl);
+ 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 sources.source.read', fetchImpl,
+ }) : null;
+
+ async function get(path) {
+ if (!enabled) throw new Error('Sources client disabled (OV_OAUTH_CLIENT_SECRET unset)');
+ const res = await fetchImpl(`${config.sources.internalUrl}${path}`, {
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
+ signal: AbortSignal.timeout(15_000),
+ });
+ 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})` : ''} for ${path.split('?')[0]}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data;
+ }
+
+ return {
+ enabled,
+ items: ({ after = 0, limit = 200 } = {}) => get(`/api/v1/items?category=${encodeURIComponent(config.sources.category)}&after=${after}&limit=${limit}&include_removed=1`),
+ sources: () => get('/api/v1/sources'),
+ };
+}
+
+module.exports = { createSourcesClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * OpenVibe.Trade configuration. Every value comes from the environment (production:
+ * /etc/openvibe/trade.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 list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4860);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.trade' : `http://localhost:${port}`));
+
+ return {
+ service: 'trade',
+ 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.TRADE_DB_PATH || './data/trade.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 || 'trade',
+ 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.TRADE_FORM_SECRET || '',
+
+ // Editors: Network subjects (usr_…) who may create instruments, write context and review
+ // AI drafts. Network admins are editors too.
+ editors: list(env.TRADE_EDITORS),
+
+ // OpenVibe.Sources: the trade category (filings, market data feeds). Read with a service
+ // token (sources.item.read, sources.source.read). Off until the client secret is set.
+ sources: {
+ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720'),
+ category: 'trade',
+ syncIntervalMs: int(env.TRADE_SYNC_INTERVAL_MS, 60_000),
+ pageLimit: Math.min(Math.max(int(env.TRADE_SYNC_PAGE_LIMIT, 200), 1), 500),
+ maxPagesPerRun: Math.max(int(env.TRADE_SYNC_MAX_PAGES, 20), 1),
+ },
+
+ // Freshness. A source without its own window (Sources' stale_after_sec) uses this one.
+ freshness: {
+ defaultStaleAfterSec: Math.max(int(env.TRADE_DEFAULT_STALE_AFTER_SEC, 3 * 3600), 60),
+ // How old a monetary observation may be before the page is noindex (stale_price).
+ priceMaxAgeMs: Math.max(int(env.TRADE_PRICE_MAX_AGE_SEC, 24 * 3600), 60) * 1000,
+ },
+
+ // Indexability policy for instrument pages (openvibe-publishing/seo gate). Financial pages
+ // are a sensitive category: nothing is indexable before a person-reviewed context exists.
+ gate: { minWords: Math.max(int(env.TRADE_GATE_MIN_WORDS, 60), 0) },
+
+ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // Signed webhook deliveries (sources.* topics) wake the Sources sync early.
+ webhookSecret: env.TRADE_EVENTS_WEBHOOK_SECRET || '',
+ },
+
+ worker: {
+ enabled: env.TRADE_WORKER !== 'off',
+ freshnessIntervalMs: int(env.TRADE_FRESHNESS_INTERVAL_MS, 60_000),
+ },
+
+ // 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'),
+
+ limits: {
+ watchlistsPerSubject: 20,
+ itemsPerWatchlist: 200,
+ alertRulesPerSubject: 100,
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+'use strict';
+
+/**
+ * Trade's own SQLite database: created on boot, idempotently. Nothing here is shared with another
+ * service.
+ *
+ * The nine charter tables (roadmap §15.13):
+ *
+ * trade_instruments Trade-owned symbol, name, kind, exchange, CIK, status, which context
+ * revision is published
+ * trade_instrument_aliases Trade-owned tickers, CIKs and names that resolve to an instrument
+ * trade_watchlists Trade-owned private, one owner subject (usr_…)
+ * trade_watchlist_items Trade-owned instruments on a watchlist
+ * trade_market_observations Trade-owned value, unit, currency, observed_at, source, retrieved_at;
+ * immutable (triggers abort UPDATE and DELETE)
+ * trade_source_documents Trade-owned filings and other documents from OpenVibe.Sources items
+ * trade_alert_rules Trade-owned threshold | filing_type | new_document, private per owner
+ * trade_alert_deliveries Trade-owned one row per (rule, triggering observation or document):
+ * the UNIQUE key is what makes alerts idempotent
+ * trade_context_revisions package openvibe-publishing/revisions, prefix trade_context
+ * (immutable; editor-written or reviewed AI drafts)
+ *
+ * Also here: trade_context_drafts / trade_context_revision_purges / trade_context_reviews (package
+ * companions), trade_index_revisions (Search document sequencer), trade_source_status (freshness
+ * per source), trade_sync_state (the Sources cursor), event_outbox (SDK outbox) and
+ * idempotency_receipts (SDK inbox for signed webhook deliveries).
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { createRevisionStore } = require('openvibe-publishing/revisions');
+const { createReviewLog } = require('openvibe-publishing/authorship');
+const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS trade_instruments (
+ id TEXT PRIMARY KEY, -- ins_<ULID>
+ symbol TEXT NOT NULL UNIQUE, -- canonical display ticker, upper case: /i/:symbol
+ name TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('equity','fund','index','currency','commodity','crypto','other')),
+ exchange TEXT, -- as an editor recorded it; NULL = not recorded
+ cik TEXT UNIQUE, -- SEC Central Index Key, 10 digits zero-padded
+ currency TEXT, -- ISO 4217 trading currency, when recorded
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
+ context_published_revision INTEGER, -- trade_context_revisions.number readers see
+ context_published_at INTEGER,
+ created_by TEXT, -- usr_… or svc:…
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS trade_instrument_aliases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
+ kind TEXT NOT NULL CHECK (kind IN ('ticker','cik','name')),
+ value TEXT NOT NULL, -- as entered
+ normalized TEXT NOT NULL, -- the resolution key (see domain/instruments.js)
+ added_by TEXT,
+ created_at INTEGER NOT NULL,
+ UNIQUE (instrument_id, kind, normalized)
+);
+-- A ticker or CIK names exactly one instrument; a name may be shared (resolution is then ambiguous).
+CREATE UNIQUE INDEX IF NOT EXISTS trade_aliases_unique_key ON trade_instrument_aliases (kind, normalized) WHERE kind IN ('ticker','cik');
+CREATE INDEX IF NOT EXISTS trade_aliases_lookup ON trade_instrument_aliases (kind, normalized);
+
+CREATE TABLE IF NOT EXISTS trade_watchlists (
+ id TEXT PRIMARY KEY, -- wl_<ULID>
+ owner_subject TEXT NOT NULL, -- usr_…; never shown to anyone else
+ name TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE (owner_subject, name)
+);
+
+CREATE TABLE IF NOT EXISTS trade_watchlist_items (
+ watchlist_id TEXT NOT NULL REFERENCES trade_watchlists(id) ON DELETE CASCADE,
+ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
+ note TEXT,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (watchlist_id, instrument_id)
+);
+
+CREATE TABLE IF NOT EXISTS trade_market_observations (
+ id TEXT PRIMARY KEY, -- obs_<ULID>
+ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
+ metric TEXT NOT NULL, -- e.g. price.close, volume, us-gaap:Revenues
+ value TEXT NOT NULL, -- the decimal exactly as the source stated it
+ value_num REAL NOT NULL, -- the same value, for comparisons only
+ unit TEXT NOT NULL, -- USD, shares, USD/share, pure, …
+ currency TEXT, -- ISO 4217 when the value is monetary
+ period TEXT, -- e.g. 2026-Q2, when the source states one
+ observed_at INTEGER NOT NULL, -- when the value was true, per the source
+ source_key TEXT NOT NULL, -- OpenVibe.Sources source key
+ source_item_id TEXT, -- itm_… when it came from a Sources item
+ source_url TEXT,
+ source_ref TEXT NOT NULL, -- the source's own identity of this datum
+ retrieved_at INTEGER NOT NULL, -- when the source was fetched
+ max_age_sec INTEGER, -- the value is stale after this long (NULL: only the source's freshness)
+ recorded_by TEXT NOT NULL, -- svc:… (a feed) or usr_… (never inferred)
+ recorded_at INTEGER NOT NULL,
+ UNIQUE (source_key, source_ref)
+);
+CREATE INDEX IF NOT EXISTS trade_obs_series ON trade_market_observations (instrument_id, metric, observed_at);
+CREATE TRIGGER IF NOT EXISTS trade_obs_no_update BEFORE UPDATE ON trade_market_observations
+ BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;
+CREATE TRIGGER IF NOT EXISTS trade_obs_no_delete BEFORE DELETE ON trade_market_observations
+ BEGIN SELECT RAISE(ABORT, 'trade_market_observations rows are immutable'); END;
+
+CREATE TABLE IF NOT EXISTS trade_source_documents (
+ id TEXT PRIMARY KEY, -- doc_<ULID>
+ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
+ source_key TEXT NOT NULL,
+ source_item_id TEXT NOT NULL UNIQUE, -- itm_…: Sources owns the item
+ source_revision INTEGER NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('filing','article','record')),
+ form_type TEXT, -- 10-K, 8-K, … when the source states it
+ title TEXT,
+ filer_name TEXT,
+ cik TEXT,
+ accession TEXT,
+ url TEXT,
+ published_at INTEGER, -- the source's own date; NULL if it gave none
+ source_updated_at INTEGER,
+ retrieved_at INTEGER NOT NULL,
+ first_seen_at INTEGER NOT NULL,
+ license_note TEXT,
+ terms_note TEXT,
+ removed_at INTEGER,
+ removed_reason TEXT,
+ recorded_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS trade_docs_instrument ON trade_source_documents (instrument_id, removed_at, published_at);
+
+CREATE TABLE IF NOT EXISTS trade_alert_rules (
+ id TEXT PRIMARY KEY, -- alr_<ULID>
+ owner_subject TEXT NOT NULL,
+ instrument_id TEXT NOT NULL REFERENCES trade_instruments(id),
+ kind TEXT NOT NULL CHECK (kind IN ('threshold','filing_type','new_document')),
+ metric TEXT,
+ operator TEXT CHECK (operator IN ('above','below')),
+ threshold TEXT,
+ threshold_num REAL,
+ unit TEXT,
+ currency TEXT,
+ form_types TEXT, -- JSON array (filing_type rules)
+ armed INTEGER NOT NULL DEFAULT 1, -- threshold rules fire on a crossing, then re-arm
+ last_observed_at INTEGER, -- newest observation a threshold rule evaluated
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','deleted')),
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ CHECK (kind <> 'threshold' OR (metric IS NOT NULL AND operator IS NOT NULL AND threshold IS NOT NULL AND unit IS NOT NULL)),
+ CHECK (kind <> 'filing_type' OR form_types IS NOT NULL)
+);
+CREATE INDEX IF NOT EXISTS trade_rules_owner ON trade_alert_rules (owner_subject, status);
+CREATE INDEX IF NOT EXISTS trade_rules_instrument ON trade_alert_rules (instrument_id, status, kind);
+
+CREATE TABLE IF NOT EXISTS trade_alert_deliveries (
+ id TEXT PRIMARY KEY, -- ald_<ULID>
+ rule_id TEXT NOT NULL REFERENCES trade_alert_rules(id),
+ owner_subject TEXT NOT NULL,
+ trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('observation','document')),
+ trigger_id TEXT NOT NULL,
+ event_id TEXT, -- the trade.alert.triggered envelope
+ summary TEXT NOT NULL, -- JSON snapshot of what triggered it
+ created_at INTEGER NOT NULL,
+ UNIQUE (rule_id, trigger_kind, trigger_id)
+);
+CREATE INDEX IF NOT EXISTS trade_deliveries_owner ON trade_alert_deliveries (owner_subject, created_at);
+
+-- Freshness of each source Trade shows data from (not a charter table; a projection of Sources'
+-- health plus Trade's own clock).
+CREATE TABLE IF NOT EXISTS trade_source_status (
+ source_key TEXT PRIMARY KEY,
+ name TEXT,
+ upstream_status TEXT, -- Sources' health status, NULL = never reported
+ last_success_at INTEGER, -- last successful fetch (Sources, or the newest retrieval Trade recorded)
+ stale_after_sec INTEGER,
+ reported_at INTEGER, -- when Sources last told us about it
+ stale INTEGER NOT NULL DEFAULT 1, -- as of the last evaluation
+ stale_since INTEGER,
+ evaluated_at INTEGER,
+ terms_note TEXT,
+ license_note TEXT
+);
+
+CREATE TABLE IF NOT EXISTS trade_sync_state (
+ name TEXT PRIMARY KEY,
+ cursor INTEGER NOT NULL DEFAULT 0,
+ last_ok_at INTEGER,
+ last_error TEXT,
+ last_error_at INTEGER,
+ counts TEXT NOT NULL DEFAULT '{}'
+);
+`;
+
+/**
+ * Open (or create) the database and the package stores on it.
+ * opts.now — injectable clock (epoch ms) shared by everything, so tests 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,
+ revisions: createRevisionStore(db, { prefix: 'trade_context', now }),
+ reviews: createReviewLog(db, { prefix: 'trade_context', now }),
+ sequencer: createIndexSequencer(db, { prefix: 'trade', now }),
+ tx: (fn) => db.transaction(fn)(),
+ close: () => db.close(),
+ };
+}
+
+const CHARTER_TABLES = ['trade_instruments', 'trade_instrument_aliases', 'trade_watchlists', 'trade_watchlist_items',
+ 'trade_market_observations', 'trade_source_documents', 'trade_alert_rules', 'trade_alert_deliveries', 'trade_context_revisions'];
+
+module.exports = { openStore, CHARTER_TABLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+
+/**
+ * Alert rules and their deliveries. Rules are private: one owner (usr_…), never shown to anyone
+ * else, never indexed, never in a feed.
+ *
+ * threshold metric + above|below + threshold + unit (+ currency). Fires on a CROSSING: when
+ * a new observation (newer than the last one the rule evaluated, matching metric,
+ * unit and currency exactly — there is no conversion) meets the condition while
+ * the rule is armed; it re-arms when a newer observation no longer meets it.
+ * Older observations arriving late are recorded but never trigger.
+ * filing_type a newly seen document of the instrument whose form type is in the list
+ * new_document any newly seen document of the instrument
+ *
+ * Idempotency: a delivery is the row (rule, trigger kind, trigger id) under a UNIQUE key, written
+ * in the same transaction as the observation or document that triggered it and as its
+ * `trade.alert.triggered` event. Re-processing the same observation or document (a replayed
+ * sync, a retried request, a second worker) inserts nothing and emits nothing. No email is sent:
+ * the event (visibility subject, subject = the owner) is for Notifications and the owner's own
+ * realtime stream. Following ADR-020, the payload is data (rule, instrument, trigger with its
+ * timestamps and source), never rendered notification text: Network renders the notification.
+ */
+const { ApiError } = require('../http/errors');
+const { newId, iso, parseDecimal, invalid, str, json } = require('./util');
+const { formType } = require('./mapping');
+
+const KINDS = ['threshold', 'filing_type', 'new_document'];
+const DISCLAIMER = 'Information only — not investment advice; no trading here.';
+
+function createAlerts({ store, config, ctx }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM trade_alert_rules WHERE id = ?'),
+ insert: db.prepare(`INSERT INTO trade_alert_rules (id, owner_subject, instrument_id, kind, metric, operator, threshold, threshold_num, unit, currency, form_types, created_at, updated_at)
+ VALUES (@id, @owner, @instrument_id, @kind, @metric, @operator, @threshold, @threshold_num, @unit, @currency, @form_types, @now, @now)`),
+ countOwner: db.prepare("SELECT COUNT(*) AS n FROM trade_alert_rules WHERE owner_subject = ? AND status = 'active'"),
+ forOwner: db.prepare("SELECT r.*, i.symbol, i.name AS instrument_name FROM trade_alert_rules r JOIN trade_instruments i ON i.id = r.instrument_id WHERE r.owner_subject = ? AND r.status = 'active' ORDER BY r.created_at DESC"),
+ remove: db.prepare("UPDATE trade_alert_rules SET status = 'deleted', updated_at = ? WHERE id = ? AND status = 'active'"),
+ thresholds: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind = 'threshold' AND metric = ? AND unit = ?"),
+ docRules: db.prepare("SELECT * FROM trade_alert_rules WHERE instrument_id = ? AND status = 'active' AND kind IN ('filing_type','new_document')"),
+ setState: db.prepare('UPDATE trade_alert_rules SET armed = @armed, last_observed_at = @observed, updated_at = @now WHERE id = @id'),
+ deliver: db.prepare(`INSERT OR IGNORE INTO trade_alert_deliveries (id, rule_id, owner_subject, trigger_kind, trigger_id, summary, created_at)
+ VALUES (@id, @rule_id, @owner, @kind, @trigger_id, @summary, @now)`),
+ setEvent: db.prepare('UPDATE trade_alert_deliveries SET event_id = ? WHERE id = ?'),
+ deliveries: db.prepare(`SELECT d.*, r.kind AS rule_kind FROM trade_alert_deliveries d JOIN trade_alert_rules r ON r.id = d.rule_id
+ WHERE d.owner_subject = ? ORDER BY d.created_at DESC, d.id DESC LIMIT ?`),
+ countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_alert_deliveries WHERE rule_id = ?'),
+ };
+
+ function ruleDto(r, instrument) {
+ const ins = instrument || ctx.instruments.get(r.instrument_id);
+ return {
+ id: r.id, kind: r.kind, status: r.status,
+ instrument: ins ? { id: ins.id, symbol: ins.symbol, name: ins.name } : { id: r.instrument_id },
+ metric: r.metric, operator: r.operator, threshold: r.threshold, unit: r.unit, currency: r.currency,
+ form_types: r.form_types ? json(r.form_types, []) : null,
+ armed: r.kind === 'threshold' ? Boolean(r.armed) : null,
+ created_at: iso(r.created_at), updated_at: iso(r.updated_at),
+ };
+ }
+
+ function deliver(rule, instrument, kind, trigger, summary, { traceparent } = {}) {
+ const id = newId('ald', store.now());
+ const info = q.deliver.run({ id, rule_id: rule.id, owner: rule.owner_subject, kind, trigger_id: trigger.id, summary: JSON.stringify(summary), now: store.now() });
+ if (!info.changes) return null; // already delivered for this (rule, trigger)
+ const env = ctx.outbox.emit({
+ event_type: 'trade.alert.triggered', actor: { type: 'service', id: 'trade' }, visibility: 'subject', priority: 'important',
+ subject: { type: 'user', id: rule.owner_subject },
+ payload: {
+ delivery_id: id,
+ rule: ruleDto(rule, instrument),
+ instrument: { id: instrument.id, symbol: instrument.symbol, name: instrument.name, url: ctx.urls.instrument(instrument) },
+ trigger: { kind, id: trigger.id, ...summary },
+ disclaimer: DISCLAIMER,
+ },
+ }, { traceparent });
+ q.setEvent.run(env.event_id, id);
+ return { id, event_id: env.event_id };
+ }
+
+ const api = {
+ KINDS,
+ DISCLAIMER,
+ get: (id) => q.byId.get(id) || null,
+ dto: ruleDto,
+
+ forOwner(subject) { return q.forOwner.all(subject).map((r) => ruleDto(r, { id: r.instrument_id, symbol: r.symbol, name: r.instrument_name })); },
+
+ deliveries(subject, limit = 50) {
+ return q.deliveries.all(subject, Math.min(Math.max(Number(limit) || 50, 1), 200)).map((d) => ({
+ id: d.id, rule_id: d.rule_id, rule_kind: d.rule_kind, trigger: { kind: d.trigger_kind, id: d.trigger_id, ...json(d.summary, {}) },
+ event_id: d.event_id, created_at: iso(d.created_at),
+ }));
+ },
+ deliveryCount: (ruleId) => q.countFor.get(ruleId).n,
+
+ create(subject, instrument, input) {
+ if (!subject) throw new ApiError(403, 'subject.required', 'Alert rules belong to a person');
+ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
+ const kind = String(input.kind || '');
+ if (!KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);
+ if (q.countOwner.get(subject).n >= config.limits.alertRulesPerSubject) throw new ApiError(429, 'alert.limit', `At most ${config.limits.alertRulesPerSubject} alert rules`);
+ const row = { id: newId('alr', store.now()), owner: subject, instrument_id: instrument.id, kind, metric: null, operator: null, threshold: null, threshold_num: null, unit: null, currency: null, form_types: null, now: store.now() };
+ if (kind === 'threshold') {
+ row.metric = str(input.metric, 80, 'metric', { required: true });
+ if (!['above', 'below'].includes(input.operator)) throw invalid('operator must be above or below');
+ row.operator = input.operator;
+ const t = parseDecimal(input.threshold);
+ if (!t) throw invalid('threshold must be a finite decimal');
+ row.threshold = t.text; row.threshold_num = t.num;
+ row.unit = str(input.unit, 20, 'unit', { required: true });
+ const c = input.currency == null || input.currency === '' ? null : String(input.currency).trim().toUpperCase();
+ if (c && !/^[A-Z]{3}$/.test(c)) throw invalid('currency must be an ISO 4217 code like USD');
+ row.currency = c;
+ } else if (kind === 'filing_type') {
+ const raw = Array.isArray(input.form_types) ? input.form_types : String(input.form_types || '').split(',');
+ const list = [...new Set(raw.map((x) => formType(String(x))).filter(Boolean))];
+ if (!list.length || list.length > 10) throw invalid('form_types must list 1–10 form types, e.g. 10-K, 10-Q, 8-K');
+ row.form_types = JSON.stringify(list);
+ }
+ q.insert.run(row);
+ return q.byId.get(row.id);
+ },
+
+ remove(subject, id) {
+ const r = q.byId.get(id);
+ // Someone else's rule is "not found": its existence is not disclosed.
+ if (!r || r.owner_subject !== subject || r.status !== 'active') throw new ApiError(404, 'alert.not_found', 'No such alert rule');
+ q.remove.run(store.now(), id);
+ return true;
+ },
+
+ /** Inside the transaction that recorded `obs`. */
+ onObservation(obs, instrument, opts = {}) {
+ const out = [];
+ for (const rule of q.thresholds.all(instrument.id, obs.metric, obs.unit)) {
+ if (rule.currency && rule.currency !== obs.currency) continue;
+ if (rule.created_at > obs.recorded_at) continue;
+ if (rule.last_observed_at != null && obs.observed_at <= rule.last_observed_at) continue; // late arrival
+ const meets = rule.operator === 'above' ? obs.value_num > rule.threshold_num : obs.value_num < rule.threshold_num;
+ let armed = rule.armed;
+ if (meets && armed) {
+ const d = deliver(rule, instrument, 'observation', obs, {
+ metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency,
+ observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), source_key: obs.source_key, source_url: obs.source_url,
+ }, opts);
+ if (d) out.push(d);
+ armed = 0;
+ } else if (!meets) {
+ armed = 1;
+ }
+ q.setState.run({ id: rule.id, armed, observed: obs.observed_at, now: store.now() });
+ }
+ return out;
+ },
+
+ /** Inside the transaction that first recorded `doc`. */
+ onDocument(doc, instrument, opts = {}) {
+ const out = [];
+ for (const rule of q.docRules.all(instrument.id)) {
+ if (rule.kind === 'filing_type') {
+ const types = json(rule.form_types, []);
+ if (!doc.form_type || !types.includes(doc.form_type)) continue;
+ }
+ const d = deliver(rule, instrument, 'document', doc, {
+ form_type: doc.form_type, title: doc.title, url: doc.url, published_at: iso(doc.published_at),
+ retrieved_at: iso(doc.retrieved_at), source_key: doc.source_key,
+ }, opts);
+ if (d) out.push(d);
+ }
+ return out;
+ },
+ };
+ return api;
+}
+
+module.exports = { createAlerts, DISCLAIMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,291 @@
@@ -0,0 +1,291 @@
+'use strict';
+
+/**
+ * Context: the short, sourced explanation shown on an instrument page, as immutable revisions
+ * (openvibe-publishing/revisions, prefix trade_context → trade_context_revisions) that cite
+ * Trade's own documents and observations.
+ *
+ * editor-written a person on the editor list (TRADE_EDITORS or a Network admin) writes it;
+ * authorship human; publishable at once
+ * AI draft OpenVibe.AI's trade.summarize_market_context output, delivered by the AI
+ * service (X-OV-Origin: ai). Authorship ai with the workflow run, never
+ * attributed to a person, stored as a draft: it cannot be published — and the
+ * page stays noindex — until a person records an approving review
+ * (openvibe-publishing/authorship). Always labelled on the page and in JSON.
+ *
+ * Every revision records what it cites ({ kind: document|observation, id }) and as_of: the newest
+ * observation or document time among the citations (never the time of writing). Text that reads as
+ * personalised advice or a recommendation to trade is refused (ADR-025): Trade is information only.
+ */
+const authorship = require('openvibe-publishing/authorship');
+const ssr = require('openvibe-publishing/ssr');
+const { ApiError } = require('../http/errors');
+const { iso, invalid } = require('./util');
+
+const WORKFLOW = 'trade.summarize_market_context';
+const ADVICE_RE = /\b(you should (buy|sell|hold|invest|short)|we recommend|i recommend|recommend(s|ed)? (buying|selling|holding)|strong (buy|sell)|(buy|sell|hold|outperform|underperform) rating|price target|guaranteed (return|profit)s?)\b/i;
+const MAX_BODY = 20000;
+
+function createContext({ store, config, ctx }) {
+ const { db } = store;
+ const editors = new Set(config.editors || []);
+ const setPublished = db.prepare('UPDATE trade_instruments SET context_published_revision = ?, context_published_at = ?, updated_at = ? WHERE id = ?');
+
+ function refuseAdvice(text) {
+ if (ADVICE_RE.test(text)) {
+ throw new ApiError(422, 'context.advice_refused', 'Trade publishes information only: context cannot recommend buying, selling or holding, or give price targets (ADR-025)');
+ }
+ }
+
+ /** Validate cited refs: they must be this instrument's documents (not removed) or observations. */
+ function resolveCites(instrument, cites) {
+ if (cites == null) return [];
+ if (!Array.isArray(cites) || cites.length > 50) throw invalid('cites must be a list of at most 50 { kind, id }');
+ const seen = new Set();
+ const out = [];
+ for (const c of cites) {
+ const kind = c && c.kind;
+ const id = c && String(c.id || '');
+ if (kind === 'document') {
+ const d = ctx.documents.get(id);
+ if (!d || d.instrument_id !== instrument.id || d.removed_at) throw invalid(`document ${id} is not a current document of ${instrument.symbol}`, 'context.bad_citation');
+ } else if (kind === 'observation') {
+ const o = ctx.observations.get(id);
+ if (!o || o.instrument_id !== instrument.id) throw invalid(`observation ${id} is not an observation of ${instrument.symbol}`, 'context.bad_citation');
+ } else {
+ throw invalid('each citation is { kind: "document" | "observation", id }', 'context.bad_citation');
+ }
+ const key = `${kind}:${id}`;
+ if (!seen.has(key)) { seen.add(key); out.push({ kind, id }); }
+ }
+ return out;
+ }
+
+ function asOf(cites) {
+ let t = null;
+ for (const c of cites) {
+ let v = null;
+ if (c.kind === 'observation') { const o = ctx.observations.get(c.id); v = o && o.observed_at; }
+ else { const d = ctx.documents.get(c.id); v = d && (d.published_at || d.retrieved_at); }
+ if (v != null && (t == null || v > t)) t = v;
+ }
+ return iso(t);
+ }
+
+ function isEditorSubject(viewer) {
+ if (viewer.kind === 'user') return Boolean(viewer.editor && viewer.subject);
+ if (viewer.kind === 'service') return Boolean(viewer.origin !== 'ai' && viewer.subject && editors.has(viewer.subject));
+ return false;
+ }
+
+ function create(instrument, { content, fields, rec, author, message, expectedRevision }) {
+ const head = store.revisions.headNumber(instrument.id);
+ const expected = expectedRevision == null ? head : Number(expectedRevision);
+ if (!Number.isInteger(expected) || expected < 0) throw invalid('expected_revision must be a revision number');
+ return store.revisions.create({ entityId: instrument.id, expectedRevision: expected, content, fields, meta: { authorship: rec }, author, message, allowUnchanged: true }).revision;
+ }
+
+ /** AI output → markdown. Citations are indices into input_sources (what the AI was given). */
+ function fromAiOutput(instrument, body) {
+ const wf = body.workflow || {};
+ if (wf.id !== WORKFLOW) throw invalid(`AI context must come from the ${WORKFLOW} workflow`, 'context.wrong_workflow');
+ const runId = wf.run_id || wf.runId;
+ if (!runId || typeof runId !== 'string') throw invalid('workflow.run_id is required', 'authorship.workflow_required');
+ const out = body.output;
+ if (!out || typeof out !== 'object' || typeof out.summary !== 'string' || !out.summary.trim()) throw invalid('output.summary is required');
+ const sources = Array.isArray(body.input_sources) ? body.input_sources : [];
+ const refs = sources.map((s) => {
+ const t = s && s.source_type;
+ const kind = t === 'trade.document' ? 'document' : t === 'trade.observation' ? 'observation' : null;
+ if (!kind) throw invalid('input_sources entries are { source_type: trade.document | trade.observation, source_id }', 'context.bad_citation');
+ return { kind, id: String(s.source_id || '') };
+ });
+ const refOf = (i) => {
+ if (!Number.isInteger(i) || i < 0 || i >= refs.length) throw invalid(`citation ${i} is not one of the ${refs.length} input sources`, 'context.bad_citation');
+ return refs[i];
+ };
+ const all = [];
+ const cite = (list) => (Array.isArray(list) ? list.map((i) => { const r = refOf(i); all.push(r); return `[${i + 1}]`; }).join('') : '');
+ const lines = [String(out.summary).trim().slice(0, 3000)];
+ cite(out.citations);
+ const obs = Array.isArray(out.observations) ? out.observations.slice(0, 30) : [];
+ if (obs.length) {
+ lines.push('', '**What the sources state**', '');
+ for (const o of obs) {
+ const when = typeof o.observed_at === 'string' && o.observed_at ? ` (as of ${o.observed_at.slice(0, 40)})` : ' (the source states no time)';
+ lines.push(`- ${String(o.text || '').slice(0, 600)}${when} ${cite(o.citations)}`.trimEnd());
+ }
+ }
+ const gaps = Array.isArray(out.gaps) ? out.gaps.slice(0, 20).map((g) => String(g).slice(0, 300)) : [];
+ if (gaps.length) {
+ lines.push('', '**Not covered by the sources**', '');
+ for (const g of gaps) lines.push(`- ${g}`);
+ }
+ if (refs.length) {
+ lines.push('', '**Sources given to the model**', '');
+ refs.forEach((r, i) => lines.push(`${i + 1}. ${r.kind} ${r.id}`));
+ }
+ const rec = authorship.record({
+ mode: 'ai',
+ workflow: { id: WORKFLOW, runId, ...(wf.version != null ? { version: wf.version } : {}), ...(wf.model ? { model: String(wf.model) } : {}) },
+ stubProvider: body.stub_provider === true,
+ source: { label: `${instrument.symbol} documents and observations on OpenVibe.Trade` },
+ });
+ resolveCites(instrument, refs); // every source the model was given must exist
+ return { content: lines.join('\n'), cites: resolveCites(instrument, all), rec, gaps };
+ }
+
+ const api = {
+ WORKFLOW,
+ isEditorSubject,
+
+ head: (instrument) => store.revisions.head(instrument.id),
+ revision: (instrument, n) => store.revisions.get(instrument.id, n),
+ revisions: (instrument, opts) => store.revisions.list(instrument.id, opts),
+
+ /**
+ * A new context revision. Editors: { body, cites?, expected_revision?, publish? }.
+ * OpenVibe.AI (service, X-OV-Origin: ai): { workflow: { id, run_id, version?, model? },
+ * input_sources: [{ source_type, source_id }], output: { summary, observations, gaps, citations },
+ * stub_provider? } — always a draft.
+ */
+ propose(viewer, instrument, body = {}) {
+ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
+ if (viewer.kind === 'service' && viewer.origin === 'ai') {
+ const { content, cites, rec, gaps } = fromAiOutput(instrument, body);
+ refuseAdvice(content);
+ const fields = { as_of: asOf(cites), cites, origin: 'ai', gaps };
+ const revision = store.tx(() => create(instrument, { content, fields, rec, author: viewer.service, message: 'AI draft (needs a person\'s review)' }));
+ return { revision, published: false };
+ }
+ if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors write context');
+ const text = typeof body.body === 'string' ? body.body.replace(/\r\n/g, '\n').trim() : '';
+ if (!text) throw invalid('body is required');
+ if (text.length > MAX_BODY) throw invalid(`body is longer than ${MAX_BODY} characters`);
+ refuseAdvice(text);
+ const cites = resolveCites(instrument, body.cites);
+ const rec = authorship.record({ mode: 'human', authors: [viewer.subject] });
+ const fields = { as_of: asOf(cites), cites, origin: 'editor' };
+ return store.tx(() => {
+ const revision = create(instrument, { content: text, fields, rec, author: viewer.subject, message: body.message ? String(body.message).slice(0, 300) : null, expectedRevision: body.expected_revision });
+ if (body.publish === true || body.publish === 'on' || body.publish === '1') {
+ api.publish(instrument, revision.number);
+ return { revision, published: true };
+ }
+ return { revision, published: false };
+ });
+ },
+
+ /** A person's review. Approving an AI draft publishes it. */
+ review(viewer, instrument, number, { decision, note } = {}) {
+ if (viewer.kind !== 'user' || !viewer.editor || !viewer.subject) throw new ApiError(403, 'review.forbidden', 'Only a person on the editor list can review context');
+ const rev = store.revisions.get(instrument.id, Number(number));
+ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
+ return store.tx(() => {
+ const review = store.reviews.record({ entityId: instrument.id, revision: rev.number, reviewer: viewer.subject, decision, note });
+ let published = false;
+ if (decision === 'approved') { api.publish(instrument, rev.number); published = true; }
+ return { review, published };
+ });
+ },
+
+ publish(instrument, number) {
+ const rev = store.revisions.get(instrument.id, Number(number));
+ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such context revision');
+ const rec = rev.meta.authorship;
+ const ok = authorship.canPublish(rec, store.reviews.latest(instrument.id, rev.number));
+ if (!ok.ok) throw new ApiError(409, 'context.review_required', 'AI-generated context needs an approving review by a person before it is published');
+ return store.tx(() => {
+ setPublished.run(rev.number, store.now(), store.now(), instrument.id);
+ ctx.indexing.refresh(ctx.instruments.get(instrument.id));
+ return rev;
+ });
+ },
+
+ retract(viewer, instrument) {
+ if (!isEditorSubject(viewer)) throw new ApiError(403, 'context.forbidden', 'Only Trade editors retract context');
+ return store.tx(() => {
+ setPublished.run(null, null, store.now(), instrument.id);
+ ctx.indexing.refresh(ctx.instruments.get(instrument.id));
+ return true;
+ });
+ },
+
+ /** The published revision's view, or null. */
+ published(instrument) {
+ if (!instrument.context_published_revision) return null;
+ const rev = store.revisions.get(instrument.id, instrument.context_published_revision);
+ return rev ? api.view(instrument, rev) : null;
+ },
+
+ /** Revisions after the published one (drafts awaiting review or publication). */
+ pending(instrument) {
+ const published = instrument.context_published_revision || 0;
+ return store.revisions.list(instrument.id, { limit: 20 }).filter((r) => r.number > published).map((r) => api.view(instrument, r));
+ },
+
+ view(instrument, rev) {
+ const rec = rev.meta.authorship || null;
+ const review = store.reviews.latest(instrument.id, rev.number);
+ const cites = (rev.fields.cites || []).map((c) => {
+ if (c.kind === 'observation') {
+ const o = ctx.observations.get(c.id);
+ return o ? { kind: c.kind, id: c.id, observation: ctx.observations.dto(o) } : { kind: c.kind, id: c.id, missing: true };
+ }
+ const d = ctx.documents.get(c.id);
+ return d && !d.removed_at ? { kind: c.kind, id: c.id, document: ctx.documents.dto(d) } : { kind: c.kind, id: c.id, missing: true };
+ });
+ return {
+ revision: rev.number,
+ published: instrument.context_published_revision === rev.number,
+ published_at: instrument.context_published_revision === rev.number ? iso(instrument.context_published_at) : null,
+ written_at: rev.createdAt,
+ as_of: rev.fields.as_of || null,
+ origin: rev.fields.origin || null,
+ body_markdown: rev.content,
+ body_html: ssr.renderMarkdown(rev.content),
+ text: ssr.markdownToText(rev.content),
+ cites,
+ authorship: rec,
+ disclosure: rec ? authorship.disclosure(rec, review) : null,
+ review: review ? { decision: review.decision, reviewed_at: review.reviewedAt, reviewer: review.reviewer } : null,
+ needs_review: rec ? !authorship.canPublish(rec, review).ok : false,
+ };
+ },
+
+ /** The input OpenVibe.AI's trade.summarize_market_context expects, numbered sources included. */
+ input(instrument) {
+ const sources = [];
+ for (const o of ctx.observations.latest(instrument)) {
+ const dto = ctx.observations.dto(o);
+ sources.push({
+ source_type: 'trade.observation', source_id: o.id, url: o.source_url || undefined,
+ title: `${o.metric} of ${instrument.symbol}`,
+ observed_at: dto.observed_at, retrieved_at: dto.retrieved_at,
+ snippet: `${o.metric} = ${o.value} ${o.unit}${o.currency && o.currency !== o.unit ? ` (${o.currency})` : ''}${o.period ? `, period ${o.period}` : ''}`,
+ provenance: { source_key: o.source_key, stale: dto.freshness.stale, stale_since: dto.freshness.stale_since },
+ });
+ }
+ for (const d of ctx.documents.forInstrument(instrument, { limit: 20 })) {
+ const fresh = ctx.freshness.view(d.source_key);
+ sources.push({
+ source_type: 'trade.document', source_id: d.id, url: d.url || undefined, title: d.title || undefined,
+ published_at: iso(d.published_at) || undefined, retrieved_at: iso(d.retrieved_at),
+ snippet: [d.form_type, d.filer_name, d.accession].filter(Boolean).join(' · ') || undefined,
+ provenance: { source_key: d.source_key, stale: fresh.stale, stale_since: fresh.stale_since },
+ });
+ }
+ const clean = sources.map((s) => JSON.parse(JSON.stringify(s)));
+ const windows = [...new Set(clean.map((s) => ctx.freshness.view(s.provenance.source_key).stale_after_sec))];
+ const hours = Math.max(1, Math.ceil((windows.length ? Math.min(...windows) : config.freshness.defaultStaleAfterSec) / 3600));
+ return {
+ workflow: WORKFLOW,
+ input: { instrument: { symbol: instrument.symbol, name: instrument.name, ...(instrument.exchange ? { exchange: instrument.exchange } : {}) }, stale_after_hours: hours, sources: clean },
+ deliver_to: { method: 'POST', path: `/api/v1/instruments/${encodeURIComponent(instrument.symbol)}/context`, headers: { 'X-OV-Origin': 'ai' } },
+ };
+ },
+ };
+ return api;
+}
+
+module.exports = { createContext, WORKFLOW, ADVICE_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Source documents (filings and other documents) linked from OpenVibe.Sources items. Sources owns
+ * the item; Trade keeps the metadata it shows (form type, filer, dates, URL, provenance) keyed by
+ * the item id, follows the item's revisions, and hides the document when Sources removes the item
+ * (takedown or licence), keeping the row as the record that it happened.
+ *
+ * A newly seen document is what new_document and filing_type alert rules trigger on (once per rule
+ * and document: alerts.onDocument). Item revisions update the row and never trigger again.
+ */
+const { newId, iso } = require('./util');
+
+function createDocuments({ store, ctx }) {
+ const { db } = store;
+ const q = {
+ byItem: db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?'),
+ byId: db.prepare('SELECT * FROM trade_source_documents WHERE id = ?'),
+ insert: db.prepare(`INSERT INTO trade_source_documents (id, instrument_id, source_key, source_item_id, source_revision, kind, form_type, title, filer_name,
+ cik, accession, url, published_at, source_updated_at, retrieved_at, first_seen_at, license_note, terms_note, recorded_at, updated_at)
+ VALUES (@id, @instrument_id, @source_key, @source_item_id, @source_revision, @kind, @form_type, @title, @filer_name,
+ @cik, @accession, @url, @published_at, @source_updated_at, @retrieved_at, @first_seen_at, @license_note, @terms_note, @now, @now)`),
+ revise: db.prepare(`UPDATE trade_source_documents SET source_revision = @source_revision, form_type = @form_type, title = @title, filer_name = @filer_name,
+ url = @url, published_at = @published_at, source_updated_at = @source_updated_at, retrieved_at = @retrieved_at,
+ license_note = @license_note, terms_note = @terms_note, updated_at = @now WHERE id = @id`),
+ touch: db.prepare('UPDATE trade_source_documents SET retrieved_at = @retrieved_at, updated_at = @now WHERE id = @id AND retrieved_at < @retrieved_at'),
+ remove: db.prepare('UPDATE trade_source_documents SET removed_at = @at, removed_reason = @reason, updated_at = @now WHERE id = @id AND removed_at IS NULL'),
+ forInstrument: db.prepare(`SELECT * FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL
+ ORDER BY COALESCE(published_at, first_seen_at) DESC, id DESC LIMIT ? OFFSET ?`),
+ countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL'),
+ recent: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
+ WHERE d.removed_at IS NULL AND i.status = 'active' ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT ?`),
+ };
+
+ const api = {
+ get: (id) => q.byId.get(id) || null,
+ byItem: (itemId) => q.byItem.get(itemId) || null,
+ forInstrument: (instrument, { limit = 50, offset = 0 } = {}) => q.forInstrument.all(instrument.id, limit, offset),
+ count: (instrument) => q.countFor.get(instrument.id).n,
+ recent: (limit = 50) => q.recent.all(limit),
+
+ /** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */
+ upsert(doc, instrument, { traceparent } = {}) {
+ return store.tx(() => {
+ const existing = q.byItem.get(doc.source_item_id);
+ if (existing) {
+ if (existing.instrument_id !== instrument.id) return { document: existing, created: false, changed: false };
+ if (doc.source_revision > existing.source_revision) {
+ q.revise.run({ ...doc, id: existing.id, now: store.now() });
+ } else {
+ q.touch.run({ id: existing.id, retrieved_at: doc.retrieved_at, now: store.now() });
+ }
+ ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
+ return { document: q.byId.get(existing.id), created: false, changed: doc.source_revision > existing.source_revision };
+ }
+ const id = newId('doc', store.now());
+ q.insert.run({ ...doc, id, instrument_id: instrument.id, now: store.now() });
+ const row = q.byId.get(id);
+ ctx.freshness.noteRetrieval(doc.source_key, doc.retrieved_at);
+ ctx.alerts.onDocument(row, instrument, { traceparent });
+ return { document: row, created: true, changed: true };
+ });
+ },
+
+ /** Sources removed the item: hide the document (the row stays as the record). */
+ remove(itemId, { at, reason }) {
+ const row = q.byItem.get(itemId);
+ if (!row) return false;
+ return q.remove.run({ id: row.id, at: at || store.now(), reason: String(reason || 'removed at the source').slice(0, 500), now: store.now() }).changes > 0;
+ },
+
+ dto(d) {
+ return {
+ id: d.id, kind: d.kind, form_type: d.form_type, title: d.title, filer_name: d.filer_name, cik: d.cik, accession: d.accession, url: d.url,
+ published_at: iso(d.published_at), source_updated_at: iso(d.source_updated_at), retrieved_at: iso(d.retrieved_at), first_seen_at: iso(d.first_seen_at),
+ source: { key: d.source_key, item_id: d.source_item_id, revision: d.source_revision, license_note: d.license_note, terms_note: d.terms_note },
+ };
+ },
+ };
+ return api;
+}
+
+module.exports = { createDocuments };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Freshness per source: is what Trade shows from a source still current?
+ *
+ * A source is FRESH at time `now` only when its last successful fetch is known and younger than its
+ * staleness window (Sources' stale_after_sec, else TRADE_DEFAULT_STALE_AFTER_SEC). Everything
+ * else is stale:
+ * - the last success is older than the window → stale since last success + window
+ * - Sources never reported a success → stale since Trade first saw the source
+ * - Trade has never heard of the source at all → freshness unknown (shown as stale)
+ * Sources' own health status (failing, disabled, …) is shown next to it, but the verdict is
+ * computed with Trade's clock, so an outage of Sources or of the sync makes data go stale on its own
+ * rather than look current forever. Nothing is ever replaced by an invented value: a stale value is
+ * shown with its timestamps and a "stale since" label, and a missing one is shown as missing.
+ *
+ * `trade.source.stale` and `trade.source.recovered` are emitted only on a transition (evaluate()),
+ * so a replay or a second tick changes nothing.
+ */
+const { iso } = require('./util');
+
+function createFreshness({ store, config, outbox }) {
+ const { db } = store;
+ const def = config.freshness.defaultStaleAfterSec;
+ const q = {
+ get: db.prepare('SELECT * FROM trade_source_status WHERE source_key = ?'),
+ all: db.prepare('SELECT * FROM trade_source_status ORDER BY source_key'),
+ ensure: db.prepare(`INSERT OR IGNORE INTO trade_source_status (source_key, stale, stale_since, evaluated_at) VALUES (?, 1, ?, NULL)`),
+ report: db.prepare(`UPDATE trade_source_status SET name = COALESCE(@name, name), upstream_status = @status,
+ last_success_at = CASE WHEN @last IS NULL THEN last_success_at WHEN last_success_at IS NULL OR @last > last_success_at THEN @last ELSE last_success_at END,
+ stale_after_sec = COALESCE(@window, stale_after_sec), reported_at = @now,
+ terms_note = COALESCE(@terms, terms_note), license_note = COALESCE(@license, license_note)
+ WHERE source_key = @key`),
+ retrieval: db.prepare(`UPDATE trade_source_status SET last_success_at = @at WHERE source_key = @key AND (last_success_at IS NULL OR last_success_at < @at)`),
+ mark: db.prepare('UPDATE trade_source_status SET stale = @stale, stale_since = @since, evaluated_at = @now WHERE source_key = @key'),
+ };
+
+ /** Pure: the verdict for one status row at `now`. row may be null (unknown source). */
+ function verdict(row, now) {
+ if (!row) return { known: false, stale: true, staleSince: null, window: def, lastSuccessAt: null };
+ const window = row.stale_after_sec || def;
+ if (row.last_success_at == null) return { known: true, stale: true, staleSince: row.stale_since, window, lastSuccessAt: null };
+ const until = row.last_success_at + window * 1000;
+ return { known: true, stale: now > until, staleSince: now > until ? until : null, window, lastSuccessAt: row.last_success_at };
+ }
+
+ function view(key, now = store.now()) {
+ const row = q.get.get(key) || null;
+ const v = verdict(row, now);
+ return {
+ key,
+ name: row ? row.name : null,
+ known: v.known,
+ status: !v.known ? 'unknown' : v.stale ? 'stale' : 'fresh',
+ stale: v.stale,
+ stale_since: iso(v.staleSince),
+ last_success_at: iso(v.lastSuccessAt),
+ stale_after_sec: v.window,
+ upstream_status: row ? row.upstream_status : null,
+ reported_at: row ? iso(row.reported_at) : null,
+ terms_note: row ? row.terms_note : null,
+ license_note: row ? row.license_note : null,
+ };
+ }
+
+ function ensure(key) { q.ensure.run(key, store.now()); }
+
+ /** Emit on a transition only. Inside the caller's transaction (or its own). */
+ function evaluate(key) {
+ return store.tx(() => {
+ const row = q.get.get(key);
+ if (!row) return null;
+ const v = verdict(row, store.now());
+ const first = row.evaluated_at == null;
+ const was = Boolean(row.stale);
+ const since = v.stale ? (v.staleSince != null ? v.staleSince : (row.stale_since || store.now())) : null;
+ q.mark.run({ key, stale: v.stale ? 1 : 0, since, now: store.now() });
+ // First sight: a fresh source is not a recovery; a stale one is reported once.
+ if (first ? !v.stale : v.stale === was) return null;
+ const envelope = outbox.emit({
+ event_type: v.stale ? 'trade.source.stale' : 'trade.source.recovered',
+ actor: { type: 'service', id: 'trade' }, visibility: 'public', priority: 'low',
+ subject: { type: 'source', id: key },
+ payload: {
+ source_key: key, stale: v.stale, stale_since: iso(since), last_success_at: iso(v.lastSuccessAt),
+ stale_after_sec: v.window, upstream_status: row.upstream_status || null, evaluated_at: iso(store.now()),
+ },
+ });
+ return envelope;
+ });
+ }
+
+ return {
+ verdict,
+ view,
+ ensure,
+ evaluate,
+ evaluateAll() { return q.all.all().map((r) => evaluate(r.source_key)).filter(Boolean); },
+ all(now = store.now()) { return q.all.all().map((r) => view(r.source_key, now)); },
+
+ /** What Sources says about a source (its health block). Evaluates the transition. */
+ report(key, { name = null, status = null, lastSuccessAt = null, staleAfterSec = null, termsNote = null, licenseNote = null } = {}) {
+ return store.tx(() => {
+ ensure(key);
+ q.report.run({ key, name, status, last: lastSuccessAt, window: staleAfterSec, now: store.now(), terms: termsNote, license: licenseNote });
+ return evaluate(key);
+ });
+ },
+
+ /** A datum retrieved at `at` proves a successful fetch at that time. */
+ noteRetrieval(key, at) {
+ ensure(key);
+ q.retrieval.run({ key, at });
+ return evaluate(key);
+ },
+ };
+}
+
+module.exports = { createFreshness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * Indexability and OpenVibe.Search for instrument pages (openvibe-publishing/seo gate and
+ * index-hooks). The only public, indexable objects Trade has are instrument pages; watchlists,
+ * alert rules and deliveries are private and never reach this module.
+ *
+ * Gate facts for an instrument page:
+ * state active → published, archived → unpublished
+ * sensitive always (financial information): unreviewed_sensitive until a person-reviewed
+ * context is published (human-written, or an AI draft a person approved)
+ * authorship the published context's record (an unreviewed AI draft is never published anyway)
+ * text the published context (policy minWords, TRADE_GATE_MIN_WORDS)
+ * price the newest monetary observation's observed_at: older than TRADE_PRICE_MAX_AGE_SEC
+ * → stale_price (noindex), so a crawler never indexes an old number as current
+ *
+ * Search gets a document only while the page is listable; otherwise a tombstone (and nothing at all
+ * for a page that was never indexed). The sequencer raises the document revision only when the
+ * document changes, so replays and worker ticks send nothing new.
+ */
+const seo = require('openvibe-publishing/seo');
+const hooks = require('openvibe-publishing/index-hooks');
+const authorship = require('openvibe-publishing/authorship');
+const ssr = require('openvibe-publishing/ssr');
+
+const OWNER = 'trade';
+
+function createIndexing({ store, config, ctx }) {
+ const { db } = store;
+ const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL
+ ORDER BY observed_at DESC LIMIT 1`);
+
+ function publishedContext(instrument) {
+ if (!instrument.context_published_revision) return null;
+ return store.revisions.get(instrument.id, instrument.context_published_revision);
+ }
+
+ function decide(instrument, now = store.now()) {
+ const rev = publishedContext(instrument);
+ const rec = rev ? rev.meta.authorship : null;
+ const review = rev ? store.reviews.latest(instrument.id, rev.number) : null;
+ const reviewedContext = Boolean(rec && authorship.canPublish(rec, review).ok);
+ const facts = {
+ state: instrument.status === 'active' ? 'published' : 'unpublished',
+ visibility: 'public',
+ canonicalUrl: ctx.urls.instrument(instrument),
+ text: rev ? ssr.markdownToText(rev.content) : '',
+ sensitive: true,
+ sensitiveReviewed: reviewedContext,
+ };
+ if (rec) Object.assign(facts, authorship.gateFacts(rec, review));
+ const m = latestMonetary.get(instrument.id);
+ if (m) facts.price = { observedAt: m.observed_at };
+ return seo.evaluate(facts, { policy: { minWords: config.gate.minWords, priceMaxAgeMs: config.freshness.priceMaxAgeMs }, now });
+ }
+
+ function document(instrument, decision) {
+ const rev = publishedContext(instrument);
+ const identity = { owner: OWNER, type: 'instrument', id: instrument.id, revision: 0 };
+ if (!decision.listable || instrument.status !== 'active') return hooks.tombstone(identity);
+ const rec = rev ? rev.meta.authorship : null;
+ const aliases = ctx.instruments.aliases(instrument).map((a) => a.value);
+ return hooks.buildIndexDocument({
+ ...identity,
+ state: 'published',
+ visibility: 'public',
+ canonicalUrl: ctx.urls.instrument(instrument),
+ title: `${instrument.symbol} — ${instrument.name}`,
+ summary: rev ? ssr.markdownToText(rev.content, 300) : null,
+ body: [rev ? ssr.markdownToText(rev.content) : '', `Also known as: ${aliases.join(', ')}`].join('\n\n'),
+ facets: { kind: instrument.kind, symbol: instrument.symbol, ...(instrument.exchange ? { exchange: instrument.exchange } : {}) },
+ authorship: rec,
+ provenance: (rev && rev.fields.cites || []).slice(0, 40).map((c) => ({ service: OWNER, type: c.kind, id: c.id })),
+ decision,
+ publishedAt: instrument.context_published_at,
+ updatedAt: rev ? rev.createdAt : null,
+ language: 'en',
+ });
+ }
+
+ /** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
+ function refresh(instrument, { traceparent } = {}) {
+ if (!instrument) return null;
+ const decision = decide(instrument);
+ const doc = document(instrument, decision);
+ const prev = store.sequencer.current(OWNER, 'instrument', instrument.id);
+ if (doc.deleted && prev == null) return null;
+ const stamped = store.sequencer.stamp(doc);
+ if (prev != null && stamped.revision === prev) return null;
+ return ctx.outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ }
+
+ return {
+ OWNER,
+ decide,
+ refresh,
+ refreshAll: () => store.tx(() => ctx.instruments.active().map((i) => refresh(i)).filter(Boolean)),
+ };
+}
+
+module.exports = { createIndexing, OWNER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
+'use strict';
+
+/**
+ * Instruments and their aliases, with deterministic resolution.
+ *
+ * Normalisation (the key an alias is stored and looked up under):
+ * ticker upper case, trimmed; A–Z, 0–9, '.', '-' only, 1–16 characters (BRK.B, RDS-A)
+ * cik digits only ("CIK" prefix allowed), zero-padded to 10 (320193 → 0000320193)
+ * name NFKD without diacritics, upper case, '&' → AND, dots and apostrophes removed (S.A. → SA),
+ * other punctuation → space, whitespace
+ * collapsed, a trailing SEC state marker (/DE/) dropped, then trailing corporate-form
+ * words (INC, CORP, CO, LTD, PLC, LLC, …, and a dangling AND from "& Co") dropped
+ *
+ * resolve(query, { kind }) tries, in this fixed order, and stops at the first rule that matches:
+ * 1. CIK when the query is all digits (optionally "CIK…"): the instrument's CIK or a cik alias
+ * 2. ticker the instrument's symbol, then a ticker alias
+ * 3. name name aliases; one instrument → resolved, several → ambiguous (candidates listed,
+ * none chosen), none → not_found
+ * There is no fuzzy or prefix matching and no ranking: the same database answers the same query the
+ * same way. A ticker or CIK names exactly one instrument (a unique index enforces it).
+ */
+const { ApiError } = require('../http/errors');
+const { newId, invalid, str } = require('./util');
+
+const KINDS = ['equity', 'fund', 'index', 'currency', 'commodity', 'crypto', 'other'];
+const SYMBOL_RE = /^[A-Z0-9][A-Z0-9.-]{0,15}$/;
+const CORPORATE_WORDS = new Set(['INC', 'INCORPORATED', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'PLC', 'LLC', 'LP', 'LLP', 'SA', 'AG', 'NV', 'SE', 'THE']);
+
+function normSymbol(v) {
+ if (v == null) return null;
+ const s = String(v).trim().toUpperCase();
+ return SYMBOL_RE.test(s) ? s : null;
+}
+
+function normCik(v) {
+ if (v == null) return null;
+ const s = String(v).trim().toUpperCase().replace(/^CIK/, '');
+ return /^\d{1,10}$/.test(s) ? s.padStart(10, '0') : null;
+}
+
+function normName(v) {
+ if (v == null) return null;
+ let s = String(v).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toUpperCase();
+ s = s.replace(/&/g, ' AND ').replace(/\s*\/[A-Z]{2}\/\s*$/, ' ');
+ s = s.replace(/[.'’]/g, '').replace(/[^A-Z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
+ const words = s.split(' ').filter(Boolean);
+ while (words.length > 1 && (CORPORATE_WORDS.has(words[words.length - 1]) || words[words.length - 1] === 'AND')) words.pop();
+ while (words.length > 1 && words[0] === 'THE') words.shift();
+ const out = words.join(' ');
+ return out || null;
+}
+
+const NORMALIZE = { ticker: normSymbol, cik: normCik, name: normName };
+
+function createInstruments({ store }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM trade_instruments WHERE id = ?'),
+ bySymbol: db.prepare('SELECT * FROM trade_instruments WHERE symbol = ?'),
+ byCik: db.prepare('SELECT * FROM trade_instruments WHERE cik = ?'),
+ alias: db.prepare('SELECT DISTINCT i.* FROM trade_instrument_aliases a JOIN trade_instruments i ON i.id = a.instrument_id WHERE a.kind = ? AND a.normalized = ? ORDER BY i.symbol'),
+ aliasesOf: db.prepare('SELECT kind, value, normalized, created_at FROM trade_instrument_aliases WHERE instrument_id = ? ORDER BY kind, normalized'),
+ insert: db.prepare(`INSERT INTO trade_instruments (id, symbol, name, kind, exchange, cik, currency, status, created_by, created_at, updated_at)
+ VALUES (@id, @symbol, @name, @kind, @exchange, @cik, @currency, 'active', @by, @now, @now)`),
+ insertAlias: db.prepare(`INSERT OR IGNORE INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`),
+ count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),
+ page: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol LIMIT ? OFFSET ?"),
+ active: db.prepare("SELECT * FROM trade_instruments WHERE status = 'active' ORDER BY symbol"),
+ };
+
+ function fields(input, { partial = false } = {}) {
+ const out = {};
+ if (!partial || input.name !== undefined) out.name = str(input.name, 200, 'name', { required: !partial });
+ if (!partial || input.kind !== undefined) {
+ const kind = input.kind == null || input.kind === '' ? (partial ? undefined : 'equity') : String(input.kind);
+ if (kind !== undefined && !KINDS.includes(kind)) throw invalid(`kind must be one of ${KINDS.join(', ')}`);
+ if (kind !== undefined) out.kind = kind;
+ }
+ if (!partial || input.exchange !== undefined) out.exchange = str(input.exchange, 40, 'exchange');
+ if (!partial || input.cik !== undefined) {
+ if (input.cik == null || input.cik === '') out.cik = null;
+ else {
+ out.cik = normCik(input.cik);
+ if (!out.cik) throw invalid('cik must be up to 10 digits');
+ }
+ }
+ if (!partial || input.currency !== undefined) {
+ const c = input.currency == null || input.currency === '' ? null : String(input.currency).trim().toUpperCase();
+ if (c && !/^[A-Z]{3}$/.test(c)) throw invalid('currency must be an ISO 4217 code like USD');
+ out.currency = c;
+ }
+ return out;
+ }
+
+ function conflictGuard(fn) {
+ try { return fn(); } catch (err) {
+ if (err && /UNIQUE constraint failed/.test(err.message)) {
+ if (/trade_instruments\.symbol/.test(err.message)) throw new ApiError(409, 'instrument.symbol_taken', 'Another instrument has this symbol');
+ if (/trade_instruments\.cik/.test(err.message)) throw new ApiError(409, 'instrument.cik_taken', 'Another instrument has this CIK');
+ throw new ApiError(409, 'alias.taken', 'This ticker or CIK already names another instrument');
+ }
+ throw err;
+ }
+ }
+
+ const api = {
+ KINDS,
+ normSymbol, normCik, normName,
+
+ get: (id) => q.byId.get(id) || null,
+ bySymbol: (symbol) => { const s = normSymbol(symbol); return s ? q.bySymbol.get(s) || null : null; },
+ aliases: (instrument) => q.aliasesOf.all(instrument.id),
+ active: () => q.active.all(),
+ page({ limit = 50, offset = 0 } = {}) {
+ return { total: q.count.get().n, instruments: q.page.all(limit, offset) };
+ },
+
+ /** input: { symbol, name, kind?, exchange?, cik?, currency? } → the new row. Aliases for the symbol, CIK and name are added. */
+ create(input, by) {
+ const symbol = normSymbol(input.symbol);
+ if (!symbol) throw invalid('symbol must be 1–16 characters of A–Z, 0–9, "." or "-"');
+ const f = fields(input);
+ const id = newId('ins', store.now());
+ return conflictGuard(() => store.tx(() => {
+ // A ticker alias of another instrument must not be shadowed by a new symbol.
+ const holder = q.alias.all('ticker', symbol).find((i) => i.symbol !== symbol);
+ if (holder) throw new ApiError(409, 'alias.taken', `${symbol} is already an alias of ${holder.symbol}`);
+ q.insert.run({ id, symbol, ...f, by: by || null, now: store.now() });
+ q.insertAlias.run(id, 'ticker', symbol, symbol, by || null, store.now());
+ if (f.cik) q.insertAlias.run(id, 'cik', f.cik, f.cik, by || null, store.now());
+ const n = normName(f.name);
+ if (n) q.insertAlias.run(id, 'name', f.name, n, by || null, store.now());
+ return q.byId.get(id);
+ }));
+ },
+
+ update(instrument, input) {
+ const f = fields(input, { partial: true });
+ if (input.status !== undefined) {
+ if (!['active', 'archived'].includes(input.status)) throw invalid('status must be active or archived');
+ f.status = input.status;
+ }
+ const keys = Object.keys(f);
+ if (!keys.length) return instrument;
+ return conflictGuard(() => store.tx(() => {
+ db.prepare(`UPDATE trade_instruments SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...f, now: store.now(), id: instrument.id });
+ if (f.cik) q.insertAlias.run(instrument.id, 'cik', f.cik, f.cik, null, store.now());
+ if (f.name) { const n = normName(f.name); if (n) q.insertAlias.run(instrument.id, 'name', f.name, n, null, store.now()); }
+ return q.byId.get(instrument.id);
+ }));
+ },
+
+ addAlias(instrument, kind, value, by) {
+ if (!NORMALIZE[kind]) throw invalid('alias kind must be ticker, cik or name');
+ const raw = str(value, 200, 'alias', { required: true });
+ const n = NORMALIZE[kind](raw);
+ if (!n) throw invalid(`not a valid ${kind}`);
+ if (kind === 'ticker') {
+ const owner = q.bySymbol.get(n);
+ if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the symbol of ${owner.symbol}`);
+ }
+ if (kind === 'cik') {
+ const owner = q.byCik.get(n);
+ if (owner && owner.id !== instrument.id) throw new ApiError(409, 'alias.taken', `${n} is the CIK of ${owner.symbol}`);
+ }
+ conflictGuard(() => q.insertAlias.run(instrument.id, kind, raw, n, by || null, store.now()));
+ return { kind, value: raw, normalized: n };
+ },
+
+ /**
+ * → { query, status: 'resolved'|'ambiguous'|'not_found', match: { kind, value } | null,
+ * instrument | null, candidates: [] }
+ */
+ resolve(query, { kind = null } = {}) {
+ const raw = String(query == null ? '' : query).trim().slice(0, 200);
+ const out = (status, match, instrument, candidates = []) => ({ query: raw, status, match, instrument: instrument || null, candidates });
+ if (!raw) return out('not_found', null, null);
+ if (kind && !NORMALIZE[kind]) throw invalid('kind must be ticker, cik or name');
+
+ if (!kind || kind === 'cik') {
+ const cik = /^(CIK)?\d{1,10}$/i.test(raw) ? normCik(raw) : null;
+ if (cik) {
+ const hit = q.byCik.get(cik) || q.alias.all('cik', cik)[0];
+ if (hit) return out('resolved', { kind: 'cik', value: cik }, hit);
+ }
+ }
+ if (!kind || kind === 'ticker') {
+ const sym = normSymbol(raw);
+ if (sym) {
+ const hit = q.bySymbol.get(sym);
+ if (hit) return out('resolved', { kind: 'symbol', value: sym }, hit);
+ const alias = q.alias.all('ticker', sym)[0];
+ if (alias) return out('resolved', { kind: 'ticker', value: sym }, alias);
+ }
+ }
+ if (!kind || kind === 'name') {
+ const n = normName(raw);
+ if (n) {
+ const hits = q.alias.all('name', n);
+ if (hits.length === 1) return out('resolved', { kind: 'name', value: n }, hits[0]);
+ if (hits.length > 1) return out('ambiguous', { kind: 'name', value: n }, null, hits);
+ }
+ }
+ return out('not_found', null, null);
+ },
+ };
+ return api;
+}
+
+module.exports = { createInstruments, normSymbol, normCik, normName, KINDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+
+/**
+ * From an OpenVibe.Sources item (sources.item@1, category trade) to what Trade records. Pure and
+ * deterministic; it reads only what the item states and never fills a gap.
+ *
+ * observation the item states a value: fields { metric, value, unit, observed_at } plus a
+ * symbol/ticker or CIK (currency, period, max_age_sec optional). An item without its
+ * own observation time is skipped — the retrieval time is NOT an observation time.
+ * document an SEC EDGAR filing: the canonical URL is under
+ * sec.gov/Archives/edgar/data/<CIK>/<accession>/ (the xbrlrss feed's index links),
+ * so the CIK and accession number come from the URL; the form type is the item's
+ * summary when it looks like a form type (the feed's <description>, e.g. "10-Q"),
+ * else fields.form_type; the filer is the item title.
+ * Any other item with fields.cik or fields.symbol/ticker is a document too
+ * (kind 'record' for record items, else 'article').
+ * skip everything else, with a reason (counted in the sync state, never guessed at).
+ */
+const { parseTime, parseDecimal } = require('./util');
+
+const SEC_PATH_RE = /^https?:\/\/(?:www\.)?sec\.gov\/Archives\/edgar\/data\/(\d{1,10})\/(\d{18})(?:\/|$)/i;
+const FORM_RE = /^[A-Z0-9][A-Z0-9 \-/.]{0,19}$/;
+
+function formType(v) {
+ if (typeof v !== 'string') return null;
+ const s = v.trim().toUpperCase();
+ return FORM_RE.test(s) && /\d|^[A-Z]{1,6}(\/A)?$/.test(s) ? s : null;
+}
+
+function accessionOf(digits) {
+ return `${digits.slice(0, 10)}-${digits.slice(10, 12)}-${digits.slice(12)}`;
+}
+
+function filerName(title) {
+ if (typeof title !== 'string') return null;
+ const s = title.replace(/\s*\(\d{10}\)\s*(\((Filer|Subject|Reporting|Filed by)[^)]*\))?\s*$/i, '').trim();
+ return s || null;
+}
+
+/** → { type: 'observation', keys, observation } | { type: 'document', keys, document } | { type: 'skip', reason } */
+function mapItem(item) {
+ if (!item || typeof item !== 'object' || !item.id) return { type: 'skip', reason: 'malformed_item' };
+ const f = item.fields && typeof item.fields === 'object' ? item.fields : {};
+ const keys = {
+ cik: f.cik != null ? String(f.cik) : null,
+ symbol: f.symbol != null ? String(f.symbol) : (f.ticker != null ? String(f.ticker) : null),
+ };
+ const prov = item.provenance || {};
+ const retrievedAt = parseTime(prov.retrieved_at);
+ if (retrievedAt == null) return { type: 'skip', reason: 'no_retrieval_time' };
+
+ if (item.kind === 'observation' || (f.metric != null && f.value != null)) {
+ if (!keys.cik && !keys.symbol) return { type: 'skip', reason: 'no_instrument_key' };
+ if (f.metric == null || f.unit == null || !parseDecimal(f.value)) return { type: 'skip', reason: 'incomplete_observation' };
+ if (parseTime(f.observed_at) == null) return { type: 'skip', reason: 'no_observation_time' };
+ return {
+ type: 'observation', keys,
+ observation: {
+ metric: f.metric, value: f.value, unit: f.unit, currency: f.currency == null ? null : f.currency,
+ period: f.period == null ? null : String(f.period), observed_at: f.observed_at, retrieved_at: prov.retrieved_at,
+ source_key: item.source_key, source_item_id: item.id, source_url: item.canonical_url || null,
+ source_ref: `${item.id}#r${item.revision}:${f.metric}`, max_age_sec: f.max_age_sec == null ? null : f.max_age_sec,
+ },
+ };
+ }
+
+ const base = {
+ source_key: item.source_key, source_item_id: item.id, source_revision: item.revision,
+ title: item.title || null, url: item.canonical_url || null,
+ published_at: parseTime(item.published_at), source_updated_at: parseTime(item.source_updated_at),
+ retrieved_at: retrievedAt, first_seen_at: parseTime(prov.first_seen_at) || retrievedAt,
+ license_note: prov.license_note || null, terms_note: prov.terms_note || null,
+ };
+ const sec = item.canonical_url ? SEC_PATH_RE.exec(item.canonical_url) : null;
+ if (sec) {
+ const cik = sec[1].padStart(10, '0');
+ return {
+ type: 'document', keys: { cik, symbol: keys.symbol },
+ document: { ...base, kind: 'filing', form_type: formType(item.summary) || formType(f.form_type), filer_name: filerName(item.title), cik, accession: accessionOf(sec[2]) },
+ };
+ }
+ if (keys.cik || keys.symbol) {
+ return {
+ type: 'document', keys,
+ document: { ...base, kind: item.kind === 'record' ? 'record' : 'article', form_type: formType(f.form_type), filer_name: null, cik: keys.cik ? keys.cik.replace(/^CIK/i, '').padStart(10, '0') : null, accession: null },
+ };
+ }
+ return { type: 'skip', reason: 'no_instrument_key' };
+}
+
+module.exports = { mapItem, formType, filerName, accessionOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+
+/**
+ * Market observations: one value a source stated for an instrument at a time.
+ *
+ * Every row carries value (the decimal exactly as stated), unit, currency (when monetary),
+ * observed_at (when the value was true, per the source), the source (key, Sources item, URL, the
+ * source's own reference) and retrieved_at (when the source was fetched). None of these is ever
+ * filled in: a datum without an observation time or a retrieval time is refused, and an instrument
+ * without observations shows no number at all.
+ *
+ * Rows are immutable (SQLite triggers). The same (source_key, source_ref) recorded twice is one
+ * observation (a replay answers created: false); the same reference with a DIFFERENT value is a
+ * 409 — a source correcting itself sends a new reference, and both stay on record.
+ *
+ * Recording runs in one transaction with: trade.observation.created, the source's freshness
+ * (a retrieval proves a successful fetch), alert evaluation (alerts.onObservation) and the
+ * instrument's Search document (the gate's stale_price rule looks at monetary observations).
+ */
+const { ApiError } = require('../http/errors');
+const { newId, iso, parseTime, parseDecimal, invalid, str, httpUrl } = require('./util');
+
+const METRIC_RE = /^[A-Za-z][A-Za-z0-9_.:-]{0,79}$/;
+const UNIT_RE = /^[A-Za-z][A-Za-z0-9/%._-]{0,19}$/;
+const SOURCE_KEY_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
+const FUTURE_SKEW_MS = 5 * 60 * 1000;
+
+function createObservations({ store, ctx }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM trade_market_observations WHERE id = ?'),
+ byRef: db.prepare('SELECT * FROM trade_market_observations WHERE source_key = ? AND source_ref = ?'),
+ insert: db.prepare(`INSERT INTO trade_market_observations (id, instrument_id, metric, value, value_num, unit, currency, period, observed_at,
+ source_key, source_item_id, source_url, source_ref, retrieved_at, max_age_sec, recorded_by, recorded_at)
+ VALUES (@id, @instrument_id, @metric, @value, @value_num, @unit, @currency, @period, @observed_at,
+ @source_key, @source_item_id, @source_url, @source_ref, @retrieved_at, @max_age_sec, @recorded_by, @recorded_at)`),
+ latest: db.prepare(`SELECT * FROM (
+ SELECT o.*, ROW_NUMBER() OVER (PARTITION BY metric, unit, IFNULL(currency, '') ORDER BY observed_at DESC, recorded_at DESC, id DESC) AS rn
+ FROM trade_market_observations o WHERE instrument_id = ?)
+ WHERE rn = 1 ORDER BY metric, unit`),
+ history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ? AND (@metric IS NULL OR metric = @metric)
+ AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),
+ count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),
+ };
+
+ function validate(input, instrument) {
+ const metric = str(input.metric, 80, 'metric', { required: true });
+ if (!METRIC_RE.test(metric)) throw invalid('metric must start with a letter and use letters, digits, "_", ".", ":" or "-"');
+ const value = parseDecimal(input.value);
+ if (!value) throw invalid('value must be a finite decimal as the source stated it');
+ const unit = str(input.unit, 20, 'unit', { required: true });
+ if (!UNIT_RE.test(unit)) throw invalid('unit is malformed');
+ const currency = input.currency == null || input.currency === '' ? null : String(input.currency).trim();
+ if (currency && !/^[A-Z]{3}$/.test(currency)) throw invalid('currency must be an ISO 4217 code like USD');
+ const observedAt = parseTime(input.observed_at);
+ if (observedAt == null) throw invalid('observed_at must be the date-time (with zone) the source states for this value', 'observation.time_required');
+ const retrievedAt = parseTime(input.retrieved_at);
+ if (retrievedAt == null) throw invalid('retrieved_at must be the date-time (with zone) the source was fetched', 'observation.time_required');
+ const limit = store.now() + FUTURE_SKEW_MS;
+ if (observedAt > limit || retrievedAt > limit) throw invalid('observed_at and retrieved_at cannot be in the future');
+ const sourceKey = str(input.source_key, 64, 'source_key', { required: true });
+ if (!SOURCE_KEY_RE.test(sourceKey)) throw invalid('source_key must be an OpenVibe.Sources source key');
+ const sourceRef = str(input.source_ref, 300, 'source_ref', { required: true });
+ const sourceItemId = input.source_item_id == null || input.source_item_id === '' ? null : String(input.source_item_id);
+ if (sourceItemId && !/^itm_[0-9A-HJKMNP-TV-Z]{26}$/.test(sourceItemId)) throw invalid('source_item_id must be a Sources item id (itm_…)');
+ let maxAge = null;
+ if (input.max_age_sec != null && input.max_age_sec !== '') {
+ maxAge = Number(input.max_age_sec);
+ if (!Number.isInteger(maxAge) || maxAge < 60 || maxAge > 10 * 365 * 86400) throw invalid('max_age_sec must be an integer between 60 and ten years');
+ }
+ return {
+ instrument_id: instrument.id, metric, value: value.text, value_num: value.num, unit, currency,
+ period: str(input.period, 40, 'period'), observed_at: observedAt, source_key: sourceKey, source_item_id: sourceItemId,
+ source_url: httpUrl(input.source_url, 'source_url'), source_ref: sourceRef, retrieved_at: retrievedAt, max_age_sec: maxAge,
+ };
+ }
+
+ const same = (a, b) => a.instrument_id === b.instrument_id && a.metric === b.metric && a.value === b.value && a.unit === b.unit
+ && (a.currency || null) === (b.currency || null) && a.observed_at === b.observed_at;
+
+ const api = {
+ get: (id) => q.byId.get(id) || null,
+ count: (instrument) => q.count.get(instrument.id).n,
+ latest: (instrument) => q.latest.all(instrument.id),
+ history(instrument, { metric = null, before = null, limit = 50 } = {}) {
+ const b = parseTime(before);
+ return q.history.all(instrument.id, { metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });
+ },
+
+ /**
+ * → { observation, created }. recordedBy: 'svc:…' or 'usr_…' (who recorded it here).
+ */
+ record(input, instrument, { recordedBy, traceparent } = {}) {
+ if (!instrument || instrument.status !== 'active') throw new ApiError(404, 'instrument.not_found', 'No such active instrument');
+ if (!recordedBy) throw new TypeError('recordedBy is required');
+ const row = validate(input || {}, instrument);
+ return store.tx(() => {
+ const existing = q.byRef.get(row.source_key, row.source_ref);
+ if (existing) {
+ if (same(existing, row)) return { observation: existing, created: false };
+ throw new ApiError(409, 'observation.conflict', 'This source reference already recorded a different value; a correction needs its own source_ref');
+ }
+ const id = newId('obs', store.now());
+ q.insert.run({ id, ...row, recorded_by: recordedBy, recorded_at: store.now() });
+ const obs = q.byId.get(id);
+ ctx.outbox.emit({
+ event_type: 'trade.observation.created', actor: actorOf(recordedBy), visibility: 'public', priority: 'low',
+ subject: { type: 'observation', id },
+ payload: {
+ instrument: { id: instrument.id, symbol: instrument.symbol },
+ ...api.dto(obs, { withFreshness: false }),
+ },
+ }, { traceparent });
+ ctx.freshness.noteRetrieval(obs.source_key, obs.retrieved_at);
+ ctx.alerts.onObservation(obs, instrument, { traceparent });
+ ctx.indexing.refresh(instrument, { traceparent });
+ return { observation: obs, created: true };
+ });
+ },
+
+ /** Is this observation stale at `now`? Its source's freshness, and its own max age. */
+ staleness(obs, now = store.now()) {
+ const src = ctx.freshness.view(obs.source_key, now);
+ const own = obs.max_age_sec ? obs.observed_at + obs.max_age_sec * 1000 : null;
+ const ownStale = own != null && now > own;
+ const candidates = [];
+ if (src.stale && src.stale_since) candidates.push(Date.parse(src.stale_since));
+ if (ownStale) candidates.push(own);
+ return {
+ stale: src.stale || ownStale,
+ stale_since: candidates.length ? iso(Math.min(...candidates)) : null,
+ reason: ownStale ? 'value_older_than_max_age' : !src.known ? 'source_unknown' : src.stale ? 'source_stale' : null,
+ source: src,
+ };
+ },
+
+ dto(obs, { withFreshness = true, now = store.now() } = {}) {
+ const out = {
+ id: obs.id, metric: obs.metric, value: obs.value, unit: obs.unit, currency: obs.currency, period: obs.period,
+ observed_at: iso(obs.observed_at), retrieved_at: iso(obs.retrieved_at), recorded_at: iso(obs.recorded_at),
+ max_age_sec: obs.max_age_sec,
+ source: { key: obs.source_key, item_id: obs.source_item_id, url: obs.source_url, ref: obs.source_ref },
+ };
+ if (withFreshness) {
+ const s = api.staleness(obs, now);
+ out.freshness = { stale: s.stale, stale_since: s.stale_since, reason: s.reason, source_status: s.source.status, source_last_success_at: s.source.last_success_at };
+ }
+ return out;
+ },
+ };
+ return api;
+}
+
+function actorOf(by) {
+ const s = String(by || '');
+ if (s.startsWith('svc:')) return { type: 'service', id: s.slice(4) };
+ if (/^usr_/.test(s)) return { type: 'user', id: s };
+ return { type: 'service', id: 'trade' };
+}
+
+module.exports = { createObservations, actorOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * Read models shared by the pages, the JSON twins and the API, so the HTML and the
+ * machine-readable representation always describe the same data (§32.5: no divergence).
+ */
+const seo = require('openvibe-publishing/seo');
+const { iso } = require('./util');
+const { DISCLAIMER } = require('./alerts');
+
+function createReading({ store, ctx }) {
+ function instrumentDto(i) {
+ return {
+ id: i.id, symbol: i.symbol, name: i.name, kind: i.kind, exchange: i.exchange, cik: i.cik, currency: i.currency,
+ status: i.status, url: ctx.urls.instrument(i), created_at: iso(i.created_at), updated_at: iso(i.updated_at),
+ };
+ }
+
+ /** Everything the instrument page shows. docsPage: { limit, offset }. */
+ function instrument(i, { limit = 25, offset = 0 } = {}) {
+ const now = store.now();
+ const observations = ctx.observations.latest(i).map((o) => ctx.observations.dto(o, { now }));
+ const docs = ctx.documents.forInstrument(i, { limit, offset }).map((d) => {
+ const dto = ctx.documents.dto(d);
+ const f = ctx.freshness.view(d.source_key, now);
+ dto.freshness = { stale: f.stale, stale_since: f.stale_since, reason: !f.known ? 'source_unknown' : f.stale ? 'source_stale' : null };
+ return dto;
+ });
+ const keys = [...new Set([...observations.map((o) => o.source.key), ...docs.map((d) => d.source.key)])];
+ const sources = keys.sort().map((k) => ctx.freshness.view(k, now));
+ const decision = ctx.indexing.decide(i, now);
+ return {
+ instrument: instrumentDto(i),
+ aliases: ctx.instruments.aliases(i).map((a) => ({ kind: a.kind, value: a.value, normalized: a.normalized })),
+ observations,
+ documents: docs,
+ documents_total: ctx.documents.count(i),
+ sources,
+ context: ctx.context.published(i),
+ indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons },
+ disclaimer: DISCLAIMER,
+ generated_at: iso(now),
+ decision,
+ };
+ }
+
+ /** JSON-LD from real fields only: the page, what it is about, breadcrumbs. */
+ function jsonLd(i) {
+ const url = ctx.urls.instrument(i);
+ const about = i.kind === 'equity'
+ ? { '@type': 'Corporation', name: i.name, tickerSymbol: i.symbol }
+ : { '@type': 'Thing', name: i.name };
+ return [
+ seo.compact({
+ '@context': 'https://schema.org', '@type': 'WebPage', '@id': `${url}#page`, url, name: `${i.symbol} — ${i.name}`,
+ about, dateModified: i.context_published_at ? iso(i.context_published_at) : undefined, inLanguage: 'en',
+ }),
+ seo.structuredData.breadcrumbs([{ name: 'Instruments', url: ctx.urls.abs('/') }, { name: i.symbol, url }]),
+ ];
+ }
+
+ return { instrumentDto, instrument, jsonLd };
+}
+
+module.exports = { createReading };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+
+/**
+ * Pull the trade category from OpenVibe.Sources and record what it states.
+ *
+ * 1. GET /api/v1/sources every trade source's health → freshness (report)
+ * 2. GET /api/v1/items?category=trade&after=<cursor>&include_removed=1, in change order:
+ * removed item → the document is hidden (the row stays)
+ * observation item → an observation (domain/observations.js; idempotent per item revision)
+ * filing / keyed item → a document (domain/documents.js; new → document alerts)
+ * no instrument matches → skipped (counted by reason; nothing is created for it)
+ * Each page is applied in one transaction together with the cursor, so a crash replays the
+ * page and every effect is idempotent (UNIQUE keys on observations, documents and deliveries).
+ * The page's per-source health block → freshness as well.
+ *
+ * A failed call is recorded in trade_sync_state (last_error) and changes nothing else; freshness
+ * then decays on Trade's own clock, so pages show the data as stale instead of pretending.
+ */
+const { mapItem } = require('./mapping');
+const { parseTime, iso, json } = require('./util');
+
+const CURSOR = 'sources.trade';
+
+function createSync({ store, config, ctx, sources, log = console }) {
+ const { db } = store;
+ const q = {
+ get: db.prepare('SELECT * FROM trade_sync_state WHERE name = ?'),
+ ensure: db.prepare("INSERT OR IGNORE INTO trade_sync_state (name, cursor, counts) VALUES (?, 0, '{}')"),
+ advance: db.prepare('UPDATE trade_sync_state SET cursor = @cursor, counts = @counts WHERE name = @name'),
+ ok: db.prepare('UPDATE trade_sync_state SET last_ok_at = ?, last_error = NULL WHERE name = ?'),
+ fail: db.prepare('UPDATE trade_sync_state SET last_error = ?, last_error_at = ? WHERE name = ?'),
+ };
+ q.ensure.run(CURSOR);
+ let running = null;
+
+ function reportHealth(key, h, extra = {}) {
+ if (!h || typeof h !== 'object') return;
+ ctx.freshness.report(key, {
+ name: extra.name || null,
+ status: h.status || null,
+ lastSuccessAt: parseTime(h.last_success_at),
+ staleAfterSec: Number.isInteger(h.stale_after_sec) ? h.stale_after_sec : null,
+ termsNote: extra.terms_note || null,
+ licenseNote: extra.license_note || null,
+ });
+ }
+
+ function resolveKeys(keys) {
+ if (keys.cik) {
+ const r = ctx.instruments.resolve(keys.cik, { kind: 'cik' });
+ if (r.status === 'resolved') return r.instrument;
+ }
+ if (keys.symbol) {
+ const r = ctx.instruments.resolve(keys.symbol, { kind: 'ticker' });
+ if (r.status === 'resolved') return r.instrument;
+ }
+ return null;
+ }
+
+ /** Apply one item. → a counter name. Inside the page transaction. */
+ function applyItem(item) {
+ if (item.removed) return ctx.documents.remove(item.id, { at: parseTime(item.removed.at), reason: item.removed.reason }) ? 'documents_removed' : 'removed_unknown';
+ const m = mapItem(item);
+ if (m.type === 'skip') return `skipped_${m.reason}`;
+ const instrument = resolveKeys(m.keys);
+ if (!instrument) return 'skipped_no_matching_instrument';
+ if (instrument.status !== 'active') return 'skipped_archived_instrument';
+ if (m.type === 'observation') {
+ try {
+ const r = ctx.observations.record(m.observation, instrument, { recordedBy: 'svc:trade' });
+ return r.created ? 'observations_created' : 'observations_unchanged';
+ } catch (err) {
+ if (err && (err.status === 409 || err.status === 422)) return `skipped_${String(err.code || 'invalid').replace(/\W+/g, '_')}`;
+ throw err;
+ }
+ }
+ const r = ctx.documents.upsert(m.document, instrument);
+ return r.created ? 'documents_created' : r.changed ? 'documents_updated' : 'documents_unchanged';
+ }
+
+ async function runOnce() {
+ if (!sources.enabled) return { ok: false, skipped: 'disabled' };
+ const summary = { pages: 0, counts: {} };
+ try {
+ const list = await sources.sources();
+ for (const s of (list.sources || [])) {
+ if (s && s.category === config.sources.category && s.key) reportHealth(s.key, s.health, s);
+ }
+ for (let page = 0; page < config.sources.maxPagesPerRun; page++) {
+ const state = q.get.get(CURSOR);
+ const data = await sources.items({ after: state.cursor, limit: config.sources.pageLimit });
+ const items = Array.isArray(data.items) ? data.items : [];
+ store.tx(() => {
+ const counts = json(q.get.get(CURSOR).counts, {});
+ for (const item of items) {
+ const k = applyItem(item);
+ counts[k] = (counts[k] || 0) + 1;
+ summary.counts[k] = (summary.counts[k] || 0) + 1;
+ }
+ for (const [key, h] of Object.entries(data.sources || {})) reportHealth(key, h);
+ const next = Number.isInteger(data.next_after) ? data.next_after : state.cursor;
+ q.advance.run({ name: CURSOR, cursor: Math.max(next, state.cursor), counts: JSON.stringify(counts) });
+ });
+ summary.pages++;
+ if (!data.more || !items.length) break;
+ }
+ q.ok.run(store.now(), CURSOR);
+ ctx.outbox.kick();
+ return { ok: true, ...summary };
+ } catch (err) {
+ q.fail.run(String(err.message).slice(0, 500), store.now(), CURSOR);
+ log.warn('[Trade] Sources sync failed:', err.message);
+ return { ok: false, error: err.message, ...summary };
+ }
+ }
+
+ return {
+ CURSOR,
+ enabled: sources.enabled,
+ applyItem,
+ /** One run at a time; concurrent callers share it. */
+ run() {
+ if (!running) running = runOnce().finally(() => { running = null; });
+ return running;
+ },
+ state() {
+ const s = q.get.get(CURSOR);
+ return { enabled: sources.enabled, cursor: s.cursor, last_ok_at: iso(s.last_ok_at), last_error: s.last_error, last_error_at: iso(s.last_error_at), counts: json(s.counts, {}) };
+ },
+ };
+}
+
+module.exports = { createSync };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+/** Canonical paths and absolute URLs (from BASE_URL). One form per page. */
+const seo = require('openvibe-publishing/seo');
+
+function createUrls(config) {
+ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
+ const path = {
+ home: () => '/',
+ instrument: (i) => `/i/${encodeURIComponent(i.symbol)}`,
+ instrumentJson: (i) => `/i/${encodeURIComponent(i.symbol)}.json`,
+ documentsFeed: (i, type) => `/i/${encodeURIComponent(i.symbol)}/documents.${type === 'atom' ? 'atom' : type === 'json' ? 'json' : 'xml'}`,
+ sources: () => '/sources',
+ watchlists: () => '/watchlists',
+ };
+ return {
+ abs,
+ path,
+ instrument: (i) => abs(path.instrument(i)),
+ documentAnchor: (i, d) => `${abs(path.instrument(i))}#${d.id}`,
+ };
+}
+
+module.exports = { createUrls };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/** Small shared helpers: ids, times, decimals, validation errors. Nothing here invents a value. */
+const { ids } = require('openvibe-contracts');
+const { ApiError } = require('../http/errors');
+
+const newId = (prefix, now) => `${prefix}_${ids.ulid(now)}`;
+
+/** epoch ms → ISO string, or null. */
+const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
+
+/**
+ * A date the caller STATES: an ISO 8601 date-time with a zone (…Z or ±hh:mm), or epoch ms as a
+ * number. Anything else (including a missing value) is null — never "now".
+ */
+function parseTime(v) {
+ if (v == null || v === '') return null;
+ if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? Math.floor(v) : null;
+ const s = String(v).trim();
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/i.test(s)) return null;
+ const t = Date.parse(s);
+ return Number.isFinite(t) ? t : null;
+}
+
+const DECIMAL_RE = /^-?(\d{1,20})(\.\d{1,12})?([eE][-+]?\d{1,3})?$/;
+
+/** A decimal as stated: → { text, num } or null. Numbers are kept as their shortest string form. */
+function parseDecimal(v) {
+ if (typeof v === 'number') {
+ if (!Number.isFinite(v)) return null;
+ return { text: String(v), num: v };
+ }
+ if (typeof v !== 'string') return null;
+ const s = v.trim();
+ if (!DECIMAL_RE.test(s)) return null;
+ const num = Number(s);
+ return Number.isFinite(num) ? { text: s, num } : null;
+}
+
+const invalid = (detail, code = 'request.invalid') => new ApiError(422, code, detail);
+
+function str(v, max, name, { required = false } = {}) {
+ if (v == null || v === '') {
+ if (required) throw invalid(`${name} is required`);
+ return null;
+ }
+ if (typeof v !== 'string') throw invalid(`${name} must be a string`);
+ const s = v.trim();
+ if (!s && required) throw invalid(`${name} is required`);
+ if (s.length > max) throw invalid(`${name} is longer than ${max} characters`);
+ return s || null;
+}
+
+function httpUrl(v, name) {
+ if (v == null || v === '') return null;
+ let u;
+ try { u = new URL(String(v)); } catch { throw invalid(`${name} must be an absolute URL`); }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw invalid(`${name} must be http(s)`);
+ return u.toString();
+}
+
+/** Parse JSON stored by this service; a broken value is an empty default, never a crash. */
+function json(text, def) {
+ if (text == null) return def;
+ try { return JSON.parse(text); } catch { return def; }
+}
+
+module.exports = { newId, iso, parseTime, parseDecimal, invalid, str, httpUrl, json };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+
+/**
+ * Watchlists: private per subject (usr_…). Only the owner (or a service acting for the owner with
+ * X-OV-Subject and a trade.watchlist.* capability) can read or change one; anyone else gets 404, so
+ * a watchlist's existence is never disclosed. Watchlists are never in a sitemap, a feed, Search or
+ * an event, and every response that shows one is Cache-Control: private, no-store.
+ */
+const { ApiError } = require('../http/errors');
+const { newId, iso, str } = require('./util');
+
+function createWatchlists({ store, config, ctx }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM trade_watchlists WHERE id = ?'),
+ forOwner: db.prepare('SELECT * FROM trade_watchlists WHERE owner_subject = ? ORDER BY name'),
+ count: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists WHERE owner_subject = ?'),
+ insert: db.prepare('INSERT INTO trade_watchlists (id, owner_subject, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'),
+ rename: db.prepare('UPDATE trade_watchlists SET name = ?, updated_at = ? WHERE id = ?'),
+ remove: db.prepare('DELETE FROM trade_watchlists WHERE id = ?'),
+ items: db.prepare(`SELECT w.*, i.symbol, i.name, i.kind, i.exchange, i.status FROM trade_watchlist_items w
+ JOIN trade_instruments i ON i.id = w.instrument_id WHERE w.watchlist_id = ? ORDER BY i.symbol`),
+ itemCount: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?'),
+ addItem: db.prepare('INSERT OR IGNORE INTO trade_watchlist_items (watchlist_id, instrument_id, note, added_at) VALUES (?, ?, ?, ?)'),
+ removeItem: db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ? AND instrument_id = ?'),
+ touch: db.prepare('UPDATE trade_watchlists SET updated_at = ? WHERE id = ?'),
+ };
+
+ const unique = (fn) => {
+ try { return fn(); } catch (err) {
+ if (err && /UNIQUE constraint failed: trade_watchlists/.test(err.message)) throw new ApiError(409, 'watchlist.name_taken', 'You already have a watchlist with this name');
+ throw err;
+ }
+ };
+
+ const api = {
+ /** The owner's watchlist, or 404 for anyone else (existence is not disclosed). */
+ mustOwn(subject, id) {
+ const w = q.byId.get(String(id || ''));
+ if (!w || !subject || w.owner_subject !== subject) throw new ApiError(404, 'watchlist.not_found', 'No such watchlist');
+ return w;
+ },
+ forOwner: (subject) => (subject ? q.forOwner.all(subject) : []),
+ items: (w) => q.items.all(w.id),
+
+ create(subject, input) {
+ if (!subject) throw new ApiError(403, 'subject.required', 'Watchlists belong to a person');
+ const name = str(input.name, 80, 'name', { required: true });
+ if (q.count.get(subject).n >= config.limits.watchlistsPerSubject) throw new ApiError(429, 'watchlist.limit', `At most ${config.limits.watchlistsPerSubject} watchlists`);
+ const id = newId('wl', store.now());
+ unique(() => q.insert.run(id, subject, name, store.now(), store.now()));
+ return q.byId.get(id);
+ },
+
+ rename(w, input) {
+ const name = str(input.name, 80, 'name', { required: true });
+ unique(() => q.rename.run(name, store.now(), w.id));
+ return q.byId.get(w.id);
+ },
+
+ remove(w) { return store.tx(() => { db.prepare('DELETE FROM trade_watchlist_items WHERE watchlist_id = ?').run(w.id); return q.remove.run(w.id).changes > 0; }); },
+
+ add(w, instrument, note = null) {
+ if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
+ if (q.itemCount.get(w.id).n >= config.limits.itemsPerWatchlist) throw new ApiError(429, 'watchlist.full', `At most ${config.limits.itemsPerWatchlist} instruments per watchlist`);
+ const added = q.addItem.run(w.id, instrument.id, str(note, 300, 'note'), store.now()).changes > 0;
+ if (added) q.touch.run(store.now(), w.id);
+ return added;
+ },
+
+ drop(w, instrument) {
+ if (!instrument) return false;
+ const removed = q.removeItem.run(w.id, instrument.id).changes > 0;
+ if (removed) q.touch.run(store.now(), w.id);
+ return removed;
+ },
+
+ dto(w, { withItems = true } = {}) {
+ const out = { id: w.id, name: w.name, created_at: iso(w.created_at), updated_at: iso(w.updated_at) };
+ if (withItems) {
+ out.items = q.items.all(w.id).map((it) => ({
+ instrument: { id: it.instrument_id, symbol: it.symbol, name: it.name, kind: it.kind, exchange: it.exchange, status: it.status, url: ctx.urls.instrument({ symbol: it.symbol }) },
+ note: it.note, added_at: iso(it.added_at),
+ }));
+ }
+ return out;
+ },
+ };
+ return api;
+}
+
+module.exports = { createWatchlists };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Trade → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * trade.observation.created a market observation was recorded (public data)
+ * trade.alert.triggered one delivery of one alert rule (visibility subject: only
+ * the rule's owner, e.g. through Notifications); no email
+ * trade.source.stale|recovered a source's freshness changed (only on a transition)
+ * trade.index_document.upserted|deleted the OpenVibe.Search document or tombstone of an
+ * instrument page (index-hooks indexEvent)
+ *
+ * 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 Trade's 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 createTradeOutbox({ 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: 'trade' });
+ 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('[Trade] 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 = { createTradeOutbox };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+/**
+ * POST /internal/events — signed OpenVibe.Events webhook deliveries (subscription topic sources.*).
+ *
+ * The body is verified against X-OpenVibe-Signature (HMAC-SHA256 of the raw body with
+ * TRADE_EVENTS_WEBHOOK_SECRET) before anything is read; a bad or missing signature is 401 and the
+ * route answers 404 while no secret is configured. Each event is recorded once in the SDK inbox
+ * (idempotency_receipts, consumer 'sources'); a webhook is a wake-up, not durable truth: a
+ * sources.item.* or sources.fetch.failed event makes the cursor-based sync run now, and the sync
+ * reads the items themselves from the Sources API.
+ */
+const express = require('express');
+const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { http } = require('openvibe-contracts');
+
+function createWebhook({ store, config, sync, log = console }) {
+ const router = express.Router();
+ const inbox = createInbox(store.db, { now: store.now });
+ inbox.ensureSchema();
+
+ router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {
+ if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
+ const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret);
+ if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });
+ const e = delivery.event;
+ if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });
+ const r = inbox.once('sources', e.event_id, () => true);
+ const wake = !r.duplicate && /^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(e.event_type);
+ if (wake) sync.run().catch((err) => log.warn('[Trade] sync after webhook failed:', err.message));
+ res.status(200).json({ accepted: true, duplicate: r.duplicate, sync: wake });
+ });
+
+ return { router, inbox };
+}
+
+module.exports = { createWebhook };
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
+'use strict';
+
+/**
+ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.trade, one
+ * capability per route; private data needs X-OV-Subject, the person the service acts for) and for
+ * browsers or apps with a Network user JWT (judged by ownership / the editor list). Errors are
+ * problem+json. Every response is private, no-store and noindex.
+ *
+ * Public reads (no capability; services need none either):
+ * GET /instruments?limit&offset active instruments
+ * GET /instruments/:symbol the instrument page's data (as /i/:symbol.json)
+ * GET /instruments/:symbol/observations?metric&before&limit history, each with freshness
+ * GET /instruments/:symbol/documents?limit&offset
+ * GET /sources freshness of every source
+ * Capabilities:
+ * GET /instruments/resolve?q=&kind= trade.instrument.resolve
+ * POST /instruments trade.instrument.manage (editors)
+ * PATCH /instruments/:symbol trade.instrument.manage (editors)
+ * POST /instruments/:symbol/aliases trade.instrument.manage (editors)
+ * POST /observations trade.observation.write (services only)
+ * GET /instruments/:symbol/context trade.context.read (published; ?all=1 drafts for editors/services)
+ * GET /instruments/:symbol/context/input trade.context.read (OpenVibe.AI workflow input)
+ * POST /instruments/:symbol/context trade.context.propose (AI drafts with X-OV-Origin: ai; editors)
+ * POST /instruments/:symbol/context/revisions/:n/review people on the editor list only
+ * GET /watchlists trade.watchlist.read
+ * POST /watchlists trade.watchlist.create
+ * GET /watchlists/:id trade.watchlist.read
+ * PATCH /watchlists/:id trade.watchlist.update
+ * PUT /watchlists/:id/items/:symbol trade.watchlist.update
+ * DELETE /watchlists/:id/items/:symbol trade.watchlist.update
+ * DELETE /watchlists/:id trade.watchlist.delete
+ * GET /alerts trade.alert.read
+ * GET /alerts/deliveries trade.alert.read
+ * POST /alerts trade.alert.create
+ * DELETE /alerts/:id trade.alert.delete
+ *
+ * There is no endpoint that takes an order, holds or moves value, or gives personal advice
+ * (ADR-025); test/route-inventory.test.js fails the build if one appears.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
+const { guard } = require('../auth/viewer');
+const { CAPABILITIES: C } = require('../auth/capabilities');
+const { define } = require('./routes');
+const { DISCLAIMER } = require('../domain/alerts');
+
+function cors(origins) {
+ const allowed = new Set(origins);
+ return function apiCors(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');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ next();
+ };
+}
+
+function createApi(ctx) {
+ const { config, instruments, observations, documents, freshness, context, watchlists, alerts, reading, viewers, indexing, store } = ctx;
+ const router = express.Router();
+ router.use(cors(config.apiCorsOrigins));
+ router.use(viewers.middleware());
+ router.use(function privateApi(req, res, next) { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
+
+ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
+
+ function mustInstrument(req) {
+ const i = instruments.bySymbol(req.params.symbol);
+ if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
+ return i;
+ }
+
+ /** The person whose private data this is: the signed-in user, or X-OV-Subject for a service. */
+ function owner(req) {
+ const v = req.viewer;
+ if ((v.kind === 'user' || v.kind === 'service') && v.subject) return v.subject;
+ if (v.kind === 'service') throw new ApiError(400, 'subject.required', 'X-OV-Subject names the person this service acts for');
+ throw new ApiError(401, 'auth.required', 'Sign in with OpenVibe');
+ }
+
+ function mustEditor(req) {
+ const v = req.viewer;
+ if (v.kind === 'service') return; // the capability guard already decided
+ if (v.kind !== 'user' || !v.editor) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors can do this');
+ }
+
+ const paging = (req, def = 50, max = 200) => ({
+ limit: Math.min(Math.max(parseInt(req.query.limit, 10) || def, 1), max),
+ offset: Math.max(parseInt(req.query.offset, 10) || 0, 0),
+ });
+
+ // ── Instruments ─────────────────────────────────────────
+
+ define(router, 'get', '/instruments', 'getInstruments', run((req) => {
+ const { limit, offset } = paging(req);
+ const page = instruments.page({ limit, offset });
+ return { total: page.total, instruments: page.instruments.map(reading.instrumentDto), disclaimer: DISCLAIMER };
+ }));
+
+ define(router, 'get', '/instruments/resolve', 'resolveInstrument', guard(C.INSTRUMENT_RESOLVE), run((req) => {
+ const r = instruments.resolve(req.query.q, { kind: req.query.kind || null });
+ return {
+ query: r.query, status: r.status, match: r.match,
+ instrument: r.instrument ? reading.instrumentDto(r.instrument) : null,
+ candidates: r.candidates.map(reading.instrumentDto),
+ };
+ }));
+
+ define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
+ mustEditor(req);
+ const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
+ const i = instruments.create(req.body || {}, by);
+ store.tx(() => indexing.refresh(i, tp(req)));
+ return { instrument: reading.instrumentDto(i), aliases: instruments.aliases(i) };
+ }, 201));
+
+ define(router, 'get', '/instruments/:symbol', 'getInstrument', run((req) => {
+ const { decision, ...data } = reading.instrument(mustInstrument(req), paging(req, 25, 100));
+ return data;
+ }));
+
+ define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
+ mustEditor(req);
+ const i = instruments.update(mustInstrument(req), req.body || {});
+ store.tx(() => indexing.refresh(i, tp(req)));
+ return { instrument: reading.instrumentDto(i) };
+ }));
+
+ define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
+ mustEditor(req);
+ const i = mustInstrument(req);
+ const b = req.body || {};
+ const alias = instruments.addAlias(i, String(b.kind || ''), b.value, req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject);
+ return { alias, aliases: instruments.aliases(i) };
+ }, 201));
+
+ define(router, 'get', '/instruments/:symbol/observations', 'getObservations', run((req) => {
+ const i = mustInstrument(req);
+ const list = observations.history(i, { metric: req.query.metric || null, before: req.query.before || null, limit: req.query.limit });
+ return { instrument: reading.instrumentDto(i), observations: list.map((o) => observations.dto(o)), disclaimer: DISCLAIMER };
+ }));
+
+ define(router, 'get', '/instruments/:symbol/documents', 'getDocuments', run((req) => {
+ const i = mustInstrument(req);
+ return { instrument: reading.instrumentDto(i), total: documents.count(i), documents: documents.forInstrument(i, paging(req)).map(documents.dto) };
+ }));
+
+ // ── Observations (first-party feeds) ────────────────────
+
+ define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), jsonBody, run((req) => {
+ if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');
+ const b = req.body || {};
+ const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);
+ if (!instrument) throw new ApiError(404, 'instrument.not_found', 'No such instrument (symbol or instrument_id)');
+ const out = observations.record(b, instrument, { recordedBy: req.viewer.service, ...tp(req) });
+ ctx.outbox.kick();
+ return { observation: observations.dto(out.observation), created: out.created };
+ }, (out) => (out.created ? 201 : 200)));
+
+ // ── Context ─────────────────────────────────────────────
+
+ define(router, 'get', '/instruments/:symbol/context', 'getContext', guard(C.CONTEXT_READ), run((req) => {
+ const i = mustInstrument(req);
+ const out = { instrument: reading.instrumentDto(i), published: context.published(i), disclaimer: DISCLAIMER };
+ if (req.query.all === '1') {
+ const v = req.viewer;
+ if (!(v.kind === 'service' || (v.kind === 'user' && v.editor))) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors see drafts');
+ out.pending = context.pending(i);
+ }
+ return out;
+ }));
+
+ define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run((req) => context.input(mustInstrument(req))));
+
+ define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), jsonBody, run((req) => {
+ const i = mustInstrument(req);
+ const out = context.propose(req.viewer, i, req.body || {});
+ ctx.outbox.kick();
+ return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };
+ }, 201));
+
+ define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', jsonBody, run((req) => {
+ const i = mustInstrument(req);
+ const b = req.body || {};
+ const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });
+ ctx.outbox.kick();
+ return out;
+ }, 201));
+
+ // ── Sources ─────────────────────────────────────────────
+
+ define(router, 'get', '/sources', 'getSourceFreshness', run(() => ({ sources: freshness.all(), sync: ctx.sync.state() })));
+
+ // ── Watchlists (private) ────────────────────────────────
+
+ define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run((req) => ({ watchlists: watchlists.forOwner(owner(req)).map((w) => watchlists.dto(w)) })));
+
+ define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));
+
+ define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run((req) => ({ watchlist: watchlists.dto(watchlists.mustOwn(owner(req), req.params.id)) })));
+
+ define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {
+ const w = watchlists.mustOwn(owner(req), req.params.id);
+ return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };
+ }));
+
+ define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {
+ const w = watchlists.mustOwn(owner(req), req.params.id);
+ const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);
+ return { added, watchlist: watchlists.dto(w) };
+ }));
+
+ define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), run((req) => {
+ const w = watchlists.mustOwn(owner(req), req.params.id);
+ return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };
+ }));
+
+ define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));
+
+ // ── Alerts (private) ────────────────────────────────────
+
+ define(router, 'get', '/alerts', 'getAlertRules', guard(C.ALERT_READ), run((req) => ({ rules: alerts.forOwner(owner(req)) })));
+
+ define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run((req) => ({ deliveries: alerts.deliveries(owner(req), req.query.limit) })));
+
+ define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), jsonBody, run((req) => {
+ const b = req.body || {};
+ const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);
+ return { rule: alerts.dto(rule) };
+ }, 201));
+
+ define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));
+
+ router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });
+ return router;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * Shared helpers for the HTML routers: caching, the page renderer, form parsing with the CSRF
+ * check, notices after a redirect, and the not-found page.
+ *
+ * Caching (no leaks through shared caches):
+ * - public, max-age=60 only pages that are the same for everyone, served to an anonymous viewer
+ * - private, no-store everything personal (signed-in views, watchlists, alerts, the editor),
+ * every error and every form response
+ * Every HTML response varies on Cookie and Authorization.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const { renderPage } = require('../render/layout');
+const views = require('../render/views');
+const { csrfToken, checkCsrf } = require('../auth/forms');
+const { ApiError, asApiError, privateNoStore } = require('./errors');
+
+const NOTICES = {
+ watchlist_created: 'Watchlist created.',
+ watchlist_renamed: 'Watchlist renamed.',
+ watchlist_deleted: 'Watchlist deleted.',
+ item_added: 'Added to your watchlist.',
+ item_present: 'It was already on that watchlist.',
+ item_removed: 'Removed from your watchlist.',
+ alert_created: 'Alert created. You will be notified once per triggering document or observation.',
+ alert_deleted: 'Alert deleted.',
+ instrument_created: 'Instrument added.',
+ instrument_saved: 'Saved.',
+ alias_added: 'Alias added.',
+ context_saved: 'Context revision saved.',
+ context_published: 'Context published.',
+ context_rejected: 'Review recorded: rejected.',
+ context_retracted: 'Published context retracted.',
+};
+
+function createCommon({ config, store }) {
+ const formBody = express.urlencoded({ extended: false, limit: '64kb' });
+
+ function vary(res) { res.vary('Cookie'); res.vary('Authorization'); }
+
+ function cacheFor(req, res, { personal = false } = {}) {
+ vary(res);
+ if (personal || (req.viewer && req.viewer.kind !== 'anonymous')) privateNoStore(res);
+ else res.set('Cache-Control', 'public, max-age=60');
+ }
+
+ /** Render a full page. o: status, title, description, decision, canonical, body, jsonLd, feeds, personal */
+ function page(req, res, o) {
+ const decision = o.decision || seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: o.canonical || null, noindex: true }, { policy: { minWords: 0 } });
+ cacheFor(req, res, { personal: o.personal || (o.status && o.status >= 400) });
+ res.set('X-Robots-Tag', decision.robots);
+ res.status(o.status || 200).type('html').send(renderPage({ ...o, decision, viewer: req.viewer, config, path: req.originalUrl }));
+ }
+
+ function notFound(req, res, message = 'There is nothing at this address.') {
+ page(req, res, { status: 404, title: 'Not found', body: views.errorPage({ status: 404, title: 'Not found', message }), personal: true });
+ }
+
+ function failure(req, res, err) {
+ const e = asApiError(err);
+ if (!e) throw err;
+ page(req, res, { status: e.status, title: e.status === 404 ? 'Not found' : 'That did not work', body: views.errorPage({ status: e.status, title: e.status === 404 ? 'Not found' : 'That did not work', message: e.message }), personal: true });
+ }
+
+ const noticeOf = (req) => {
+ const n = req.query && typeof req.query.n === 'string' ? req.query.n : null;
+ if (n && NOTICES[n]) return { text: NOTICES[n] };
+ const e = req.query && typeof req.query.e === 'string' ? req.query.e.slice(0, 300) : null;
+ return e ? { text: e, error: true } : null;
+ };
+
+ /** Form middleware: parse, require a signed-in person, check the form token. */
+ const signedInForm = [formBody, function requireFormSession(req, res, next) {
+ if (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.subject) {
+ privateNoStore(res);
+ return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.get('referer') ? new URL(req.get('referer'), config.baseUrl).pathname : '/watchlists')}`);
+ }
+ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) {
+ return failure(req, res, new ApiError(403, 'form.token_invalid', 'This form expired. Go back, reload the page and try again.'));
+ }
+ next();
+ }];
+
+ const loginHref = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}`;
+
+ /** Redirect after a form, with a notice code or an error message. */
+ function after(res, path, { n, e } = {}) {
+ privateNoStore(res);
+ const url = new URL(path, config.baseUrl);
+ if (n) url.searchParams.set('n', n);
+ if (e) url.searchParams.set('e', e);
+ res.redirect(303, url.pathname + url.search + url.hash);
+ }
+
+ return {
+ page, notFound, failure, cacheFor, vary, noticeOf, signedInForm, formBody, loginHref, after,
+ csrf: (req) => csrfToken(config, req.viewer),
+ now: () => store.now(),
+ };
+}
+
+module.exports = { createCommon, NOTICES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+
+/**
+ * Crawl, feed and machine-readability artifacts (roadmap §32.4/§32.5):
+ *
+ * GET /robots.txt sitemap + explicit automated-consumer policy
+ * GET /llms.txt orientation for language models
+ * GET /sitemap.xml sitemap index
+ * GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:
+ * person-reviewed context, no stale price); lastmod = the
+ * context's real publication time
+ * GET /feed.xml | /atom.xml | /feed.json recent public documents across instruments
+ * GET /i/:symbol/documents.xml|.atom|.json one instrument's documents
+ *
+ * Built from the database on every request, never from the viewer. Watchlists, alert rules,
+ * deliveries and the editor never appear in any of them.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const sharedSeo = require('openvibe-shared/seo');
+const { define } = require('./routes');
+const { DISCLAIMER } = require('../render/layout');
+
+function createDiscovery(ctx) {
+ const { store, instruments, documents, indexing, urls, common } = ctx;
+ const router = express.Router();
+ const abs = urls.abs;
+ const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', 'public, max-age=300').send(body);
+ const listing = (url) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0 } });
+
+ function feedItem(d, instrument) {
+ const url = urls.documentAnchor(instrument, d);
+ const when = (ms) => (ms == null ? 'no date stated' : new Date(ms).toISOString());
+ return {
+ id: `urn:openvibe:trade:document:${d.id}`,
+ url,
+ title: `${instrument.symbol}: ${d.form_type ? `${d.form_type} — ` : ''}${d.title || d.filer_name || 'document'}`,
+ summary: `${d.kind === 'filing' ? 'Filed' : 'Published'} ${when(d.published_at)}; retrieved ${when(d.retrieved_at)} from source ${d.source_key}.${d.url ? ` Original: ${d.url}` : ''} ${DISCLAIMER}`,
+ published: d.published_at == null ? null : new Date(d.published_at).toISOString(),
+ tags: [instrument.symbol, ...(d.form_type ? [d.form_type] : [])],
+ decision: listing(url),
+ };
+ }
+
+ function sendFeed(res, type, channel, items) {
+ if (type === 'json') {
+ return res.type('application/feed+json').set('Cache-Control', 'public, max-age=300').send(JSON.stringify(seo.jsonFeed(channel, items)));
+ }
+ if (type === 'atom') {
+ const newest = items.map((i) => i.published).filter(Boolean).sort().pop();
+ return xml(res, seo.atomFeed({ ...channel, ...(newest ? {} : { updated: new Date(store.now()).toISOString() }) }, items), 'application/atom+xml');
+ }
+ return xml(res, seo.rssFeed(channel, items), 'application/rss+xml');
+ }
+
+ define(router, 'get', '/robots.txt', 'robotsTxt', (_req, res) => {
+ const body = [
+ '# openvibe.trade automated-consumer policy: search engines and AI crawlers are welcome to read',
+ '# instrument pages, feeds and sitemaps. Watchlists, alerts, the editor, sign-in and the API are',
+ '# private or not for crawling. Pages decide their own indexability (meta robots / X-Robots-Tag).',
+ '# Information only: nothing here is investment advice, and there is no trading here.',
+ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/watchlists', '/alerts', '/editor', '/resolve', '/auth/', '/api/', '/internal/'] }),
+ ].join('\n');
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
+ });
+
+ define(router, 'get', '/llms.txt', 'llmsTxt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
+ name: 'OpenVibe.Trade',
+ summary: 'Informational market context: instruments, timestamped observations with their sources, filings from OpenVibe.Sources, and reviewed context. Information only — not investment advice; no trading here.',
+ details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI-written context is labelled and is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'Instruments', url: abs('/'), note: 'all instruments' },
+ { title: 'Source freshness', url: abs('/sources') },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ ] },
+ { title: 'Feeds', links: [
+ { title: 'Recent documents (RSS)', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') },
+ { title: 'Per instrument', url: abs('/'), note: '/i/<SYMBOL>/documents.xml, .atom and .json' },
+ ] },
+ { title: 'Data', links: [{ title: 'Instrument JSON', url: abs('/'), note: 'append .json to an instrument URL (/i/<SYMBOL>.json)' }] },
+ ],
+ }));
+ });
+
+ define(router, 'get', '/sitemap.xml', 'sitemapIndex', (_req, res) => {
+ const times = instruments.active().filter((i) => indexing.decide(i).indexable && i.context_published_at).map((i) => i.context_published_at);
+ const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
+ xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));
+ });
+
+ define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', (_req, res) => {
+ const entries = instruments.active().map((i) => ({
+ loc: urls.instrument(i),
+ ...(i.context_published_at ? { lastmod: new Date(i.context_published_at).toISOString() } : {}),
+ decision: indexing.decide(i),
+ }));
+ xml(res, seo.sitemap(entries).files[0]);
+ });
+
+ const siteChannel = (type) => ({
+ title: 'Recent documents — OpenVibe.Trade',
+ link: abs('/'),
+ feedUrl: abs(type === 'atom' ? '/atom.xml' : type === 'json' ? '/feed.json' : '/feed.xml'),
+ description: `Filings and documents recorded from OpenVibe.Sources, with their source dates. ${DISCLAIMER}`,
+ language: 'en',
+ });
+ const recentItems = () => documents.recent(50).map((d) => feedItem(d, instruments.get(d.instrument_id)));
+ define(router, 'get', '/feed.xml', 'documentsRss', (_req, res) => sendFeed(res, 'rss', siteChannel('rss'), recentItems()));
+ define(router, 'get', '/atom.xml', 'documentsAtom', (_req, res) => sendFeed(res, 'atom', siteChannel('atom'), recentItems()));
+ define(router, 'get', '/feed.json', 'documentsJsonFeed', (_req, res) => sendFeed(res, 'json', siteChannel('json'), recentItems()));
+
+ define(router, 'get', '/i/:symbol/documents.:format', 'instrumentDocumentsFeed', (req, res) => {
+ const type = { xml: 'rss', atom: 'atom', json: 'json' }[req.params.format];
+ const instrument = instruments.bySymbol(req.params.symbol);
+ if (!type || !instrument || instrument.status !== 'active') return common.notFound(req, res);
+ if (instrument.symbol !== req.params.symbol) return res.redirect(301, urls.path.documentsFeed(instrument, type));
+ const items = documents.forInstrument(instrument, { limit: 50 }).map((d) => feedItem(d, instrument));
+ sendFeed(res, type, {
+ title: `${instrument.symbol} documents — OpenVibe.Trade`, link: urls.instrument(instrument),
+ feedUrl: abs(urls.path.documentsFeed(instrument, type)),
+ description: `Filings and documents about ${instrument.name}, with their source dates. ${DISCLAIMER}`, language: 'en',
+ }, items);
+ });
+
+ return router;
+}
+
+module.exports = { createDiscovery };
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+
+/**
+ * The editor: plain HTML forms for people on the editor list (TRADE_EDITORS, Network admins).
+ * Private, never cached by shared caches, never indexed.
+ *
+ * GET /editor add instruments; context waiting for review
+ * POST /editor/instruments add an instrument
+ * GET /editor/i/:symbol details, aliases, context revisions
+ * POST /editor/i/:symbol save details (name, kind, exchange, CIK, currency, status)
+ * POST /editor/i/:symbol/aliases add a ticker, CIK or name alias
+ * POST /editor/i/:symbol/context write a context revision (optionally publish)
+ * POST /editor/i/:symbol/context/:n/review approve (publishes) or reject an AI draft
+ * POST /editor/i/:symbol/context/:n/publish publish a revision that needs no review
+ * POST /editor/i/:symbol/context/retract remove the published context from the page
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const views = require('../render/views');
+const { define } = require('./routes');
+const { ApiError, asApiError } = require('./errors');
+
+function createEditor(ctx) {
+ const { instruments, context, documents, observations, common, indexing } = ctx;
+ const router = express.Router();
+ const decision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });
+
+ function requireEditor(req, res, next) {
+ const v = req.viewer;
+ if (v.kind !== 'user' || !v.subject) return common.after(res, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (!v.editor) return common.failure(req, res, new ApiError(403, 'editor.forbidden', 'Only people on the Trade editor list can use the editor.'));
+ next();
+ }
+ const form = [...common.signedInForm, requireEditor];
+
+ function mustInstrument(req) {
+ const i = instruments.bySymbol(req.params.symbol);
+ if (!i) throw new ApiError(404, 'instrument.not_found', 'No such instrument');
+ return i;
+ }
+ const editPath = (i) => `/editor/i/${encodeURIComponent(i.symbol)}`;
+
+ function fail(req, res, path, err) {
+ const e = asApiError(err);
+ if (!e) throw err;
+ common.after(res, path, { e: e.message });
+ }
+
+ define(router, 'get', '/editor', 'showEditor', requireEditor, (req, res) => {
+ const all = instruments.active();
+ const pending = [];
+ for (const i of all) for (const p of context.pending(i)) pending.push({ symbol: i.symbol, revision: p.revision, label: p.needs_review ? 'AI draft, needs review' : 'not published' });
+ common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });
+ });
+
+ define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, (req, res) => {
+ try {
+ const i = instruments.create(req.body, req.viewer.subject);
+ ctx.store.tx(() => indexing.refresh(i));
+ common.after(res, editPath(i), { n: 'instrument_created' });
+ } catch (err) { fail(req, res, '/editor', err); }
+ });
+
+ define(router, 'get', '/editor/i/:symbol', 'showEditorInstrument', requireEditor, (req, res) => {
+ let i;
+ try { i = mustInstrument(req); } catch (err) { return common.failure(req, res, err); }
+ const revisions = context.revisions(i, { limit: 30 }).map((r) => context.view(i, r));
+ common.page(req, res, {
+ title: `Editor · ${i.symbol}`, decision, personal: true,
+ body: views.editorInstrument({
+ instrument: i, aliases: instruments.aliases(i), revisions, head: context.head(i) ? context.head(i).number : 0,
+ documents: documents.forInstrument(i, { limit: 30 }), observations: observations.latest(i),
+ csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS,
+ }),
+ });
+ });
+
+ define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ const updated = instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });
+ ctx.store.tx(() => indexing.refresh(updated));
+ common.after(res, editPath(updated), { n: 'instrument_saved' });
+ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
+ });
+
+ define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
+ common.after(res, editPath(i), { n: 'alias_added' });
+ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
+ });
+
+ define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
+ const cites = raw.map((s) => { const [kind, id] = String(s).split(':'); return { kind, id }; });
+ const out = context.propose(req.viewer, i, { body: req.body.body, cites, expected_revision: req.body.expected_revision, publish: req.body.publish === '1' });
+ common.after(res, editPath(i), { n: out.published ? 'context_published' : 'context_saved' });
+ } catch (err) {
+ if (err && err.code === 'revision.conflict') return common.after(res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, { e: 'Someone saved a newer revision while you were writing. Your text was not saved: copy it, reload and try again.' });
+ fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err);
+ }
+ });
+
+ define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
+ context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: decisionValue, note: req.body.note || null });
+ common.after(res, editPath(i), { n: decisionValue === 'approved' ? 'context_published' : 'context_rejected' });
+ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
+ });
+
+ define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ context.publish(i, parseInt(req.params.n, 10));
+ common.after(res, editPath(i), { n: 'context_published' });
+ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
+ });
+
+ define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, (req, res) => {
+ try {
+ const i = mustInstrument(req);
+ context.retract(req.viewer, i);
+ common.after(res, editPath(i), { n: 'context_retracted' });
+ } catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
+ });
+
+ return router;
+}
+
+module.exports = { createEditor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'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');
+
+/** A refusal with a stable problem code (e.g. 404 'post.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;
+ }
+}
+
+/** Publishing package errors carry { status, code } too. */
+function asApiError(err) {
+ if (err instanceof ApiError) return err;
+ if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {
+ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
+ return new ApiError(err.status, err.code, err.message, extra);
+ }
+ if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {
+ return new ApiError(422, 'request.invalid', err.message);
+ }
+ return null;
+}
+
+/** 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;
+ const e = asApiError(err);
+ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
+ console.error('[Trade 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: '512kb' });
+/** 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, asApiError, run, jsonBody, privateNoStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * Public pages, server-rendered and complete without JavaScript:
+ *
+ * GET / instruments, recent documents, feeds
+ * GET /resolve?q= deterministic resolution (CIK → ticker → exact name); one match → 302
+ * GET /i/:symbol the instrument page: observations with timestamps and sources, context
+ * with its disclosure, documents, source freshness ("stale since")
+ * GET /i/:symbol.json the same data as JSON
+ * GET /sources freshness of every source
+ *
+ * A lower-case symbol or a ticker alias answers 301 to the canonical /i/:SYMBOL.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const { paginate } = require('openvibe-publishing/ssr');
+const views = require('../render/views');
+const { define } = require('./routes');
+
+function createPages(ctx) {
+ const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync } = ctx;
+ const router = express.Router();
+ const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
+
+ define(router, 'get', '/', 'showHome', (req, res) => {
+ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
+ const per = 50;
+ const first = instruments.page({ limit: 0, offset: 0 });
+ const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });
+ if (pager.outOfRange && first.total) return common.notFound(req, res);
+ const page = instruments.page({ limit: pager.limit, offset: pager.offset });
+ const recent = documents.recent(10).map((d) => ({ document: documents.dto(d), instrument: instruments.get(d.instrument_id) }));
+ const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);
+ common.page(req, res, {
+ title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),
+ feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
+ jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
+ body: views.home({ page, recent, urls, sync: sync.state(), pager }),
+ });
+ });
+
+ define(router, 'get', '/resolve', 'showResolve', (req, res) => {
+ const result = instruments.resolve(req.query.q);
+ if (result.status === 'resolved') {
+ common.vary(res);
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.redirect(302, urls.path.instrument(result.instrument));
+ }
+ common.page(req, res, { status: result.status === 'not_found' ? 404 : 200, title: 'Find an instrument', body: views.resolvePage({ result, urls }), personal: true });
+ });
+
+ define(router, 'get', '/sources', 'showSources', (req, res) => {
+ common.page(req, res, {
+ title: 'Source freshness', canonical: urls.abs('/sources'),
+ body: views.sourcesPage({ sources: freshness.all(), sync: sync.state() }),
+ });
+ });
+
+ define(router, 'get', '/i/:symbol', 'showInstrument', (req, res) => {
+ let raw = String(req.params.symbol);
+ const asJson = raw.endsWith('.json');
+ if (asJson) raw = raw.slice(0, -5);
+ let instrument = instruments.bySymbol(raw);
+ if (!instrument) {
+ const r = instruments.resolve(raw, { kind: 'ticker' });
+ instrument = r.status === 'resolved' ? r.instrument : null;
+ }
+ if (!instrument) return asJson ? res.status(404).set('Cache-Control', 'private, no-store').json({ code: 'instrument.not_found', error: 'No such instrument' }) : common.notFound(req, res, `No instrument has the symbol “${raw}”.`);
+ if (instrument.symbol !== raw) {
+ common.vary(res);
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.redirect(301, asJson ? urls.path.instrumentJson(instrument) : urls.path.instrument(instrument) + (req.query.page ? `?page=${encodeURIComponent(req.query.page)}` : ''));
+ }
+
+ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
+ const total = documents.count(instrument);
+ const pager = paginate({ page: pageNo, perPage: 25, total, href: (p) => (p === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${p}`) });
+ if (pager.outOfRange && total) return common.notFound(req, res);
+ const data = reading.instrument(instrument, { limit: pager.limit, offset: pager.offset });
+ const { decision } = data;
+
+ if (asJson) {
+ common.cacheFor(req, res);
+ res.set('X-Robots-Tag', decision.robots);
+ const { decision: _d, ...out } = data;
+ return res.json(out);
+ }
+ const viewer = req.viewer;
+ const signedIn = viewer.kind === 'user' && viewer.subject;
+ common.page(req, res, {
+ title: `${instrument.symbol} — ${instrument.name}`,
+ description: `${instrument.name} (${instrument.symbol}): observations with their observation times and sources, filings and source freshness. Information only.`,
+ canonical: urls.abs(pageNo === 1 ? urls.path.instrument(instrument) : `${urls.path.instrument(instrument)}?page=${pageNo}`),
+ decision: pageNo === 1 ? decision : { ...decision, canonical: urls.instrument(instrument) },
+ jsonLd: reading.jsonLd(instrument),
+ feeds: ['rss', 'atom', 'json'].map((t) => ({ type: t, href: urls.path.documentsFeed(instrument, t), title: `${instrument.symbol} documents` })),
+ personal: Boolean(signedIn),
+ body: views.instrumentPage({
+ ...data, instrument, urls, pager, notice: common.noticeOf(req),
+ context: data.context, pending: viewer.editor ? context.pending(instrument) : [],
+ viewer, watchlists: signedIn ? watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),
+ }),
+ });
+ });
+
+ return router;
+}
+
+module.exports = { createPages };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * Private pages and forms: watchlists and alert rules. Only for the signed-in owner; every response
+ * is Cache-Control: private, no-store with X-Robots-Tag: noindex, nofollow. Someone else's
+ * watchlist or rule answers "not found". Plain HTML forms with the form token (no JavaScript).
+ *
+ * GET /watchlists your watchlists, alert rules and recent deliveries
+ * POST /watchlists create { name }
+ * POST /watchlists/rename { watchlist_id, name }
+ * POST /watchlists/delete { watchlist_id }
+ * POST /watchlists/item add { watchlist_id, symbol | q, note? }
+ * POST /watchlists/item/remove { watchlist_id, symbol }
+ * POST /alerts create { symbol, kind, … }
+ * POST /alerts/delete { rule_id }
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const views = require('../render/views');
+const { define } = require('./routes');
+const { asApiError } = require('./errors');
+
+function createPrivate(ctx) {
+ const { instruments, watchlists, alerts, common } = ctx;
+ const router = express.Router();
+ const privateDecision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });
+
+ function back(req, res, fallback, err) {
+ const e = asApiError(err);
+ if (!e) throw err;
+ common.after(res, fallback, { e: e.message });
+ }
+
+ function instrumentFrom(body) {
+ if (body.symbol) return instruments.bySymbol(body.symbol);
+ const r = instruments.resolve(body.q);
+ return r.status === 'resolved' ? r.instrument : null;
+ }
+
+ define(router, 'get', '/watchlists', 'showWatchlists', (req, res) => {
+ const v = req.viewer;
+ const signedIn = v.kind === 'user' && v.subject;
+ const lists = signedIn ? watchlists.forOwner(v.subject).map((w) => watchlists.dto(w)) : [];
+ common.page(req, res, {
+ title: 'Your watchlists', decision: privateDecision, personal: true,
+ body: views.watchlistsPage({
+ viewer: v, lists, rules: signedIn ? alerts.forOwner(v.subject) : [], deliveries: signedIn ? alerts.deliveries(v.subject, 20) : [],
+ csrf: common.csrf(req), loginHref: common.loginHref(req), notice: common.noticeOf(req),
+ }),
+ });
+ });
+
+ define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, (req, res) => {
+ try {
+ watchlists.create(req.viewer.subject, { name: req.body.name });
+ common.after(res, '/watchlists', { n: 'watchlist_created' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, (req, res) => {
+ try {
+ watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
+ common.after(res, '/watchlists', { n: 'watchlist_renamed' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, (req, res) => {
+ try {
+ watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
+ common.after(res, '/watchlists', { n: 'watchlist_deleted' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, (req, res) => {
+ try {
+ const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ const instrument = instrumentFrom(req.body);
+ const added = watchlists.add(w, instrument, req.body.note);
+ const from = req.get('referer') && /\/i\//.test(new URL(req.get('referer'), ctx.config.baseUrl).pathname) ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists';
+ common.after(res, from, { n: added ? 'item_added' : 'item_present' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, (req, res) => {
+ try {
+ const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
+ watchlists.drop(w, instruments.bySymbol(req.body.symbol));
+ common.after(res, '/watchlists', { n: 'item_removed' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, (req, res) => {
+ const instrument = instruments.bySymbol(req.body.symbol);
+ try {
+ alerts.create(req.viewer.subject, instrument, {
+ kind: req.body.kind, metric: req.body.metric, operator: req.body.operator, threshold: req.body.threshold,
+ unit: req.body.unit, currency: req.body.currency, form_types: req.body.form_types,
+ });
+ common.after(res, instrument ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists', { n: 'alert_created' });
+ } catch (err) {
+ const e = asApiError(err);
+ if (!e) throw err;
+ common.after(res, instrument ? `/i/${encodeURIComponent(instrument.symbol)}` : '/watchlists', { e: e.message });
+ }
+ });
+
+ define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, (req, res) => {
+ try {
+ alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
+ common.after(res, '/watchlists', { n: 'alert_deleted' });
+ } catch (err) { back(req, res, '/watchlists', err); }
+ });
+
+ return router;
+}
+
+module.exports = { createPrivate };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * Route definition with an explicit handler name. Every route Trade serves (outside the shared
+ * /auth session layer) is registered through define(), so the route inventory test
+ * (test/route-inventory.test.js) can read each path AND a meaningful handler name from the live
+ * Express stack and fail the build if either carries order, buy/sell, execution, custody, wallet,
+ * escrow, checkout or listing semantics (ADR-025).
+ */
+function named(name, fn) {
+ if (typeof name !== 'string' || !/^[a-z][A-Za-z0-9]+$/.test(name)) throw new TypeError(`handler name "${name}" must be camelCase`);
+ Object.defineProperty(fn, 'name', { value: name });
+ return fn;
+}
+
+/** define(router, 'get', '/path', 'handlerName', ...middleware, handler) */
+function define(router, method, path, name, ...handlers) {
+ if (!handlers.length) throw new TypeError('a route needs a handler');
+ const last = handlers.pop();
+ router[method](path, ...handlers, named(name, last));
+}
+
+module.exports = { define, named };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * OpenVibe.Trade — process entry. `node server/index.js`
+ * Listens on PORT (4860) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the
+ * client secret are set) and the worker (Sources sync, freshness).
+ */
+const { createApp } = require('./app');
+
+const { app, ctx } = createApp();
+const { config } = ctx;
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Trade] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Trade] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; Sources sync ${ctx.sync.enabled ? 'on' : 'off'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
+});
+server.keepAliveTimeout = 65_000;
+ctx.outbox.start();
+ctx.worker.start();
+
+function shutdown(signal) {
+ console.log(`[Trade] ${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,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready).
+ *
+ * db required a real query on Trade's SQLite (the nine charter tables answer)
+ * network_jwks optional the Network signing key has loaded; without it pages and feeds
+ * 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
+ * sources_sync optional the Sources sync is configured and its last run succeeded; while it
+ * fails, pages keep serving what was recorded and mark it stale
+ * freshness optional how many sources are stale right now (information, never hidden)
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { CHARTER_TABLES } = require('./db');
+
+function createTradeReadiness({ store, auth, outbox, sync, freshness, release = null }) {
+ const { db } = store;
+ return createReadiness({
+ service: 'trade',
+ 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_sync', required: false,
+ check: () => {
+ const s = sync.state();
+ if (!s.enabled) return 'Sources sync off (OV_OAUTH_CLIENT_SECRET unset): no new filings or observations arrive';
+ if (s.last_error) return `last Sources sync failed: ${s.last_error}`;
+ if (!s.last_ok_at) return 'Sources sync has not completed yet';
+ return { ok: true, detail: { cursor: s.cursor, last_ok_at: s.last_ok_at } };
+ },
+ },
+ {
+ name: 'freshness', required: false,
+ check: () => {
+ const all = freshness.all();
+ const stale = all.filter((s) => s.stale).map((s) => s.key);
+ return stale.length ? `${stale.length} of ${all.length} sources stale: ${stale.slice(0, 10).join(', ')}` : { ok: true, detail: { sources: all.length } };
+ },
+ },
+ ],
+ });
+}
+
+module.exports = { createTradeReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'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, JSON-LD from real fields only, feed links, the shared app icon
+ * - the persistent disclaimer, above the content on every page:
+ * "Information only — not investment advice; no trading here."
+ * - 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.Trade';
+const DISCLAIMER = 'Information only — not investment advice; no trading here.';
+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)}`;
+
+const NAV_LINKS = [
+ { label: 'Instruments', href: '/' },
+ { label: 'Sources', href: '/sources' },
+ { label: 'Watchlists', href: '/watchlists' },
+];
+
+/**
+ * o: title, description, decision (required), canonical, type, jsonLd [], feeds [{ type, href, title }],
+ * body (HTML), viewer, config, path, 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} — sourced market information`,
+ description: o.description || `Instruments, timestamped observations and filings with their sources. ${DISCLAIMER}`,
+ decision: o.decision,
+ canonical: o.canonical,
+ type: o.type || 'website',
+ siteName: SITE_NAME,
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ });
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const loginNext = encodeURIComponent(o.path || '/');
+ const nav = {
+ service: 'trade',
+ apiBase: NETWORK_URL,
+ links: NAV_LINKS,
+ 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.editor ? '<a href="/editor">Editor</a> · ' : ''}<a href="/watchlists">Your watchlists</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="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+${head}
+${seo.feedLinks(o.feeds || [])}
+${appIcon.headTags({ site: 'trade' })}
+<link rel="stylesheet" href="${asset('css/trade.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: NAV_LINKS })}
+<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
+<p class="disclaimer" role="note"><strong>${esc(DISCLAIMER)}</strong> Every number shows when it was observed and where it came from; stale sources are labelled as stale.</p>
+<main id="main" class="page">
+${o.body || ''}
+</main>
+<p class="disclaimer disclaimer-foot" role="note">${esc(DISCLAIMER)} OpenVibe.Trade holds no money or assets, takes no orders and gives no personal recommendations.</p>
+${chrome.footer({ service: 'trade', 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, DISCLAIMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,328 @@
@@ -0,0 +1,328 @@
+'use strict';
+
+/**
+ * Page bodies (HTML fragments for layout.renderPage). All escaping goes through
+ * openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the
+ * package's safe Markdown renderer.
+ *
+ * The honesty rules are visible here:
+ * - a number appears only in an observation row, next to its observed_at, retrieved_at and source
+ * - no observation → the sentence "no number is shown", never a placeholder value
+ * - a stale source or value carries a "stale since …" badge; unknown freshness says so
+ * - AI context carries its disclosure; the disclaimer is in the layout on every page
+ */
+const { html, raw, breadcrumbsHtml, paginationHtml } = require('openvibe-publishing/ssr');
+
+const ISO = (v) => (v == null ? null : (typeof v === 'number' ? new Date(v).toISOString() : String(v)));
+
+/** A timestamp as `<time>` in UTC to the minute, or the given fallback text. */
+function when(v, fallback = 'not stated by the source') {
+ const s = ISO(v);
+ if (!s) return html`<span class="muted">${fallback}</span>`;
+ const d = new Date(s);
+ if (Number.isNaN(d.getTime())) return html`<span class="muted">${fallback}</span>`;
+ return html`<time datetime="${d.toISOString()}">${d.toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`;
+}
+
+function staleBadge(f) {
+ if (!f) return '';
+ if (f.stale) {
+ return html`<span class="badge stale">stale</span>${f.stale_since ? html` since ${when(f.stale_since)}` : html` <span class="muted">(${f.reason === 'source_unknown' ? 'freshness unknown' : 'never fetched successfully'})</span>`}`;
+ }
+ return html`<span class="badge fresh">current</span>`;
+}
+
+function csrfField(token) { return html`<input type="hidden" name="_csrf" value="${token}">`; }
+
+function notice(n) {
+ if (!n) return '';
+ return html`<p class="notice ${n.error ? 'error' : ''}" role="status">${n.text}</p>`;
+}
+
+// ── Home ─────────────────────────────────────────────────────────────────────
+
+function home({ page, recent, urls, sync, pager }) {
+ const rows = page.instruments.map((i) => html`<tr>
+ <td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>
+ <td>${i.name}</td><td>${i.kind}</td><td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>
+ </tr>`);
+ const docs = recent.map((r) => html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}">${r.instrument.symbol}</a>
+ ${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}
+ — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`);
+ return html`
+<h1>Sourced market information</h1>
+<p class="lede">Instruments with the observations and filings that sources published about them. Every value shows when it was observed, when it was retrieved and which source stated it. When a source falls behind, its data is marked stale — it is never replaced with a guess.</p>
+<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>
+<form class="search" method="get" action="/resolve" role="search">
+ <label for="q">Find an instrument by ticker, CIK or company name</label>
+ <input id="q" name="q" type="search" maxlength="200" required>
+ <button type="submit">Find</button>
+</form>
+<h2>Instruments</h2>
+${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Exchange</th></tr></thead><tbody>${rows}</tbody></table>
+${raw(paginationHtml(pager))}`
+ : html`<p class="empty">No instruments have been added yet.</p>`}
+<h2>Recent documents</h2>
+${recent.length ? html`<ul class="docs">${docs}</ul>` : html`<p class="empty">No documents have been recorded yet.</p>`}
+<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${sync && sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(sync.last_ok_at)}.` : ''}</p>`;
+}
+
+// ── Resolve ──────────────────────────────────────────────────────────────────
+
+function resolvePage({ result, urls }) {
+ const cands = result.candidates.map((i) => html`<li><a href="${urls.path.instrument(i)}">${i.symbol}</a> — ${i.name}</li>`);
+ return html`
+<h1>Find an instrument</h1>
+<form class="search" method="get" action="/resolve" role="search">
+ <label for="q">Ticker, CIK or company name</label>
+ <input id="q" name="q" type="search" maxlength="200" value="${result.query}" required>
+ <button type="submit">Find</button>
+</form>
+${result.status === 'ambiguous' ? html`<p>“${result.query}” matches several instruments by name. None was chosen for you:</p><ul>${cands}</ul>` : ''}
+${result.status === 'not_found' ? html`<p class="empty">No instrument matches “${result.query}” as a CIK, a ticker or an exact company name. Resolution is exact on purpose: no guess is made.</p>` : ''}`;
+}
+
+// ── Instrument ───────────────────────────────────────────────────────────────
+
+function observationsTable(instrument, observations) {
+ if (!observations.length) {
+ return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;
+ }
+ const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">
+ <th scope="row">${o.metric}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>
+ <td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>
+ <td>${when(o.observed_at)}</td>
+ <td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>
+ <td>${when(o.retrieved_at)}</td>
+ <td>${staleBadge(o.freshness)}</td>
+ </tr>`);
+ return html`<table class="data observations">
+<caption>Latest observation per metric. Values are exactly as the source stated them.</caption>
+<thead><tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">Observed</th><th scope="col">Source</th><th scope="col">Retrieved</th><th scope="col">Freshness</th></tr></thead>
+<tbody>${rows}</tbody></table>`;
+}
+
+function sourcesTable(list, { caption }) {
+ if (!list.length) return html`<p class="empty">No sources have reported yet.</p>`;
+ const rows = list.map((s) => html`<tr id="source-${s.key}">
+ <th scope="row">${s.name || s.key}${s.name && s.name !== s.key ? html` <span class="muted">${s.key}</span>` : ''}</th>
+ <td>${s.status === 'fresh' ? html`<span class="badge fresh">current</span>` : s.status === 'unknown' ? html`<span class="badge stale">freshness unknown</span>` : html`<span class="badge stale">stale</span>${s.stale_since ? html` since ${when(s.stale_since)}` : ''}`}</td>
+ <td>${when(s.last_success_at, 'never')}</td>
+ <td>${s.stale_after_sec ? `${Math.round(s.stale_after_sec / 60)} min` : ''}</td>
+ <td>${s.upstream_status || html`<span class="muted">not reported</span>`}</td>
+ </tr>`);
+ return html`<table class="data sources"><caption>${caption}</caption>
+<thead><tr><th scope="col">Source</th><th scope="col">Freshness</th><th scope="col">Last successful fetch</th><th scope="col">Stale after</th><th scope="col">Sources reports</th></tr></thead>
+<tbody>${rows}</tbody></table>`;
+}
+
+function citeList(cites, instrument, urls) {
+ if (!cites.length) return html`<p class="muted">This context cites no documents or observations.</p>`;
+ const items = cites.map((c) => {
+ if (c.missing) return html`<li class="muted">${c.kind} ${c.id} is no longer available.</li>`;
+ if (c.observation) {
+ const o = c.observation;
+ return html`<li><a href="${urls.path.instrument(instrument)}#${o.id}">${o.metric} = ${o.value} ${o.unit}</a>, observed ${when(o.observed_at)}, source ${o.source.key}, retrieved ${when(o.retrieved_at)} ${staleBadge(o.freshness)}</li>`;
+ }
+ const d = c.document;
+ return html`<li><a href="${urls.path.instrument(instrument)}#${d.id}">${d.form_type ? `${d.form_type}: ` : ''}${d.title || d.filer_name || d.id}</a>, ${d.published_at ? html`published ${when(d.published_at)}` : 'no publication date stated'}, retrieved ${when(d.retrieved_at)}</li>`;
+ });
+ return html`<ol class="cites">${items}</ol>`;
+}
+
+function disclosureLine(ctx) {
+ if (ctx.disclosure) {
+ return html`<p class="disclosure"><strong>${ctx.disclosure.short}.</strong> ${ctx.disclosure.long}${ctx.review && ctx.review.decision === 'approved' ? html` Approved ${when(ctx.review.reviewed_at)}.` : ''}</p>`;
+ }
+ return html`<p class="disclosure">Written by a Trade editor.</p>`;
+}
+
+function contextSection(instrument, ctx, urls, { pending = [], editor = false } = {}) {
+ const pend = editor && pending.length
+ ? html`<p class="notice">${pending.length} context revision${pending.length === 1 ? '' : 's'} waiting — <a href="/editor/i/${encodeURIComponent(instrument.symbol)}">review in the editor</a>.</p>` : '';
+ if (!ctx) {
+ return html`<section class="context" aria-labelledby="context-h"><h2 id="context-h">Context</h2>
+<p class="empty">No reviewed context has been published for ${instrument.symbol}. The observations and documents below are shown as their sources stated them.</p>${pend}</section>`;
+ }
+ return html`<section class="context" aria-labelledby="context-h"><h2 id="context-h">Context</h2>
+${disclosureLine(ctx)}
+<p class="meta">As of ${ctx.as_of ? when(ctx.as_of) : html`<span class="muted">no dated source cited</span>`} · written ${when(ctx.written_at)} · published ${when(ctx.published_at)} · revision ${ctx.revision}</p>
+<div class="context-body">${raw(ctx.body_html)}</div>
+<h3>Cited</h3>
+${citeList(ctx.cites, instrument, urls)}
+<p class="muted">Context describes what sources state. It is not a recommendation to buy, sell or hold anything.</p>
+${pend}</section>`;
+}
+
+function documentsSection(instrument, docs, { pager, urls }) {
+ const feeds = html`<p class="feeds">Feeds of ${instrument.symbol} documents: <a href="${urls.path.documentsFeed(instrument, 'rss')}">RSS</a> · <a href="${urls.path.documentsFeed(instrument, 'atom')}">Atom</a> · <a href="${urls.path.documentsFeed(instrument, 'json')}">JSON Feed</a></p>`;
+ if (!docs.length) return html`<section aria-labelledby="docs-h"><h2 id="docs-h">Documents</h2><p class="empty">No documents from sources have been recorded for ${instrument.symbol}.</p>${feeds}</section>`;
+ const items = docs.map((d) => html`<li id="${d.id}" class="doc">
+ ${d.form_type ? html`<span class="badge">${d.form_type}</span> ` : ''}${d.url ? html`<a href="${d.url}" rel="nofollow noopener">${d.title || d.filer_name || 'Document'}</a>` : (d.title || 'Document')}
+ <div class="meta">${d.kind === 'filing' ? 'Filed' : 'Published'} ${when(d.published_at)} · retrieved ${when(d.retrieved_at)} · source ${d.source.key}${d.freshness && d.freshness.stale ? html` (${staleBadge(d.freshness)}: newer documents may be missing)` : ''}${d.accession ? ` · accession ${d.accession}` : ''}</div>
+ </li>`);
+ return html`<section aria-labelledby="docs-h"><h2 id="docs-h">Documents</h2><ul class="docs">${items}</ul>
+${raw(paginationHtml(pager))}${feeds}</section>`;
+}
+
+function personalTools(instrument, { viewer, watchlists, csrf, loginHref }) {
+ if (!viewer || viewer.kind !== 'user' || !viewer.subject) {
+ return html`<section class="personal" aria-labelledby="personal-h"><h2 id="personal-h">Watch this instrument</h2>
+<p><a href="${loginHref}">Sign in</a> to add ${instrument.symbol} to a private watchlist or to get an alert when a new document or observation arrives.</p></section>`;
+ }
+ const options = watchlists.map((w) => html`<option value="${w.id}">${w.name}</option>`);
+ return html`<section class="personal" aria-labelledby="personal-h"><h2 id="personal-h">Watch this instrument <span class="badge">private</span></h2>
+${watchlists.length ? html`<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}
+ <input type="hidden" name="symbol" value="${instrument.symbol}">
+ <label for="wl">Add to watchlist</label><select id="wl" name="watchlist_id">${options}</select>
+ <button type="submit">Add</button></form>` : html`<p><a href="/watchlists">Create a watchlist</a> first.</p>`}
+<form method="post" action="/alerts" class="alert-form">${csrfField(csrf)}
+ <input type="hidden" name="symbol" value="${instrument.symbol}">
+ <fieldset><legend>New alert for ${instrument.symbol}</legend>
+ <label><input type="radio" name="kind" value="new_document" checked> Any new document</label>
+ <label><input type="radio" name="kind" value="filing_type"> New filing of type(s) <input name="form_types" placeholder="10-K, 10-Q, 8-K" maxlength="120"></label>
+ <label><input type="radio" name="kind" value="threshold"> Observation
+ <input name="metric" placeholder="metric, e.g. price.close" maxlength="80">
+ <select name="operator"><option value="above">above</option><option value="below">below</option></select>
+ <input name="threshold" placeholder="value" maxlength="40"> <input name="unit" placeholder="unit, e.g. USD" maxlength="20"></label>
+ </fieldset>
+ <button type="submit">Create alert</button>
+ <p class="muted">Alerts are delivered as OpenVibe notifications, once per triggering observation or document. No email is sent.</p>
+</form></section>`;
+}
+
+function instrumentPage(d) {
+ const { instrument, aliases, observations, sources, documents, context, urls } = d;
+ const crumbs = breadcrumbsHtml([{ name: 'Instruments', url: '/' }, { name: instrument.symbol }]);
+ const meta = [instrument.kind, instrument.exchange ? `exchange ${instrument.exchange}` : null, instrument.cik ? `CIK ${instrument.cik}` : null, instrument.currency ? `trades in ${instrument.currency}` : null].filter(Boolean).join(' · ');
+ const other = aliases.filter((a) => !(a.kind === 'ticker' && a.normalized === instrument.symbol) && !(a.kind === 'cik' && a.normalized === instrument.cik) && a.kind !== 'name');
+ return html`${raw(crumbs)}
+<h1>${instrument.symbol} <span class="name">${instrument.name}</span></h1>
+<p class="meta">${meta}${other.length ? html` · also ${other.map((a) => a.value).join(', ')}` : ''}${instrument.status !== 'active' ? html` · <span class="badge stale">archived</span>` : ''}</p>
+${notice(d.notice)}
+<section aria-labelledby="obs-h"><h2 id="obs-h">Observations</h2>${observationsTable(instrument, observations)}</section>
+${contextSection(instrument, context, urls, { pending: d.pending, editor: d.viewer && d.viewer.editor })}
+${documentsSection(instrument, documents, { pager: d.pager, urls })}
+<section aria-labelledby="src-h"><h2 id="src-h">Source freshness</h2>${sourcesTable(sources, { caption: `Sources behind this page. A source is stale when its last successful fetch is older than its window.` })}</section>
+${personalTools(instrument, d)}
+<p class="muted">Machine-readable: <a href="${urls.path.instrumentJson(instrument)}">${instrument.symbol}.json</a> (the same data with every timestamp and source).</p>`;
+}
+
+// ── Sources ──────────────────────────────────────────────────────────────────
+
+function sourcesPage({ sources, sync }) {
+ return html`<h1>Source freshness</h1>
+<p class="lede">Every source Trade shows data from, with its last successful fetch. A stale source's data stays on the pages with its timestamps and a “stale since” label; it is never replaced with an invented value.</p>
+${sourcesTable(sources, { caption: 'Sources and their freshness, computed with this site’s clock.' })}
+<p class="muted">Synchronisation with OpenVibe.Sources: ${!sync.enabled ? 'not configured' : sync.last_error ? html`failing since ${when(sync.last_error_at)} (${sync.last_error})` : sync.last_ok_at ? html`last succeeded ${when(sync.last_ok_at)}` : 'not run yet'}.</p>`;
+}
+
+// ── Watchlists (private) ─────────────────────────────────────────────────────
+
+function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, notice: n }) {
+ if (!viewer || viewer.kind !== 'user' || !viewer.subject) {
+ return html`<h1>Watchlists</h1><p>Watchlists and alerts are private to your OpenVibe account. <a href="${loginHref}">Sign in</a> to see yours.</p>`;
+ }
+ const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">
+<h2>${w.name}</h2>
+${w.items.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
+${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td>
+<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}
+</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}
+<form method="post" action="/watchlists/item" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
+ <label>Add by ticker, CIK or name <input name="q" maxlength="200" required></label> <button type="submit">Add</button></form>
+<form method="post" action="/watchlists/rename" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}">
+ <label>Rename <input name="name" maxlength="80" value="${w.name}" required></label> <button type="submit">Rename</button></form>
+<form method="post" action="/watchlists/delete" class="inline-form">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><button type="submit" class="danger">Delete this watchlist</button></form>
+</section>`);
+ const ruleRows = rules.map((r) => html`<tr><td><a href="/i/${encodeURIComponent(r.instrument.symbol)}">${r.instrument.symbol}</a></td>
+<td>${r.kind === 'threshold' ? `${r.metric} ${r.operator} ${r.threshold} ${r.unit}${r.currency ? ` (${r.currency})` : ''}` : r.kind === 'filing_type' ? `new filing: ${(r.form_types || []).join(', ')}` : 'any new document'}</td>
+<td>${when(r.created_at)}</td>
+<td><form method="post" action="/alerts/delete" class="inline">${csrfField(csrf)}<input type="hidden" name="rule_id" value="${r.id}"><button type="submit">Delete</button></form></td></tr>`);
+ const delRows = deliveries.map((x) => html`<li>${when(x.created_at)} — ${x.trigger.kind === 'observation'
+ ? html`${x.trigger.metric} = ${x.trigger.value} ${x.trigger.unit}, observed ${when(x.trigger.observed_at)} (${x.trigger.source_key})`
+ : html`${x.trigger.form_type ? `${x.trigger.form_type}: ` : ''}${x.trigger.title || 'document'}, ${x.trigger.published_at ? html`published ${when(x.trigger.published_at)}` : 'no date stated'}`}</li>`);
+ return html`<h1>Your watchlists <span class="badge">private</span></h1>
+<p class="muted">Only you can see this page. It is never cached by shared caches, indexed or listed anywhere.</p>
+${notice(n)}
+${listHtml}
+<form method="post" action="/watchlists" class="card">${csrfField(csrf)}
+ <label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>
+ <button type="submit">Create</button></form>
+<h2>Your alerts</h2>
+${rules.length ? html`<table class="data"><thead><tr><th scope="col">Instrument</th><th scope="col">Condition</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${ruleRows}</tbody></table>`
+ : html`<p class="empty">No alerts. Create one from an instrument’s page.</p>`}
+<h2>Recent alert deliveries</h2>
+${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;
+}
+
+// ── Editor ───────────────────────────────────────────────────────────────────
+
+function editorHome({ instruments, pending, csrf, notice: n, kinds }) {
+ return html`<h1>Editor</h1>${notice(n)}
+<form method="post" action="/editor/instruments" class="card">${csrfField(csrf)}
+ <h2>Add an instrument</h2>
+ <label>Symbol <input name="symbol" maxlength="16" required></label>
+ <label>Name <input name="name" maxlength="200" required></label>
+ <label>Kind <select name="kind">${kinds.map((k) => html`<option>${k}</option>`)}</select></label>
+ <label>Exchange (optional) <input name="exchange" maxlength="40"></label>
+ <label>SEC CIK (optional) <input name="cik" maxlength="13"></label>
+ <label>Trading currency (optional) <input name="currency" maxlength="3"></label>
+ <button type="submit">Add instrument</button></form>
+<h2>Context waiting for review</h2>
+${pending.length ? html`<ul>${pending.map((p) => html`<li><a href="/editor/i/${encodeURIComponent(p.symbol)}">${p.symbol}</a>: revision ${p.revision} (${p.label})</li>`)}</ul>` : html`<p class="empty">Nothing is waiting.</p>`}
+<h2>Instruments</h2>
+<ul>${instruments.map((i) => html`<li><a href="/editor/i/${encodeURIComponent(i.symbol)}">${i.symbol}</a> — ${i.name}</li>`)}</ul>`;
+}
+
+function editorInstrument({ instrument, aliases, revisions, documents, observations, csrf, notice: n, kinds, head }) {
+ const revs = revisions.map((r) => html`<li class="card">
+<p><strong>Revision ${r.revision}</strong> ${r.published ? html`<span class="badge fresh">published</span>` : ''} ${r.disclosure ? html`<span class="badge">${r.disclosure.short}</span>` : html`<span class="badge">editor</span>`}
+ ${r.needs_review ? html`<span class="badge stale">needs a person’s review</span>` : ''} · written ${when(r.written_at)} · as of ${r.as_of ? when(r.as_of) : 'no dated citation'}</p>
+<div class="context-body">${raw(r.body_html)}</div>
+${r.disclosure ? html`<p class="disclosure">${r.disclosure.long}</p>` : ''}
+${!r.published && r.needs_review ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/${r.revision}/review" class="inline-form">${csrfField(csrf)}
+ <label>Note <input name="note" maxlength="500"></label>
+ <button type="submit" name="decision" value="approved">Approve and publish</button> <button type="submit" name="decision" value="rejected" class="danger">Reject</button></form>` : ''}
+${!r.published && !r.needs_review ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/${r.revision}/publish" class="inline-form">${csrfField(csrf)}<button type="submit">Publish this revision</button></form>` : ''}
+</li>`);
+ const citeOptions = [
+ ...observations.map((o) => html`<label><input type="checkbox" name="cite" value="observation:${o.id}"> ${o.metric} = ${o.value} ${o.unit}, observed ${when(o.observed_at)}</label>`),
+ ...documents.map((d) => html`<label><input type="checkbox" name="cite" value="document:${d.id}"> ${d.form_type || d.kind}: ${d.title || d.filer_name || d.id}, ${d.published_at ? html`published ${when(d.published_at)}` : 'no date'}</label>`),
+ ];
+ return html`${raw(breadcrumbsHtml([{ name: 'Editor', url: '/editor' }, { name: instrument.symbol }]))}
+<h1>${instrument.symbol} — ${instrument.name}</h1>${notice(n)}
+<p><a href="/i/${encodeURIComponent(instrument.symbol)}">Public page</a></p>
+<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}" class="card">${csrfField(csrf)}
+ <h2>Details</h2>
+ <label>Name <input name="name" maxlength="200" value="${instrument.name}" required></label>
+ <label>Kind <select name="kind">${kinds.map((k) => html`<option ${k === instrument.kind ? 'selected' : ''}>${k}</option>`)}</select></label>
+ <label>Exchange <input name="exchange" maxlength="40" value="${instrument.exchange || ''}"></label>
+ <label>SEC CIK <input name="cik" maxlength="13" value="${instrument.cik || ''}"></label>
+ <label>Trading currency <input name="currency" maxlength="3" value="${instrument.currency || ''}"></label>
+ <label>Status <select name="status"><option ${instrument.status === 'active' ? 'selected' : ''}>active</option><option ${instrument.status === 'archived' ? 'selected' : ''}>archived</option></select></label>
+ <button type="submit">Save</button></form>
+<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/aliases" class="card">${csrfField(csrf)}
+ <h2>Aliases</h2>
+ <ul>${aliases.map((a) => html`<li>${a.kind}: ${a.value} <span class="muted">(${a.normalized})</span></li>`)}</ul>
+ <label>Kind <select name="kind"><option>ticker</option><option>cik</option><option>name</option></select></label>
+ <label>Value <input name="value" maxlength="200" required></label>
+ <button type="submit">Add alias</button></form>
+<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context" class="card">${csrfField(csrf)}
+ <h2>Write context</h2>
+ <p class="muted">Describe what the cited sources state. No recommendations, targets or predictions: text that reads as advice is refused.</p>
+ <input type="hidden" name="expected_revision" value="${head}">
+ <label for="ctx-body">Context (Markdown)</label><textarea id="ctx-body" name="body" rows="10" maxlength="20000" required></textarea>
+ <fieldset><legend>Cite</legend>${citeOptions.length ? citeOptions : html`<p class="muted">Nothing to cite yet.</p>`}</fieldset>
+ <label><input type="checkbox" name="publish" value="1"> Publish now</label>
+ <button type="submit">Save revision</button></form>
+${instrument.context_published_revision ? html`<form method="post" action="/editor/i/${encodeURIComponent(instrument.symbol)}/context/retract" class="inline-form">${csrfField(csrf)}<button type="submit" class="danger">Retract the published context</button></form>` : ''}
+<h2>Context revisions</h2>
+${revisions.length ? html`<ol class="revisions">${revs}</ol>` : html`<p class="empty">No context yet.</p>`}`;
+}
+
+function errorPage({ status, title, message }) {
+ return html`<h1>${title}</h1><p>${message}</p><p class="muted">Error ${status}. <a href="/">All instruments</a></p>`;
+}
+
+module.exports = { home, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * Background work in the Trade process (two timers):
+ *
+ * Sources sync every TRADE_SYNC_INTERVAL_MS (and early when a signed webhook arrives):
+ * domain/sync.js pulls the trade category; idempotent per item revision
+ * freshness every TRADE_FRESHNESS_INTERVAL_MS: re-evaluate every source on Trade's clock
+ * (trade.source.stale / recovered on transitions only) and re-send any instrument
+ * Search document whose gate decision changed (e.g. a price observation aged
+ * past TRADE_PRICE_MAX_AGE_SEC → noindex)
+ */
+function createWorker({ config, sync, freshness, indexing, outbox, log = console }) {
+ let syncTimer = null;
+ let freshTimer = null;
+
+ function freshnessTick() {
+ try {
+ const changed = freshness.evaluateAll();
+ const docs = indexing.refreshAll();
+ if (changed.length || docs.length) outbox.kick();
+ return { sources: changed.length, documents: docs.length };
+ } catch (err) {
+ log.error('[Trade] freshness tick failed:', err.message);
+ return null;
+ }
+ }
+
+ return {
+ freshnessTick,
+ start() {
+ if (!config.worker.enabled) return;
+ if (sync.enabled) {
+ syncTimer = setInterval(() => sync.run().catch(() => {}), config.sources.syncIntervalMs);
+ syncTimer.unref();
+ setTimeout(() => sync.run().catch(() => {}), 2000).unref();
+ }
+ freshTimer = setInterval(freshnessTick, config.worker.freshnessIntervalMs);
+ freshTimer.unref();
+ },
+ stop() { clearInterval(syncTimer); clearInterval(freshTimer); },
+ };
+}
+
+module.exports = { createWorker };
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+/**
+ * Alerts are idempotent per triggering observation (and per document): one delivery row and one
+ * trade.alert.triggered event per (rule, trigger), whatever is replayed or retried. Threshold rules
+ * fire on a crossing; late (older) observations never fire. No email: the event is for the owner.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const MIN = 60e3;
+
+(async () => {
+ const t = await boot();
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const iso = t.iso;
+ const obs = (value, minutes, ref) => t.observe(acme, { metric: 'price.close', value, source_ref: ref, observed_at: iso(t.T0 + minutes * MIN), retrieved_at: iso(t.T0 + minutes * MIN) });
+ const deliveries = () => t.ctx.store.db.prepare('SELECT * FROM trade_alert_deliveries ORDER BY created_at, id').all();
+ let ruleId;
+
+ await check('alice creates a threshold rule through the API (for herself only)', async () => {
+ t.clock.advance(-10 * MIN);
+ const r = await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '100', unit: 'USD', currency: 'USD' } });
+ assert.strictEqual(r.status, 201, r.text);
+ ruleId = r.json().rule.id;
+ t.clock.set(t.T0 + 60 * MIN);
+ });
+
+ await check('an observation crossing the threshold delivers exactly once, with one event', async () => {
+ obs('99.50', 1, 'o1');
+ assert.strictEqual(deliveries().length, 0, 'below: nothing');
+ const { observation } = obs('101.00', 2, 'o2');
+ const d = deliveries();
+ assert.strictEqual(d.length, 1);
+ assert.strictEqual(d[0].trigger_kind, 'observation');
+ assert.strictEqual(d[0].trigger_id, observation.id);
+ const ev = t.events('trade.alert.triggered');
+ assert.strictEqual(ev.length, 1);
+ assert.strictEqual(ev[0].visibility, 'subject');
+ assert.deepStrictEqual(ev[0].subject, { type: 'user', id: t.alice.subject });
+ assert.strictEqual(ev[0].payload.trigger.value, '101.00');
+ assert.strictEqual(ev[0].payload.trigger.observed_at, iso(t.T0 + 2 * MIN));
+ assert.ok(ev[0].payload.disclaimer.includes('not investment advice'));
+ assert.strictEqual(d[0].event_id, ev[0].event_id);
+ });
+
+ await check('replaying the same observation (same source reference) delivers nothing new', async () => {
+ const r = obs('101.00', 2, 'o2');
+ assert.strictEqual(r.created, false);
+ const api = await t.get('/api/v1/observations', { as: t.network.serviceToken('feed', ['trade.observation.write']), json: { symbol: 'ACME', metric: 'price.close', value: '101.00', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'o2', observed_at: iso(t.T0 + 2 * MIN), retrieved_at: iso(t.T0 + 2 * MIN) } });
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(deliveries().length, 1);
+ assert.strictEqual(t.events('trade.alert.triggered').length, 1);
+ });
+
+ await check('re-running evaluation for the same observation cannot deliver twice (UNIQUE rule × trigger)', async () => {
+ const o = t.ctx.store.db.prepare("SELECT * FROM trade_market_observations WHERE source_ref = 'o2'").get();
+ t.ctx.store.db.prepare('UPDATE trade_alert_rules SET armed = 1, last_observed_at = NULL WHERE id = ?').run(ruleId);
+ t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));
+ t.ctx.store.tx(() => t.ctx.alerts.onObservation(o, acme));
+ assert.strictEqual(deliveries().length, 1);
+ assert.strictEqual(t.events('trade.alert.triggered').length, 1);
+ });
+
+ await check('staying above does not repeat; dropping below re-arms; crossing again delivers once more', async () => {
+ obs('102.00', 3, 'o3');
+ assert.strictEqual(deliveries().length, 1, 'still above: no repeat');
+ obs('98.00', 4, 'o4');
+ assert.strictEqual(deliveries().length, 1);
+ obs('100.50', 5, 'o5');
+ assert.strictEqual(deliveries().length, 2);
+ assert.strictEqual(t.events('trade.alert.triggered').length, 2);
+ });
+
+ await check('a late observation (older than the last one evaluated) never fires', async () => {
+ obs('97.00', 6, 'o6'); // re-arms
+ obs('150.00', 1.5, 'late');
+ assert.strictEqual(deliveries().length, 2);
+ });
+
+ await check('unit or currency mismatch never fires (no conversion)', async () => {
+ t.observe(acme, { metric: 'price.close', value: '500', unit: 'EUR', currency: 'EUR', source_ref: 'eur', observed_at: iso(t.T0 + 7 * MIN), retrieved_at: iso(t.T0 + 7 * MIN) });
+ assert.strictEqual(deliveries().length, 2);
+ });
+
+ await check('document rules: new_document and filing_type fire once per document; revisions of the item never re-fire', async () => {
+ const nd = await t.get('/api/v1/alerts', { as: t.bob, json: { symbol: 'ACME', kind: 'new_document' } });
+ assert.strictEqual(nd.status, 201);
+ const ft = await t.get('/api/v1/alerts', { as: t.bob, json: { symbol: 'ACME', kind: 'filing_type', form_types: ['8-K'] } });
+ assert.strictEqual(ft.status, 201);
+ const url = 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm';
+ const item = t.sources.putItem({ canonical_url: url, title: 'ACME CORP', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
+ await t.ctx.sync.run();
+ const bobs = () => deliveries().filter((d) => d.owner_subject === t.bob.subject);
+ assert.strictEqual(bobs().length, 1, 'new_document fired, filing_type(8-K) did not for a 10-Q');
+ t.sources.putItem({ id: item.id, canonical_url: url, title: 'ACME CORP (amended title)', summary: '10-Q', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
+ await t.ctx.sync.run();
+ t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
+ await t.ctx.sync.run();
+ assert.strictEqual(bobs().length, 1, 'item revision and page replay: no second delivery');
+ assert.strictEqual((await t.get('/i/ACME.json')).json().documents[0].title, 'ACME CORP (amended title)');
+ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', published_at: iso(t.T0), retrieved_at: iso(t.clock.now()) });
+ await t.ctx.sync.run();
+ assert.strictEqual(bobs().length, 3, 'an 8-K: new_document + filing_type');
+ });
+
+ await check('rules and deliveries are private: someone else sees none and cannot delete them', async () => {
+ const mine = (await t.get('/api/v1/alerts', { as: t.alice })).json().rules;
+ assert.strictEqual(mine.length, 1);
+ const bobsView = (await t.get('/api/v1/alerts', { as: t.bob })).json().rules;
+ assert.ok(bobsView.every((r) => r.id !== ruleId));
+ assert.strictEqual((await t.get(`/api/v1/alerts/${ruleId}`, { as: t.bob, method: 'DELETE' })).status, 404);
+ const dl = (await t.get('/api/v1/alerts/deliveries', { as: t.alice })).json().deliveries;
+ assert.strictEqual(dl.length, 2);
+ assert.ok(dl.every((d) => d.trigger.observed_at && d.trigger.source_key));
+ assert.strictEqual((await t.get(`/api/v1/alerts/${ruleId}`, { as: t.alice, method: 'DELETE' })).status, 200);
+ });
+
+ await check('a service acting for a person needs the capability and X-OV-Subject', async () => {
+ const svc = t.network.serviceToken('notifications', ['trade.alert.read']);
+ assert.strictEqual((await t.get('/api/v1/alerts', { as: svc })).status, 400);
+ const r = await t.get('/api/v1/alerts', { as: svc, headers: { 'x-ov-subject': t.bob.subject } });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().rules.length, 2);
+ assert.strictEqual((await t.get('/api/v1/alerts', { as: svc, method: 'POST', json: { symbol: 'ACME', kind: 'new_document' }, headers: { 'x-ov-subject': t.bob.subject } })).status, 403);
+ });
+
+ await check('no email is ever sent: Trade has no mail client and no mail configuration', async () => {
+ const fs = require('fs');
+ const path = require('path');
+ const files = [];
+ (function walk(d) { for (const f of fs.readdirSync(d)) { const p = path.join(d, f); if (fs.statSync(p).isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(path.join(__dirname, '..', 'server'));
+ const src = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
+ assert.ok(!/nodemailer|smtp|sendmail|SMTP_/i.test(src));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * Context: OpenVibe.AI's trade.summarize_market_context is a seam — its output is a draft context
+ * revision, labelled, never attributed to a person, noindex (the page stays out of sitemaps and
+ * Search) until a person on the editor list approves it. Editors write context with plain forms.
+ * Text that reads as advice is refused.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const errOf = (r) => new URL(r.headers.get('location'), 'https://x').searchParams.get('e') || '';
+const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
+
+(async () => {
+ const t = await boot();
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const { observation } = t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
+ t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });
+ const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);
+ let input;
+
+ await check('the AI workflow input lists numbered sources with observation/retrieval times and staleness', async () => {
+ const r = await t.get('/api/v1/instruments/ACME/context/input', { as: ai });
+ assert.strictEqual(r.status, 200, r.text);
+ input = r.json();
+ assert.strictEqual(input.workflow, 'trade.summarize_market_context');
+ assert.strictEqual(input.input.instrument.symbol, 'ACME');
+ const s = input.input.sources[0];
+ assert.strictEqual(s.source_type, 'trade.observation');
+ assert.strictEqual(s.source_id, observation.id);
+ assert.strictEqual(s.observed_at, t.iso(t.T0 - 60e3));
+ assert.strictEqual(s.retrieved_at, t.iso(t.T0));
+ assert.strictEqual(s.provenance.stale, false);
+ assert.strictEqual(input.input.stale_after_hours, 24);
+ });
+
+ let draftRev;
+ await check('AI output becomes a labelled DRAFT: not published, needs review, page not indexable', async () => {
+ const r = await t.get('/api/v1/instruments/ACME/context', {
+ as: ai, headers: { 'x-ov-origin': 'ai' },
+ json: {
+ workflow: { id: 'trade.summarize_market_context', run_id: 'run_01JABCDEFGHJKMNPQRSTVWXYZ0', version: 1 },
+ input_sources: input.input.sources.map((s) => ({ source_type: s.source_type, source_id: s.source_id })),
+ output: { summary: `The latest recorded close for ACME is 10.50 USD. ${WORDS(70)}`, observations: [{ text: 'Close of 10.50 USD', observed_at: t.iso(t.T0 - 60e3), citations: [0] }], stale_sources: [], disclaimer: 'Informational only; not investment advice.', citations: [0], gaps: ['No filings were provided.'] },
+ },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const rev = r.json().revision;
+ draftRev = rev.revision;
+ assert.strictEqual(r.json().published, false);
+ assert.strictEqual(rev.needs_review, true);
+ assert.strictEqual(rev.authorship.mode, 'ai');
+ assert.deepStrictEqual(rev.authorship.authors, [], 'never attributed to a person');
+ assert.strictEqual(rev.authorship.workflow.id, 'trade.summarize_market_context');
+ assert.strictEqual(rev.disclosure.short, 'AI-generated');
+ assert.strictEqual(rev.as_of, t.iso(t.T0 - 60e3), 'as_of = newest cited observation time');
+ assert.deepStrictEqual(rev.cites, [{ kind: 'observation', id: observation.id, observation: rev.cites[0].observation }]);
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('No reviewed context has been published'));
+ assert.ok(!page.text.includes('latest recorded close for ACME'), 'the draft is not shown to readers');
+ assert.ok(page.text.includes('<meta name="robots" content="noindex, nofollow">'));
+ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
+ assert.strictEqual(t.events('trade.index_document.upserted').length, 0);
+ });
+
+ await check('AI output cannot be published without a person, nor delivered as if by a person', async () => {
+ const pub = await t.get(`/editor/i/ACME/context/${draftRev}/publish`, { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
+ assert.strictEqual(pub.status, 303);
+ assert.ok(/review/i.test(errOf(pub)));
+ assert.strictEqual(t.ctx.instruments.bySymbol('ACME').context_published_revision, null);
+ const svcReview = await t.get(`/api/v1/instruments/ACME/context/revisions/${draftRev}/review`, { as: ai, json: { decision: 'approved' } });
+ assert.strictEqual(svcReview.status, 403, 'a service cannot review');
+ const noRun = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context' }, output: { summary: 'x' } } });
+ assert.strictEqual(noRun.status, 422);
+ const wrongWf = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'blog.draft_post', run_id: 'r' }, output: { summary: 'x' } } });
+ assert.strictEqual(wrongWf.status, 422);
+ const asUser = await t.get('/api/v1/instruments/ACME/context', { as: ai, json: { body: 'hello' } });
+ assert.strictEqual(asUser.status, 403, 'a service without X-OV-Origin: ai and without an editor subject cannot write context');
+ const badCite = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context', run_id: 'r2' }, input_sources: [{ source_type: 'trade.observation', source_id: 'obs_NOPE' }], output: { summary: 'x', citations: [0] } } });
+ assert.strictEqual(badCite.status, 422);
+ });
+
+ await check('advice is refused, from the AI and from editors alike', async () => {
+ const r = await t.get('/api/v1/instruments/ACME/context', { as: ai, headers: { 'x-ov-origin': 'ai' }, json: { workflow: { id: 'trade.summarize_market_context', run_id: 'r3' }, input_sources: [], output: { summary: 'You should buy ACME now; price target 20.' } } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'context.advice_refused');
+ const f = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: 'We recommend holding ACME.', expected_revision: String(draftRev), publish: '1' } });
+ assert.strictEqual(f.status, 303);
+ assert.ok(/information only/i.test(errOf(f)));
+ });
+
+ await check('a person on the editor list approves the draft: published, disclosed, indexable, in the sitemap and Search', async () => {
+ const nonEditor = await t.get(`/editor/i/ACME/context/${draftRev}/review`, { as: t.alice, form: { _csrf: t.csrf(t.alice), decision: 'approved' } });
+ assert.strictEqual(nonEditor.status, 403);
+ const r = await t.get(`/editor/i/ACME/context/${draftRev}/review`, { as: t.editor, form: { _csrf: t.csrf(t.editor), decision: 'approved', note: 'Checked against the source.' } });
+ assert.strictEqual(r.status, 303);
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('latest recorded close for ACME'));
+ assert.ok(page.text.includes('AI-generated'), 'the label stays after review');
+ assert.ok(page.text.includes('reviewed by a person'));
+ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), page.text.match(/<meta name="robots"[^>]*>/)[0]);
+ const sm = (await t.get('/sitemaps/instruments.xml')).text;
+ assert.ok(sm.includes('https://openvibe.trade/i/ACME'));
+ const up = t.events('trade.index_document.upserted');
+ assert.strictEqual(up.length, 1);
+ assert.strictEqual(up[0].payload.authorship, 'ai_generated');
+ assert.strictEqual(up[0].payload.visibility, 'public');
+ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.ok(ld.find((b) => b['@type'] === 'WebPage').dateModified, 'dateModified is the real publication time');
+ });
+
+ await check('an editor writes human context with a plain form; a stale base is refused without losing the published text', async () => {
+ const head = t.ctx.context.head(t.ctx.instruments.bySymbol('ACME')).number;
+ const ok = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: `Acme filed nothing new this week. ${WORDS(70)}`, expected_revision: String(head), cite: `observation:${observation.id}`, publish: '1' } });
+ assert.strictEqual(ok.status, 303);
+ assert.ok(/n=context_published/.test(ok.headers.get('location')));
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('Written by a Trade editor.'));
+ assert.ok(page.text.includes('Acme filed nothing new this week.'));
+ const stale = await t.get('/editor/i/ACME/context', { as: t.editor, form: { _csrf: t.csrf(t.editor), body: 'Late edit.', expected_revision: String(head) } });
+ assert.ok(/newer revision/.test(errOf(stale)));
+ assert.ok((await t.get('/i/ACME')).text.includes('Acme filed nothing new this week.'));
+ });
+
+ await check('retracting the context takes the page out of the sitemap and sends a Search tombstone', async () => {
+ const r = await t.get('/editor/i/ACME/context/retract', { as: t.editor, form: { _csrf: t.csrf(t.editor) } });
+ assert.strictEqual(r.status, 303);
+ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('/i/ACME'));
+ assert.strictEqual(t.events('trade.index_document.deleted').length, 1);
+ });
+
+ await check('the editor is for editors only; its pages are private and noindex', async () => {
+ assert.strictEqual((await t.get('/editor', { as: t.alice })).status, 403);
+ assert.strictEqual((await t.get('/editor')).status, 303);
+ const e = await t.get('/editor', { as: t.editor });
+ assert.strictEqual(e.status, 200);
+ assert.ok(/private/.test(e.headers.get('cache-control')));
+ assert.strictEqual(e.headers.get('x-robots-tag'), 'noindex, nofollow');
+ const add = await t.get('/editor/instruments', { as: t.editor, form: { _csrf: t.csrf(t.editor), symbol: 'nwco', name: 'New Co', kind: 'equity', cik: '' } });
+ assert.strictEqual(add.status, 303);
+ assert.ok(t.ctx.instruments.bySymbol('NWCO'));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'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 every envelope Trade produces is a
+ * valid events.event-envelope@1.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { PROPOSED } = require('../server/auth/capabilities');
+
+const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
+
+(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'));
+
+ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by trade', 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, 'trade');
+ 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 === 'trade', `${c.id} collides with a released capability`);
+ }
+ });
+
+ await check('the proposals are exactly the capabilities the code enforces, and cover the charter\'s', async () => {
+ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
+ for (const id of ['trade.watchlist.create', 'trade.watchlist.update', 'trade.instrument.resolve', 'trade.alert.create', 'trade.alert.delete', 'trade.context.read']) assert.ok(PROPOSED.has(id), id);
+ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
+ for (const c of caps) for (const route of c.implementedBy) assert.ok(src.includes(`'${route.split(' ')[1].replace('/api/v1', '')}'`), `${c.id}: ${route} is not in http/api.js`);
+ });
+
+ await check('the service manifest proposal is a valid registry.service-manifest@1 and declares every event', async () => {
+ const v = contracts.validate('registry.service-manifest@1', manifest);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(manifest.id, 'trade');
+ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);
+ const src = fs.readdirSync(path.join(__dirname, '..', 'server', 'domain')).map((f) => fs.readFileSync(path.join(__dirname, '..', 'server', 'domain', f), 'utf8')).join('\n');
+ for (const m of src.matchAll(/event_type: (?:[a-z.]+ \? )?'([a-z_.]+)'(?: : '([a-z_.]+)')?/g)) {
+ for (const e of [m[1], m[2]].filter(Boolean)) assert.ok(manifest.eventsProduced.includes(e), e);
+ }
+ });
+
+ await check('every envelope Trade actually produces validates as events.event-envelope@1 (and Search documents as search.index-document@1)', async () => {
+ const t = await boot();
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 60 });
+ await t.get('/api/v1/alerts', { as: t.alice, json: { symbol: 'ACME', kind: 'threshold', metric: 'price.close', operator: 'above', threshold: '1', unit: 'USD' } });
+ t.clock.advance(1000);
+ t.observe(acme, { metric: 'price.close', value: '2', source_ref: 'x', observed_at: t.iso(t.clock.now() - 1000), retrieved_at: t.iso(t.clock.now()) });
+ t.clock.advance(3600e3);
+ t.ctx.freshness.evaluateAll();
+ const rev = t.ctx.context.propose({ kind: 'user', subject: t.editor.subject, editor: true }, acme, { body: Array.from({ length: 80 }, () => 'word').join(' ') });
+ t.ctx.context.publish(acme, rev.revision.number);
+ const all = t.events();
+ const types = new Set(all.map((e) => e.event_type));
+ for (const ty of ['trade.observation.created', 'trade.alert.triggered', 'trade.source.stale', 'trade.index_document.upserted']) assert.ok(types.has(ty), `${ty} produced`);
+ for (const e of all) {
+ const v = contracts.validate('events.event-envelope@1', e);
+ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
+ assert.ok(manifest.eventsProduced.includes(e.event_type), e.event_type);
+ if (e.event_type === 'trade.index_document.upserted') {
+ const d = contracts.validate('search.index-document@1', e.payload);
+ assert.ok(d.valid, JSON.stringify(d.errors));
+ }
+ }
+ await t.close();
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * Stale or unavailable feeds are shown as stale — with "stale since" — and never replaced with
+ * invented values. Driven by an injected clock; transitions emit trade.source.stale|recovered once.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const HOUR = 3600e3;
+
+(async () => {
+ const t = await boot();
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ const iso = t.iso;
+
+ await check('an observation from a source with a known window is current inside the window', async () => {
+ t.ctx.freshness.report('test-feed', { name: 'Test feed', status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 3600 });
+ t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: iso(t.T0 - 60e3), retrieved_at: iso(t.T0) });
+ const json = (await t.get('/i/ACME.json')).json();
+ assert.strictEqual(json.observations[0].freshness.stale, false);
+ assert.strictEqual(json.sources[0].status, 'fresh');
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('<span class="badge fresh">current</span>'));
+ assert.ok(!page.text.includes('badge stale'));
+ });
+
+ await check('after the window, the same value is shown as stale since last success + window (clock advanced, nothing else changed)', async () => {
+ t.clock.advance(2 * HOUR);
+ const json = (await t.get('/i/ACME.json')).json();
+ const o = json.observations[0];
+ assert.strictEqual(o.value, '10.50', 'the value is kept, not replaced');
+ assert.strictEqual(o.freshness.stale, true);
+ assert.strictEqual(o.freshness.stale_since, iso(t.T0 + HOUR));
+ assert.strictEqual(o.freshness.reason, 'source_stale');
+ assert.strictEqual(json.sources[0].status, 'stale');
+ assert.strictEqual(json.sources[0].stale_since, iso(t.T0 + HOUR));
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('<span class="badge stale">stale</span> since <time datetime="2026-09-22T13:00:00.000Z">2026-09-22 13:00 UTC</time>'), 'explicit stale-since label');
+ assert.ok(page.text.includes('<data value="10.50">10.50</data>'), 'the old value stays visible with its timestamps');
+ assert.ok(/class="is-stale"/.test(page.text));
+ });
+
+ await check('the transition emits trade.source.stale exactly once, and recovery emits trade.source.recovered once', async () => {
+ t.ctx.freshness.evaluateAll();
+ t.ctx.freshness.evaluateAll();
+ const stale = t.events('trade.source.stale');
+ assert.strictEqual(stale.length, 1);
+ assert.strictEqual(stale[0].payload.stale_since, iso(t.T0 + HOUR));
+ t.observe(acme, { metric: 'price.close', value: '10.60', source_ref: 'b', observed_at: iso(t.clock.now() - 60e3), retrieved_at: iso(t.clock.now()) });
+ t.ctx.freshness.evaluateAll();
+ assert.strictEqual(t.events('trade.source.recovered').length, 1);
+ assert.strictEqual(t.events('trade.source.stale').length, 1);
+ const json = (await t.get('/i/ACME.json')).json();
+ assert.strictEqual(json.observations[0].value, '10.60');
+ assert.strictEqual(json.observations[0].freshness.stale, false);
+ });
+
+ await check('a value older than its own max_age is stale even while its source is fresh', async () => {
+ t.observe(acme, { metric: 'eps.diluted', value: '1.23', unit: 'USD/share', source_ref: 'c', max_age_sec: 3600, observed_at: iso(t.clock.now() - 2 * HOUR), retrieved_at: iso(t.clock.now()) });
+ const o = (await t.get('/i/ACME.json')).json().observations.find((x) => x.metric === 'eps.diluted');
+ assert.strictEqual(o.freshness.stale, true);
+ assert.strictEqual(o.freshness.reason, 'value_older_than_max_age');
+ assert.strictEqual(o.freshness.stale_since, iso(t.clock.now() - HOUR));
+ });
+
+ await check('a source Trade has never heard from is "freshness unknown", never "current"', async () => {
+ const v = t.ctx.freshness.view('nobody-knows');
+ assert.strictEqual(v.status, 'unknown');
+ assert.strictEqual(v.stale, true);
+ });
+
+ await check('Sources unavailable: the sync records the failure and invents nothing; data decays to stale on Trade\'s clock', async () => {
+ const before = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;
+ t.sources.setDown(true);
+ const r = await t.ctx.sync.run();
+ assert.strictEqual(r.ok, false);
+ const state = t.ctx.sync.state();
+ assert.ok(state.last_error && /503/.test(state.last_error));
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, before);
+ t.clock.advance(5 * HOUR);
+ const json = (await t.get('/i/ACME.json')).json();
+ assert.ok(json.observations.every((o) => o.freshness.stale), 'everything is stale now');
+ assert.ok(json.observations.find((o) => o.metric === 'price.close').value === '10.60', 'last real value kept, labelled stale');
+ const sourcesPage = await t.get('/sources');
+ assert.ok(/failing since/.test(sourcesPage.text), 'the sources page says the sync is failing');
+ const ready = (await t.get('/api/ready')).json();
+ assert.ok(JSON.stringify(ready).includes('last Sources sync failed'));
+ t.sources.setDown(false);
+ });
+
+ await check('the gate: a monetary observation older than TRADE_PRICE_MAX_AGE_SEC makes the page noindex (stale_price)', async () => {
+ const acmeNow = t.ctx.instruments.bySymbol('ACME');
+ assert.ok(!t.ctx.indexing.decide(acmeNow).codes.includes('stale_price'), 'observed hours ago: not yet');
+ t.clock.advance(24 * HOUR);
+ const d = t.ctx.indexing.decide(acmeNow);
+ assert.ok(d.codes.includes('stale_price'), d.codes.join(','));
+ assert.ok(!d.indexable);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * Boots Trade 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.base, t.get(path, { as: user | token, json, form })
+ * t.clock.advance(ms) // the app's clock (freshness, gate, alerts)
+ * t.editor, t.alice, t.bob // Network users (the editor is in TRADE_EDITORS)
+ * t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)
+ * t.events(type) // parsed envelopes in event_outbox
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { startNetwork, startSources } = require('./mocks');
+
+const T0 = Date.parse('2026-09-22T12:00:00Z');
+
+function makeClock(start = T0) {
+ 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 sources = await startSources({ network });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-trade-test-'));
+ const dbPath = path.join(dir, 'trade.db');
+ const clock = opts.clock || makeClock();
+ const editor = network.addUser('editor');
+ const alice = network.addUser('alice');
+ const bob = network.addUser('bob');
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.trade', TRUST_PROXY: '1',
+ TRADE_DB_PATH: dbPath,
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'trade', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
+ OV_SOURCES_INTERNAL_URL: sources.url,
+ TRADE_EDITORS: editor.subject, TRADE_WORKER: 'off', TRADE_FORM_SECRET: 'test-form-secret',
+ TRADE_EVENTS_WEBHOOK_SECRET: 'hook-secret',
+ ...(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. */
+ 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}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams(o.form).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); } };
+ }
+
+ 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)));
+ }
+
+ const t = {
+ network, sources, clock, dbPath, editor, alice, bob, get, events, T0,
+ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.TRADE_FORM_SECRET }, user),
+ iso: (ms) => new Date(ms).toISOString(),
+ instrument(input) { return t.ctx.instruments.create({ kind: 'equity', ...input }, editor.subject); },
+ /** Record an observation as a first-party feed would (domain call, recordedBy svc:feed). */
+ observe(instrument, input) {
+ return t.ctx.observations.record({ source_key: 'test-feed', unit: 'USD', currency: 'USD', ...input }, instrument, { recordedBy: 'svc:feed' });
+ },
+ async restart() { await stop(); await start(); },
+ async close() { await stop(); await network.close(); await sources.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ };
+ await start();
+ return t;
+}
+
+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, T0 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+/**
+ * In-process stand-ins for Trade's neighbours, with a real RS256 key pair:
+ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as
+ * capabilities)
+ * Sources GET /api/v1/sources and GET /api/v1/items (sources.item@1 shape, change order,
+ * include_removed), gated on the capabilities; the test controls items, health and outages
+ * userToken()/serviceToken() mint the tokens browsers and services present to Trade.
+ */
+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 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' });
+ }
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+ function signService({ sub, aud, cap }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
+ }
+ function addUser(username, extra = {}) {
+ return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
+ }
+ 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.trade'], cap });
+ }
+ return { ...srv, publicPem, grants, addUser, userToken, serviceToken, signService };
+}
+
+/** A stand-in for OpenVibe.Sources' read API. */
+async function startSources({ network }) {
+ const items = []; // { ...item, change_seq }
+ const sources = new Map(); // key → source view
+ const calls = [];
+ let seq = 0;
+ let down = false;
+ const srv = await listen((req, raw, json) => {
+ calls.push({ method: req.method, url: 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) return json(401, { code: v.code });
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/api/v1/sources') {
+ if (!(v.claims.cap || []).includes('sources.source.read')) return json(403, { code: 'capability.denied' });
+ return json(200, { sources: [...sources.values()] });
+ }
+ if (u.pathname === '/api/v1/items') {
+ if (!(v.claims.cap || []).includes('sources.item.read')) return json(403, { code: 'capability.denied' });
+ const after = Number(u.searchParams.get('after') || 0);
+ const limit = Number(u.searchParams.get('limit') || 100);
+ const cat = u.searchParams.get('category');
+ const incl = u.searchParams.get('include_removed') === '1';
+ const rows = items.filter((i) => i.change_seq > after && (!cat || i.category === cat) && (incl || !i.removed)).sort((a, b) => a.change_seq - b.change_seq);
+ const page = rows.slice(0, limit);
+ const keys = [...new Set(page.map((i) => i.source_key))];
+ const health = {};
+ for (const k of keys) if (sources.has(k)) health[k] = sources.get(k).health;
+ return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: health });
+ }
+ return json(404, { code: 'route.not_found' });
+ });
+
+ const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+ return {
+ ...srv, calls,
+ setDown: (d) => { down = d; },
+ /** A source with its health block (as GET /api/v1/sources shows it). */
+ setSource(key, { name = key, category = 'trade', status = 'healthy', lastSuccessAt = null, staleAfterSec = 3600, stale = false } = {}) {
+ sources.set(key, {
+ key, name, category, type: 'rss', terms_note: 'test terms', license_note: null,
+ health: { status, stale, last_success_at: lastSuccessAt, stale_after_sec: staleAfterSec },
+ });
+ },
+ /** Add or revise an item; returns it. */
+ putItem(input) {
+ const existing = items.find((i) => i.id === input.id);
+ const base = existing || { id: input.id || `itm_${ids.ulid()}`, revision: 0 };
+ const item = {
+ id: base.id, source_key: input.source_key || 'sec-xbrl-filings', category: input.category || 'trade', kind: input.kind || 'article',
+ identity: input.identity || input.canonical_url || base.id, canonical_url: input.canonical_url || null,
+ title: input.title == null ? null : input.title, summary: input.summary == null ? null : input.summary, authors: [],
+ published_at: input.published_at || null, source_updated_at: input.source_updated_at || null, fields: input.fields || {},
+ revision: existing && input.sameRevision ? existing.revision : base.revision + 1,
+ provenance: {
+ retrieved_at: input.retrieved_at, first_seen_at: existing ? existing.provenance.first_seen_at : input.retrieved_at,
+ content_hash: hash(JSON.stringify(input)), raw_body_hash: null, parser_version: 'feed@1', fetch_run_id: null,
+ license_note: null, terms_note: 'test terms', entered_by: null,
+ },
+ removed: input.removed || null,
+ change_seq: ++seq,
+ };
+ if (existing) items.splice(items.indexOf(existing), 1, item); else items.push(item);
+ return item;
+ },
+ remove(id, { at, reason }) {
+ const it = items.find((i) => i.id === id);
+ it.removed = { at, reason };
+ it.change_seq = ++seq;
+ return it;
+ },
+ };
+}
+
+module.exports = { startNetwork, startSources, listen };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * No observation → no number shown. Every datum exposes its observation and source timestamps.
+ * Observations are immutable and idempotent per source reference.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const feed = t.network.serviceToken('feed', ['trade.observation.write']);
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+
+ await check('an instrument without observations shows no number, in HTML and JSON', async () => {
+ const page = await t.get('/i/ACME');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('No observations have been recorded for ACME, so no number is shown.'));
+ assert.ok(!/<table class="data observations"/.test(page.text), 'no observations table');
+ assert.ok(!/<data value=/.test(page.text), 'no <data> value element');
+ const json = (await t.get('/i/ACME.json')).json();
+ assert.deepStrictEqual(json.observations, []);
+ assert.ok(!('price' in json) && !('value' in json.instrument), 'no invented top-level value');
+ });
+
+ await check('an observation without observed_at or retrieved_at is refused, never defaulted to now', async () => {
+ const noObserved = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', retrieved_at: t.iso(t.T0) } });
+ assert.strictEqual(noObserved.status, 422);
+ assert.strictEqual(noObserved.json().code, 'observation.time_required');
+ const noRetrieved = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0) } });
+ assert.strictEqual(noRetrieved.status, 422);
+ const future = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0 + 3600e3), retrieved_at: t.iso(t.T0) } });
+ assert.strictEqual(future.status, 422);
+ const noValue = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: 'about ten', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0), retrieved_at: t.iso(t.T0) } });
+ assert.strictEqual(noValue.status, 422);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, 0);
+ });
+
+ await check('only a service with trade.observation.write records observations; people cannot', async () => {
+ const body = { symbol: 'ACME', metric: 'price.close', value: '10.5', unit: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) };
+ assert.strictEqual((await t.get('/api/v1/observations', { as: t.network.serviceToken('other', ['trade.context.read']), json: body })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/observations', { as: t.editor, json: body })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/observations', { json: body })).status, 403);
+ });
+
+ let obsId;
+ await check('a recorded observation shows its value with observed_at, retrieved_at and source', async () => {
+ const r = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.50', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', source_url: 'https://example.org/acme/close', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const o = r.json().observation;
+ obsId = o.id;
+ assert.strictEqual(o.value, '10.50', 'the decimal exactly as stated');
+ assert.strictEqual(o.observed_at, '2026-09-22T11:59:00.000Z');
+ assert.strictEqual(o.retrieved_at, '2026-09-22T12:00:00.000Z');
+ assert.strictEqual(o.source.key, 'test-feed');
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('<data value="10.50">10.50</data>'));
+ assert.ok(page.text.includes('datetime="2026-09-22T11:59:00.000Z"'), 'observed_at shown');
+ assert.ok(page.text.includes('datetime="2026-09-22T12:00:00.000Z"'), 'retrieved_at shown');
+ assert.ok(page.text.includes('href="https://example.org/acme/close"'));
+ const json = (await t.get('/i/ACME.json')).json();
+ assert.strictEqual(json.observations.length, 1);
+ for (const k of ['observed_at', 'retrieved_at', 'recorded_at']) assert.ok(json.observations[0][k], k);
+ assert.ok(json.observations[0].freshness, 'every observation carries its freshness');
+ const ev = t.events('trade.observation.created');
+ assert.strictEqual(ev.length, 1);
+ assert.strictEqual(ev[0].payload.observed_at, '2026-09-22T11:59:00.000Z');
+ assert.strictEqual(ev[0].payload.retrieved_at, '2026-09-22T12:00:00.000Z');
+ });
+
+ await check('the same source reference again is a replay (200, no second row or event); a different value is 409', async () => {
+ const same = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '10.50', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });
+ assert.strictEqual(same.status, 200);
+ assert.strictEqual(same.json().created, false);
+ assert.strictEqual(same.json().observation.id, obsId);
+ const diff = await t.get('/api/v1/observations', { as: feed, json: { symbol: 'ACME', metric: 'price.close', value: '11', unit: 'USD', currency: 'USD', source_key: 'test-feed', source_ref: 'r1', observed_at: '2026-09-22T11:59:00Z', retrieved_at: '2026-09-22T12:00:00Z' } });
+ assert.strictEqual(diff.status, 409);
+ assert.strictEqual(t.events('trade.observation.created').length, 1);
+ });
+
+ await check('observations are immutable in the database', async () => {
+ assert.throws(() => t.ctx.store.db.prepare("UPDATE trade_market_observations SET value = '99' WHERE id = ?").run(obsId), /immutable/);
+ assert.throws(() => t.ctx.store.db.prepare('DELETE FROM trade_market_observations WHERE id = ?').run(obsId), /immutable/);
+ });
+
+ await check('history keeps every observation, newest first, each with its timestamps', async () => {
+ t.observe(acme, { metric: 'price.close', value: '10.75', source_ref: 'r2', observed_at: '2026-09-22T12:01:00Z', retrieved_at: '2026-09-22T12:02:00Z' });
+ const h = (await t.get('/api/v1/instruments/ACME/observations?metric=price.close')).json();
+ assert.deepStrictEqual(h.observations.map((o) => o.value), ['10.75', '10.50']);
+ const latest = (await t.get('/i/ACME.json')).json().observations;
+ assert.strictEqual(latest.length, 1, 'latest per metric');
+ assert.strictEqual(latest[0].value, '10.75');
+ });
+
+ await check('JSON-LD carries only real fields (no price, no rating, no invented date)', async () => {
+ const page = await t.get('/i/ACME');
+ const blocks = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.ok(blocks.length >= 1);
+ const flat = JSON.stringify(blocks);
+ for (const k of ['price', 'offers', 'aggregateRating', 'ratingValue', 'datePublished']) assert.ok(!flat.includes(`"${k}"`), k);
+ const pageLd = blocks.find((b) => b['@type'] === 'WebPage');
+ assert.strictEqual(pageLd.about.tickerSymbol, 'ACME');
+ assert.ok(!('dateModified' in pageLd), 'no context published → no dateModified');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * Public surfaces are useful without JavaScript and carry the persistent disclaimer; the JSON twin
+ * describes the same data as the HTML; feeds, sitemaps, robots and llms.txt behave; machine
+ * endpoints answer; the historical marketplace branch is recorded as an open question, not built.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const DISCLAIMER = 'Information only — not investment advice; no trading here.';
+
+(async () => {
+ const t = await boot();
+ const acme = t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567', exchange: 'NYSE' });
+ t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
+ t.sources.setSource('sec-xbrl-filings', { lastSuccessAt: t.iso(t.T0), staleAfterSec: 2700 });
+ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', title: 'ACME CORP', summary: '10-K', published_at: '2026-09-22T10:00:00Z', retrieved_at: t.iso(t.T0) });
+ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000002/0001234567-26-000002-index.htm', title: 'ACME CORP', summary: '8-K', retrieved_at: t.iso(t.T0) });
+ await t.ctx.sync.run();
+
+ await check('every public page carries the persistent, plain disclaimer (header and footer)', async () => {
+ for (const p of ['/', '/i/ACME', '/sources', '/watchlists', '/resolve?q=nothing', '/does-not-exist']) {
+ const r = await t.get(p);
+ assert.ok(r.text.split(DISCLAIMER).length - 1 >= 2, `${p}: disclaimer missing`);
+ }
+ assert.strictEqual((await t.get('/i/ACME.json')).json().disclaimer, DISCLAIMER);
+ assert.ok((await t.get('/feed.xml')).text.includes('not investment advice'));
+ assert.ok((await t.get('/llms.txt')).text.includes('not investment advice'));
+ });
+
+ await check('the instrument page is complete without JavaScript: observations, documents, freshness, context, feeds', async () => {
+ const r = await t.get('/i/ACME');
+ assert.strictEqual(r.status, 200);
+ const html = r.text.replace(/<script[\s\S]*?<\/script>/g, '');
+ for (const s of ['<h1>ACME', 'price.close', '10.50', '10-K', '8-K', 'accession 0001234567-26-000001', 'Source freshness', 'No reviewed context has been published', 'documents.xml', 'Sign in']) assert.ok(html.includes(s), s);
+ assert.ok(html.includes('Filed <span class="muted">not stated by the source</span>'), 'a filing without a date says so');
+ assert.ok(/<link rel="canonical" href="https:\/\/openvibe.trade\/i\/ACME">/.test(r.text));
+ assert.ok(r.text.includes('<link rel="alternate" type="application/rss+xml"'));
+ });
+
+ await check('the JSON twin describes the same data as the HTML (no divergence)', async () => {
+ const j = (await t.get('/i/ACME.json')).json();
+ const h = (await t.get('/i/ACME')).text;
+ for (const o of j.observations) assert.ok(h.includes(`<data value="${o.value}">`) && h.includes(`datetime="${o.observed_at}"`));
+ for (const d of j.documents) assert.ok(h.includes(`id="${d.id}"`));
+ assert.strictEqual(j.documents.length, 2);
+ assert.deepStrictEqual(j.indexability.reasons.map((x) => x.code).includes('unreviewed_sensitive'), true);
+ });
+
+ await check('feeds: RSS, Atom and JSON Feed per instrument and site-wide; undated items get no invented date', async () => {
+ const rss = await t.get('/i/ACME/documents.xml');
+ assert.strictEqual(rss.status, 200);
+ assert.ok(/application\/rss\+xml/.test(rss.headers.get('content-type')));
+ assert.strictEqual((rss.text.match(/<item>/g) || []).length, 2);
+ assert.strictEqual((rss.text.match(/<pubDate>/g) || []).length, 1, 'only the dated filing has a pubDate');
+ assert.ok(rss.text.includes('https://openvibe.trade/i/ACME#doc_'), 'items link back to the canonical page');
+ const atom = await t.get('/i/ACME/documents.atom');
+ assert.strictEqual((atom.text.match(/<entry>/g) || []).length, 1, 'Atom leaves out the undated entry');
+ const jf = (await t.get('/i/ACME/documents.json')).json();
+ assert.strictEqual(jf.items.length, 2);
+ assert.strictEqual(jf.items.filter((i) => i.date_published).length, 1);
+ assert.strictEqual((await t.get('/i/NOPE/documents.xml')).status, 404);
+ assert.strictEqual((await t.get('/feed.json')).json().items.length, 2);
+ });
+
+ await check('robots, llms.txt and the sitemap index; no page is indexable before reviewed context', async () => {
+ const robots = (await t.get('/robots.txt')).text;
+ assert.ok(robots.includes('Sitemap: https://openvibe.trade/sitemap.xml'));
+ assert.ok(/automated-consumer policy/.test(robots));
+ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.trade/sitemaps/instruments.xml'));
+ assert.ok(!(await t.get('/sitemaps/instruments.xml')).text.includes('<loc>'), 'no indexable instrument yet');
+ assert.ok((await t.get('/llms.txt')).text.startsWith('# OpenVibe.Trade'));
+ });
+
+ await check('resolve: one match redirects to the canonical page; a miss says no guess was made', async () => {
+ const r = await t.get('/resolve?q=1234567');
+ assert.strictEqual(r.status, 302);
+ assert.strictEqual(r.headers.get('location'), '/i/ACME');
+ const miss = await t.get('/resolve?q=Acm');
+ assert.strictEqual(miss.status, 404);
+ assert.ok(miss.text.includes('no guess is made'));
+ });
+
+ await check('machine endpoints: health, readiness (truthful), release, legal pages', async () => {
+ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-trade');
+ const ready = await t.get('/api/ready');
+ assert.strictEqual(ready.status, 200);
+ const body = JSON.stringify(ready.json());
+ assert.ok(body.includes('relay off'), 'the events relay is reported off in tests');
+ assert.strictEqual((await t.get('/release.json')).status, 200);
+ const terms = await t.get('/terms');
+ assert.strictEqual(terms.status, 200);
+ assert.ok(terms.text.includes('OpenVibe.Trade'));
+ });
+
+ await check('the historical marketplace branch is an open question in docs, and nothing of it is implemented', async () => {
+ const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'marketplace-open-question.md'), 'utf8');
+ assert.ok(/ADR-025/.test(doc) && /unresolved|open question/i.test(doc));
+ const tables = t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
+ assert.ok(!tables.some((n) => /order|listing|escrow|wallet|custody|payment|cart/.test(n)), tables.join(','));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * Watchlists never leak: private cache headers, noindex, owner-only (404 for anyone else), never in
+ * sitemaps, feeds, robots-allowed paths, Search documents or events. Works without JavaScript
+ * through plain forms with a form token.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ t.instrument({ symbol: 'ZETA', name: 'Zeta Holdings' });
+ const form = (user, fields) => ({ as: user, form: { _csrf: t.csrf(user), ...fields } });
+ let wlId;
+
+ await check('anonymous /watchlists is a sign-in prompt, private and noindex', async () => {
+ const r = await t.get('/watchlists');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Sign in'));
+ assert.ok(/private/.test(r.headers.get('cache-control')) && /no-store/.test(r.headers.get('cache-control')));
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));
+ });
+
+ await check('alice creates a watchlist and adds instruments with plain forms (no JavaScript)', async () => {
+ const c = await t.get('/watchlists', form(t.alice, { name: 'Secret plans' }));
+ assert.strictEqual(c.status, 303);
+ assert.ok(/private, no-store/.test(c.headers.get('cache-control')));
+ wlId = t.ctx.watchlists.forOwner(t.alice.subject)[0].id;
+ assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, symbol: 'ACME' }))).status, 303);
+ assert.strictEqual((await t.get('/watchlists/item', form(t.alice, { watchlist_id: wlId, q: 'Zeta Holdings' }))).status, 303);
+ const page = await t.get('/watchlists', { as: t.alice });
+ assert.ok(page.text.includes('Secret plans') && page.text.includes('ACME') && page.text.includes('ZETA'));
+ assert.ok(/private/.test(page.headers.get('cache-control')) && /no-store/.test(page.headers.get('cache-control')));
+ assert.ok(/Cookie/.test(page.headers.get('vary')));
+ assert.strictEqual(page.headers.get('x-robots-tag'), 'noindex, nofollow');
+ });
+
+ await check('a form without the right token changes nothing', async () => {
+ const r = await t.get('/watchlists', { as: t.alice, form: { _csrf: 'forged', name: 'Injected' } });
+ assert.strictEqual(r.status, 403);
+ const r2 = await t.get('/watchlists', { as: t.alice, form: { _csrf: t.csrf(t.bob), name: 'Injected' } });
+ assert.strictEqual(r2.status, 403);
+ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1);
+ });
+
+ await check('bob cannot see, change or delete alice\'s watchlist (404, existence not disclosed)', async () => {
+ const bobPage = await t.get('/watchlists', { as: t.bob });
+ assert.ok(!bobPage.text.includes('Secret plans'));
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob, method: 'PATCH', json: { name: 'x' } })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}/items/ACME`, { as: t.bob, method: 'DELETE' })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`, { as: t.bob, method: 'DELETE' })).status, 404);
+ const del = await t.get('/watchlists/delete', form(t.bob, { watchlist_id: wlId }));
+ assert.strictEqual(del.status, 303);
+ assert.strictEqual(new URL(del.headers.get('location'), 'https://x').searchParams.get('e'), 'No such watchlist');
+ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 1, 'still there');
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${wlId}`)).status, 401, 'anonymous API');
+ });
+
+ await check('the API answers the owner, privately', async () => {
+ const r = await t.get('/api/v1/watchlists', { as: t.alice });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json().watchlists[0].items.map((i) => i.instrument.symbol), ['ACME', 'ZETA']);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');
+ });
+
+ await check('a service needs trade.watchlist.* and X-OV-Subject, and sees only that person\'s lists', async () => {
+ const reader = t.network.serviceToken('network', ['trade.watchlist.read']);
+ assert.strictEqual((await t.get('/api/v1/watchlists', { as: reader })).status, 400);
+ const asBob = await t.get('/api/v1/watchlists', { as: reader, headers: { 'x-ov-subject': t.bob.subject } });
+ assert.deepStrictEqual(asBob.json().watchlists, []);
+ const asAlice = await t.get('/api/v1/watchlists', { as: reader, headers: { 'x-ov-subject': t.alice.subject } });
+ assert.strictEqual(asAlice.json().watchlists.length, 1);
+ assert.strictEqual((await t.get('/api/v1/watchlists', { as: reader, method: 'POST', json: { name: 'x' }, headers: { 'x-ov-subject': t.alice.subject } })).status, 403);
+ const wrongAud = t.network.signService({ sub: 'svc:network', aud: ['openvibe.blog'], cap: ['trade.watchlist.read'] });
+ assert.strictEqual((await t.get('/api/v1/watchlists', { as: wrongAud, headers: { 'x-ov-subject': t.alice.subject } })).status, 401);
+ });
+
+ await check('an instrument page shown to a signed-in person (with her watchlist names) is private; anonymous is public', async () => {
+ const mine = await t.get('/i/ACME', { as: t.alice });
+ assert.ok(mine.text.includes('Secret plans'), 'her own list offered in the add form');
+ assert.ok(/private/.test(mine.headers.get('cache-control')) && /no-store/.test(mine.headers.get('cache-control')));
+ const anon = await t.get('/i/ACME');
+ assert.ok(!anon.text.includes('Secret plans'));
+ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(/Cookie/.test(anon.headers.get('vary')));
+ });
+
+ await check('watchlists never appear in sitemaps, feeds, robots-allowed paths, Search documents or events', async () => {
+ const blobs = [];
+ for (const p of ['/sitemap.xml', '/sitemaps/instruments.xml', '/feed.xml', '/atom.xml', '/feed.json', '/i/ACME/documents.xml', '/llms.txt', '/i/ACME.json', '/', '/sources']) {
+ const r = await t.get(p);
+ assert.ok(r.status < 400, `${p} ${r.status}`);
+ blobs.push(r.text);
+ }
+ const all = blobs.join('\n');
+ assert.ok(!all.includes('Secret plans'));
+ assert.ok(!all.includes(wlId));
+ assert.ok(!all.includes(t.alice.subject));
+ const robots = (await t.get('/robots.txt')).text;
+ for (const p of ['/watchlists', '/alerts', '/editor', '/api/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
+ const events = JSON.stringify(t.events());
+ assert.ok(!events.includes(wlId) && !events.includes('Secret plans') && !events.includes('watchlist'));
+ assert.ok(!t.events().some((e) => e.event_type.includes('watchlist')));
+ });
+
+ await check('removing and deleting work for the owner', async () => {
+ assert.strictEqual((await t.get('/watchlists/item/remove', form(t.alice, { watchlist_id: wlId, symbol: 'ZETA' }))).status, 303);
+ assert.deepStrictEqual(t.ctx.watchlists.items(t.ctx.watchlists.forOwner(t.alice.subject)[0]).map((i) => i.symbol), ['ACME']);
+ assert.strictEqual((await t.get('/watchlists/delete', form(t.alice, { watchlist_id: wlId }))).status, 303);
+ assert.strictEqual(t.ctx.watchlists.forOwner(t.alice.subject).length, 0);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE watchlist_id = ?').get(wlId).n, 0);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * ADR-025 acceptance: "No Trade endpoint accepts an order, holds value or gives personalised advice
+ * (a route-inventory test in Trade's CI)."
+ *
+ * Walks the LIVE Express stack of the real app (every router, every mount) and fails if any route
+ * path, handler name, capability id or event type carries order / buy / sell / execution / custody /
+ * wallet / escrow / checkout / listing semantics (or the close cousins: purchase, payment, payout,
+ * deposit, withdraw, cart). Every route outside the shared /auth session layer and /metrics must have a named
+ * handler (http/routes.js define()), so a new route cannot slip through anonymously. A negative
+ * control proves the checker catches a violating route.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const { boot, check, done } = require('./helpers/boot');
+const { PROPOSED } = require('../server/auth/capabilities');
+
+const FORBIDDEN = ['order', 'buy', 'sell', 'execut', 'custod', 'wallet', 'escrow', 'checkout', 'listing',
+ 'purchas', 'payment', 'payout', 'deposit', 'withdraw', 'cart'];
+
+/** Words of a path or identifier: split on non-alphanumerics and camelCase boundaries, lower-cased. */
+function words(s) {
+ return String(s || '')
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .split(/[^A-Za-z0-9]+/)
+ .map((w) => w.toLowerCase())
+ .filter(Boolean);
+}
+function violations(s) {
+ return words(s).filter((w) => FORBIDDEN.some((f) => w.startsWith(f)));
+}
+
+function mountOf(layer) {
+ if (!layer.regexp || layer.regexp.fast_slash) return '';
+ const src = layer.regexp.source;
+ return src.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/, '').replace(/\\\//g, '/').replace(/\(\?:\(\[\^\\\/]\+\?\)\)/g, ':param');
+}
+
+/** Every route in the app: { path, methods, handlers: [names] }. */
+function inventory(app) {
+ const out = [];
+ function walk(stack, prefix) {
+ for (const layer of stack) {
+ if (layer.route) {
+ const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
+ for (const p of paths) {
+ out.push({
+ path: prefix + p,
+ methods: Object.keys(layer.route.methods),
+ handlers: layer.route.stack.map((l) => l.handle && l.handle.name).filter((n) => n !== undefined),
+ final: layer.route.stack.length ? layer.route.stack[layer.route.stack.length - 1].handle.name : '',
+ });
+ }
+ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
+ walk(layer.handle.stack, prefix + mountOf(layer));
+ }
+ }
+ }
+ walk(app._router.stack, '');
+ return out;
+}
+
+(async () => {
+ const t = await boot();
+ const routes = inventory(t.app);
+
+ await check('the walker sees the real routes (pages, API, discovery, private, editor, webhook, machine)', async () => {
+ const paths = new Set(routes.map((r) => r.path));
+ for (const p of ['/', '/i/:symbol', '/watchlists', '/editor', '/robots.txt', '/sitemap.xml', '/api/v1/instruments', '/api/v1/alerts', '/api/v1/observations', '/internal/events', '/api/ready', '/auth/login']) {
+ assert.ok(paths.has(p), `route ${p} not found in the inventory: ${[...paths].join(' ')}`);
+ }
+ assert.ok(routes.length >= 50, `only ${routes.length} routes found`);
+ });
+
+ await check('no route path carries order/buy/sell/execute/custody/wallet/escrow/checkout/listing semantics', async () => {
+ const bad = routes.filter((r) => violations(r.path).length).map((r) => `${r.methods.join(',')} ${r.path} (${violations(r.path).join(', ')})`);
+ assert.deepStrictEqual(bad, []);
+ });
+
+ await check('no handler name carries those semantics either', async () => {
+ const bad = [];
+ for (const r of routes) for (const h of r.handlers) if (violations(h).length) bad.push(`${r.path}: ${h}`);
+ assert.deepStrictEqual(bad, []);
+ });
+
+ await check('every route outside /auth has a named final handler (registered through define())', async () => {
+ // /auth/* is the shared Network session layer and /metrics the shared metrics endpoint
+ // (openvibe-shared, loopback only); both are checked for paths above like everything else.
+ const shared = (p) => p.startsWith('/auth') || p === '/metrics';
+ const anon = routes.filter((r) => !shared(r.path) && (!r.final || r.final === 'anonymous' || /^bound /.test(r.final)));
+ assert.deepStrictEqual(anon.map((r) => r.path), []);
+ });
+
+ await check('no capability id and no produced event type carries those semantics', async () => {
+ for (const id of PROPOSED) assert.deepStrictEqual(violations(id), [], id);
+ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
+ for (const e of manifest.eventsProduced) assert.deepStrictEqual(violations(e), [], e);
+ for (const c of manifest.capabilities) assert.deepStrictEqual(violations(c), [], c);
+ });
+
+ await check('no route answers a value-moving verb on an unknown path (the API 404s everything else)', async () => {
+ for (const p of ['/api/v1/orders', '/api/v1/instruments/ANY/buy', '/api/v1/wallet', '/api/v1/checkout', '/api/v1/escrow', '/api/v1/listings']) {
+ const r = await t.get(p, { method: 'POST', json: {} });
+ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);
+ }
+ });
+
+ await check('negative control: the checker flags a violating route and handler', async () => {
+ const bad = express();
+ const r = express.Router();
+ r.post('/orders', function placeOrder(_req, res) { res.end(); });
+ r.get('/portfolio', function sellPosition(_req, res) { res.end(); });
+ r.post('/custody/:id', function hold(_req, res) { res.end(); });
+ bad.use('/api/v1', r);
+ const inv = inventory(bad);
+ assert.strictEqual(inv.length, 3);
+ assert.ok(violations(inv[0].path).length && violations(inv[0].final).length);
+ assert.ok(violations(inv[1].final).length);
+ assert.ok(violations(inv[2].path).length);
+ // …and does not flag innocent words that merely contain the letters.
+ assert.deepStrictEqual(violations('/borders/sorted/reorder-free/watchlists'), []);
+ });
+
+ 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 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(30)} ${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,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * OpenVibe.Sources → Trade: SEC EDGAR filings become documents of the instrument with that CIK;
+ * observation items become observations only with their own observation time; unmatched items are
+ * skipped and counted; removals hide documents; replaying pages changes nothing; the signed webhook
+ * wakes the sync and is idempotent per event id. Deterministic resolution.
+ */
+const assert = require('assert');
+const { signDelivery } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+const { mapItem } = require('../server/domain/mapping');
+
+(async () => {
+ const t = await boot();
+ const apple = t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'NASDAQ' });
+ t.instrument({ symbol: 'ACME', name: 'Acme Corp' });
+ t.instrument({ symbol: 'ACMX', name: 'ACME CORPORATION' }); // normalises to the same name as ACME Corp
+ const ret = t.iso(t.T0 - 5 * 60e3);
+ t.sources.setSource('sec-xbrl-filings', { name: 'SEC EDGAR: latest XBRL filings', lastSuccessAt: ret, staleAfterSec: 2700 });
+
+ const filing = t.sources.putItem({
+ canonical_url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000105/0000320193-26-000105-index.htm',
+ title: 'APPLE INC (0000320193) (Filer)', summary: '10-Q', published_at: '2026-09-22T11:30:00.000Z', retrieved_at: ret,
+ });
+ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/789019/000078901926000050/0000789019-26-000050-index.htm', title: 'MICROSOFT CORP', summary: '8-K', published_at: '2026-09-22T11:40:00.000Z', retrieved_at: ret });
+ t.sources.putItem({ source_key: 'test-quotes', kind: 'observation', canonical_url: 'https://quotes.example/aapl', title: 'AAPL close', retrieved_at: ret, fields: { symbol: 'AAPL', metric: 'price.close', value: '231.40', unit: 'USD', currency: 'USD', observed_at: '2026-09-21T20:00:00Z' } });
+ t.sources.putItem({ source_key: 'test-quotes', kind: 'observation', canonical_url: 'https://quotes.example/aapl2', title: 'AAPL open (no time)', retrieved_at: ret, fields: { symbol: 'AAPL', metric: 'price.open', value: '229.00', unit: 'USD', currency: 'USD' } });
+
+ await check('mapping: an EDGAR index URL yields CIK, accession, form type and filer; nothing else is guessed', async () => {
+ const m = mapItem(filing);
+ assert.strictEqual(m.type, 'document');
+ assert.strictEqual(m.document.cik, '0000320193');
+ assert.strictEqual(m.document.accession, '0000320193-26-000105');
+ assert.strictEqual(m.document.form_type, '10-Q');
+ assert.strictEqual(m.document.filer_name, 'APPLE INC');
+ const noDate = mapItem({ ...filing, id: 'itm_X', published_at: null });
+ assert.strictEqual(noDate.document.published_at, null, 'no publication date stays null');
+ assert.strictEqual(mapItem({ ...filing, summary: 'APPLE INC quarterly report' }).document.form_type, null, 'not a form type → null');
+ assert.strictEqual(mapItem({ ...filing, provenance: { ...filing.provenance, retrieved_at: null } }).type, 'skip');
+ });
+
+ await check('a sync run records matched filings and observations, skips the rest, and advances the cursor', async () => {
+ const r = await t.ctx.sync.run();
+ assert.strictEqual(r.ok, true, JSON.stringify(r));
+ assert.strictEqual(r.counts.documents_created, 1);
+ assert.strictEqual(r.counts.observations_created, 1);
+ assert.strictEqual(r.counts.skipped_no_matching_instrument, 1, 'MICROSOFT has no instrument here');
+ assert.strictEqual(r.counts.skipped_no_observation_time, 1, 'an observation without its own time is not recorded');
+ const json = (await t.get('/i/AAPL.json')).json();
+ assert.strictEqual(json.documents.length, 1);
+ const d = json.documents[0];
+ assert.strictEqual(d.form_type, '10-Q');
+ assert.strictEqual(d.published_at, '2026-09-22T11:30:00.000Z');
+ assert.strictEqual(d.retrieved_at, ret);
+ assert.strictEqual(d.source.item_id, filing.id);
+ assert.deepStrictEqual(json.observations.map((o) => [o.metric, o.value, o.observed_at]), [['price.close', '231.40', '2026-09-21T20:00:00.000Z']]);
+ assert.strictEqual(t.ctx.sync.state().cursor, 4);
+ const src = json.sources.find((s) => s.key === 'sec-xbrl-filings');
+ assert.strictEqual(src.name, 'SEC EDGAR: latest XBRL filings');
+ assert.strictEqual(src.stale_after_sec, 2700);
+ });
+
+ await check('replaying the same Sources pages (cursor reset) creates nothing new', async () => {
+ const docs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n;
+ const obs = t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n;
+ const evts = t.events().length;
+ t.ctx.store.db.prepare("UPDATE trade_sync_state SET cursor = 0 WHERE name = 'sources.trade'").run();
+ const r = await t.ctx.sync.run();
+ assert.strictEqual(r.ok, true);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents').get().n, docs);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations').get().n, obs);
+ assert.strictEqual(t.events('trade.observation.created').length, 1);
+ assert.strictEqual(t.events().length, evts, 'no new events at all');
+ });
+
+ await check('a removed Sources item hides the document (the row stays as the record)', async () => {
+ t.sources.remove(filing.id, { at: t.iso(t.T0), reason: 'takedown request' });
+ await t.ctx.sync.run();
+ assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 0);
+ const row = t.ctx.store.db.prepare('SELECT * FROM trade_source_documents WHERE source_item_id = ?').get(filing.id);
+ assert.strictEqual(row.removed_reason, 'takedown request');
+ const feed = await t.get('/i/AAPL/documents.xml');
+ assert.ok(!feed.text.includes('10-Q'), 'gone from the feed too');
+ });
+
+ await check('resolution is deterministic: CIK, then ticker, then exact normalised name; several names → ambiguous', async () => {
+ const r = (q, k) => t.ctx.instruments.resolve(q, k ? { kind: k } : {});
+ assert.strictEqual(r('320193').instrument.symbol, 'AAPL');
+ assert.strictEqual(r('CIK0000320193').match.kind, 'cik');
+ assert.strictEqual(r('aapl').instrument.symbol, 'AAPL');
+ assert.strictEqual(r('Apple Inc').instrument.symbol, 'AAPL');
+ assert.strictEqual(r('APPLE, INC.').instrument.symbol, 'AAPL');
+ const amb = r('Acme Corporation');
+ assert.strictEqual(amb.status, 'ambiguous');
+ assert.deepStrictEqual(amb.candidates.map((i) => i.symbol), ['ACME', 'ACMX']);
+ assert.strictEqual(amb.instrument, null, 'none is chosen');
+ assert.strictEqual(r('Appl').status, 'not_found', 'no prefix or fuzzy matching');
+ t.ctx.instruments.addAlias(apple, 'ticker', 'APC', t.editor.subject);
+ assert.strictEqual(r('APC').match.kind, 'ticker');
+ for (let i = 0; i < 3; i++) assert.deepStrictEqual(r('Acme Corporation').candidates.map((x) => x.id), amb.candidates.map((x) => x.id));
+ const page = await t.get('/i/APC');
+ assert.strictEqual(page.status, 301);
+ assert.strictEqual(page.headers.get('location'), '/i/AAPL');
+ assert.strictEqual((await t.get('/i/aapl')).headers.get('location'), '/i/AAPL');
+ const api = await t.get('/api/v1/instruments/resolve?q=Acme%20Corp', { as: t.network.serviceToken('ai', ['trade.instrument.resolve']) });
+ assert.strictEqual(api.json().status, 'ambiguous');
+ assert.strictEqual((await t.get('/api/v1/instruments/resolve?q=AAPL', { as: t.network.serviceToken('ai', ['trade.context.read']) })).status, 403);
+ });
+
+ await check('a ticker or CIK names one instrument: collisions are 409', async () => {
+ const ed = t.editor;
+ assert.strictEqual((await t.get('/api/v1/instruments', { as: { ...ed, role: 'user' }, json: { symbol: 'APC', name: 'Other' } })).status, 409);
+ const acme = t.ctx.instruments.bySymbol('ACME');
+ assert.throws(() => t.ctx.instruments.addAlias(acme, 'cik', '320193'), (e) => e.status === 409);
+ });
+
+ await check('the signed webhook wakes the sync once per event id; a bad signature is 401', async () => {
+ t.sources.putItem({ canonical_url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000106/0000320193-26-000106-index.htm', title: 'APPLE INC', summary: '8-K', published_at: '2026-09-22T12:10:00.000Z', retrieved_at: t.iso(t.T0) });
+ const body = JSON.stringify({ event: { event_id: 'evt_01JABCDEFGHJKMNPQRSTVWXYZ0', event_type: 'sources.item.created', payload: {} }, seq: 7 });
+ const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': 'sha256=00' } });
+ assert.strictEqual(bad.status, 401);
+ const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });
+ assert.strictEqual(ok.status, 200);
+ assert.strictEqual(ok.json().sync, true);
+ await t.ctx.sync.run();
+ assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 1);
+ const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });
+ assert.strictEqual(again.json().duplicate, true);
+ assert.strictEqual(again.json().sync, false);
+ });
+
+ await check('Trade asks Sources with its own service token for exactly sources.item.read and sources.source.read', async () => {
+ const g = t.network.grants.filter((x) => x.audience === 'openvibe.sources');
+ assert.ok(g.length >= 1);
+ assert.deepStrictEqual(g[0].scope.split(' ').sort(), ['sources.item.read', 'sources.source.read']);
+ assert.ok(t.sources.calls.some((c) => /category=trade/.test(c.url) && /include_removed=1/.test(c.url)));
+ });
+
+ await t.close();
+ done();
+})();