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

OP OpenVibers committed 23 Sept 2026 · 4d40b52232 · 52 files, +7377 −0


Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..2f8317d
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..8832a1b
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/trade.css b/public/css/trade.css
nnew file mode 100644
iindex 0000000..f248570
---- /dev/null
++++ b/public/css/trade.css
@@@ -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; }
++}
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..ff53509
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..c03d42e
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -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 };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..fd03245
---- /dev/null
++++ b/server/auth/forms.js
@@@ -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 };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..bef301f
---- /dev/null
++++ b/server/auth/sso.js
@@@ -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 };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..3a7a9f4
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -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 };
ddiff --git a/server/clients/sources.js b/server/clients/sources.js
nnew file mode 100644
iindex 0000000..9a477a2
---- /dev/null
++++ b/server/clients/sources.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..8499e0e
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..53d609d
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/alerts.js b/server/domain/alerts.js
nnew file mode 100644
iindex 0000000..d770b57
---- /dev/null
++++ b/server/domain/alerts.js
@@@ -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 };
ddiff --git a/server/domain/context.js b/server/domain/context.js
nnew file mode 100644
iindex 0000000..6217908
---- /dev/null
++++ b/server/domain/context.js
@@@ -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 };
ddiff --git a/server/domain/documents.js b/server/domain/documents.js
nnew file mode 100644
iindex 0000000..1a95689
---- /dev/null
++++ b/server/domain/documents.js
@@@ -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 };
ddiff --git a/server/domain/freshness.js b/server/domain/freshness.js
nnew file mode 100644
iindex 0000000..f1fe97f
---- /dev/null
++++ b/server/domain/freshness.js
@@@ -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 };
ddiff --git a/server/domain/indexing.js b/server/domain/indexing.js
nnew file mode 100644
iindex 0000000..23c23ed
---- /dev/null
++++ b/server/domain/indexing.js
@@@ -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 };
ddiff --git a/server/domain/instruments.js b/server/domain/instruments.js
nnew file mode 100644
iindex 0000000..df3ca7a
---- /dev/null
++++ b/server/domain/instruments.js
@@@ -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 };
ddiff --git a/server/domain/mapping.js b/server/domain/mapping.js
nnew file mode 100644
iindex 0000000..9333825
---- /dev/null
++++ b/server/domain/mapping.js
@@@ -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 };
ddiff --git a/server/domain/observations.js b/server/domain/observations.js
nnew file mode 100644
iindex 0000000..e771f10
---- /dev/null
++++ b/server/domain/observations.js
@@@ -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 };
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
nnew file mode 100644
iindex 0000000..e477014
---- /dev/null
++++ b/server/domain/reading.js
@@@ -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 };
ddiff --git a/server/domain/sync.js b/server/domain/sync.js
nnew file mode 100644
iindex 0000000..43c40ec
---- /dev/null
++++ b/server/domain/sync.js
@@@ -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 };
ddiff --git a/server/domain/urls.js b/server/domain/urls.js
nnew file mode 100644
iindex 0000000..0c38682
---- /dev/null
++++ b/server/domain/urls.js
@@@ -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 };
ddiff --git a/server/domain/util.js b/server/domain/util.js
nnew file mode 100644
iindex 0000000..4f73bc3
---- /dev/null
++++ b/server/domain/util.js
@@@ -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 };
ddiff --git a/server/domain/watchlists.js b/server/domain/watchlists.js
nnew file mode 100644
iindex 0000000..d35addd
---- /dev/null
++++ b/server/domain/watchlists.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..4011053
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/events/webhook.js b/server/events/webhook.js
nnew file mode 100644
iindex 0000000..97339fb
---- /dev/null
++++ b/server/events/webhook.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..7eca74c
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/common.js b/server/http/common.js
nnew file mode 100644
iindex 0000000..2e9a648
---- /dev/null
++++ b/server/http/common.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..f93e505
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/server/http/editor.js b/server/http/editor.js
nnew file mode 100644
iindex 0000000..2fb8c3e
---- /dev/null
++++ b/server/http/editor.js
@@@ -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 };
ddiff --git a/server/http/errors.js b/server/http/errors.js
nnew file mode 100644
iindex 0000000..e00897f
---- /dev/null
++++ b/server/http/errors.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..be76d70
---- /dev/null
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/http/private.js b/server/http/private.js
nnew file mode 100644
iindex 0000000..3f9d235
---- /dev/null
++++ b/server/http/private.js
@@@ -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 };
ddiff --git a/server/http/routes.js b/server/http/routes.js
nnew file mode 100644
iindex 0000000..cf0c90b
---- /dev/null
++++ b/server/http/routes.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..4780d0c
---- /dev/null
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..7a57ae8
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..48a9472
---- /dev/null
++++ b/server/render/layout.js
@@@ -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 };
ddiff --git a/server/render/views.js b/server/render/views.js
nnew file mode 100644
iindex 0000000..9fc8654
---- /dev/null
++++ b/server/render/views.js
@@@ -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 };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..6683d5b
---- /dev/null
++++ b/server/worker.js
@@@ -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 };
ddiff --git a/test/alerts.test.js b/test/alerts.test.js
nnew file mode 100644
iindex 0000000..86303fa
---- /dev/null
++++ b/test/alerts.test.js
@@@ -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();
++})();
ddiff --git a/test/context.test.js b/test/context.test.js
nnew file mode 100644
iindex 0000000..23636d4
---- /dev/null
++++ b/test/context.test.js
@@@ -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();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..0985360
---- /dev/null
++++ b/test/contracts.test.js
@@@ -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();
++})();
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
nnew file mode 100644
iindex 0000000..3250ad3
---- /dev/null
++++ b/test/freshness.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..e2e1ae9
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..4fe3828
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -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 };
ddiff --git a/test/observations.test.js b/test/observations.test.js
nnew file mode 100644
iindex 0000000..984f7f6
---- /dev/null
++++ b/test/observations.test.js
@@@ -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();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..5f61017
---- /dev/null
++++ b/test/pages.test.js
@@@ -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();
++})();
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
nnew file mode 100644
iindex 0000000..a4bf918
---- /dev/null
++++ b/test/privacy.test.js
@@@ -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();
++})();
ddiff --git a/test/route-inventory.test.js b/test/route-inventory.test.js
nnew file mode 100644
iindex 0000000..14d248a
---- /dev/null
++++ b/test/route-inventory.test.js
@@@ -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();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..92512ea
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
++ * databases and in-process mocks of OpenVibe.Network 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);
++})();
ddiff --git a/test/sync.test.js b/test/sync.test.js
nnew file mode 100644
iindex 0000000..24fc73f
---- /dev/null
++++ b/test/sync.test.js
@@@ -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();
++})();