openvibers/openvibe.blog Commits
OpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.0
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -0,0 +1,1562 @@++{++ "name": "openvibe-blog",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-blog",++ "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.13.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.13.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",++ "integrity": "sha512-MeIGJYHQsbr6ISeEErduUw/PD52X5IBclH2ht/G/t4lSX5X4o2AwcZYLEzOZrcbaXQyplgC7r86RCGFvi1ifUA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-publishing": {++ "version": "0.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",++ "license": "MIT",++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "better-sqlite3": ">=11"++ },++ "peerDependenciesMeta": {++ "better-sqlite3": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.3.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,35 @@++{++ "name": "openvibe-blog",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Blog — the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "seed": "node scripts/seed.js"++ },++ "keywords": ["openvibe", "blog", "publishing", "feeds"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Blog",++ "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.13.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"++ },++ "devDependencies": {++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,59 @@++/* OpenVibe.Blog — readable, theme-token driven (openvibe-shared presets). No JavaScript needed. */++:root { color-scheme: dark; }++* { box-sizing: border-box; }++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 17px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--accent-light, #60a5fa); }++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }++.account-bar { max-width: 760px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.page { max-width: 760px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }++.blog-surface { background: var(--bg-primary); color: var(--text-primary); border-radius: 12px; }++body.editor .page { max-width: 900px; }++h1 { font-size: 2.1rem; line-height: 1.2; margin: 1.2rem 0 .6rem; }++h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }++.lede { color: var(--text-secondary, #96a7c2); font-size: 1.1rem; }++.meta, .byline, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; }++.badge { display: inline-block; font-size: .75rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary); vertical-align: middle; }++.post-list, .comment-list, .replies { list-style: none; padding: 0; }++.post-item { padding: 14px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.post-item-title { margin: 0 0 4px; font-size: 1.3rem; }++.post-item-title a { color: var(--text-primary); text-decoration: none; }++.post-item-title a:hover { text-decoration: underline; }++.summary { margin: 6px 0; }++.tags a, .categories a { margin-right: 6px; }++.post-body img, .ov-media img { max-width: 100%; height: auto; border-radius: 8px; }++.post-body pre { overflow-x: auto; background: var(--bg-tertiary, #172238); padding: 12px; border-radius: 8px; }++.post-body blockquote { border-left: 3px solid var(--accent, #3b82f6); margin: 0; padding-left: 14px; color: var(--text-secondary); }++.ov-media { margin: 1.2rem 0; }++.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }++.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }++.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }++.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }++.sources ol { padding-left: 1.2rem; }++.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }++.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }++.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }++.comments { margin-top: 40px; border-top: 1px solid var(--border, #1f2d47); }++.comment { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.replies { margin-left: 22px; }++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }++.notice.error { border-color: var(--danger, #ef4444); }++.card { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 16px; margin: 16px 0; background: var(--bg-card, #131c2e); }++label { display: block; margin: 10px 0 4px; font-weight: 600; font-size: .95rem; }++label input[type=checkbox] { margin-right: 6px; }++input, textarea, select { width: 100%; font: inherit; color: var(--text-primary); background: var(--bg-input, #0d1424); border: 1px solid var(--border-light, #2c3d5c); border-radius: 6px; padding: 8px 10px; }++input[type=checkbox] { width: auto; }++textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; }++button, .button { display: inline-block; margin-top: 10px; font: inherit; font-weight: 600; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 8px 16px; cursor: pointer; text-decoration: none; }++button.danger { background: var(--danger, #ef4444); }++form.inline { display: inline; }++form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }++.actions form { margin: 10px 0; }++table.posts { width: 100%; border-collapse: collapse; }++table.posts th, table.posts td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }++.ov-diff ins { background: rgba(34,197,94,.25); text-decoration: none; }++.ov-diff del { background: rgba(239,68,68,.25); }++.ov-diff { white-space: pre-wrap; }++@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }++@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } }nnew file mode 100644@@@ -0,0 +1,30 @@++#!/usr/bin/env node++'use strict';++/**++ * Seed the official blog with its first post (a DRAFT built from verbatim quotes; see server/seed.js).++ *++ * npm run seed # draft only (author: first BLOG_OFFICIAL_OWNERS)++ * npm run seed -- --author usr_… # a different accountable author++ * npm run seed -- --publish --reviewer usr_… # a person reviewed it: record that and publish++ *++ * Uses the same database and outbox as the service (BLOG_DB_PATH); events wait in event_outbox and++ * are relayed by the running service.++ */++const { createApp } = require('../server/app');++const { seedOfficialPost } = require('../server/seed');++++const args = process.argv.slice(2);++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };++++const { ctx } = createApp();++try {++ const out = seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });++ console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(ctx.blogs.get(out.post.blog_id), out.post)}`);++ if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);++ if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');++} catch (err) {++ console.error(`[seed] ${err.message}`);++ process.exitCode = 1;++} finally {++ ctx.store.close();++}nnew file mode 100644@@@ -0,0 +1,130 @@++{++ "week": {++ "from": "2026-09-16",++ "to": "2026-09-22"++ },++ "collected_at": "2026-09-23T02:20:56+00:00",++ "items": [++ {++ "repo": "OpenVibe.Billing",++ "heading": "OpenVibe.Billing (alpha, shadow)",++ "quote": "OpenVibe.Billing W8: balanced append-only journal, provider receipts, subscriptions, payouts, freeze, reconciliation and the Live import (ADR-012)",++ "kind": "commit",++ "ref": "ab5ae8197a80f20df0723f4ef83aeb310072f302",++ "date": "2026-09-22T18:18:02-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Billing/commit/ab5ae8197a80f20df0723f4ef83aeb310072f302"++ },++ {++ "repo": "OpenVibe.Chat",++ "heading": "OpenVibe.Chat service",++ "quote": "Chat service: Live's chat behind a service boundary (roadmap Wave 6)",++ "kind": "commit",++ "ref": "abf1e4b40ab6c0dd478bd879bbd28436d7404c0c",++ "date": "2026-09-22T18:53:50-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Chat/commit/abf1e4b40ab6c0dd478bd879bbd28436d7404c0c"++ },++ {++ "repo": "OpenVibe.Community",++ "heading": "Community forum and comments",++ "quote": "Wave 5: typed comment threads, forum (spaces/threads/posts), Discord relay, Pulse",++ "kind": "commit",++ "ref": "139aedd004c39575bd4832b9dcbfbeef41c61fa5",++ "date": "2026-09-22T18:06:24-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Community/commit/139aedd004c39575bd4832b9dcbfbeef41c61fa5"++ },++ {++ "repo": "OpenVibe.Contracts",++ "heading": "OpenVibe.Contracts first release",++ "quote": "v0.1.0: first contracts (subject, legacy identity map, service-token claims, entity ref, problem, service manifest, capability, event envelope, media ref)",++ "kind": "tag",++ "ref": "bae88d185ab402915fad1c434ca11031adf63af8",++ "date": "2026-09-22T14:08:20-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Contracts/commit/bae88d185ab402915fad1c434ca11031adf63af8",++ "tag": "v0.1.0"++ },++ {++ "repo": "OpenVibe.Events",++ "heading": "OpenVibe.Events service",++ "quote": "Events service: durable store, publish API, signed delivery with retry/DLQ/replay, SSE realtime gateway, client library",++ "kind": "commit",++ "ref": "f8c2e2418938498664fafbd6ce096faaf45284c4",++ "date": "2026-09-22T17:52:23-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Events/commit/f8c2e2418938498664fafbd6ce096faaf45284c4"++ },++ {++ "repo": "OpenVibe.Live",++ "heading": "Live release prompts",++ "quote": "GET /release.json; openvibe-shared v1.2.1 (the navbar loads release-watch.js: open tabs are prompted after a deploy and never reloaded while watching, broadcasting or typing)",++ "kind": "commit",++ "ref": "829ac9fb35df92d98eb79ea538f5d661690984a3",++ "date": "2026-09-22T18:52:38-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Live/commit/829ac9fb35df92d98eb79ea538f5d661690984a3"++ },++ {++ "repo": "OpenVibe.Media",++ "heading": "Media canonical object model",++ "quote": "Canonical object model (Wave 4): media_objects with locations, relationships, variants, jobs, retention holds and the public-size invariant; vods/clips/files/screenshot pastes become projections via object_id",++ "kind": "commit",++ "ref": "cced10f81e529c76346d6578c8b26a5350f744be",++ "date": "2026-09-22T18:01:01-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Media/commit/cced10f81e529c76346d6578c8b26a5350f744be"++ },++ {++ "repo": "OpenVibe.Network",++ "heading": "Network sign-in everywhere",++ "quote": "Verify-email page, sign-in everywhere, cross-site history, linked services for every site, navbar brand + modular menus",++ "kind": "commit",++ "ref": "d8b7f65f015ced051ed64a8269252395f6bed320",++ "date": "2026-09-17T20:08:00-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/d8b7f65f015ced051ed64a8269252395f6bed320"++ },++ {++ "repo": "OpenVibe.Publishing",++ "heading": "openvibe-publishing 0.1.0",++ "quote": "openvibe-publishing 0.1.0: shared publishing packages (revisions, schedule, taxonomy, citations, media, discussion, seo gate, authorship, index hooks, ssr) with the two-products exit proof",++ "kind": "tag",++ "ref": "6b4f426badb8f9d2a0dc85fbd2fbdc4bae066354",++ "date": "2026-09-22T18:38:23-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/commit/6b4f426badb8f9d2a0dc85fbd2fbdc4bae066354",++ "tag": "v0.1.0"++ },++ {++ "repo": "OpenVibe.SDK",++ "heading": "openvibe-sdk 0.1.0",++ "quote": "openvibe-sdk 0.1.0: core client, auth, registry, identity, modules, events, realtime, media, community and a mock platform",++ "kind": "tag",++ "ref": "c5d6d281c86e0aa00eafcb6630936d21225bbfdb",++ "date": "2026-09-22T18:20:42-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.SDK/commit/c5d6d281c86e0aa00eafcb6630936d21225bbfdb",++ "tag": "v0.1.0"++ },++ {++ "repo": "OpenVibe.Search",++ "heading": "OpenVibe.Search service",++ "quote": "Search service: index documents with revision ordering and tombstones, event inbox, permission-aware FTS5 query API, removal events through an outbox",++ "kind": "commit",++ "ref": "a9d48430eb8d019948b3b912f4440045307c52ec",++ "date": "2026-09-22T18:32:49-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Search/commit/a9d48430eb8d019948b3b912f4440045307c52ec"++ },++ {++ "repo": "OpenVibe.Shared",++ "heading": "openvibe-shared 1.0.0",++ "quote": "First release from its own repository.",++ "kind": "changelog",++ "ref": "CHANGELOG.md#1.0.0 — 2026-09-22",++ "date": "2026-09-22",++ "url": "https://github.com/OpenVibers/OpenVibe.Shared/blob/11735628f73026823ebfe40c45a35f2e23b8ba3d/CHANGELOG.md"++ },++ {++ "repo": "OpenVibe.Tools",++ "heading": "Tools durable jobs",++ "quote": "Durable jobs on Img, Audio and Docs: /api/v1/jobs with SSE progress, cancel, reattach, idempotency keys and owner scoping; results can be Media objects",++ "kind": "commit",++ "ref": "7b76c41792688d089ff826f4273efe4dd82c2350",++ "date": "2026-09-22T18:42:49-07:00",++ "url": "https://github.com/OpenVibers/OpenVibe.Tools/commit/7b76c41792688d089ff826f4273efe4dd82c2350"++ }++ ],++ "excluded_notes": "Dates are committer dates in -07:00; the 2026-09-22 evening commits are 2026-09-23 in UTC. CHANGELOG entries dated 2026-09-23 (openvibe-shared 1.1.0-1.3.0, openvibe-sdk 0.2.0-0.2.2) were left out as outside the week. No deployment claims: STATUS.json says \"deployed\": false for Chat, Events, Search and Sources; Billing says \"authoritative\": false (\"neither is applied or executed, so Live stays authoritative.\"); Host Stage A is \"not installed on the production host\". Conflicting signals left out: Live commit 9f4f1da (\"Wave 6 cutover, 2026-09-23 02:03 UTC\") and Contracts ADR-010 (\"executed 2026-09-23\") vs Chat STATUS \"deployed\": false; Sites 7b7c917 (\"OpenVibe.Events serves it\") vs Events STATUS \"deployed\": false. Also left out for length: OpenVibe.Sources service (alpha W14, deployed false), OpenVibe.Host Stage A CLI, OpenVibe.Games Wave 12 platform commit (very long subject), Community paste authority cutover (786e6f8), Network canonical subject ids / ecosystem registry / FedCM, Tools' twelve single-purpose tools, Live observability, Contracts v0.2.0-v0.14.0 tags, Sites placeholders, and charter-only placeholder repos (OpenRe.Stream, AI, Tips, VIP, Wiki; Realtime closed by ADR-005). Tag items carry an extra \"tag\" field; their quote is the tagged commit's subject."++}\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++++/**++ * OpenVibe.Blog — Express app factory. server/index.js listens and starts the worker; tests build++ * their own instance with a temp database, an injectable clock and mock neighbours.++ *++ * Pages (server-rendered, http/public.js) Editor (forms, http/editor.js) API (http/api.js)++ * Discovery (robots, llms, sitemaps) /auth/* (Network SSO) /api/health, /api/ready,++ * /release.json, /metrics++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createAuthClient, createAuthRoutes } = require('./auth/sso');++const { createViewerResolver } = require('./auth/viewer');++const access = require('./domain/access');++const { createBlogs } = require('./domain/blogs');++const { createPublication } = require('./domain/publication');++const { createPosts } = require('./domain/posts');++const { createReading } = require('./domain/reading');++const { createEffects } = require('./domain/effects');++const { createPeople } = require('./clients/network');++const { createCommunity } = require('./clients/community');++const { createMedia } = require('./clients/media');++const { createBlogOutbox } = require('./events/outbox');++const { createPublicRoutes } = require('./http/public');++const { createEditorRoutes } = require('./http/editor');++const { createApi } = require('./http/api');++const { createDiscoveryRoutes } = require('./http/discovery');++const { createBlogReadiness } = 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),++ * entitlementCheck ({ subject, key }) → bool, log++ */++function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++++ const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });++ const blogs = createBlogs({ store, config });++ const publication = createPublication({ store, config, outbox });++ const posts = createPosts({ store, blogs, publication, access, outbox, log });++ const people = createPeople({ store, config, fetchImpl });++ const community = createCommunity({ store, config, fetchImpl });++ const media = createMedia({ config, fetchImpl });++ const reading = createReading({ store, blogs, posts, publication, people, media });++ const effects = createEffects({ outbox, community });++ const entitlements = access.createEntitlementChecker({ provider: config.entitlements.provider, check: opts.entitlementCheck });++ const auth = opts.auth || createAuthClient(config);++ const viewers = createViewerResolver({ auth, config, people });++ const worker = createWorker({ config, store, posts, media, outbox, log });++ blogs.ensureOfficial();++++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, auth, viewers, access, worker };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'blog', root: path.join(__dirname, '..') });++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', 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'],++ // Media objects are served by openvibe.media (which may redirect to object storage).++ imgSrc: ["'self'", 'data:', 'https:'],++ mediaSrc: ["'self'", 'https:'],++ connectSrc: ["'self'", 'https://openvibe.network'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'self'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));++ app.get('/release.json', release.handler);++ const readiness = createBlogReadiness({ store, auth, outbox, release: release.release });++ app.get('/api/ready', readiness.handler);++++ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', createAuthRoutes(config, auth));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'blog', service: 'blog', host: 'openvibe.blog', name: 'OpenVibe.Blog', profile: 'ugc' })); }++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));++++ // ── Discovery, editor, public pages ─────────────────────++ app.use(createDiscoveryRoutes(ctx));++ const publicRoutes = createPublicRoutes(ctx);++ ctx.publicRoutes = publicRoutes;++ app.use('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));++ app.use(publicRoutes.router);++ app.use((req, res) => publicRoutes.notFound(req, res));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[Blog]', err && err.stack ? err.stack : err);++ if (res.headersSent) return;++ res.set('Cache-Control', 'private, no-store');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * Capability checks for service tokens (audience openvibe.blog), including the ids Blog introduces++ * before the contracts library knows them.++ *++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its++ * manifests yet. Blog'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: they are judged by their blog++ * membership (domain/access.js). A service token is judged by its capability AND by the membership++ * of the person it acts for (X-OV-Subject).++ */++const { capabilities } = require('openvibe-contracts');++++const CAPABILITIES = Object.freeze({++ BLOG_CREATE: 'blog.blog.create', // the charter's blog.create, as a 3-segment id++ BLOG_CONFIGURE: 'blog.blog.configure', // title, description, language, feed settings++ THEME_SET: 'blog.theme.set',++ MEMBER_MANAGE: 'blog.member.manage',++ POST_CREATE: 'blog.post.create',++ POST_READ: 'blog.post.read',++ POST_UPDATE: 'blog.post.update',++ POST_PUBLISH: 'blog.post.publish',++ POST_SCHEDULE: 'blog.post.schedule',++ POST_UNPUBLISH: 'blog.post.unpublish',++ POST_DELETE: 'blog.post.delete',++ FEED_READ: 'blog.feed.read',++});++const PROPOSED = new Set(Object.values(CAPABILITIES));++++/** → { allowed, code, reason } like capabilities.check(). */++function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }++ return capabilities.check(claims, capabilityId);++}++++module.exports = { CAPABILITIES, PROPOSED, checkCapability };nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++/**++ * Form tokens for the no-JavaScript editor and comment forms (CSRF). The session cookie is++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under++ * BLOG_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(`blog-form:${viewer.subject}`).digest('base64url').slice(0, 32);++}++++function checkCsrf(config, viewer, token) {++ const expected = csrfToken(config, viewer);++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));++}++++module.exports = { csrfToken, checkCsrf };nnew file mode 100644@@@ -0,0 +1,420 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// openvibe.blog — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)++//++// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with++// Blog's client id: registered in the Network as OAuth client `blog`, redirect++// https://openvibe.blog/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.blog):++// 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(`[Blog] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);++ return client.publicKey;++ }++ } catch (err) {++ console.warn(`[Blog 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('[Blog 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('[Blog 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('[Blog auth] Refresh failed:', err.message);++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */++function optionalAuth(auth) {++ return async (req, _res, next) => {++ const token = extractToken(req);++ if (token) {++ const claims = await auth.verify(token);++ if (claims) { req.user = claimsToUser(claims); req.token = token; }++ }++ next();++ };++}++++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * Who is calling, resolved once per request into `req.viewer`:++ *++ * { kind: 'anonymous', subject: null, staff: false }++ * { kind: 'user', subject: 'usr_…'|null, staff, user, token }++ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the++ * token's subject_id claim. role 'admin' makes the viewer staff. X-OV-* headers are ignored.++ * { 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.blog.++ * X-OV-Subject names the person it acts for; X-OV-Origin: ai marks AI output (OpenVibe.AI's++ * blog.draft_post), which is never attributed to that person as author — it is AI-generated,++ * starts as a draft and needs a person's review before it can be published.++ *++ * Identity never comes from a request body or query. A request presenting a service token is judged++ * on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.++ */++const contracts = require('openvibe-contracts');++const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');++const { checkCapability } = require('./capabilities');++++const { ids, serviceAuth, http } = contracts;++const STAFF_ROLES = new Set(['admin']);++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const AUDIENCE = 'openvibe.blog';++++class ViewerError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });++++function createViewerResolver({ auth, config, people }) {++ async function fromServiceToken(req, token) {++ const publicKey = await auth.ensureKey();++ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });++ if (!r.ok) throw new ViewerError(401, r.code, r.reason);++ const originHeader = req.get('x-ov-origin');++ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');++ const subjectHeader = req.get('x-ov-subject');++ let subject = null;++ if (subjectHeader) {++ if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');++ subject = subjectHeader;++ }++ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };++ }++++ async function fromUserToken(token) {++ const claims = await auth.verify(token);++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user: claimsToUser(claims), token };++ }++++ /** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */++ async function resolve(req, opts = {}) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodeJwtPayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ if (opts.services === false) return ANONYMOUS;++ return fromServiceToken(req, token);++ }++ }++ const token = extractToken(req);++ if (!token) return ANONYMOUS;++ return (await fromUserToken(token)) || ANONYMOUS;++ }++++ function middleware(opts = {}) {++ return async (req, res, next) => {++ try {++ req.viewer = await resolve(req, opts);++ next();++ } catch (err) {++ if (!(err instanceof ViewerError)) return next(err);++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ }++ };++ }++++ return { resolve, middleware };++}++++/**++ * Route guard: a service token must hold `cap`; browsers and anonymous callers pass here and are++ * judged by membership in the domain layer.++ */++function guard(cap) {++ return (req, res, next) => {++ const v = req.viewer;++ if (!v || v.kind !== 'service') return next();++ const c = checkCapability(v.claims, cap);++ if (c.allowed) return next();++ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++ };++}++++module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * Comments are OpenVibe.Community threads, referenced — never copied. A public post's thread is the++ * one Community resolves for EntityRef { service: 'blog', type: 'post', id } (openvibe-publishing/++ * discussion stores only the thread id, in blog_post_discussion_refs). Pages read the thread from++ * Community on render, so moderation and deletion there are always what readers see; a failure is++ * shown as "comments are unavailable", never as an empty or invented thread.++ *++ * Only PUBLIC posts get threads: a Community thread is readable by anyone who has its id, so a++ * members-only or private post must not have one.++ *++ * Calls with Blog's service token (audience openvibe.community):++ * community.comment.write resolve a thread; comment as the signed-in member (X-OV-Subject)++ * community.comment.moderate (optional) hide the thread of a post that was deleted or made non-public++ */++const { serviceAuth, http } = require('openvibe-contracts');++const { createDiscussionClient } = require('openvibe-publishing/discussion');++++function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {++ const base = config.community.internalUrl;++ const enabled = Boolean(config.oauth.clientSecret && base);++ const tokenClient = (scope) => serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.community', scope, fetchImpl,++ });++ const writeTokens = enabled ? tokenClient('community.comment.write') : null;++ const modTokens = enabled ? tokenClient('community.comment.moderate') : null;++ const discussion = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;++++ const refFor = (post, label) => ({ service: 'blog', type: 'post', id: post.id, ...(label ? { label: String(label).slice(0, 200) } : {}) });++++ async function call(method, path, { tokens, subject, body, ctx, timeoutMs = 4000 } = {}) {++ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };++ if (tokens) Object.assign(headers, await tokens.authHeaders());++ if (subject) headers['X-OV-Subject'] = subject;++ if (body) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });++ const data = await res.json().catch(() => null);++ if (res.status === 401 && tokens && tokens.invalidate) tokens.invalidate();++ if (!res.ok) {++ const err = new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);++ err.status = res.status;++ err.code = data && data.code;++ throw err;++ }++ return data;++ }++++ return {++ enabled,++ publicUrl: config.community.publicUrl,++++ /** The stored thread id, or resolve it once through Community. Public posts only. */++ async threadFor(post, label, ctx) {++ if (post.visibility !== 'public') return null;++ const known = store.discussion.get(post.id);++ if (known) return known.threadId;++ if (!discussion) return null;++ const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });++ return out.threadId;++ },++++ /** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */++ async readThread(threadId, { after, ctx } = {}) {++ const qs = after ? `?after=${encodeURIComponent(after)}` : '';++ return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });++ },++++ /** Comment as the signed-in member. */++ async comment(threadId, subject, { message, parentId } = {}, ctx) {++ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }++ return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {++ tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,++ });++ },++++ /**++ * Best effort: hide a post's thread when the post is deleted or stops being public, show it++ * again when it is public again. Needs community.comment.moderate.++ */++ async setThreadVisibility(post, visibility, ctx) {++ const known = store.discussion.get(post.id);++ if (!known || !modTokens) return false;++ try {++ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });++ return true;++ } catch (err) {++ console.warn(`[Blog] could not set the comment thread of ${post.id} to ${visibility}: ${err.message}`);++ return false;++ }++ },++ };++}++++module.exports = { createCommunity };nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/**++ * OpenVibe.Media: attachments are Media object ids (med_…); the bytes stay in Media.++ *++ * urlFor(mediaId) the public delivery URL (GET /o/:id on the Media host)++ * resolve(mediaId) for openvibe-publishing/media verify(): asks Media's object API++ * (GET /api/v2/<app>/objects/:id, capability media.object.read for the++ * namespace BLOG_MEDIA_APP, audience openvibe.media) whether the object exists:++ * 200 ready/uploading → { exists: true }++ * 200 lifecycle deleted → { exists: false, reason: 'deleted' }++ * 410 → { exists: false, reason: 'deleted' }++ * 404 → { exists: false, reason: 'not_found' }++ * anything else (401/403 grants, 5xx, timeouts) THROWS: an outage or a++ * missing grant is not evidence the object is gone (check_failed)++ *++ * Media does not publish a deletion event yet, so Blog's worker re-verifies the attachments of++ * published posts every BLOG_MEDIA_VERIFY_INTERVAL_MS; a broken attachment renders as an explicit++ * "media unavailable" placeholder, never as a silently missing image.++ */++const { serviceAuth } = require('openvibe-contracts');++++function createMedia({ config, fetchImpl = globalThis.fetch }) {++ const enabled = Boolean(config.oauth.clientSecret && config.media.internalUrl);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.media', scope: 'media.object.read', fetchImpl,++ }) : null;++++ return {++ enabled,++ urlFor: (mediaId) => `${config.media.publicUrl}/o/${encodeURIComponent(mediaId)}`,++++ async resolve(mediaId) {++ if (!tokens) throw new Error('Media verification is not configured');++ const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(config.media.app)}/objects/${encodeURIComponent(mediaId)}`, {++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },++ signal: AbortSignal.timeout(5000),++ });++ if (res.status === 404) return { exists: false, reason: 'not_found' };++ if (res.status === 410) return { exists: false, reason: 'deleted' };++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();++ if (!res.ok) throw new Error(`Media answered ${res.status}`);++ const data = await res.json().catch(() => null);++ const obj = data && (data.object || data);++ if (obj && obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };++ return { exists: true };++ },++ };++}++++module.exports = { createMedia };nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++++/**++ * Who a subject is, for display (author bylines, author pages, member lists).++ *++ * Blog stores people as Network subject ids (usr_…) only. Names come from the signed-in member's++ * own token claims and, for everyone else, from the Network's resolve-batch endpoint++ * (capability identity.subject.resolve, a client-credentials token for audience openvibe.network).++ * They are cached in subject_projections; the cache is never authority, and an unresolvable++ * subject is shown as "a member", never as a made-up name.++ */++const { serviceAuth, ids } = require('openvibe-contracts');++++const TTL_MS = 6 * 60 * 60 * 1000;++const WAIT_MS = 2500;++++function createPeople({ store, config, fetchImpl = globalThis.fetch }) {++ const { db } = store;++ const enabled = Boolean(config.oauth.clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,++ }) : null;++ const q = {++ get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),++ byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),++ put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,++ avatar_url = excluded.avatar_url, refreshed_at = excluded.refreshed_at`),++ };++ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);++++ function remember(subject, p) {++ if (!ids.isSubjectId('user', subject) || !p) return;++ q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());++ }++++ async function resolve(subjects) {++ if (!tokens || !subjects.length) return;++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ subject_ids: subjects.slice(0, 500) }),++ signal: AbortSignal.timeout(8000),++ });++ if (res.status === 401) tokens.invalidate && tokens.invalidate();++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);++ for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);++ }++++ const shape = (row, subject) => (row++ ? { subject: row.subject, username: row.username, name: row.display_name || row.username || 'a member', avatarUrl: row.avatar_url, known: Boolean(row.username || row.display_name) }++ : { subject, username: null, name: 'a member', avatarUrl: null, known: false });++++ return {++ enabled,++ /** From a verified sign-in: the member's own claims are a fresh projection. */++ rememberClaims(subject, claims) { remember(subject, claims); },++++ /** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */++ async many(subjects) {++ const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];++ const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });++ if (missing.length && tokens) {++ let timer;++ await Promise.race([++ resolve(missing).catch((err) => console.warn('[Blog] identity resolve failed:', err.message)),++ new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),++ ]);++ clearTimeout(timer);++ }++ return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));++ },++++ async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },++++ /** The subject a username belongs to, from people Blog has seen. */++ byUsername(username) {++ const row = q.byUsername.get(String(username || '').toLowerCase());++ return row ? shape(row, row.subject) : null;++ },++++ cached: (subject) => shape(q.get.get(subject), subject),++ };++}++++module.exports = { createPeople };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++/**++ * OpenVibe.Blog configuration. Every value comes from the environment (production:++ * /etc/openvibe/blog.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, 4810);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.blog' : `http://localhost:${port}`));++++ return {++ service: 'blog',++ 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.BLOG_DB_PATH || './data/blog.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 || 'blog',++ 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 (forms opened++ // before a restart must be re-submitted).++ formSecret: env.BLOG_FORM_SECRET || '',++++ // The official blog (served at /). Its owners are Network subjects (usr_…); Network++ // admins may also edit it (staff).++ official: {++ handle: 'openvibe',++ title: env.BLOG_OFFICIAL_TITLE || 'The OpenVibe blog',++ description: env.BLOG_OFFICIAL_DESCRIPTION || 'News and release notes from the OpenVibe network.',++ owners: list(env.BLOG_OFFICIAL_OWNERS),++ },++++ // OpenVibe.Media: attachments are Media object ids; bytes are served by Media.++ media: {++ publicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),++ internalUrl: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),++ // The Media tenant (namespace) Blog's objects live in; media.object.read is granted for it.++ app: env.BLOG_MEDIA_APP || 'blog',++ verifyIntervalMs: int(env.BLOG_MEDIA_VERIFY_INTERVAL_MS, 10 * 60 * 1000),++ },++++ // OpenVibe.Community: comment threads (referenced by id, never copied).++ community: {++ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),++ internalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),++ },++++ // OpenVibe.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),++ },++++ // Scheduled publication worker.++ worker: {++ enabled: env.BLOG_WORKER !== 'off',++ intervalMs: int(env.BLOG_SCHEDULE_INTERVAL_MS, 15000),++ id: env.BLOG_WORKER_ID || `blog-${process.pid}`,++ },++++ // Entitlement checks for members-only (VIP) posts. 'none' (default): no entitlement service++ // exists yet, so the check fails closed — only the post's blog members and staff can read.++ entitlements: { provider: env.BLOG_ENTITLEMENTS_PROVIDER || 'none' },++++ // Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).++ apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media'),++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++++/**++ * Blog's own SQLite database: created on boot, idempotently. Nothing here is shared with another++ * service; the publishing packages create their tables inside this database with Blog's prefixes.++ *++ * The ten charter tables (roadmap §15.13):++ *++ * blogs Blog-owned official + member blogs, owner = Network subject++ * blog_memberships Blog-owned owner | editor | author per blog++ * blog_posts Blog-owned publication state: slug, state, visibility, published revision++ * blog_post_revisions package openvibe-publishing/revisions, prefix blog_post (immutable)++ * blog_series Blog-owned ordered series per blog++ * blog_taxonomy view over blog_terms (openvibe-publishing/taxonomy, prefix blog)++ * blog_post_terms view over blog_term_links (same package)++ * blog_schedules view over blog_schedule_jobs (openvibe-publishing/schedule, prefix blog)++ * blog_redirects package openvibe-publishing/seo redirect store, prefix blog++ * blog_feed_settings Blog-owned which feeds a blog offers, item count, full text or summary++ *++ * The three views keep the charter's names readable (`SELECT * FROM blog_schedules`) without a++ * second copy of the package's state: the package tables are the only truth.++ *++ * Also here: the package's companions (blog_post_drafts, blog_post_revision_purges,++ * blog_post_citations, blog_post_attachments, blog_post_reviews, blog_post_discussion_refs,++ * blog_index_revisions), the SDK's event_outbox, and subject_projections (a display cache of++ * Network names, never authority).++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++const { createRevisionStore } = require('openvibe-publishing/revisions');++const { createCitationStore } = require('openvibe-publishing/citations');++const { createTaxonomy } = require('openvibe-publishing/taxonomy');++const { createScheduler } = require('openvibe-publishing/schedule');++const { createAttachmentStore } = require('openvibe-publishing/media');++const { createDiscussionRefs } = require('openvibe-publishing/discussion');++const { createReviewLog } = require('openvibe-publishing/authorship');++const { createRedirectStore } = require('openvibe-publishing/seo');++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS blogs (++ id TEXT PRIMARY KEY, -- blg_<ULID>++ kind TEXT NOT NULL CHECK (kind IN ('official','member')),++ handle TEXT NOT NULL UNIQUE, -- /@handle; the official blog is 'openvibe' and served at /++ owner_subject TEXT, -- usr_… (NULL for the official blog: its owners are memberships)++ title TEXT NOT NULL,++ description TEXT,++ theme TEXT NOT NULL DEFAULT 'vibe', -- an openvibe-shared theme preset slug++ language TEXT NOT NULL DEFAULT 'en',++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended')),++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++CREATE UNIQUE INDEX IF NOT EXISTS blogs_owner ON blogs (owner_subject) WHERE kind = 'member';++++CREATE TABLE IF NOT EXISTS blog_memberships (++ blog_id TEXT NOT NULL REFERENCES blogs(id),++ subject TEXT NOT NULL, -- usr_…++ role TEXT NOT NULL CHECK (role IN ('owner','editor','author')),++ added_by TEXT,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ PRIMARY KEY (blog_id, subject)++);++CREATE INDEX IF NOT EXISTS blog_memberships_subject ON blog_memberships (subject);++++CREATE TABLE IF NOT EXISTS blog_series (++ id TEXT PRIMARY KEY, -- ser_<ULID>++ blog_id TEXT NOT NULL REFERENCES blogs(id),++ slug TEXT NOT NULL,++ title TEXT NOT NULL,++ description TEXT,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ UNIQUE (blog_id, slug)++);++++CREATE TABLE IF NOT EXISTS blog_posts (++ id TEXT PRIMARY KEY, -- pst_<ULID>++ blog_id TEXT NOT NULL REFERENCES blogs(id),++ slug TEXT NOT NULL,++ state TEXT NOT NULL DEFAULT 'draft'++ CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),++ visibility TEXT NOT NULL DEFAULT 'public'++ CHECK (visibility IN ('public','unlisted','members','private')),++ entitlement_key TEXT, -- members (VIP) posts: the entitlement that may read it++ author_subject TEXT NOT NULL, -- usr_… accountable for the post++ series_id TEXT REFERENCES blog_series(id),++ series_position INTEGER,++ published_revision INTEGER, -- which revision readers see (NULL: never published)++ first_published_at INTEGER,++ published_at INTEGER, -- the latest publication of published_revision++ allow_comments INTEGER NOT NULL DEFAULT 1,++ noindex INTEGER NOT NULL DEFAULT 0, -- the author asked search engines not to index it++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ deleted_at INTEGER,++ CHECK (visibility <> 'members' OR entitlement_key IS NOT NULL)++);++CREATE UNIQUE INDEX IF NOT EXISTS blog_posts_slug ON blog_posts (blog_id, slug) WHERE state <> 'deleted';++CREATE INDEX IF NOT EXISTS blog_posts_listing ON blog_posts (blog_id, state, visibility, published_at);++CREATE INDEX IF NOT EXISTS blog_posts_author ON blog_posts (author_subject, state);++CREATE INDEX IF NOT EXISTS blog_posts_series ON blog_posts (series_id, series_position);++++CREATE TABLE IF NOT EXISTS blog_feed_settings (++ blog_id TEXT PRIMARY KEY REFERENCES blogs(id),++ rss INTEGER NOT NULL DEFAULT 1,++ atom INTEGER NOT NULL DEFAULT 1,++ json INTEGER NOT NULL DEFAULT 1,++ item_count INTEGER NOT NULL DEFAULT 20 CHECK (item_count BETWEEN 1 AND 50),++ full_content INTEGER NOT NULL DEFAULT 1,++ updated_at INTEGER NOT NULL++);++++-- Display cache of Network names for subjects (from sign-in claims or identity.subject.resolve).++CREATE TABLE IF NOT EXISTS subject_projections (++ subject TEXT PRIMARY KEY,++ username TEXT,++ display_name TEXT,++ avatar_url TEXT,++ refreshed_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS subject_projections_username ON subject_projections (username);++`;++++// Charter names over the package tables (created after the stores, which create the tables).++const VIEWS = `++CREATE VIEW IF NOT EXISTS blog_taxonomy AS++ SELECT t.id, CASE WHEN t.vocabulary = 'tag' THEN 'tag' ELSE 'category' END AS kind,++ b.id AS blog_id, t.vocabulary, t.slug, t.name, t.parent_id, t.description, t.created_at++ FROM blog_terms t++ LEFT JOIN blogs b ON t.vocabulary = 'category_' || lower(substr(b.id, 5));++CREATE VIEW IF NOT EXISTS blog_post_terms AS++ SELECT l.entity_id AS post_id, l.term_id, t.vocabulary, l.position, l.created_at++ FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id;++CREATE VIEW IF NOT EXISTS blog_schedules AS++ SELECT id, idem_key, entity_id AS post_id, action, revision, run_at, status, attempts,++ lease_owner, lease_until, last_error, result, created_at, updated_at++ FROM blog_schedule_jobs;++`;++++/**++ * Open (or create) the database and every store on it.++ * opts.now — injectable clock (epoch ms) shared by the stores, so tests and replays are deterministic.++ */++function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {++ 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);++++ const revisions = createRevisionStore(db, { prefix: 'blog_post', now });++ const store = {++ db,++ now,++ revisions,++ citations: createCitationStore(db, { prefix: 'blog_post', now, revisions }),++ taxonomy: createTaxonomy(db, { prefix: 'blog', now }),++ // Few attempts: a scheduled publish that cannot happen should say so soon (blog.schedule.failed).++ scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),++ attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),++ discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),++ reviews: createReviewLog(db, { prefix: 'blog_post', now }),++ redirects: createRedirectStore(db, { prefix: 'blog', now }),++ sequencer: createIndexSequencer(db, { prefix: 'blog', now }),++ tx: (fn) => db.transaction(fn)(),++ close: () => db.close(),++ };++ db.exec(VIEWS);++ return store;++}++++const CHARTER_TABLES = ['blogs', 'blog_memberships', 'blog_posts', 'blog_post_revisions', 'blog_series',++ 'blog_taxonomy', 'blog_post_terms', 'blog_schedules', 'blog_redirects', 'blog_feed_settings'];++++module.exports = { openStore, CHARTER_TABLES };nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++++/**++ * Who may read and write what. Pure decisions over (viewer, blog, post, membership); the routes++ * resolve the viewer (auth/viewer.js) and the data, then ask here.++ *++ * Viewers:++ * { kind: 'anonymous' }++ * { kind: 'user', subject: 'usr_…'|null, staff, user } a browser or a Bearer user JWT++ * { kind: 'service', service: 'svc:…', subject, origin, claims } a Network service token; it acts++ * for the person in X-OV-Subject++ * The acting person is `viewer.subject`. Staff = a Network admin (role claim) on a user token.++ *++ * Roles per blog (blog_memberships): owner > editor > author.++ * owner everything on the blog, including members, settings, theme and feeds++ * editor every post of the blog (write, publish, schedule, unpublish, delete)++ * author their own posts only++ * Staff: owner rights on the official blog; unpublish/delete (moderation) on every blog.++ *++ * Reading (visibility of a post):++ * public everyone once published++ * unlisted everyone with the link once published; never listed, fed, mapped or indexed++ * members the blog's members, staff, and viewers the entitlement check admits (VIP). No++ * entitlement service exists yet: the default checker admits nobody (fails closed)++ * private the blog's members and staff++ * Anything not published (draft, scheduled, unpublished) is readable by the blog's members++ * (authors: their own posts) and staff only. Deleted posts are readable by nobody.++ */++++const ROLE_RANK = { author: 1, editor: 2, owner: 3 };++++function roleOf(store, blogId, subject) {++ if (!subject) return null;++ const row = store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);++ return row ? row.role : null;++}++++/** Effective role of the viewer on a blog, staff included ('owner' on the official blog). */++function effectiveRole(store, viewer, blog) {++ const role = roleOf(store, blog.id, viewer && viewer.subject);++ if (viewer && viewer.staff && blog.kind === 'official') return 'owner';++ return role;++}++++const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[min];++++function isStaff(viewer) { return Boolean(viewer && viewer.staff); }++++/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */++function memberCanSee(store, viewer, blog, post) {++ const role = effectiveRole(store, viewer, blog);++ if (!role) return false;++ if (atLeast(role, 'editor')) return true;++ return post.author_subject === viewer.subject;++}++++/**++ * The entitlement seam for members-only (VIP) posts.++ * check({ subject, key }) → true | false (never throws: an error counts as "no")++ * 'none' — no entitlement service exists yet, so nobody outside the blog's members and staff is++ * admitted. A cached badge or a client claim is never authorization.++ */++function createEntitlementChecker({ provider = 'none', check = null } = {}) {++ if (typeof check === 'function') {++ return {++ provider: 'custom',++ async has(subject, key) {++ if (!subject || !key) return false;++ try { return (await check({ subject, key })) === true; } catch { return false; }++ },++ };++ }++ if (provider !== 'none') {++ // A provider name we do not implement must not silently admit anyone.++ console.warn(`[Blog] BLOG_ENTITLEMENTS_PROVIDER=${provider} is not implemented; members-only posts stay closed`);++ }++ return { provider: 'none', async has() { return false; } };++}++++/**++ * Read decision for one post. → { allowed, status, reason }++ * status 404 hides existence (drafts, private, deleted); 403 says "members only".++ */++async function canReadPost(store, viewer, blog, post, entitlements) {++ if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };++ if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };++ const member = memberCanSee(store, viewer, blog, post) || isStaff(viewer);++ if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };++ if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };++ if (member) return { allowed: true, status: 200, reason: 'member' };++ if (post.visibility === 'private') return { allowed: false, status: 404, reason: 'private' };++ // members (VIP): the entitlement check, failing closed.++ if (viewer && viewer.subject && post.entitlement_key && await entitlements.has(viewer.subject, post.entitlement_key)) {++ return { allowed: true, status: 200, reason: 'entitled' };++ }++ return { allowed: false, status: 403, reason: 'members_only' };++}++++/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */++function canWrite(store, viewer, blog, action, post = null) {++ if (!viewer || !viewer.subject) return false;++ if (blog.status !== 'active' && !isStaff(viewer)) return false;++ const role = effectiveRole(store, viewer, blog);++ switch (action) {++ case 'create': return atLeast(role, 'author');++ case 'edit':++ case 'publish':++ if (atLeast(role, 'editor')) return true;++ return role === 'author' && post && post.author_subject === viewer.subject;++ case 'delete':++ case 'unpublish':++ if (isStaff(viewer)) return true; // moderation on any blog++ if (atLeast(role, 'editor')) return true;++ return role === 'author' && post && post.author_subject === viewer.subject;++ case 'configure':++ case 'members':++ return atLeast(role, 'owner');++ default:++ return false;++ }++}++++module.exports = { ROLE_RANK, roleOf, effectiveRole, atLeast, isStaff, memberCanSee, canReadPost, canWrite, createEntitlementChecker };nnew file mode 100644@@@ -0,0 +1,198 @@++'use strict';++++/**++ * Blogs, memberships, series, feed settings and themes.++ *++ * - The official blog (handle 'openvibe', served at /) exists from the first boot; its owners are++ * the Network subjects named in BLOG_OFFICIAL_OWNERS, and Network admins act as its owners.++ * - Every member gets a blog on first use (ensureMemberBlog): owner = the member's Network subject,++ * handle = their Network username when it is free and valid. One member blog per subject.++ * - Themes are a small set of openvibe-shared token presets; there is no site-local theme engine.++ */++const { ids } = require('openvibe-contracts');++const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');++const { slugify } = require('openvibe-publishing/taxonomy');++const { ApiError } = require('../http/errors');++++const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{1,39}$/;++// Paths the site uses, and names that would impersonate the network.++const RESERVED = new Set(['openvibe', 'official', 'admin', 'staff', 'api', 'auth', 'write', 'tags', 'authors', 'series',++ 'categories', 'feed', 'feeds', 'sitemap', 'sitemaps', 'robots', 'llms', 'release', 'metrics', 'assets', 'css', 'js',++ 'network', 'blog', 'blogs', 'help', 'support', 'security', 'terms', 'privacy', 'dmca', 'new', 'settings', 'me']);++++// The presets a blog may choose: a readable handful from openvibe-shared's built-in themes.++const THEME_PRESETS = ['vibe', 'paper', 'slate', 'sand', 'nord', 'high-contrast'];++const THEMES = Object.fromEntries(BUILTIN_THEMES.filter((t) => THEME_PRESETS.includes(t.id)).map((t) => [t.id, t]));++const ROLES = ['owner', 'editor', 'author'];++++const newBlogId = (now) => `blg_${ids.ulid(now)}`;++const newSeriesId = (now) => `ser_${ids.ulid(now)}`;++++function validHandle(h) { return typeof h === 'string' && HANDLE_RE.test(h) && !RESERVED.has(h); }++++function createBlogs({ store, config }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM blogs WHERE id = ?'),++ byHandle: db.prepare('SELECT * FROM blogs WHERE handle = ?'),++ byOwner: db.prepare("SELECT * FROM blogs WHERE owner_subject = ? AND kind = 'member'"),++ official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),++ insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)++ VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),++ feedDefaults: db.prepare('INSERT OR IGNORE INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?)'),++ member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),++ members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),++ putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)++ ON CONFLICT (blog_id, subject) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`),++ dropMember: db.prepare('DELETE FROM blog_memberships WHERE blog_id = ? AND subject = ?'),++ ownerCount: db.prepare("SELECT COUNT(*) AS n FROM blog_memberships WHERE blog_id = ? AND role = 'owner'"),++ blogsOf: db.prepare(`SELECT b.*, m.role FROM blog_memberships m JOIN blogs b ON b.id = m.blog_id WHERE m.subject = ? ORDER BY b.kind DESC, b.created_at`),++ feed: db.prepare('SELECT * FROM blog_feed_settings WHERE blog_id = ?'),++ series: db.prepare('SELECT * FROM blog_series WHERE blog_id = ? ORDER BY title'),++ seriesBySlug: db.prepare('SELECT * FROM blog_series WHERE blog_id = ? AND slug = ?'),++ seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),++ };++++ function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {++ const now = store.now();++ const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };++ q.insert.run(row);++ q.feedDefaults.run(row.id, now);++ if (owner) q.putMember.run(row.id, owner, 'owner', owner, now, now);++ return q.byId.get(row.id);++ }++++ const api = {++ THEMES, THEME_PRESETS, ROLES, validHandle,++++ get: (id) => q.byId.get(id) || null,++ byHandle: (handle) => q.byHandle.get(String(handle || '').toLowerCase()) || null,++ ownedBy: (subject) => (subject ? q.byOwner.get(subject) || null : null),++ blogsOf: (subject) => (subject ? q.blogsOf.all(subject) : []),++++ /** The official blog, created on first boot; config owners become owner memberships. */++ ensureOfficial() {++ return store.tx(() => {++ let blog = q.official.get();++ if (!blog) {++ blog = create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });++ }++ const now = store.now();++ for (const s of config.official.owners) {++ if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }++ if (!q.member.get(blog.id, s)) q.putMember.run(blog.id, s, 'owner', 'config', now, now);++ }++ return blog;++ });++ },++++ official: () => q.official.get() || null,++++ /**++ * The member's own blog, created on first use. Handle: the requested one, else the Network++ * username. A taken or reserved handle is a 409 the member resolves by choosing another.++ */++ ensureMemberBlog({ subject, username, handle, title, description } = {}) {++ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');++ return store.tx(() => {++ const existing = q.byOwner.get(subject);++ if (existing) return { blog: existing, created: false };++ const h = String(handle || username || '').toLowerCase();++ if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);++ if (q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);++ const blog = create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });++ return { blog, created: true };++ });++ },++++ update(blog, { title, description, language } = {}) {++ const sets = [];++ const vals = [];++ if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }++ if (description != null) { sets.push('description = ?'); vals.push(String(description).trim().slice(0, 500) || null); }++ if (language != null) {++ if (!/^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(String(language))) throw new ApiError(422, 'blog.invalid_language', 'language must be a BCP 47 tag, e.g. en or pt-BR');++ sets.push('language = ?'); vals.push(String(language));++ }++ if (!sets.length) return blog;++ db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);++ return q.byId.get(blog.id);++ },++++ setTheme(blog, theme) {++ if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);++ db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);++ return q.byId.get(blog.id);++ },++++ /** CSS custom properties of the blog's preset, for its content surface. */++ themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },++++ feedSettings(blog) { return q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },++++ setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {++ const cur = api.feedSettings(blog);++ const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);++ const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);++ if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');++ db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,++ item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`)++ .run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());++ return api.feedSettings(blog);++ },++++ members: (blog) => q.members.all(blog.id),++ membership: (blog, subject) => q.member.get(blog.id, subject) || null,++++ setMember(blog, subject, role, addedBy) {++ if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');++ if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);++ return store.tx(() => {++ const cur = q.member.get(blog.id, subject);++ if (cur && cur.role === 'owner' && role !== 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') {++ throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');++ }++ const now = store.now();++ q.putMember.run(blog.id, subject, role, addedBy || null, now, now);++ return q.member.get(blog.id, subject);++ });++ },++++ removeMember(blog, subject) {++ return store.tx(() => {++ const cur = q.member.get(blog.id, subject);++ if (!cur) return false;++ if (cur.role === 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');++ q.dropMember.run(blog.id, subject);++ return true;++ });++ },++++ series: (blog) => q.series.all(blog.id),++ seriesBySlug: (blog, slug) => q.seriesBySlug.get(blog.id, slug) || null,++ seriesById: (id) => (id ? q.seriesById.get(id) || null : null),++++ /** Get-or-create a series by title. */++ ensureSeries(blog, title, description = null) {++ const t = String(title || '').trim().slice(0, 120);++ if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');++ const slug = slugify(t);++ return store.tx(() => {++ const cur = q.seriesBySlug.get(blog.id, slug);++ if (cur) return cur;++ const now = store.now();++ const id = newSeriesId(now);++ db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(id, blog.id, slug, t, description, now, now);++ return q.seriesById.get(id);++ });++ },++++ /** The taxonomy vocabulary that holds this blog's categories (tags are network-wide). */++ categoryVocabulary: (blog) => `category_${blog.id.slice(4).toLowerCase()}`,++ };++ return api;++}++++module.exports = { createBlogs, THEME_PRESETS, RESERVED, HANDLE_RE, validHandle };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * What happens after a write has committed (never inside the transaction):++ * - wake the outbox relay so events leave promptly;++ * - keep the Community comment thread in step with the post: hidden when the post is deleted or++ * stops being public, shown again when it is public again (best effort; needs++ * community.comment.moderate). A thread is never created for a non-public post.++ */++function createEffects({ outbox, community }) {++ return {++ after(before, post, ctx) {++ outbox.kick();++ if (!post || !before) return;++ const wasPublic = before.visibility === 'public' && before.state !== 'deleted';++ const isPublic = post.visibility === 'public' && post.state !== 'deleted';++ if (wasPublic && !isPublic) community.setThreadVisibility(post, 'hidden', ctx).catch(() => {});++ else if (!wasPublic && isPublic) community.setThreadVisibility(post, 'public', ctx).catch(() => {});++ },++ };++}++++module.exports = { createEffects };nnew file mode 100644@@@ -0,0 +1,505 @@++'use strict';++++/**++ * Posts: drafts, immutable revisions, publication, scheduling, unpublish, delete, slug changes,++ * taxonomy, series, citations, media attachments, and the human review AI drafts need.++ *++ * Every state change runs in one SQLite transaction together with its events (SDK outbox) and its++ * Search document (index-hooks), so the event exists if and only if the change committed.++ *++ * Content lives in blog_post_revisions (openvibe-publishing/revisions): content = Markdown body,++ * fields = { title, summary }, meta = { authorship } (openvibe-publishing/authorship record).++ * blog_posts.published_revision says which revision readers see; editing a published post adds a++ * revision without changing what readers see until that revision is published.++ *++ * Scheduled publication (openvibe-publishing/schedule) applies the idempotent effect "revision N is++ * the published one": a worker that dies after applying it and before recording the job as done++ * re-runs it after the lease expires, and the second run changes nothing and emits nothing.++ */++const { ids } = require('openvibe-contracts');++const { slugify } = require('openvibe-publishing/taxonomy');++const authorship = require('openvibe-publishing/authorship');++const { isMediaId } = require('openvibe-publishing/media');++const { ApiError } = require('../http/errors');++const { actorRef } = require('./publication');++++const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];++const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;++const KEY_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;++const MAX_BODY = 200000;++const MAX_TAGS = 20;++const MEDIA_ROLES = ['cover', 'inline', 'gallery'];++++const newPostId = (now) => `pst_${ids.ulid(now)}`;++++const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';++++function listOf(v) {++ if (v == null || v === '') return [];++ const arr = Array.isArray(v) ? v : String(v).split(',');++ return arr.map((s) => String(s).replace(/\s+/g, ' ').trim()).filter(Boolean);++}++++function createPosts({ store, blogs, publication, access, outbox, log = console }) {++ const { db } = store;++ const q = {++ byId: db.prepare('SELECT * FROM blog_posts WHERE id = ?'),++ bySlug: db.prepare("SELECT * FROM blog_posts WHERE blog_id = ? AND slug = ? AND state <> 'deleted'"),++ deletedBySlug: db.prepare("SELECT * FROM blog_posts WHERE blog_id = ? AND slug = ? AND state = 'deleted' ORDER BY deleted_at DESC LIMIT 1"),++ slugTaken: db.prepare("SELECT id FROM blog_posts WHERE blog_id = ? AND slug = ? AND state <> 'deleted'"),++ insert: db.prepare(`INSERT INTO blog_posts (id, blog_id, slug, state, visibility, entitlement_key, author_subject, series_id, series_position,++ allow_comments, noindex, created_at, updated_at)++ VALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,++ @allow_comments, @noindex, @now, @now)`),++ };++ const blogOf = (post) => blogs.get(post.blog_id);++++ // ── Input normalisation ─────────────────────────────────++++ function title(v) {++ const t = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();++ if (!t) throw new ApiError(422, 'post.invalid_title', 'A post needs a title');++ if (t.length > 200) throw new ApiError(422, 'post.invalid_title', 'A title is at most 200 characters');++ return t;++ }++ function body(v) {++ const b = String(v == null ? '' : v).replace(/\r\n/g, '\n');++ if (b.length > MAX_BODY) throw new ApiError(413, 'post.too_long', `A post is at most ${MAX_BODY} characters`);++ return b;++ }++ function summary(v) {++ if (v == null) return null;++ const s = String(v).replace(/\s+/g, ' ').trim().slice(0, 500);++ return s || null;++ }++ function visibilityOf(blog, v, key) {++ const vis = v == null || v === '' ? 'public' : String(v);++ if (!VISIBILITIES.includes(vis)) throw new ApiError(422, 'post.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);++ if (vis !== 'members') return { visibility: vis, entitlement_key: null };++ const k = String(key || `blog:${blog.handle}:members`).trim().toLowerCase();++ if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');++ return { visibility: vis, entitlement_key: k };++ }++ function slugFor(blog, wanted, postId = null) {++ let base;++ try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }++ if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');++ for (let i = 1; i < 1000; i++) {++ const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;++ const hit = q.slugTaken.get(blog.id, s);++ if (!hit || hit.id === postId) return s;++ }++ throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');++ }++++ /** The acting person (usr_) or refuse. */++ function actingSubject(viewer) {++ if (!viewer || !viewer.subject) throw new ApiError(403, 'subject.required', 'This needs a signed-in member (or a service acting for one with X-OV-Subject)');++ return viewer.subject;++ }++++ /** The authorship record of a new revision, from who is writing and what came before. */++ function authorshipFor(viewer, input, previous) {++ const subject = viewer.subject;++ const a = input.authorship || {};++ if (viewer.kind === 'service' && viewer.origin === 'ai') {++ // OpenVibe.AI output (blog.draft_post): AI-generated, draft + noindex until a person reviews it.++ return authorship.record({ mode: 'ai', workflow: a.workflow, stubProvider: Boolean(a.stubProvider || a.stub_provider), source: a.source || null });++ }++ if (a.mode === 'hybrid' || (previous && (previous.mode === 'ai' || previous.mode === 'hybrid'))) {++ const workflow = a.workflow || (previous && previous.workflow);++ return authorship.record({ mode: 'hybrid', authors: [subject], workflow });++ }++ if (a.mode === 'imported') {++ return authorship.record({ mode: 'imported', authors: [], importedFrom: a.importedFrom || a.imported_from });++ }++ return authorship.record({ mode: 'human', authors: [subject] });++ }++++ function authorLabel(viewer) {++ if (viewer.kind === 'service') return viewer.origin === 'ai' ? `${viewer.service} (ai)` : `${viewer.service} for ${viewer.subject}`;++ return viewer.subject;++ }++++ function setTerms(blog, post, input) {++ if (input.tags !== undefined) {++ const tags = listOf(input.tags);++ if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);++ for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');++ store.taxonomy.setTerms(post.id, 'tag', tags);++ }++ if (input.categories !== undefined) {++ const vocab = blogs.categoryVocabulary(blog);++ const termIds = [];++ // "Parent > Child" makes a nested category (breadcrumbs, parent pages include children).++ for (const pathText of listOf(input.categories).slice(0, 10)) {++ let parentId = null;++ for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {++ const term = store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });++ parentId = term.id;++ }++ if (parentId != null) termIds.push(parentId);++ }++ store.taxonomy.setTerms(post.id, vocab, termIds);++ }++ }++++ function seriesOf(blog, input, current) {++ if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;++ const v = input.seriesId ?? input.series_id ?? input.series;++ if (v == null || v === '') return { series_id: null, series_position: null };++ let series = typeof v === 'string' && v.startsWith('ser_') ? blogs.seriesById(v) : blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);++ if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');++ const posRaw = input.seriesPosition ?? input.series_position;++ const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);++ if (pos != null && (!Number.isInteger(pos) || pos < 1 || pos > 10000)) throw new ApiError(422, 'post.invalid_series_position', 'series position is a whole number from 1');++ return { series_id: series.id, series_position: pos };++ }++++ function attachCitations(post, revision, list) {++ if (!Array.isArray(list) || !list.length) return;++ store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({++ url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,++ retrievedAt: c.retrievedAt || c.retrieved_at || null,++ quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,++ licenseNote: c.licenseNote || c.license_note || null,++ })));++ }++++ // ── Reads ───────────────────────────────────────────────++++ const api = {++ VISIBILITIES, MEDIA_ROLES,++++ get: (id) => q.byId.get(String(id || '')) || null,++ bySlug: (blog, slug) => q.bySlug.get(blog.id, String(slug || '')) || null,++ deletedBySlug: (blog, slug) => q.deletedBySlug.get(blog.id, String(slug || '')) || null,++ blogOf,++++ /** Must exist and not be deleted, else 404. */++ mustGet(id) {++ const p = api.get(id);++ if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');++ return p;++ },++++ /**++ * Published posts for listings. `restricted` = also members/private posts (the viewer is a++ * member of the blog or staff). Unlisted posts are never listed.++ * filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset++ */++ listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {++ const where = ["p.state = 'published'"];++ const args = [];++ if (restricted) where.push("p.visibility IN ('public','members','private')");++ else where.push("p.visibility = 'public'");++ if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }++ if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }++ if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }++ if (termIds) {++ if (!termIds.length) return { total: 0, posts: [] };++ where.push(`p.id IN (SELECT entity_id FROM blog_term_links WHERE term_id IN (${termIds.map(() => '?').join(',')}))`);++ args.push(...termIds);++ }++ where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");++ const w = where.join(' AND ');++ const total = db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args).n;++ const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';++ const posts = db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);++ return { total, posts };++ },++++ /** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */++ listForDashboard(blog, viewer) {++ const role = access.effectiveRole(store, viewer, blog);++ if (!role && !access.isStaff(viewer)) return [];++ const own = role === 'author' ? ' AND author_subject = ?' : '';++ const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];++ return db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);++ },++++ head: (post) => store.revisions.head(post.id),++ revision: (post, n) => store.revisions.get(post.id, n),++ revisions: (post, opts) => store.revisions.list(post.id, opts),++ diff: (post, from, to, mode) => store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),++ pendingJobs: (post) => store.scheduler.jobs(post.id).filter((j) => j.status === 'pending' || j.status === 'running'),++ attachments: (post) => store.attachments.list(post.id),++ citations: (post, rev) => (rev ? store.citations.forRevision(post.id, rev) : []),++++ // ── Writes ──────────────────────────────────────────++++ /** A new draft (revision 1). Returns { post, revision }. */++ create(viewer, blog, input = {}, { traceparent } = {}) {++ const subject = actingSubject(viewer);++ if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');++ const t = title(input.title);++ const content = body(input.body);++ const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);++ const rec = authorshipFor(viewer, input, null);++ return store.tx(() => {++ const now = store.now();++ const id = newPostId(now);++ const slug = slugFor(blog, input.slug || t);++ store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path++ const ser = seriesOf(blog, input, { series_id: null, series_position: null });++ q.insert.run({++ id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,++ allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),++ noindex: truthy(input.noindex) ? 1 : 0, now,++ });++ const { revision } = store.revisions.create({++ entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },++ meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',++ });++ attachCitations({ id }, revision.number, input.citations);++ const post = q.byId.get(id);++ setTerms(blog, post, input);++ outbox.emit({++ event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',++ subject: { type: 'post', id, revision: revision.number },++ payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },++ }, { traceparent });++ return { post: q.byId.get(id), revision };++ });++ },++++ /**++ * Edit: content (title/body/summary) becomes a new revision (expectedRevision required, 412++ * on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug++ * change leaves a 301 from the old path.++ */++ update(viewer, post, input = {}, { traceparent } = {}) {++ actingSubject(viewer);++ const blog = blogOf(post);++ if (!access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');++ return store.tx(() => {++ const before = publication.snapshot(blog, post);++ const head = store.revisions.head(post.id);++ let revision = head;++ let created = false;++ const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;++ if (contentChange) {++ const expected = input.expectedRevision ?? input.expected_revision;++ if (expected == null || expected === '') throw new ApiError(428, 'revision.expected_required', 'Send expectedRevision (the revision you edited) so concurrent edits are not lost');++ const fields = {++ title: input.title !== undefined ? title(input.title) : head.fields.title,++ summary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),++ };++ const rec = authorshipFor(viewer, input, publication.authorshipOf(head));++ const out = store.revisions.create({++ entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,++ fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,++ });++ revision = out.revision;++ created = out.created;++ if (created) attachCitations(post, revision.number, input.citations);++ }++ const sets = {};++ if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {++ const slug = slugFor(blog, input.slug, post.id);++ if (slug !== post.slug) {++ store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });++ sets.slug = slug;++ }++ }++ if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {++ Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));++ }++ Object.assign(sets, seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));++ if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;++ if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;++ const keys = Object.keys(sets);++ db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)++ .run({ ...sets, now: store.now(), id: post.id });++ setTerms(blog, post, input);++ const { post: after } = publication.afterChange(before, post.id, { actor: viewer, traceparent });++ return { post: after, revision, created };++ });++ },++++ /** The idempotent effect: revision N is the published one. Returns { changed, post }. */++ applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {++ return store.tx(() => {++ const post = q.byId.get(postId);++ if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');++ const rev = revisionNumber ? store.revisions.get(post.id, revisionNumber) : store.revisions.head(post.id);++ if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);++ if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);++ const before = publication.snapshot(blogOf(post), post);++ const now = store.now();++ db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),++ published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);++ const out = publication.afterChange(before, post.id, { actor, traceparent });++ return { changed: true, post: out.post };++ });++ },++++ applyUnpublish(postId, actor, { traceparent } = {}) {++ return store.tx(() => {++ const post = q.byId.get(postId);++ if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');++ if (post.state !== 'published') return { changed: false, post };++ const before = publication.snapshot(blogOf(post), post);++ db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ return { changed: true, post: publication.afterChange(before, post.id, { actor, traceparent }).post };++ });++ },++++ publish(viewer, post, { revision } = {}, ctx = {}) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');++ const n = revision == null || revision === '' ? null : parseInt(revision, 10);++ return store.tx(() => {++ store.scheduler.cancelPending(post.id, 'publish');++ return api.applyPublish(post.id, n, viewer, ctx);++ });++ },++++ unpublish(viewer, post, ctx = {}) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');++ return store.tx(() => {++ store.scheduler.cancelPending(post.id);++ const cur = q.byId.get(post.id);++ if (cur.state === 'scheduled') {++ db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ return { changed: true, post: q.byId.get(post.id) };++ }++ return api.applyUnpublish(post.id, viewer, ctx);++ });++ },++++ /**++ * Schedule a publish (of revision N, default the head) or an unpublish at a future time.++ * Idempotent: the same (post, action, time, revision) is one job.++ */++ schedule(viewer, post, { at, revision, action = 'publish' } = {}) {++ actingSubject(viewer);++ const blog = blogOf(post);++ if (!access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');++ if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');++ const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);++ if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');++ if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');++ const rev = action === 'publish' ? (revision ? store.revisions.get(post.id, parseInt(revision, 10)) : store.revisions.head(post.id)) : null;++ if (action === 'publish') {++ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);++ }++ return store.tx(() => {++ const { job, created } = store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });++ const cur = q.byId.get(post.id);++ if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {++ db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ }++ return { job, created, post: q.byId.get(post.id) };++ });++ },++++ cancelSchedule(viewer, post) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');++ return store.tx(() => {++ const n = store.scheduler.cancelPending(post.id);++ const cur = q.byId.get(post.id);++ if (cur.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ return { cancelled: n, post: q.byId.get(post.id) };++ });++ },++++ /**++ * Worker step: claim and run due jobs. A failed job (after its retries) emits++ * blog.schedule.failed and returns a scheduled post to draft.++ */++ async runScheduled(worker) {++ const summary = await store.scheduler.runDue({++ worker,++ handler: (job) => (job.action === 'publish'++ ? api.applyPublish(job.entityId, job.revision, null).changed++ : api.applyUnpublish(job.entityId, null).changed),++ });++ for (const job of summary.failed) {++ store.tx(() => {++ const post = q.byId.get(job.entityId);++ if (post && post.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ outbox.emit({++ event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',++ subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },++ payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },++ });++ });++ log.warn(`[Blog] scheduled ${job.action} of ${job.entityId} failed: ${job.lastError}`);++ }++ return summary;++ },++++ /** Soft delete: readers get 410, Search a tombstone, revisions are kept. */++ remove(viewer, post, ctx = {}) {++ actingSubject(viewer);++ const blog = blogOf(post);++ if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');++ return store.tx(() => {++ store.scheduler.cancelPending(post.id);++ const before = publication.snapshot(blog, q.byId.get(post.id));++ const now = store.now();++ db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);++ return publication.afterChange(before, post.id, { actor: viewer, ...ctx });++ });++ },++++ /** Revert = a new revision copying revision N (content, fields and its authorship). */++ revert(viewer, post, { toRevision, expectedRevision } = {}) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ const target = store.revisions.get(post.id, parseInt(toRevision, 10));++ if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);++ return store.tx(() => {++ const { revision } = store.revisions.revert({++ entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),++ author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },++ });++ store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });++ db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);++ return { revision, post: q.byId.get(post.id) };++ });++ },++++ /** A person's review of one revision (what lets an AI draft be published and indexed). */++ review(viewer, post, { revision, decision, note } = {}, ctx = {}) {++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');++ const blog = blogOf(post);++ if (!access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');++ const n = parseInt(revision, 10);++ if (!store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);++ return store.tx(() => {++ const before = publication.snapshot(blog, post);++ const row = store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });++ publication.afterChange(before, post.id, { actor: viewer, ...ctx });++ return row;++ });++ },++++ attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ const id = String(mediaId || '').trim();++ if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');++ if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);++ const pos = parseInt(position, 10);++ return store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });++ },++++ detach(viewer, post, attachmentId) {++ actingSubject(viewer);++ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ return store.attachments.detach(post.id, attachmentId);++ },++ };++ return api;++}++++module.exports = { createPosts, VISIBILITIES, MEDIA_ROLES, listOf, truthy };nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++++/**++ * Publication mechanics shared by the routes, the worker and the seed:++ * - canonical paths and URLs++ * - the indexability gate (openvibe-publishing/seo) with Blog's editorial policy++ * - the Search document and the publication events (openvibe-publishing/index-hooks), enqueued++ * in the same transaction as the change through the SDK outbox++ *++ * Search receives ONLY published, public, listable posts. Everything else (drafts, scheduled,++ * unlisted, members/VIP, private, unpublished, deleted) is a tombstone, so a visibility change or++ * an unpublish removes the old copy; a post that was never indexed gets no tombstone.++ */++const seo = require('openvibe-publishing/seo');++const ssr = require('openvibe-publishing/ssr');++const hooks = require('openvibe-publishing/index-hooks');++const authorship = require('openvibe-publishing/authorship');++++// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is++// served and listed in feeds but not offered to search engines (reason "thin").++const POLICY = Object.freeze({ minWords: 80, requireSources: false });++const OWNER = 'blog';++++/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */++const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };++++function createPublication({ store, config, outbox }) {++ const { db } = store;++ const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');++ const membersOf = db.prepare('SELECT subject FROM blog_memberships WHERE blog_id = ? ORDER BY subject');++++ const blogPath = (blog) => (blog.kind === 'official' ? '/' : `/@${blog.handle}`);++ const postPath = (blog, post) => `/@${blog.handle}/${post.slug}`;++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);++ const postUrl = (blog, post) => abs(postPath(blog, post));++ const feedId = (post) => `tag:openvibe.blog,2026:post/${post.id}`; // stable across slug changes++++ function authorshipOf(rev) {++ return (rev && rev.meta && rev.meta.authorship) || null;++ }++++ function reviewOf(post, rev) {++ return rev ? store.reviews.latest(post.id, rev.number) : null;++ }++++ /** The gate's decision for a post at one revision (the published one by default). */++ function decide(blog, post, rev, { state } = {}) {++ const rec = authorshipOf(rev);++ const facts = {++ state: state || post.state,++ visibility: GATE_VISIBILITY[post.visibility],++ canonicalUrl: postUrl(blog, post),++ text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',++ noindex: Boolean(post.noindex),++ };++ if (rec) Object.assign(facts, authorship.gateFacts(rec, reviewOf(post, rev)));++ return seo.evaluate(facts, { policy: POLICY, now: store.now() });++ }++++ /** The revision readers see (published) or, for a never-published post, the head. */++ function visibleRevision(post) {++ return post.published_revision ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);++ }++++ function aclFor(blog, post) {++ const subjects = [...new Set([...membersOf.all(blog.id).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));++ if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };++ if (post.visibility === 'private') return { subjects };++ return {};++ }++++ function termsOf(post) {++ const terms = store.taxonomy.termsFor(post.id);++ return {++ tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),++ categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),++ };++ }++++ /**++ * The index document as it would describe this post (real visibility, for the product events),++ * or a tombstone when it is not published.++ */++ function documentFor(blog, post, { forSearch }) {++ const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;++ const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };++ if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(blog, post, rev) : null };++ const decision = decide(blog, post, rev);++ if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };++ const { tags, categories } = termsOf(post);++ const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;++ const rec = authorshipOf(rev);++ const acl = aclFor(blog, post);++ const doc = hooks.buildIndexDocument({++ ...identity,++ state: 'published',++ visibility: GATE_VISIBILITY[post.visibility],++ acl,++ includeUnlisted: !forSearch,++ canonicalUrl: postUrl(blog, post),++ title: rev.fields.title,++ summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),++ body: ssr.markdownToText(rev.content),++ facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },++ authorship: rec,++ citations: store.citations.forRevision(post.id, rev.number),++ decision,++ publishedAt: post.first_published_at,++ updatedAt: rev.createdAt,++ language: blog.language,++ });++ return { doc, decision };++ }++++ /** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */++ function syncIndex(blog, post, { traceparent } = {}) {++ const { doc } = documentFor(blog, post, { forSearch: true });++ const prev = store.sequencer.current(OWNER, 'post', post.id);++ if (doc.deleted && prev == null) return null; // never indexed: nothing to remove++ const stamped = store.sequencer.stamp(doc);++ if (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send++ return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ }++++ /** Snapshot for actionFor(before, after). */++ function snapshot(blog, post) {++ return post ? { state: post.state, visibility: post.visibility, revision: post.published_revision, url: postUrl(blog, post) } : null;++ }++++ /**++ * Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),++ * and re-sync Search. Inside the caller's transaction, after the row changed.++ */++ function afterChange(before, postId, { actor, traceparent } = {}) {++ const post = db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);++ const blog = blogById.get(post.blog_id);++ const after = snapshot(blog, post);++ let action = hooks.actionFor(before, after);++ if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';++ let event = null;++ if (action) {++ const { doc, decision } = documentFor(blog, post, { forSearch: false });++ event = outbox.emit(hooks.publicationEvent({++ product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,++ actor: actorRef(actor), document: doc, decision, now: store.now(),++ extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },++ }), { traceparent });++ }++ syncIndex(blog, post, { traceparent });++ return { action, event, post, blog };++ }++++ return {++ POLICY, OWNER, GATE_VISIBILITY,++ blogPath, postPath, postUrl, abs, feedId,++ decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, termsOf,++ };++}++++/** Event actor from a viewer (or 'svc:blog' for the worker). */++function actorRef(actor) {++ if (!actor) return { type: 'service', id: 'blog' };++ if (typeof actor === 'string') return hooks.subjectRef(actor);++ if (actor.kind === 'user' && actor.subject) return { type: 'user', id: actor.subject };++ if (actor.kind === 'service') {++ if (actor.subject && actor.origin !== 'ai') return { type: 'user', id: actor.subject };++ return { type: 'service', id: String(actor.service || '').replace(/^svc:/, '') || 'unknown' };++ }++ if (actor.type && actor.id) return { type: actor.type, id: actor.id };++ return { type: 'service', id: 'blog' };++}++++module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY };nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++++/**++ * Read models shared by the pages, the JSON representations, the feeds and the sitemaps: URLs for++ * every collection, list items with their authors and tags, feed items, and the category tree.++ * Nothing here decides access — callers pass rows they may show.++ */++const seo = require('openvibe-publishing/seo');++const ssr = require('openvibe-publishing/ssr');++const authorship = require('openvibe-publishing/authorship');++const { renderBody } = require('../render/pages');++++function createReading({ store, blogs, posts: postsApi, publication, people, media }) {++ const { db } = store;++++ const blogPath = (blog) => publication.blogPath(blog);++ const base = (blog) => `/@${blog.handle}`;++ const urls = {++ blog: blogPath,++ tag: (tag, blog = null) => (blog ? `${base(blog)}/tags/${tag.slug}` : `/tags/${tag.slug}`),++ category: (blog, term) => `${base(blog)}/categories/${term.slug}`,++ series: (blog, s) => `${base(blog)}/series/${s.slug}`,++ author: (person, blog = null) => `${blog ? base(blog) : ''}/authors/${encodeURIComponent(person.username || person.subject)}`,++ feed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,++ };++++ function feedsOf(blog) {++ const s = blogs.feedSettings(blog);++ const out = [];++ if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });++ if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });++ if (s.json) out.push({ type: 'json', href: urls.feed(blog, 'json'), title: `${blog.title} (JSON Feed)`, label: 'JSON Feed', mime: 'application/feed+json' });++ return out;++ }++++ function termsView(post, blog, { networkTags = false } = {}) {++ const terms = store.taxonomy.termsFor(post.id);++ return {++ tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),++ categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),++ };++ }++++ /** List items for rows of published posts (possibly from several blogs). */++ async function listItems(rows, { perBlogLinks = true } = {}) {++ const who = await people.many(rows.map((p) => p.author_subject));++ return rows.map((post) => {++ const blog = blogs.get(post.blog_id);++ const rev = store.revisions.get(post.id, post.published_revision);++ const person = who.get(post.author_subject);++ const { tags } = termsView(post, blog, { networkTags: !perBlogLinks });++ return {++ post, rev, blog,++ url: publication.postPath(blog, post),++ author: person ? { name: person.name, url: urls.author(person, blog) } : null,++ tags,++ badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,++ };++ }).filter((it) => it.rev);++ }++++ function categoriesTree(blog) {++ return store.taxonomy.tree(blogs.categoryVocabulary(blog));++ }++++ /** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */++ async function feedItems(blog, { limit, fullContent }) {++ const { posts: rows } = postsApi.listPublished({ blogId: blog.id, limit, restricted: false });++ const who = await people.many(rows.map((p) => p.author_subject));++ return rows.map((post) => {++ const rev = store.revisions.get(post.id, post.published_revision);++ if (!rev) return null;++ const decision = publication.decide(blog, post, rev);++ const person = who.get(post.author_subject);++ const atts = store.attachments.list(post.id);++ const cover = atts.find((a) => a.role === 'cover' && !a.broken);++ const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';++ return {++ id: publication.feedId(post),++ url: publication.postUrl(blog, post),++ title: rev.fields.title,++ summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),++ contentHtml: fullContent ? renderBody(rev.content, atts, { urlFor: media.urlFor, rel }) : null,++ published: post.first_published_at,++ updated: rev.createdAt,++ decision,++ authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],++ tags: termsView(post, blog).tags.map((t) => t.name),++ image: cover ? media.urlFor(cover.mediaId) : null,++ };++ }).filter(Boolean);++ }++++ /** The machine-readable representation of a post (same content as the page). */++ function postJson({ blog, post, rev, person, decision }) {++ const rec = publication.authorshipOf(rev);++ const review = publication.reviewOf(post, rev);++ const { tags, categories } = termsView(post, blog);++ const series = blogs.seriesById(post.series_id);++ return {++ id: post.id,++ url: publication.postUrl(blog, post),++ blog: { id: blog.id, handle: blog.handle, title: blog.title, url: publication.abs(blogPath(blog)) },++ title: rev.fields.title,++ summary: rev.fields.summary || null,++ body_markdown: rev.content,++ revision: rev.number,++ state: post.state,++ visibility: post.visibility,++ published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,++ revised_at: rev.createdAt,++ author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,++ authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, reviewed: authorship.isReviewed(rec, review), disclosure: authorship.disclosure(rec, review) } : null,++ tags: tags.map((t) => t.name),++ categories: categories.map((c) => c.name),++ series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,++ media: store.attachments.list(post.id).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),++ citations: store.citations.forRevision(post.id, rev.number).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),++ indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },++ };++ }++++ /** BlogPosting JSON-LD from real fields only (missing → omitted). */++ function postJsonLd({ blog, post, rev, person, tags, citations, attachments }) {++ const url = publication.postUrl(blog, post);++ const cover = attachments.find((a) => a.role === 'cover' && !a.broken);++ return seo.structuredData.article({++ type: 'BlogPosting',++ headline: rev.fields.title,++ url,++ description: rev.fields.summary || ssr.markdownToText(rev.content, 200),++ datePublished: post.first_published_at || null,++ dateModified: post.published_revision ? rev.createdAt : null,++ authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],++ publisher: blog.kind === 'official' ? { name: 'OpenVibe', url: 'https://openvibe.network' } : null,++ image: cover ? media.urlFor(cover.mediaId) : null,++ keywords: tags.map((t) => t.name),++ inLanguage: blog.language,++ citations: citations.filter((c) => c.url).map((c) => ({ url: c.url, title: c.title })),++ wordCount: ssr.wordCount(ssr.markdownToText(rev.content)),++ });++ }++++ return { urls, feedsOf, termsView, listItems, categoriesTree, feedItems, postJson, postJsonLd, db };++}++++module.exports = { createReading };nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++++/**++ * Blog → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * blog.post.created a post (draft) exists++ * blog.post.published|updated|unpublished|deleted++ * publication changes (openvibe-publishing/index-hooks++ * publicationEvent: canonical URL, state, indexability; public++ * only for public, listable posts)++ * blog.schedule.failed a scheduled publish/unpublish gave up after its retries++ * blog.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks++ * indexEvent), consumed by Search's '*.index_document.*'++ * subscription++ *++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only++ * if its change committed. The relay publishes with Blog'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 createBlogOutbox({ 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: 'blog' });++ 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('[Blog] 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 = { createBlogOutbox };nnew file mode 100644@@@ -0,0 +1,284 @@++'use strict';++++/**++ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.blog, one++ * capability per route, acting for the person in X-OV-Subject) and for browsers or apps with a++ * Network user JWT (judged by blog membership). Errors are problem+json.++ *++ * GET /blogs/:handle public++ * POST /blogs blog.blog.create get-or-create the acting member's blog++ * PATCH /blogs/:handle blog.blog.configure title, description, language, feeds++ * PUT /blogs/:handle/theme blog.theme.set++ * GET /blogs/:handle/members blog.member.manage++ * PUT /blogs/:handle/members/:subject blog.member.manage { role }++ * DELETE /blogs/:handle/members/:subject blog.member.manage++ * GET /blogs/:handle/posts public list; ?all=1 drafts too (members; services: blog.post.read)++ * POST /blogs/:handle/posts blog.post.create (X-OV-Origin: ai → an AI draft that needs review)++ * GET /blogs/:handle/feed blog.feed.read the blog's JSON Feed (public posts only)++ * GET /posts/:id blog.post.read (readers of published posts need nothing)++ * PATCH /posts/:id blog.post.update content → new revision (expected_revision)++ * POST /posts/:id/publish blog.post.publish { revision? }++ * POST /posts/:id/schedule blog.post.schedule { at, revision?, action? }++ * DELETE /posts/:id/schedule blog.post.schedule++ * POST /posts/:id/unpublish blog.post.unpublish++ * DELETE /posts/:id blog.post.delete++ * GET /posts/:id/revisions[/:n], /posts/:id/diff?from&to&mode blog.post.read++ * POST /posts/:id/revert blog.post.update { to_revision, expected_revision }++ * POST /posts/:id/reviews people only { revision, decision, note? }++ * POST /posts/:id/attachments blog.post.update { media_id, role, alt, caption, position }++ * DELETE /posts/:id/attachments/:aid blog.post.update++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const seo = require('openvibe-publishing/seo');++const { run, jsonBody, ApiError, privateNoStore } = require('./errors');++const { guard } = require('../auth/viewer');++const { checkCapability } = require('../auth/capabilities');++++function cors(origins) {++ const allowed = new Set(origins);++ return (req, res, next) => {++ const origin = req.get('origin');++ if (origin && allowed.has(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.vary('Origin');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');++ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ next();++ };++}++++function createApi(ctx) {++ const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people } = ctx;++ const router = express.Router();++ router.use(cors(config.apiCorsOrigins));++ router.use(viewers.middleware());++ router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });++++ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });++++ function mustBlog(req) {++ const blog = blogs.byHandle(req.params.handle);++ if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');++ return blog;++ }++ const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);++++ function blogDto(blog, { full = false } = {}) {++ const out = {++ id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,++ url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,++ feeds: reading.feedsOf(blog).map((f) => ({ type: f.type, url: publication.abs(f.href) })),++ };++ if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: blogs.feedSettings(blog) });++ return out;++ }++++ function postDto(post, { full = false } = {}) {++ const blog = posts.blogOf(post);++ const rev = full ? posts.head(post) : posts.revision(post, post.published_revision);++ const shown = post.published_revision ? posts.revision(post, post.published_revision) : rev;++ const decision = shown ? publication.decide(blog, post, shown) : null;++ const { tags, categories } = publication.termsOf(post);++ const series = blogs.seriesById(post.series_id);++ const out = {++ id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),++ state: post.state, visibility: post.visibility, author_subject: post.author_subject,++ title: rev ? rev.fields.title : null, summary: rev ? rev.fields.summary || null : null,++ revision: rev ? rev.number : null, published_revision: post.published_revision,++ first_published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,++ published_at: post.published_at ? new Date(post.published_at).toISOString() : null,++ tags, categories, series: series ? { id: series.id, slug: series.slug, title: series.title, position: post.series_position } : null,++ allow_comments: Boolean(post.allow_comments), noindex: Boolean(post.noindex),++ indexability: decision ? { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons } : null,++ };++ if (full) {++ const rec = publication.authorshipOf(rev);++ Object.assign(out, {++ entitlement_key: post.entitlement_key, body: rev ? rev.content : null,++ authorship: rec, review: rev ? publication.reviewOf(post, rev) : null,++ scheduled: posts.pendingJobs(post), attachments: posts.attachments(post),++ citations: rev ? posts.citations(post, rev.number) : [],++ });++ }++ return out;++ }++++ function mustPost(req, cap) {++ const post = posts.mustGet(req.params.id);++ return { post, blog: posts.blogOf(post), cap };++ }++++ function after(before, postId, req) {++ effects.after(before, posts.get(postId), req.ov);++ }++++ // ── Blogs ───────────────────────────────────────────────++++ router.get('/blogs/:handle', run((req) => {++ const blog = mustBlog(req);++ return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };++ }));++++ router.post('/blogs', guard('blog.blog.create'), jsonBody, run((req) => {++ const b = req.body || {};++ const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;++ const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });++ return { blog: blogDto(blog, { full: true }), created };++ }, (out) => (out.created ? 201 : 200)));++++ router.patch('/blogs/:handle', guard('blog.blog.configure'), jsonBody, run((req) => {++ const blog = mustBlog(req);++ if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');++ const b = req.body || {};++ const updated = store.tx(() => {++ const u = blogs.update(blog, { title: b.title, description: b.description, language: b.language });++ if (b.feeds) blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });++ return blogs.get(u.id);++ });++ return { blog: blogDto(updated, { full: true }) };++ }));++++ router.put('/blogs/:handle/theme', guard('blog.theme.set'), jsonBody, run((req) => {++ const blog = mustBlog(req);++ if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');++ return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };++ }));++++ router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {++ const blog = mustBlog(req);++ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');++ const list = blogs.members(blog);++ const who = await people.many(list.map((m) => m.subject));++ return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };++ }));++++ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), jsonBody, run((req) => {++ const blog = mustBlog(req);++ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');++ return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };++ }));++++ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), run((req) => {++ const blog = mustBlog(req);++ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');++ return { removed: blogs.removeMember(blog, req.params.subject) };++ }));++++ router.get('/blogs/:handle/posts', run((req) => {++ const blog = mustBlog(req);++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);++ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);++ if (req.query.all === '1') {++ if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');++ if (!isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');++ return { posts: posts.listForDashboard(blog, req.viewer).slice(offset, offset + limit).map((p) => postDto(p, { full: false })) };++ }++ const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted: isMember(blog, req.viewer), limit, offset });++ return { total, posts: rows.map((p) => postDto(p)) };++ }));++++ router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {++ const blog = mustBlog(req);++ const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));++ after(null, post.id, req);++ return { post: postDto(post, { full: true }), revision: revision.number };++ }, 201));++++ router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {++ const blog = mustBlog(req);++ const s = blogs.feedSettings(blog);++ const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });++ res.set('Cache-Control', 'public, max-age=300');++ res.removeHeader('X-Robots-Tag');++ return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);++ }));++++ // ── Posts ───────────────────────────────────────────────++++ router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {++ const { post, blog } = mustPost(req);++ if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };++ const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);++ if (!d.allowed) throw new ApiError(d.status, d.status === 403 ? 'post.members_only' : 'post.not_found', d.status === 403 ? 'Members only' : 'No such post');++ return { post: postDto(post) };++ }));++++ const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => {++ const { post } = mustPost(req);++ const before = { ...post };++ const out = await fn(req, post, req.body || {});++ after(before, post.id, req);++ return out;++ }, status));++++ write('patch', '/posts/:id', guard('blog.post.update'), (req, post, b) => {++ const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };++ const out = posts.update(req.viewer, post, input, tp(req));++ return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };++ });++ write('post', '/posts/:id/publish', guard('blog.post.publish'), (req, post, b) => {++ const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));++ return { post: postDto(out.post, { full: true }), changed: out.changed };++ });++ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post, b) => {++ const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });++ return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };++ }, 201);++ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post) => {++ const out = posts.cancelSchedule(req.viewer, post);++ return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };++ });++ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), (req, post) => {++ const out = posts.unpublish(req.viewer, post, tp(req));++ return { post: postDto(out.post, { full: true }), changed: out.changed };++ });++ write('delete', '/posts/:id', guard('blog.post.delete'), (req, post) => {++ posts.remove(req.viewer, post, tp(req));++ return { deleted: true, id: post.id };++ });++ write('post', '/posts/:id/revert', guard('blog.post.update'), (req, post, b) => {++ const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });++ return { revision: out.revision.number, post: postDto(out.post, { full: true }) };++ }, 201);++ router.post('/posts/:id/reviews', jsonBody, run(async (req) => {++ const { post } = mustPost(req);++ const b = req.body || {};++ const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));++ after({ ...post }, post.id, req);++ return { review };++ }, 201));++ write('post', '/posts/:id/attachments', guard('blog.post.update'), (req, post, b) => ({++ attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),++ }), 201);++ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));++++ function readable(req) {++ const { post, blog } = mustPost(req);++ if (!(access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');++ return post;++ }++ router.get('/posts/:id/revisions', guard('blog.post.read'), run((req) => {++ const post = readable(req);++ return { revisions: posts.revisions(post, { limit: req.query.limit, before: req.query.before }).map(({ content, ...r }) => ({ ...r, length: content.length })) };++ }));++ router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run((req) => {++ const post = readable(req);++ const rev = posts.revision(post, parseInt(req.params.n, 10));++ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');++ return { revision: rev, citations: posts.citations(post, rev.number) };++ }));++ router.get('/posts/:id/diff', guard('blog.post.read'), run((req) => {++ const post = readable(req);++ return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };++ }));++++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));++ return router;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):++ *++ * GET /robots.txt sitemap location + explicit automated-consumer policy++ * GET /llms.txt orientation for language models (what the site is, entry points)++ * GET /sitemap.xml sitemap index over the two sections below++ * GET /sitemaps/posts.xml published, public, INDEXABLE posts only (the gate decides), lastmod++ * = the published revision's real time++ * GET /sitemaps/blogs.xml blog front pages and series pages that have indexable posts++ *++ * Built from the database on every request, never from the viewer: drafts, scheduled, unlisted,++ * members/VIP, private, unpublished, deleted and noindex posts can never appear.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const sharedSeo = require('openvibe-shared/seo');++++function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {++ const router = express.Router();++ const { db } = store;++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);++++ const publicPosts = () => db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id++ WHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'++ ORDER BY p.published_at DESC LIMIT 50000`).all();++++ function postEntries() {++ return publicPosts().map((post) => {++ const blog = blogs.get(post.blog_id);++ const rev = store.revisions.get(post.id, post.published_revision);++ return { post, blog, rev, decision: publication.decide(blog, post, rev) };++ });++ }++++ const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);++++ router.get('/robots.txt', (_req, res) => {++ const body = [++ '# openvibe.blog automated-consumer policy: search engines and AI crawlers are welcome to read',++ '# public posts, feeds and sitemaps. Drafts, the editor, sign-in and the API are not for crawling.',++ '# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',++ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),++ ].join('\n');++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);++ });++++ router.get('/llms.txt', (_req, res) => {++ const official = blogs.official();++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({++ name: 'OpenVibe.Blog',++ summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',++ details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',++ sections: [++ { title: 'Start here', links: [++ { title: official ? official.title : 'The OpenVibe blog', url: abs('/'), note: 'official announcements and release notes' },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ ] },++ { title: 'Feeds', links: [++ { title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') },++ { title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },++ ] },++ { title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },++ ],++ }));++ });++++ router.get('/sitemap.xml', (_req, res) => {++ const entries = postEntries().filter((e) => e.decision.indexable);++ const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;++ xml(res, seo.sitemapIndex([++ { loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },++ { loc: abs('/sitemaps/blogs.xml'), ...(newest ? { lastmod: newest } : {}) },++ ]));++ });++++ router.get('/sitemaps/posts.xml', (_req, res) => {++ const out = seo.sitemap(postEntries().map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));++ xml(res, out.files[0]);++ });++++ router.get('/sitemaps/blogs.xml', (_req, res) => {++ const byBlog = new Map();++ const bySeries = new Map();++ for (const e of postEntries()) {++ if (!e.decision.indexable) continue;++ const t = e.rev.createdAt;++ if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });++ if (e.post.series_id && (!bySeries.has(e.post.series_id) || bySeries.get(e.post.series_id).lastmod < t)) bySeries.set(e.post.series_id, { blog: e.blog, lastmod: t });++ }++ const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });++ const entries = [];++ for (const { blog, lastmod } of byBlog.values()) {++ const path = publication.blogPath(blog);++ entries.push({ loc: abs(path), lastmod, decision: listing(path) });++ }++ for (const [id, { blog, lastmod }] of bySeries) {++ const s = blogs.seriesById(id);++ if (!s) continue;++ const path = reading.urls.series(blog, s);++ entries.push({ loc: abs(path), lastmod, decision: listing(path) });++ }++ xml(res, seo.sitemap(entries).files[0]);++ });++++ return router;++}++++module.exports = { createDiscoveryRoutes };nnew file mode 100644@@@ -0,0 +1,260 @@++'use strict';++++/**++ * The editor as plain HTML forms (no JavaScript needed). Signed-in members only (Network SSO);++ * every POST carries the form token. Every response is private, no-store and noindex.++ *++ * GET /write your blogs; start your blog++ * POST /write/start create your member blog (first use)++ * GET /write/@:handle the blog's posts (drafts included)++ * GET /write/@:handle/new, POST …/new a new draft++ * GET /write/posts/:id, POST … edit (a new revision; 412 when someone saved first)++ * POST /write/posts/:id/{publish,schedule,unschedule,unpublish,delete,review,revert}++ * POST /write/posts/:id/media, …/media/:aid/remove++ * GET /write/posts/:id/revisions history and diffs GET /write/posts/:id/preview++ * GET|POST /write/@:handle/settings title, theme, feeds POST /write/@:handle/members[/remove]++ */++const express = require('express');++const { ids } = require('openvibe-contracts');++const authorship = require('openvibe-publishing/authorship');++const { escapeHtml: esc } = require('openvibe-publishing/ssr');++const { renderPage } = require('../render/layout');++const editor = require('../render/editor');++const { csrfToken, checkCsrf } = require('../auth/forms');++const { asApiError } = require('./errors');++++/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */++const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);++++function formInput(body) {++ const b = {};++ for (const [k, v] of Object.entries(body || {})) b[k] = one(v);++ return b;++}++++function createEditorRoutes(ctx) {++ const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes } = ctx;++ const router = express.Router();++ const form = express.urlencoded({ extended: false, limit: '600kb' });++ router.use(viewers.middleware({ services: false }));++ router.use((req, res, next) => {++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Robots-Tag', 'noindex, nofollow');++ res.vary('Cookie');++ if (req.viewer.kind !== 'user') {++ if (req.method === 'GET') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ return publicRoutes.messagePage(req, res, 401, 'Sign in first', 'Your session ended. Sign in and try again.', { href: `/auth/login?next=${encodeURIComponent('/write')}`, label: 'Sign in' });++ }++ if (!req.viewer.subject) return publicRoutes.messagePage(req, res, 403, 'Account not ready', 'Your OpenVibe sign-in did not include an account id. Sign out and in again.');++ next();++ });++ router.post('*', form, (req, res, next) => {++ if (!checkCsrf(config, req.viewer, req.body && one(req.body._csrf))) return publicRoutes.messagePage(req, res, 403, 'Form expired', 'Reload the page and submit it again.');++ next();++ });++++ const wrap = (fn) => async (req, res, next) => {++ try { await fn(req, res, next); } catch (err) {++ const e = asApiError(err);++ if (!e) return next(err);++ const text = e.code === 'revision.conflict'++ ? `Someone saved revision ${e.extra && e.extra.current} while you were editing. Your text was not saved: open the post again, re-apply your change and save.`++ : e.message;++ publicRoutes.messagePage(req, res, e.status, 'That did not work', text, { href: req.get('referer') && /^\//.test(new URL(req.get('referer'), config.baseUrl).pathname) ? new URL(req.get('referer'), config.baseUrl).pathname : '/write', label: 'Back' });++ }++ };++++ function page(req, res, title, body, status = 200) {++ const decision = publicRoutes.pageDecision(req.path, { indexable: false });++ res.status(status).type('html').send(renderPage({ title, body, decision, viewer: req.viewer, config, path: req.originalUrl, bodyClass: 'editor' }));++ }++++ const csrf = (req) => csrfToken(config, req.viewer);++ const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);++++ function blogFor(req) {++ const blog = blogs.byHandle(req.params.handle);++ if (!blog) return null;++ return access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;++ }++++ function postFor(req) {++ const post = posts.get(req.params.id);++ if (!post || post.state === 'deleted') return null;++ const blog = posts.blogOf(post);++ if (!access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;++ return { post, blog };++ }++++ const notFound = (req, res) => publicRoutes.notFound(req, res);++++ // ── Home, first use ─────────────────────────────────────++++ router.get('/', wrap(async (req, res) => {++ const mine = blogs.blogsOf(req.viewer.subject);++ const official = blogs.official();++ if (access.isStaff(req.viewer) && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });++ page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));++ }));++++ router.post('/start', wrap(async (req, res) => {++ const b = formInput(req.body);++ const { blog } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });++ res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);++ }));++++ // ── A blog ──────────────────────────────────────────────++++ router.get('/@:handle', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog) return notFound(req, res);++ const list = posts.listForDashboard(blog, req.viewer);++ const heads = new Map(list.map((p) => [p.id, (posts.head(p) || { fields: {} }).fields]));++ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));++ }));++++ router.get('/@:handle/new', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);++ page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);++ }));++++ router.post('/@:handle/new', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog) return notFound(req, res);++ const { post } = posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });++ effects.after(null, post, req.ov);++ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);++ }));++++ router.get('/@:handle/settings', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);++ const members = blogs.members(blog);++ const who = await people.many(members.map((m) => m.subject));++ page(req, res, 'Settings', editor.settingsPage({ blog, feed: blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));++ }));++++ router.post('/@:handle/settings', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);++ const b = formInput(req.body);++ store.tx(() => {++ const updated = blogs.update(blog, { title: b.title, description: b.description, language: b.language });++ if (b.theme) blogs.setTheme(updated, b.theme);++ blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });++ });++ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);++ }));++++ router.post('/@:handle/members', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);++ const b = formInput(req.body);++ const who = String(b.member || '').trim().replace(/^@/, '');++ const subject = ids.isSubjectId('user', who) ? who : (people.byUsername(who) || {}).subject;++ if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });++ blogs.setMember(blog, subject, b.role, req.viewer.subject);++ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);++ }));++++ router.post('/@:handle/members/remove', wrap(async (req, res) => {++ const blog = blogFor(req);++ if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);++ blogs.removeMember(blog, formInput(req.body).subject);++ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);++ }));++++ // ── A post ──────────────────────────────────────────────++++ router.get('/posts/:id', wrap(async (req, res) => {++ const found = postFor(req);++ if (!found) return notFound(req, res);++ const { post, blog } = found;++ const head = posts.head(post);++ const published = post.published_revision ? posts.revision(post, post.published_revision) : null;++ const rec = publication.authorshipOf(head);++ const review = publication.reviewOf(post, head);++ const terms = publication.termsOf(post);++ const decision = publication.decide(blog, { ...post, state: 'published' }, head);++ const body = editor.editPage({++ blog, post, head, published, decision, jobs: posts.pendingJobs(post), attachments: posts.attachments(post), csrf: csrf(req),++ disclosure: rec ? authorship.disclosure(rec, review) : null,++ needsReview: rec ? !authorship.canPublish(rec, review).ok : false,++ publicUrl: publication.postPath(blog, post),++ canDelete: access.canWrite(store, req.viewer, blog, 'delete', post),++ form: editor.postForm({ blog, post, head, terms, series: blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),++ });++ page(req, res, `Edit: ${head.fields.title}`, body);++ }));++++ router.post('/posts/:id', wrap(async (req, res) => {++ const found = postFor(req);++ if (!found) return notFound(req, res);++ const before = { ...found.post };++ const { post, revision, created } = posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });++ effects.after(before, post, req.ov);++ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);++ }));++++ const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {++ const found = postFor(req);++ if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);++ const post = found ? found.post : posts.get(req.params.id);++ if (!post || post.state === 'deleted') return notFound(req, res);++ const before = { ...post };++ const out = await fn(req, post, formInput(req.body));++ const after = posts.get(post.id);++ effects.after(before, after, req.ov);++ if (name === 'delete') return res.redirect(303, `/write/@${posts.blogOf(post).handle}?saved=${encodeURIComponent('Post deleted.')}`);++ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);++ }));++++ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });++ action('publish', (req, post, b) => posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));++ action('schedule', (req, post, b) => {++ // <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.++ const raw = String(b.at || '');++ const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;++ return posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });++ }, (out) => `Scheduled for ${out.job.runAt}.`);++ action('unschedule', (req, post) => posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');++ action('unpublish', (req, post) => posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');++ action('delete', (req, post, b) => {++ if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });++ return posts.remove(req.viewer, post, tp(req));++ }, 'Deleted.');++ action('review', (req, post, b) => posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);++ action('revert', (req, post, b) => posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);++ action('media', (req, post, b) => posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');++ router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {++ const found = postFor(req);++ if (!found) return notFound(req, res);++ posts.detach(req.viewer, found.post, req.params.aid);++ res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);++ }));++++ router.get('/posts/:id/revisions', wrap(async (req, res) => {++ const found = postFor(req);++ if (!found) return notFound(req, res);++ const { post } = found;++ const head = posts.head(post);++ const from = parseInt(req.query.from, 10);++ const to = parseInt(req.query.to, 10);++ const diff = Number.isInteger(from) && Number.isInteger(to) ? posts.diff(post, from, to, 'word') : null;++ page(req, res, 'History', editor.revisionsPage({ post, head, revisions: posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));++ }));++++ router.get('/posts/:id/preview', wrap(async (req, res) => {++ const found = postFor(req);++ if (!found) return notFound(req, res);++ const n = parseInt(req.query.revision, 10);++ const rev = Number.isInteger(n) ? posts.revision(found.post, n) : posts.head(found.post);++ if (!rev) return notFound(req, res);++ return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });++ }));++++ return router;++}++++module.exports = { createEditorRoutes, formInput };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/**++ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),++ * and the small request helpers every router shares.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++++/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */++class ApiError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.name = 'ApiError';++ this.status = status;++ this.code = code;++ this.extra = extra || null;++ }++}++++/** Publishing package errors carry { status, code } too. */++function asApiError(err) {++ if (err instanceof ApiError) return err;++ if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {++ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;++ return new ApiError(err.status, err.code, err.message, extra);++ }++ if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {++ return new ApiError(422, 'request.invalid', err.message);++ }++ return null;++}++++/** Wrap a JSON handler: its return value is the body; errors become problems. */++function run(fn, status = 200) {++ return async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out === undefined || res.headersSent) return;++ res.status(typeof status === 'function' ? status(out) : status).json(out);++ } catch (err) {++ if (res.headersSent) return;++ const e = asApiError(err);++ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });++ console.error('[Blog API]', err && err.stack ? err.stack : err);++ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ }++ };++}++++const jsonParser = express.json({ limit: '512kb' });++/** JSON body parser whose syntax errors are problems too. */++function jsonBody(req, res, next) {++ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));++}++++/** Private, per-viewer responses: never stored by a shared cache, never indexed. */++function privateNoStore(res) {++ res.set('Cache-Control', 'private, no-store');++ res.vary('Cookie');++ res.vary('Authorization');++}++++module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };nnew file mode 100644@@@ -0,0 +1,366 @@++'use strict';++++/**++ * Public, server-rendered routes — useful without JavaScript:++ *++ * GET / the official blog GET /@:handle a member's blog++ * GET /@:handle/:slug a post (…/:slug.json: the same post as data)++ * POST /@:handle/:slug/comments comment (Community thread) as the signed-in member++ * GET /@:handle/tags/:tag GET /@:handle/categories/:category++ * GET /@:handle/series/:series GET /@:handle/authors/:who++ * GET /tags/:tag, /authors/:who the same across every blog++ *++ * Caching (the rule that keeps private content out of shared caches):++ * - every HTML/JSON page varies on Cookie and Authorization;++ * - only a PUBLIC, PUBLISHED post, or a list of public posts, rendered for an ANONYMOUS visitor is++ * `public, max-age=60`; everything else — signed-in views, drafts, previews, unlisted,++ * members/VIP and private posts, refusals, 404/410 — is `private, no-store` + X-Robots-Tag.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const ssr = require('openvibe-publishing/ssr');++const authorship = require('openvibe-publishing/authorship');++const { renderPage } = require('../render/layout');++const pages = require('../render/pages');++const { csrfToken, checkCsrf } = require('../auth/forms');++++const PER_PAGE = 15;++++function createPublicRoutes(ctx) {++ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers } = ctx;++ const router = express.Router();++ router.use(viewers.middleware({ services: false }));++++ // ── Helpers ─────────────────────────────────────────────++++ function cacheHeaders(res, { cacheable, robots }) {++ res.vary('Cookie');++ res.vary('Authorization');++ if (cacheable) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');++ else res.set('Cache-Control', 'private, no-store');++ if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);++ }++++ function send(req, res, status, page, { cacheable = false } = {}) {++ cacheHeaders(res, { cacheable: cacheable && req.viewer.kind === 'anonymous' && status === 200, robots: page.decision.robots });++ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));++ }++++ /** A decision for a page that is not a post (collections, messages). */++ function pageDecision(path, { indexable = true, empty = false, query = [] } = {}) {++ return seo.evaluate({++ state: indexable ? 'published' : 'draft', visibility: indexable ? 'public' : 'private',++ canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query }), wordCount: 0, noindex: empty,++ }, { policy: { minWords: 0 }, now: store.now() });++ }++++ function messagePage(req, res, status, heading, text, action) {++ send(req, res, status, { title: heading, decision: pageDecision(req.path, { indexable: false }), body: pages.message({ heading, text, action }) });++ }++ const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });++++ const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);++++ function pageNumber(req) {++ const n = parseInt(req.query.page, 10);++ return Number.isInteger(n) && n > 0 ? n : 1;++ }++++ function activeBlog(req) {++ const blog = blogs.byHandle(req.params.handle);++ if (!blog) return null;++ if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;++ return blog;++ }++++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++++ // ── Blog front pages ────────────────────────────────────++++ async function blogFront(req, res, blog) {++ const page = pageNumber(req);++ const restricted = isMember(blog, req.viewer);++ const path = publication.blogPath(blog);++ const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return notFound(req, res);++ const items = await reading.listItems(rows);++ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });++ const feeds = reading.feedsOf(blog);++ send(req, res, 200, {++ title: blog.title,++ description: blog.description,++ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),++ canonical,++ feeds,++ theme: { slug: blog.theme, vars: blogs.themeVars(blog) },++ lang: blog.language,++ prev: pager.prev ? pager.prev.href : null,++ next: pager.next ? pager.next.href : null,++ jsonLd: [blog.kind === 'official'++ ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }++ : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],++ body: pages.blogIndex({++ blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,++ series: blogs.series(blog), categories: reading.categoriesTree(blog),++ canWrite: access.canWrite(store, req.viewer, blog, 'create'),++ }),++ }, { cacheable: !restricted });++ }++++ router.get('/', wrap(async (req, res) => blogFront(req, res, blogs.official())));++++ router.get('/@:handle', wrap(async (req, res) => {++ const blog = activeBlog(req);++ if (!blog) return notFound(req, res);++ if (blog.kind === 'official') return res.redirect(301, '/');++ return blogFront(req, res, blog);++ }));++++ // ── Feeds (public posts only, never viewer-dependent) ───++++ async function feed(req, res, blog, kind) {++ const s = blogs.feedSettings(blog);++ if (!s[kind]) return notFound(req, res);++ const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });++ const link = publication.abs(publication.blogPath(blog));++ const feedUrl = publication.abs(reading.urls.feed(blog, kind));++ res.set('Cache-Control', 'public, max-age=300');++ res.vary('Accept-Encoding');++ if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));++ if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));++ const listed = items.filter((i) => i.decision.listable);++ const updated = listed.length ? null : new Date(blog.updated_at).toISOString();++ return res.type('application/atom+xml').send(seo.atomFeed({ title: blog.title, link, feedUrl, id: `tag:openvibe.blog,2026:blog/${blog.id}`, subtitle: blog.description || undefined, ...(updated ? { updated } : {}) }, items));++ }++ const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };++ for (const [file, kind] of Object.entries(FEEDS)) {++ router.get(`/${file}`, wrap((req, res) => feed(req, res, blogs.official(), kind)));++ router.get(`/@:handle/${file}`, wrap((req, res) => {++ const blog = activeBlog(req);++ if (!blog) return notFound(req, res);++ if (blog.kind === 'official') return res.redirect(301, `/${file}`);++ return feed(req, res, blog, kind);++ }));++ }++++ // ── Collections ─────────────────────────────────────────++++ async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty }) {++ const page = pageNumber(req);++ const restricted = blog ? isMember(blog, req.viewer) : false;++ const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return notFound(req, res);++ const items = await reading.listItems(rows, { perBlogLinks: Boolean(blog) });++ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });++ send(req, res, 200, {++ title: heading, description: intro, canonical,++ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),++ theme: blog ? { slug: blog.theme, vars: blogs.themeVars(blog) } : null,++ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,++ jsonLd: [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))],++ body: pages.collection({ heading, intro, breadcrumbs: crumbs, items, pager, empty }),++ }, { cacheable: !restricted });++ }++++ const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });++++ router.get('/@:handle/tags/:tag', wrap(async (req, res) => {++ const blog = activeBlog(req);++ const tag = blog && store.taxonomy.bySlug('tag', req.params.tag);++ if (!tag) return notFound(req, res);++ return collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),++ crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });++ }));++++ router.get('/tags/:tag', wrap(async (req, res) => {++ const tag = store.taxonomy.bySlug('tag', req.params.tag);++ if (!tag) return notFound(req, res);++ return collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),++ crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });++ }));++++ router.get('/@:handle/categories/:category', wrap(async (req, res) => {++ const blog = activeBlog(req);++ const term = blog && store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);++ if (!term) return notFound(req, res);++ const trail = store.taxonomy.ancestors(term.id);++ const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];++ return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),++ crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],++ filter: { termIds: ids }, empty: 'No public posts in this category yet.' });++ }));++++ router.get('/@:handle/series/:series', wrap(async (req, res) => {++ const blog = activeBlog(req);++ const series = blog && blogs.seriesBySlug(blog, req.params.series);++ if (!series) return notFound(req, res);++ return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),++ crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.' });++ }));++++ async function personFor(who) {++ if (/^usr_/.test(who)) return people.one(who);++ return people.byUsername(who);++ }++++ router.get('/@:handle/authors/:who', wrap(async (req, res) => {++ const blog = activeBlog(req);++ const person = blog && await personFor(req.params.who);++ if (!person || !blogs.membership(blog, person.subject) && !posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 }).total) return notFound(req, res);++ return collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),++ crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });++ }));++++ router.get('/authors/:who', wrap(async (req, res) => {++ const person = await personFor(req.params.who);++ if (!person) return notFound(req, res);++ return collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),++ crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });++ }));++++ // ── Posts ───────────────────────────────────────────────++++ async function commentsFor(req, blog, post, rev) {++ if (!post.allow_comments) return { state: 'disabled' };++ if (post.visibility !== 'public' || post.state !== 'published') return { state: 'restricted' };++ if (!community.enabled) return { state: 'off' };++ try {++ const threadId = await community.threadFor(post, rev.fields.title, req.ov);++ if (!threadId) return { state: 'off' };++ const data = await community.readThread(threadId, { after: req.query.comments_after, ctx: req.ov });++ return { state: 'ok', thread: data.thread, comments: data.comments || [], nextCursor: data.next_cursor, communityUrl: community.publicUrl };++ } catch (err) {++ console.warn(`[Blog] comments for ${post.id} unavailable: ${err.message}`);++ return { state: 'unavailable' };++ }++ }++++ function seriesView(blog, post) {++ const series = blogs.seriesById(post.series_id);++ if (!series) return null;++ const { posts: rows } = posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });++ return {++ title: series.title, url: reading.urls.series(blog, series), position: post.series_position,++ items: rows.map((p) => ({ title: (store.revisions.get(p.id, p.published_revision) || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),++ };++ }++++ /** Render one post (also used by the editor's preview with a chosen revision). */++ async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {++ const decision = preview ? publication.decide(blog, post, rev, { state: 'draft' }) : publication.decide(blog, post, rev);++ const person = await people.one(post.author_subject);++ const { tags, categories } = reading.termsView(post, blog);++ const attachments = store.attachments.list(post.id);++ const citations = store.citations.forRevision(post.id, rev.number);++ const rec = publication.authorshipOf(rev);++ const review = publication.reviewOf(post, rev);++ const path = publication.postPath(blog, post);++ const firstCategory = categories[0];++ const crumbs = [blogCrumb(blog), ...(firstCategory ? store.taxonomy.ancestors(firstCategory.id).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];++ const comments = preview ? null : await commentsFor(req, blog, post, rev);++ const cacheable = !preview && post.state === 'published' && post.visibility === 'public';++ const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';++ send(req, res, status, {++ title: rev.fields.title,++ description: rev.fields.summary || ssr.markdownToText(rev.content, 160),++ decision,++ canonical: publication.postUrl(blog, post),++ type: 'article',++ author: person.known ? person.name : undefined,++ image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),++ published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,++ modified: post.published_revision ? rev.createdAt : null,++ feeds: reading.feedsOf(blog),++ theme: { slug: blog.theme, vars: blogs.themeVars(blog) },++ lang: blog.language,++ jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],++ body: pages.postPage({++ blog, blogUrl: publication.blogPath(blog), post, rev,++ author: { name: person.name, url: reading.urls.author(person, blog) },++ tags, categories, series: seriesView(blog, post), attachments, citations,++ disclosure: rec ? authorship.disclosure(rec, review) : null,++ decisionNote: preview ? `preview · ${decision.robots}` : null,++ comments, canEdit: access.canWrite(store, req.viewer, blog, 'edit', post),++ urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,++ postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),++ loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,++ }),++ }, { cacheable });++ }++++ /** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */++ async function resolvePost(req, res, blog, slug) {++ const post = posts.bySlug(blog, slug);++ if (!post) {++ const path = `/@${blog.handle}/${slug}`;++ const r = store.redirects.resolve(path, {++ currentPath: (id) => { const p = posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(blogs.get(p.blog_id), p) : null; },++ });++ if (r && r.status === 301) {++ const target = posts.get(r.entityId);++ const decision = await access.canReadPost(store, req.viewer, blogs.get(target.blog_id), target, entitlements);++ if (decision.allowed) {++ cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });++ res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));++ return null;++ }++ }++ if ((r && r.status === 410) || posts.deletedBySlug(blog, slug)) {++ messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });++ return null;++ }++ notFound(req, res);++ return null;++ }++ const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements);++ if (!decision.allowed) {++ if (decision.status === 403) {++ messagePage(req, res, 403, 'Members only', 'This post is for members of this blog. Sign in with an account that has access.', { href: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`, label: 'Sign in' });++ } else notFound(req, res);++ return null;++ }++ return post;++ }++++ router.get('/@:handle/:slug', wrap(async (req, res) => {++ const blog = activeBlog(req);++ if (!blog) return notFound(req, res);++ const asJson = req.params.slug.endsWith('.json');++ const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;++ const post = await resolvePost(req, res, blog, slug);++ if (!post) return;++ // Readers see the published revision; members looking at an unpublished post see its head.++ const rev = post.state === 'published' ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);++ if (asJson) {++ const decision = publication.decide(blog, post, rev);++ cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });++ return res.json(reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));++ }++ return renderPost(req, res, { blog, post, rev });++ }));++++ router.post('/@:handle/:slug/comments', express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {++ const blog = activeBlog(req);++ const post = blog && posts.bySlug(blog, req.params.slug);++ if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);++ if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);++ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');++ if (!post.allow_comments || !community.enabled) return messagePage(req, res, 409, 'Comments are off', 'This post does not take comments.');++ const message = String((req.body && req.body.message) || '').trim();++ if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);++ try {++ const rev = store.revisions.get(post.id, post.published_revision);++ const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);++ await community.comment(threadId, req.viewer.subject, { message }, req.ov);++ } catch (err) {++ return messagePage(req, res, err.status === 429 ? 429 : 502, 'Comment not posted', `OpenVibe.Community did not accept the comment: ${err.message}`);++ }++ return res.redirect(303, `${publication.postPath(blog, post)}#comments`);++ }));++++ return { router, renderPost, notFound, messagePage, pageDecision, send };++}++++module.exports = { createPublicRoutes, PER_PAGE };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/**++ * OpenVibe.Blog — process entry. `node server/index.js`++ * Listens on PORT (4810) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the++ * client secret are set) and the worker (scheduled publication, media verification).++ */++const { createApp } = require('./app');++++const { app, ctx } = createApp();++const { config } = ctx;++++const server = app.listen(config.port, config.host, () => {++ console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);++});++server.keepAliveTimeout = 65_000;++ctx.outbox.start();++ctx.worker.start();++++function shutdown(signal) {++ console.log(`[Blog] ${signal}: closing`);++ ctx.worker.stop();++ server.close(async () => {++ try { await ctx.outbox.stop(); } catch { /* best effort */ }++ try { ctx.store.close(); } catch { /* already closed */ }++ process.exit(0);++ });++ setTimeout(() => process.exit(0), 5000).unref();++}++process.on('SIGTERM', () => shutdown('SIGTERM'));++process.on('SIGINT', () => shutdown('SIGINT'));nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * db required a real query on Blog's SQLite (the charter tables answer)++ * network_jwks optional the Network signing key has loaded; without it pages and feeds still++ * serve, but nobody can sign in and service tokens are refused (503)++ * events_relay optional the outbox relay is configured and has no rejected rows; when it is++ * off, events wait in event_outbox (Search and subscribers lag)++ * scheduler optional no scheduled job has failed and none is overdue by more than 5 minutes++ *++ * Request metrics come from openvibe-shared/metrics in app.js; content counts are not metrics.++ */++const { createReadiness } = require('openvibe-shared/ready');++const { CHARTER_TABLES } = require('./db');++++function createBlogReadiness({ store, auth, outbox, release = null }) {++ const { db } = store;++ return createReadiness({++ service: 'blog',++ 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: 'scheduler', required: false,++ check: () => {++ const failed = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000).n;++ const overdue = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000).n;++ if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;++ return true;++ },++ },++ ],++ });++}++++module.exports = { createBlogReadiness };nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++++/**++ * The editor: plain HTML forms that work without JavaScript. Every form posts to the same origin++ * with the per-session form token (_csrf); sign-in is Network SSO.++ */++const ssr = require('openvibe-publishing/ssr');++const { time } = require('./pages');++++const { html: h, raw } = ssr;++++const STATE_LABEL = { draft: 'Draft', scheduled: 'Scheduled', published: 'Published', unpublished: 'Unpublished' };++const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;++++function flash(msg) {++ if (!msg) return '';++ return h`<p class="notice ${msg.kind === 'error' ? 'error' : ''}" role="${msg.kind === 'error' ? 'alert' : 'status'}">${msg.text}</p>`;++}++++function home({ viewer, blogs, csrf, suggestedHandle, message }) {++ const own = blogs.find((b) => b.kind === 'member' && b.role === 'owner');++ return String(h`<h1>Write</h1>++${flash(message)}++${blogs.length ? h`<h2>Your blogs</h2><ul class="blog-list">${blogs.map((b) => h`<li><a href="/write/@${b.handle}">${b.title}</a> <span class="meta">@${b.handle} · ${b.role}</span></li>`)}</ul>` : ''}++${own ? '' : h`<section class="card"><h2>Start your blog</h2>++<p>Your blog lives at <code>/@handle</code> on openvibe.blog and belongs to your OpenVibe account.</p>++<form method="post" action="/write/start">${csrfField(csrf)}++<label for="handle">Handle</label><input id="handle" name="handle" value="${suggestedHandle || ''}" pattern="[a-z0-9][a-z0-9_-]{1,39}" required>++<label for="title">Blog title</label><input id="title" name="title" maxlength="120" value="${suggestedHandle ? `@${suggestedHandle}` : ''}">++<button type="submit">Start my blog</button></form></section>`}++<p class="meta">Signed in as ${viewer.user && (viewer.user.display_name || viewer.user.username) ? (viewer.user.display_name || viewer.user.username) : viewer.subject}.</p>`);++}++++function blogDashboard({ blog, posts, heads, role, csrf, message, publicUrl }) {++ return String(h`<p><a href="/write">← All your blogs</a></p>++<h1>${blog.title}</h1>++<p class="meta">@${blog.handle} · your role: ${role} · <a href="${publicUrl}">View the blog</a>${role === 'owner' ? h` · <a href="/write/@${blog.handle}/settings">Settings</a>` : ''}</p>++${flash(message)}++<p><a class="button" href="/write/@${blog.handle}/new">New post</a></p>++${posts.length ? h`<table class="posts"><thead><tr><th>Title</th><th>State</th><th>Visibility</th><th>Updated</th></tr></thead><tbody>++${posts.map((p) => h`<tr><td><a href="/write/posts/${p.id}">${(heads.get(p.id) || {}).title || p.slug}</a></td><td>${STATE_LABEL[p.state] || p.state}</td><td>${p.visibility}</td><td>${time(p.updated_at)}</td></tr>`)}++</tbody></table>` : h`<p class="empty">No posts yet.</p>`}`);++}++++function select(name, options, current) {++ return h`<select id="${name}" name="${name}">${options.map(([v, label]) => (v === current ? h`<option value="${v}" selected>${label}</option>` : h`<option value="${v}">${label}</option>`))}</select>`;++}++++/** New post and edit form (the same fields). */++function postForm({ blog, post, head, terms, series, csrf, message, action }) {++ const f = head ? head.fields : {};++ const vis = post ? post.visibility : 'public';++ return String(h`${flash(message)}++<form method="post" action="${action}" class="post-form">${csrfField(csrf)}++${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : ''}++<label for="title">Title</label><input id="title" name="title" maxlength="200" required value="${f.title || ''}">++<label for="summary">Summary <span class="meta">(optional, shown in lists and feeds)</span></label><input id="summary" name="summary" maxlength="500" value="${f.summary || ''}">++<label for="body">Text <span class="meta">(Markdown; a line <code>[[media:med_…]]</code> places an attached Media object)</span></label>++<textarea id="body" name="body" rows="22">${head ? head.content : ''}</textarea>++${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value="${post.slug}" pattern="[a-z0-9][a-z0-9-]{0,79}"><p class="meta">Changing it keeps the old address working (301).</p>` : ''}++<label for="tags">Tags <span class="meta">(comma-separated)</span></label><input id="tags" name="tags" value="${(terms.tags || []).join(', ')}">++<label for="categories">Categories <span class="meta">(comma-separated; "Parent > Child" nests)</span></label><input id="categories" name="categories" value="${(terms.categories || []).join(', ')}">++<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}">++<label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}">++<label for="visibility">Who can read it</label>++${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (VIP entitlement)'], ['private', 'Private (this blog’s members)']], vis)}++<label for="entitlementKey">Entitlement key for members-only posts</label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `blog:${blog.handle}:members`}">++<p class="meta">Members-only posts are readable by this blog’s members and staff. Other readers need the entitlement; no entitlement service is connected yet, so nobody else is admitted.</p>++<label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>++<label><input type="checkbox" name="noindex" value="1"${raw(post && post.noindex ? ' checked' : '')}> Ask search engines not to index it</label>++<input type="hidden" name="allowComments" value="0">++<label for="message">Revision note <span class="meta">(optional)</span></label><input id="message" name="message" maxlength="200">++<button type="submit">${post ? 'Save revision' : 'Save draft'}</button>++</form>`);++}++++function gateSummary(decision) {++ if (!decision) return '';++ if (decision.indexable) return h`<p class="meta">Search engines: indexable (${decision.robots}).</p>`;++ return h`<p class="meta">Search engines: <strong>${decision.robots}</strong> — ${decision.reasons.map((r) => `${r.code}${r.detail ? ` (${r.detail})` : ''}`).join('; ')}.</p>`;++}++++function editPage(o) {++ const { blog, post, head, published, decision, jobs, attachments, csrf, disclosure, needsReview, publicUrl, canDelete } = o;++ return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>++<h1>${head.fields.title}</h1>++<p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>++${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}++${raw(gateSummary(decision))}++${needsReview ? h`<section class="card"><h2>Review needed</h2><p>Revision ${head.number} is AI-generated. A person must review it before it can be published or indexed.</p>++<form method="post" action="/write/posts/${post.id}/review">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">++<label for="note">Note</label><input id="note" name="note" maxlength="500">++<button name="decision" value="approved" type="submit">Approve revision ${head.number}</button> <button name="decision" value="rejected" type="submit">Reject</button></form></section>` : ''}++<section class="card actions"><h2>Publish</h2>++<form method="post" action="/write/posts/${post.id}/publish">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}"><button type="submit">Publish revision ${head.number} now</button></form>++<form method="post" action="/write/posts/${post.id}/schedule">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">++<label for="at">Publish revision ${head.number} at (UTC)</label><input id="at" name="at" type="datetime-local" required><button type="submit">Schedule</button></form>++${jobs.length ? h`<p class="meta">Scheduled: ${jobs.map((j) => h`${j.action} of revision ${j.revision || '-'} at ${j.runAt} (${j.status}) `)}</p>++<form method="post" action="/write/posts/${post.id}/unschedule">${csrfField(csrf)}<button type="submit">Cancel schedule</button></form>` : ''}++${post.state === 'published' || post.state === 'scheduled' ? h`<form method="post" action="/write/posts/${post.id}/unpublish">${csrfField(csrf)}<button type="submit">Unpublish</button></form>` : ''}++${canDelete ? h`<form method="post" action="/write/posts/${post.id}/delete">${csrfField(csrf)}<label><input type="checkbox" name="confirm" value="1" required> I want to delete this post</label><button type="submit" class="danger">Delete</button></form>` : ''}++</section>++<section class="card"><h2>Media</h2>++${attachments.length ? h`<ul>${attachments.map((a) => h`<li><code>${a.mediaId}</code> · ${a.role}${a.broken ? h` · <strong>unavailable (${a.brokenReason})</strong>` : h` · ${a.state}`}${a.alt ? h` · alt: ${a.alt}` : ''}++<form method="post" action="/write/posts/${post.id}/media/${a.id}/remove" class="inline">${csrfField(csrf)}<button type="submit">Remove</button></form></li>`)}</ul>` : h`<p class="empty">No media attached.</p>`}++<form method="post" action="/write/posts/${post.id}/media">${csrfField(csrf)}++<label for="mediaId">OpenVibe.Media object id</label><input id="mediaId" name="mediaId" placeholder="med_…" required pattern="med_[0-9A-HJKMNP-TV-Z]{26}">++<label for="role">Role</label>${select('role', [['inline', 'Inline'], ['cover', 'Cover'], ['gallery', 'Gallery']], 'inline')}++<label for="alt">Alt text</label><input id="alt" name="alt" maxlength="1000">++<label for="caption">Caption</label><input id="caption" name="caption" maxlength="2000">++<button type="submit">Attach</button></form></section>++<h2>Edit</h2>++${raw(o.form)}`);++}++++function revisionsPage({ post, head, revisions, diff, from, to, csrf }) {++ return String(h`<p><a href="/write/posts/${post.id}">← Back to the post</a></p>++<h1>History of “${head.fields.title}”</h1>++<form method="get" action="/write/posts/${post.id}/revisions" class="inline">++<label>Compare <input name="from" type="number" min="1" value="${from || ''}"></label> <label>with <input name="to" type="number" min="1" value="${to || ''}"></label> <button type="submit">Diff</button></form>++${diff ? h`<section><h2>Revision ${diff.from} → ${diff.to}</h2>${diff.fields.length ? h`<ul>${diff.fields.map((d) => h`<li>${d.field}: “${d.from}” → “${d.to}”</li>`)}</ul>` : ''}${raw(ssr.diffHtml(diff.content))}</section>` : ''}++<ol class="revisions" reversed>${revisions.map((r) => h`<li><strong>Revision ${r.number}</strong> · ${r.kind}${r.revertedTo ? h` of ${r.revertedTo}` : ''} · ${time(Date.parse(r.createdAt))} · ${(r.meta && r.meta.authorship && r.meta.authorship.mode) || 'human'}${r.message ? h` · ${r.message}` : ''}++${r.number !== head.number ? h`<form method="post" action="/write/posts/${post.id}/revert" class="inline">${csrfField(csrf)}<input type="hidden" name="toRevision" value="${r.number}"><input type="hidden" name="expectedRevision" value="${head.number}"><button type="submit">Restore as a new revision</button></form>` : ''}</li>`)}</ol>`);++}++++function settingsPage({ blog, feed, members, people, themes, csrf, message }) {++ return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>++<h1>Settings</h1>++${flash(message)}++<form method="post" action="/write/@${blog.handle}/settings" class="card">${csrfField(csrf)}++<h2>Blog</h2>++<label for="title">Title</label><input id="title" name="title" maxlength="120" value="${blog.title}" required>++<label for="description">Description</label><input id="description" name="description" maxlength="500" value="${blog.description || ''}">++<label for="language">Language (BCP 47)</label><input id="language" name="language" value="${blog.language}">++<h2>Theme</h2>++${select('theme', themes.map((t) => [t.id, `${t.name} (${t.mode})`]), blog.theme)}++<h2>Feeds</h2>++<label><input type="checkbox" name="rss" value="1"${raw(feed.rss ? ' checked' : '')}> RSS</label>++<label><input type="checkbox" name="atom" value="1"${raw(feed.atom ? ' checked' : '')}> Atom</label>++<label><input type="checkbox" name="json" value="1"${raw(feed.json ? ' checked' : '')}> JSON Feed</label>++<label for="itemCount">Items per feed</label><input id="itemCount" name="itemCount" type="number" min="1" max="50" value="${feed.item_count}">++<label><input type="checkbox" name="fullContent" value="1"${raw(feed.full_content ? ' checked' : '')}> Full text in feeds (otherwise summaries)</label>++<button type="submit">Save settings</button></form>++<section class="card"><h2>Members</h2>++<ul>${members.map((m) => { const p = people.get(m.subject); return h`<li>${p ? p.name : m.subject} <span class="meta">${p && p.username ? `@${p.username}` : m.subject}</span> · ${m.role}++${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action="/write/@${blog.handle}/members/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="subject" value="${m.subject}"><button type="submit">Remove</button></form>` : ''}</li>`; })}</ul>++<form method="post" action="/write/@${blog.handle}/members">${csrfField(csrf)}++<label for="member">Username (someone who has signed in to OpenVibe.Blog) or subject id (usr_…)</label><input id="member" name="member" required>++<label for="role">Role</label>${select('role', [['author', 'Author (own posts)'], ['editor', 'Editor (all posts)'], ['owner', 'Owner']], 'author')}++<button type="submit">Add member</button></form></section>`);++}++++module.exports = { home, blogDashboard, postForm, editPage, revisionsPage, settingsPage, gateSummary };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through this and is complete without JavaScript:++ * - <head>: title, description, canonical and robots from the indexability gate's decision++ * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),++ * Open Graph/Twitter, JSON-LD, feed links, the shared app icon and critical canvas++ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a++ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)++ * - a blog's theme preset applied to its content surface only (openvibe-shared tokens)++ */++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.Blog';++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)}`;++++/** Inline CSS custom properties for a theme preset (values come from openvibe-shared, escaped anyway). */++function themeStyle(vars) {++ return Object.entries(vars || {})++ .filter(([k, v]) => /^--[a-z0-9-]+$/.test(k) && /^[#(),.%\w\s-]+$/.test(String(v)))++ .map(([k, v]) => `${k}:${v}`).join(';');++}++++/**++ * o: title, description, decision (required), canonical, type ('website'|'article'), image,++ * jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,++ * theme { slug, vars }, published, modified, author, prev, next, bodyClass++ */++function renderPage(o) {++ if (!o.decision) throw new TypeError('renderPage needs the gate decision');++ const head = seo.metaTags({++ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,++ description: o.description || 'The official OpenVibe blog and a blog for every member.',++ decision: o.decision,++ canonical: o.canonical,++ type: o.type || 'website',++ siteName: SITE_NAME,++ image: o.image || undefined,++ author: o.author || undefined,++ prev: o.prev || undefined,++ next: o.next || undefined,++ jsonLd: (o.jsonLd || []).filter(Boolean),++ });++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const loginNext = encodeURIComponent(o.path || '/');++ const nav = {++ service: 'blog',++ apiBase: NETWORK_URL,++ links: [++ { label: 'Blog', href: '/' },++ { label: 'Write', href: '/write', icon: 'fa-pen' },++ ],++ 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 style = o.theme ? ` style="${esc(themeStyle(o.theme.vars))}" data-blog-theme="${esc(o.theme.slug)}"` : '';++ const account = signedIn++ ? `<a href="/write">Write</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ return `<!DOCTYPE html>++<html lang="${esc(o.lang || 'en')}">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++${head}++${o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : ''}++${o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : ''}++${seo.feedLinks(o.feeds || [])}++${appIcon.headTags({ site: 'blog' })}++<link rel="stylesheet" href="${asset('css/blog.css')}">++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++</head>++<body class="${esc(o.bodyClass || '')}">++<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }] })}++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>++<main id="main" class="page blog-surface"${style}>++${o.body || ''}++</main>++${chrome.footer({ service: 'blog', 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, themeStyle, SITE_NAME, NETWORK_URL };nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++++/**++ * Public pages (HTML bodies; layout.js wraps them). Everything a reader needs is in the HTML:++ * post text, media figures (or an explicit "media unavailable" placeholder), sources, tags,++ * series navigation, author links, feeds and the Community comment thread with a plain form.++ * Values are escaped by ssr.html unless wrapped in raw(); Markdown goes through the package's++ * safe renderer.++ */++const ssr = require('openvibe-publishing/ssr');++const { figureHtml } = require('openvibe-publishing/media');++++const { html: h, raw, escapeHtml: esc } = ssr;++const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;++++const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);++const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));++++/**++ * Markdown body with media: a line `[[media:med_…]]` places that attachment's <figure> there.++ * Inline attachments that the body does not place follow the text; covers and galleries are++ * rendered by the caller. A referenced id that is not attached renders an explicit notice.++ */++function renderBody(content, attachments, { urlFor, rel }) {++ const byId = new Map(attachments.map((a) => [a.mediaId, a]));++ const used = new Set();++ const parts = String(content || '').split(new RegExp(MEDIA_LINE.source, 'm'));++ let out = '';++ parts.forEach((part, i) => {++ if (i % 2 === 0) { out += ssr.renderMarkdown(part, { rel }); return; }++ const att = byId.get(part);++ if (att) { used.add(att.id); out += figureHtml(att, { urlFor }); } else {++ out += `<figure class="ov-media ov-media-broken" data-media-id="${esc(part)}" data-state="not_attached"><div class="ov-media-missing" role="img" aria-label="Media not attached">This media is not attached to the post.</div></figure>`;++ }++ });++ for (const a of attachments) if (a.role === 'inline' && !used.has(a.id)) out += figureHtml(a, { urlFor });++ return out;++}++++function postListItem(item) {++ const { post, rev, url, author, tags, badge } = item;++ return h`<li class="post-item">++<h2 class="post-item-title"><a href="${url}">${rev.fields.title}</a>${badge ? h` <span class="badge">${badge}</span>` : ''}</h2>++<p class="meta">${time(post.published_at)}${author ? h` · <a href="${author.url}">${author.name}</a>` : ''}</p>++<p class="summary">${rev.fields.summary || ssr.markdownToText(rev.content, 220)}</p>++${tags && tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}">#${t.name}</a> `)}</p>` : ''}++</li>`;++}++++function postList(items, empty = 'Nothing published here yet.') {++ if (!items.length) return h`<p class="empty">${empty}</p>`;++ return h`<ol class="post-list">${items.map(postListItem)}</ol>`;++}++++function feedLinksHtml(feeds) {++ if (!feeds.length) return '';++ return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>`;++}++++/** A blog's front page. */++function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite }) {++ return String(h`<header class="blog-header">++<h1>${blog.title}</h1>++${blog.description ? h`<p class="lede">${blog.description}</p>` : ''}++${raw(feedLinksHtml(feeds))}++${canWrite ? h`<p><a class="button" href="/write/@${blog.handle}">Write on this blog</a></p>` : ''}++</header>++<section aria-label="Posts">${postList(items)}</section>++${raw(ssr.paginationHtml(pager))}++${series.length ? h`<section class="side"><h2>Series</h2><ul>${series.map((s) => h`<li><a href="${blogUrl}/series/${s.slug}">${s.title}</a></li>`)}</ul></section>` : ''}++${categories.length ? h`<section class="side"><h2>Categories</h2>${raw(categoryTree(categories, blogUrl))}</section>` : ''}`);++}++++function categoryTree(nodes, blogUrl) {++ if (!nodes.length) return '';++ return `<ul>${nodes.map((n) => `<li><a href="${esc(`${blogUrl}/categories/${n.slug}`)}">${esc(n.name)}</a>${categoryTree(n.children || [], blogUrl)}</li>`).join('')}</ul>`;++}++++/** Tag, category, series and author collections. */++function collection({ heading, intro, breadcrumbs, items, pager, feeds = [], empty }) {++ return String(h`${raw(ssr.breadcrumbsHtml(breadcrumbs || []))}++<header class="blog-header"><h1>${heading}</h1>${intro ? h`<p class="lede">${intro}</p>` : ''}${raw(feedLinksHtml(feeds))}</header>++<section aria-label="Posts">${postList(items, empty)}</section>++${raw(ssr.paginationHtml(pager))}`);++}++++function commentsSection(c, { postPath, csrf, signedIn, loginUrl }) {++ const head = '<h2>Comments</h2>';++ if (c.state === 'disabled') return `<section id="comments" class="comments">${head}<p class="empty">Comments are off for this post.</p></section>`;++ if (c.state === 'restricted') return `<section id="comments" class="comments">${head}<p class="empty">Comments are available on public posts only.</p></section>`;++ if (c.state === 'off') return `<section id="comments" class="comments">${head}<p class="empty">Comments are not connected on this server.</p></section>`;++ if (c.state === 'unavailable') return `<section id="comments" class="comments">${head}<p class="notice" role="status">Comments could not be loaded from OpenVibe.Community right now. Reload later.</p></section>`;++ const one = (m) => h`<li class="comment" id="comment-${m.id}">++<p class="meta"><strong>${m.deleted ? '[deleted]' : (m.display_name || 'Anonymous')}</strong>${m.origin === 'ai' ? h` <span class="badge">AI</span>` : ''} · ${raw(ssr.timeTag(m.created_at, { label: dateLabel(m.created_at) }))}</p>++${m.deleted ? h`<p class="empty">This comment was deleted.</p>` : raw(`<p>${esc(m.message).replace(/\n/g, '<br>')}</p>`)}++${m.replies && m.replies.length ? h`<ol class="replies">${m.replies.map(one)}</ol>` : ''}++</li>`;++ const list = c.comments.length ? h`<ol class="comment-list">${c.comments.map(one)}</ol>` : h`<p class="empty">No comments yet.</p>`;++ const more = c.nextCursor ? h`<p><a href="${postPath}?comments_after=${c.nextCursor}#comments">More comments</a></p>` : '';++ const form = c.thread && c.thread.visibility === 'locked'++ ? h`<p class="empty">This thread is locked.</p>`++ : signedIn++ ? h`<form method="post" action="${postPath}/comments" class="comment-form">++<input type="hidden" name="_csrf" value="${csrf}">++<label for="comment-message">Add a comment</label>++<textarea id="comment-message" name="message" rows="4" maxlength="5000" required></textarea>++<button type="submit">Post comment</button>++</form>`++ : h`<p><a href="${loginUrl}">Sign in with OpenVibe</a> to comment.</p>`;++ return String(h`<section id="comments" class="comments">${raw(head)}++<p class="meta">Comments are hosted by <a href="${c.communityUrl}">OpenVibe.Community</a>.</p>++${list}${more}${form}</section>`);++}++++/** One post. */++function postPage(o) {++ const { blog, blogUrl, post, rev, author, tags, categories, series, attachments, citations, disclosure, decisionNote, comments, canEdit, urlFor } = o;++ const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';++ const cover = attachments.filter((a) => a.role === 'cover');++ const gallery = attachments.filter((a) => a.role === 'gallery');++ const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];++ const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;++ return String(h`${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}++<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">++<header>++<h1>${rev.fields.title}</h1>++<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a>` : ''}${post.published_at ? h` · ${time(post.first_published_at || post.published_at)}` : ''}${updated ? h` · updated ${time(Date.parse(rev.createdAt))}` : ''}${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>++${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}++${series ? h`<nav class="series" aria-label="Series"><p>Part ${series.position || '?'} of the series <a href="${series.url}">${series.title}</a></p><ol>${series.items.map((s) => (s.current ? h`<li aria-current="page">${s.title}</li>` : h`<li><a href="${s.url}">${s.title}</a></li>`))}</ol></nav>` : ''}++${raw(cover.map((a) => figureHtml(a, { urlFor })).join(''))}++</header>++<div class="post-body">${raw(renderBody(rev.content, attachments, { urlFor, rel }))}</div>++${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}++${citations.length ? h`<section class="sources"><h2>Sources</h2><ol>${citations.map((c) => h`<li>${c.url ? h`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : (c.title || c.sourceItemId)}${c.quote && c.quote.text ? h` — “${c.quote.text}”` : ''}${c.retrievedAt ? h` (retrieved ${dateLabel(c.retrievedAt)})` : ''}</li>`)}</ol></section>` : ''}++<footer class="post-footer">++${tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}" rel="tag">#${t.name}</a> `)}</p>` : ''}++${categories.length ? h`<p class="categories">Filed under ${categories.map((c) => h`<a href="${c.url}">${c.name}</a> `)}</p>` : ''}++<p class="meta">Revision ${rev.number}${decisionNote ? h` · ${decisionNote}` : ''} · <a href="${o.jsonUrl}">JSON</a> · <a href="${blogUrl}">More from ${blog.title}</a>${canEdit ? h` · <a href="/write/posts/${post.id}">Edit</a>` : ''}</p>++</footer>++</article>++${raw(comments ? commentsSection(comments, o) : '')}`);++}++++function message({ heading, text, action }) {++ return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);++}++++module.exports = { renderBody, blogIndex, collection, postPage, message, postList, categoryTree, time, dateLabel };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++++/**++ * The seed: the official blog's first post, "Released in the OpenVibe repositories", built ONLY++ * from verbatim quotes of commit messages and release notes (seed/week-*.json, collected from the++ * repositories' main branches with the commit, file and date of each quote). Every quote is++ * attached as a citation (URL, retrieval time, the quoted text) to the revision that uses it.++ *++ * It is created as a DRAFT. Nothing marks it reviewed unless a person is named: with++ * { reviewer: 'usr_…', publish: true } that person's approving review is recorded and the post is++ * published. The seed never invents a reviewer, a date or a feature.++ *++ * Authorship: 'imported' — the text is quoted from the repositories (original author OpenVibers);++ * the connecting sentences only say where each quote comes from.++ */++const fs = require('fs');++const path = require('path');++const { ids } = require('openvibe-contracts');++++const SLUG = 'released-in-the-openvibe-repositories-16-22-september-2026';++++function loadFacts(file = path.join(__dirname, '..', 'seed', 'week-2026-09-16.json')) {++ const facts = JSON.parse(fs.readFileSync(file, 'utf8'));++ if (!facts || !Array.isArray(facts.items) || !facts.items.length) throw new Error(`${file}: no items`);++ for (const it of facts.items) {++ for (const k of ['repo', 'quote', 'kind', 'ref', 'url']) if (!it[k]) throw new Error(`${file}: an item has no ${k}`);++ if (!/^https:\/\/github\.com\/OpenVibers\//.test(it.url)) throw new Error(`${file}: ${it.url} is not an OpenVibers GitHub URL`);++ }++ return facts;++}++++const kindLabel = (it) => (it.kind === 'tag' ? `tag ${it.tag}, commit ${it.ref.slice(0, 7)}` : it.kind === 'commit' ? `commit ${it.ref.slice(0, 7)}` : it.kind === 'changelog' ? `CHANGELOG, ${it.ref.split('#')[1] || it.ref}` : it.kind);++++function composeBody(facts) {++ const lines = [++ `This post quotes the commit messages and release notes that reached the main branch of the OpenVibers repositories between ${facts.week.from} and ${facts.week.to}. Each quote is copied as written and links to its source. A change listed here was released in its repository; this post does not say that it is running on an OpenVibe site.`,++ '',++ ];++ let current = null;++ for (const it of facts.items) {++ if (it.repo !== current) { lines.push(`# ${it.repo}`, ''); current = it.repo; }++ lines.push(`> ${it.quote}`, '', `Source: [${it.repo}, ${kindLabel(it)}](${it.url})`, '');++ }++ return lines.join('\n').trim() + '\n';++}++++/**++ * Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;++ * default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).++ */++function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {++ const { blogs, posts, store } = ctx;++ const blog = blogs.ensureOfficial();++ const owners = blogs.members(blog).filter((m) => m.role === 'owner').map((m) => m.subject);++ const subject = author || owners[0];++ if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');++ if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');++ const actor = { kind: 'user', subject, staff: true, user: {} };++++ let post = posts.bySlug(blog, SLUG);++ let created = false;++ if (!post) {++ const out = posts.create(actor, blog, {++ title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,++ slug: SLUG,++ summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,++ body: composeBody(facts),++ tags: ['release notes', 'openvibe'],++ categories: ['Release notes'],++ authorship: { mode: 'imported', importedFrom: { label: 'commit messages and release notes in the OpenVibers repositories (quoted verbatim)', originalAuthor: 'OpenVibers' } },++ citations: facts.items.map((it) => ({ url: it.url, title: `${it.repo}: ${kindLabel(it)}`, retrievedAt: facts.collected_at, quote: { text: it.quote } })),++ message: `Seed: ${facts.items.length} verbatim quotes collected ${facts.collected_at}`,++ });++ post = out.post;++ created = true;++ }++ let review = null;++ let published = false;++ if (publish) {++ const head = store.revisions.head(post.id);++ review = posts.review({ kind: 'user', subject: reviewer, staff: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });++ published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;++ }++ return { post: posts.get(post.id), created, review, published };++}++++module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * Background work in the Blog process (openvibe-shared has no job runner; this is two timers):++ *++ * scheduled publication every BLOG_SCHEDULE_INTERVAL_MS: claim due jobs (leased, so two++ * processes never run the same job at once) and apply them; the effect++ * is idempotent, so a job re-run after a crash changes nothing++ * media verification every BLOG_MEDIA_VERIFY_INTERVAL_MS: ask OpenVibe.Media whether the++ * objects attached to published posts still exist; gone → the explicit++ * broken-asset state; an outage changes nothing (check_failed)++ */++function createWorker({ config, store, posts, media, outbox, log = console }) {++ let scheduleTimer = null;++ let mediaTimer = null;++ let running = false;++++ async function scheduleTick() {++ if (running) return null;++ running = true;++ try {++ const summary = await posts.runScheduled(config.worker.id);++ if (summary.done.length || summary.failed.length) outbox.kick();++ return summary;++ } catch (err) {++ log.error('[Blog] schedule tick failed:', err.message);++ return null;++ } finally { running = false; }++ }++++ /** Verify the attachments of published posts (at most `limit` posts per run). */++ async function verifyMedia({ limit = 200 } = {}) {++ if (!media.enabled) return [];++ const ids = store.db.prepare(`SELECT DISTINCT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id++ WHERE p.state = 'published' ORDER BY COALESCE(a.checked_at, 0) LIMIT ?`).all(limit).map((r) => r.id);++ const out = [];++ for (const id of ids) {++ const results = await store.attachments.verify(id, { resolve: media.resolve });++ out.push({ post: id, results });++ }++ return out;++ }++++ return {++ scheduleTick,++ verifyMedia,++ start() {++ if (!config.worker.enabled) return;++ scheduleTimer = setInterval(scheduleTick, config.worker.intervalMs);++ scheduleTimer.unref();++ if (media.enabled) {++ mediaTimer = setInterval(() => verifyMedia().catch((err) => log.warn('[Blog] media verification failed:', err.message)), config.media.verifyIntervalMs);++ mediaTimer.unref();++ }++ setTimeout(scheduleTick, 1000).unref();++ },++ stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); },++ };++}++++module.exports = { createWorker };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * Service tokens: one capability per route (3-segment ids), the acting person in X-OV-Subject,++ * membership still applies. OpenVibe.AI's blog.draft_post arrives as an AI-generated draft that is++ * noindex and cannot be published or scheduled until a person reviews it.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };++++(async () => {++ const t = await boot();++ const fay = t.network.addUser('fay', { display_name: 'Fay' });++ await t.get('/api/v1/blogs', { as: fay, json: {} });++ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read']);++ let draft;++++ await check('a service token without the capability is refused (problem+json)', async () => {++ const tok = t.network.serviceToken('ai', ['blog.post.read']);++ const r = await t.get('/api/v1/blogs/fay/posts', { as: tok, headers: { 'X-OV-Subject': fay.subject }, json: { title: 'x', body: 'y' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ });++++ await check('a token for another audience, or a forged one, is refused', async () => {++ const wrong = t.network.signService({ sub: 'svc:ai', aud: ['openvibe.community'], cap: ['blog.post.create'] });++ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts', { as: wrong, json: { title: 'x', body: 'y' } })).status, 401);++ const forged = `${ai.split('.').slice(0, 2).join('.')}.AAAA`;++ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts', { as: forged, json: { title: 'x', body: 'y' } })).status, 401);++ });++++ await check('a service must act for a member of the blog', async () => {++ const r = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai' }, json: { title: 'x', body: 'y', authorship: { workflow: WORKFLOW } } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'subject.required');++ const stranger = t.network.addUser('stranger');++ const s = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': stranger.subject }, json: { title: 'x', body: 'y', authorship: { workflow: WORKFLOW } } });++ assert.strictEqual(s.status, 403);++ });++++ await check('blog.draft_post output becomes an AI-generated draft for the member who asked', async () => {++ const r = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'An AI draft', body: LONG, authorship: { workflow: WORKFLOW } } });++ assert.strictEqual(r.status, 201, r.text);++ draft = r.json().post;++ assert.strictEqual(draft.state, 'draft');++ assert.strictEqual(draft.authorship.mode, 'ai');++ assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');++ assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');++ const created = t.events('blog.post.created')[0];++ assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });++ assert.strictEqual(created.payload.authorship, 'ai');++ const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });++ assert.strictEqual(noWorkflow.status, 400);++ assert.strictEqual(noWorkflow.json().code, 'authorship.workflow_required');++ });++++ await check('it cannot be published or scheduled before a person reviews it', async () => {++ const p = await t.get(`/api/v1/posts/${draft.id}/publish`, { as: fay, json: {} });++ assert.strictEqual(p.status, 409);++ assert.strictEqual(p.json().code, 'post.review_required');++ const s = await t.get(`/api/v1/posts/${draft.id}/schedule`, { as: fay, json: { at: new Date(t.clock.now() + 3600e3).toISOString() } });++ assert.strictEqual(s.status, 409);++ const editor = await t.get(`/write/posts/${draft.id}`, { as: fay });++ assert.match(editor.text, /AI-generated/);++ assert.match(editor.text, /Review needed/);++ assert.match(editor.text, /ai_generated_unreviewed/);++ });++++ await check('a service cannot review; the member can, then publish; the page discloses it', async () => {++ const svc = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.publish']);++ const bad = await t.get(`/api/v1/posts/${draft.id}/reviews`, { as: svc, headers: { 'X-OV-Subject': fay.subject }, json: { revision: 1, decision: 'approved' } });++ assert.strictEqual(bad.status, 403);++ const ok = await t.get(`/api/v1/posts/${draft.id}/reviews`, { as: fay, json: { revision: 1, decision: 'approved' } });++ assert.strictEqual(ok.status, 201, ok.text);++ const p = await t.get(`/api/v1/posts/${draft.id}/publish`, { as: fay, json: {} });++ assert.strictEqual(p.status, 200, p.text);++ const page = await t.get('/@fay/an-ai-draft');++ assert.match(page.text, /AI-generated/);++ assert.match(page.text, /reviewed by a person/);++ const doc = t.events('blog.index_document.upserted').pop().payload;++ assert.strictEqual(doc.authorship, 'ai_generated');++ assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));++ });++++ await check('a person editing the AI text makes an AI-assisted (hybrid) revision', async () => {++ const r = await t.get(`/api/v1/posts/${draft.id}`, { as: fay, method: 'PATCH', json: { expected_revision: 1, body: `${LONG} edited by Fay` } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().post.authorship.mode, 'hybrid');++ assert.deepStrictEqual(r.json().post.authorship.authors, [fay.subject]);++ });++++ await check('user tokens are judged by membership, not capabilities; staff may moderate any blog', async () => {++ const admin = t.network.addUser('admin', { role: 'admin' });++ const r = await t.get(`/api/v1/posts/${draft.id}/unpublish`, { as: admin, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ const edit = await t.get(`/api/v1/posts/${draft.id}`, { as: admin, method: 'PATCH', json: { expected_revision: 2, title: 'Staff rewrite' } });++ assert.strictEqual(edit.status, 403, 'moderation is not authorship');++ });++++ await check('theme and configuration go through their own capabilities', async () => {++ const svc = t.network.serviceToken('network', ['blog.theme.set']);++ const r = await t.get('/api/v1/blogs/fay/theme', { as: svc, method: 'PUT', headers: { 'X-OV-Subject': fay.subject }, json: { theme: 'nord' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().blog.theme, 'nord');++ const c = await t.get('/api/v1/blogs/fay', { as: svc, method: 'PATCH', headers: { 'X-OV-Subject': fay.subject }, json: { title: 'No' } });++ assert.strictEqual(c.status, 403);++ });++++ await check('responses carry the request id and trace', async () => {++ const r = await t.get('/api/v1/blogs/fay', { headers: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', 'X-OpenVibe-Request-Id': 'req_testtesttest' } });++ assert.strictEqual(r.headers.get('x-openvibe-request-id'), 'req_testtesttest');++ assert.match(r.headers.get('traceparent'), /^00-0af7651916cd43dd8448eb211c80319c-/);++ const nf = await t.get('/api/v1/nothing', { headers: { 'X-OpenVibe-Request-Id': 'req_testtesttest' } });++ assert.strictEqual(nf.json().request_id, 'req_testtesttest');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,49 @@++'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 do not collide with released ids.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { check, done } = require('./helpers/boot');++const { PROPOSED } = require('../server/auth/capabilities');++++const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');++++(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 blog', 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, 'blog');++ 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 === 'blog', `${c.id} collides with a released capability`);++ }++ });++++ await check('the proposals are exactly the capabilities the code enforces', async () => {++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());++ });++++ await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {++ const v = contracts.validate('registry.service-manifest@1', manifest);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(manifest.id, 'blog');++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);++ });++++ await check('every event type the code emits is declared', async () => {++ const src = ['server/domain/posts.js', 'server/domain/publication.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');++ for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * Boots Blog 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, ... })++ * t.clock.advance(ms) // the app's clock (scheduler, revisions, gate)++ * t.restart() // a new app on the SAME database file (a worker restart)++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { startNetwork, startCommunity, startMedia } = require('./mocks');++++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {++ let t = start;++ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };++}++++async function boot(opts = {}) {++ const network = await startNetwork();++ const community = await startCommunity({ network });++ const media = await startMedia({ network });++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blog-test-'));++ const dbPath = path.join(dir, 'blog.db');++ const clock = opts.clock || makeClock();++ const official = network.addUser('lead', { display_name: 'The Lead' });++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.blog', TRUST_PROXY: '1',++ BLOG_DB_PATH: dbPath,++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'blog', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',++ OV_COMMUNITY_URL: 'https://openvibe.community', OV_COMMUNITY_INTERNAL_URL: community.url,++ OV_MEDIA_URL: 'https://openvibe.media', OV_MEDIA_INTERNAL_URL: media.url,++ BLOG_OFFICIAL_OWNERS: official.subject, BLOG_WORKER: 'off', BLOG_FORM_SECRET: 'test-form-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, entitlementCheck: opts.entitlementCheck });++ 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); } };++ }++++ /** Rows of event_outbox as parsed envelopes. */++ function events(type = null) {++ return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));++ }++++ const t = {++ network, community, media, clock, dbPath, official, get, events,++ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),++ async restart() { await stop(); await start(); },++ async close() { await stop(); await network.close(); await community.close(); await media.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, 6).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, makeClock };nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++/**++ * In-process stand-ins for Blog's neighbours, with a real RS256 key pair:++ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as++ * capabilities), /internal/identity/resolve-batch (needs identity.subject.resolve)++ * Community /api/v1/comments/threads/resolve, GET thread, POST comment, PUT visibility++ * Media /api/v2/<app>/objects/:id → 200 | 404 | 410 by a table the test controls++ * userToken()/serviceToken() mint the tokens browsers and services present to Blog.++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ handler(req, raw, json, res);++ });++ });++ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));++ });++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const directory = new Map(); // subject → { username, display_name }++ const grants = [];++ let issuer = null;++ const srv = await listen((req, raw, json) => {++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/oauth/token' && req.method === 'POST') {++ let body = {};++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));++ else { try { body = JSON.parse(raw); } catch { /* */ } }++ grants.push(body);++ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });++ if (body.grant_type === 'client_credentials') {++ let scope = body.scope;++ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');++ const cap = String(scope || '').split(/\s+/).filter(Boolean);++ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const token = String(req.headers.authorization || '').slice(7);++ const v = serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok || !(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied' });++ const b = JSON.parse(raw || '{}');++ const results = {};++ for (const s of b.subject_ids || []) {++ const u = directory.get(s);++ results[s] = u ? { subject: { type: 'user', id: s }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;++ }++ return json(200, { results });++ }++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++ function signService({ sub, aud, cap, ns }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);++ }++ function addUser(username, extra = {}) {++ const subject = ids.newId('user');++ const u = { subject, username, display_name: extra.display_name || username[0].toUpperCase() + username.slice(1), role: extra.role || 'user' };++ directory.set(subject, u);++ return u;++ }++ function userToken(u) {++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ }++ function serviceToken(client, cap) {++ return signService({ sub: `svc:${client}`, aud: ['openvibe.blog'], cap });++ }++ return { ...srv, publicPem, grants, directory, addUser, userToken, serviceToken, signService };++}++++async function startCommunity({ network }) {++ const threads = new Map(); // id → { ref, visibility, comments: [] }++ const byRef = new Map();++ const calls = [];++ let down = false;++ let seq = 0;++ const srv = await listen((req, raw, json) => {++ calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, auth: Boolean(req.headers.authorization) });++ if (down) return json(503, { code: 'down' });++ const auth = () => {++ const token = String(req.headers.authorization || '').slice(7);++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.community' });++ return v.ok ? v.claims : null;++ };++ if (req.url === '/api/v1/comments/threads/resolve' && req.method === 'POST') {++ const c = auth();++ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });++ const { ref } = JSON.parse(raw);++ const key = `${ref.service}:${ref.type}:${ref.id}`;++ if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });++ const id = ++seq;++ threads.set(id, { ref, visibility: 'public', comments: [] });++ byRef.set(key, id);++ return json(201, { thread: { id, visibility: 'public' }, created: true });++ }++ let m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)(\?.*)?$/);++ if (m && req.method === 'GET') {++ const t = threads.get(Number(m[1]));++ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });++ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility, comment_count: t.comments.length }, comments: t.comments, next_cursor: null });++ }++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/comments$/);++ if (m && req.method === 'POST') {++ const c = auth();++ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });++ const t = threads.get(Number(m[1]));++ const body = JSON.parse(raw);++ const who = network.directory.get(req.headers['x-ov-subject']);++ const comment = { id: t.comments.length + 1, origin: 'user', display_name: who ? who.display_name : 'Someone', message: body.message, deleted: false, created_at: new Date().toISOString() };++ t.comments.push(comment);++ return json(201, { comment });++ }++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);++ if (m && req.method === 'PUT') {++ const c = auth();++ if (!c || !(c.cap || []).includes('community.comment.moderate')) return json(403, { code: 'capability.denied' });++ threads.get(Number(m[1])).visibility = JSON.parse(raw).visibility;++ return json(200, { ok: true });++ }++ return json(404, { code: 'route.not_found' });++ });++ return { ...srv, threads, calls, setDown: (v) => { down = v; } };++}++++async function startMedia({ network }) {++ const objects = new Map(); // id → 'ready' | 'deleted' | 'gone' (404)++ let down = false;++ const srv = await listen((req, raw, json) => {++ if (down) return json(503, { code: 'down' });++ const m = req.url.match(/^\/api\/v2\/([a-z0-9-]+)\/objects\/(med_[0-9A-Z]+)$/);++ if (!m) return json(404, { code: 'route.not_found' });++ const token = String(req.headers.authorization || '').slice(7);++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media' });++ if (!v.ok || !(v.claims.cap || []).includes('media.object.read')) return json(403, { code: 'capability.denied' });++ const st = objects.get(m[2]);++ if (!st || st === 'gone') return json(404, { code: 'media.object.not_found' });++ if (st === 'deleted') return json(410, { code: 'media.object.deleted' });++ return json(200, { id: m[2], lifecycle_status: 'ready' });++ });++ return { ...srv, objects, setDown: (v) => { down = v; } };++}++++module.exports = { startNetwork, startCommunity, startMedia, listen };nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++/**++ * Draft → edit → publish → revise → diff → revert → unpublish → delete, through the API with a++ * member's Network JWT, and what readers, feeds, sitemaps, Search and subscribers see at each step.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { CHARTER_TABLES } = require('../server/db');++++const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });++ let post;++++ await check('the ten charter tables exist (package tables and views included)', async () => {++ const names = new Set(t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));++ for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);++ for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);++ });++++ await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {++ const blog = t.ctx.blogs.official();++ assert.strictEqual(blog.handle, 'openvibe');++ assert.strictEqual(t.ctx.blogs.membership(blog, t.official.subject).role, 'owner');++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /The OpenVibe blog/);++ });++++ await check('a member gets a blog on first use, owned by their Network subject', async () => {++ const r = await t.get('/api/v1/blogs', { as: alice, json: {} });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json().blog;++ assert.strictEqual(b.handle, 'alice');++ assert.strictEqual(b.owner_subject, alice.subject);++ const again = await t.get('/api/v1/blogs', { as: alice, json: {} });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().created, false);++ });++++ await check('create a draft: revision 1, blog.post.created, nothing public, nothing sent to Search', async () => {++ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'First light', body: `Hello **world**.\n\n${LONG}`, tags: 'Astronomy, night sky', categories: 'Science > Space' } });++ assert.strictEqual(r.status, 201, r.text);++ post = r.json().post;++ assert.strictEqual(post.state, 'draft');++ assert.strictEqual(post.revision, 1);++ assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);++ assert.strictEqual(t.events('blog.post.created').length, 1);++ assert.strictEqual(t.events(/index_document/).length, 0);++ assert.strictEqual((await t.get('/@alice/first-light')).status, 404);++ assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);++ });++++ await check('edit: a new immutable revision; a stale expected_revision is a 412 and loses nothing', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 1, body: `Hello **stars**.\n\n${LONG}` } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().revision, 2);++ const stale = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 1, body: 'lost update' } });++ assert.strictEqual(stale.status, 412);++ assert.strictEqual(stale.json().code, 'revision.conflict');++ const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });++ assert.strictEqual(missing.status, 428);++ assert.throws(() => t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);++ });++++ await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().post.published_revision, 2);++ const page = await t.get('/@alice/first-light');++ assert.strictEqual(page.status, 200);++ const beforeScripts = page.text.split('<script src=')[0];++ assert.match(page.text, /<h1>First light<\/h1>/);++ assert.match(page.text, /Hello <strong>stars<\/strong>/);++ assert.match(beforeScripts, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@alice\/first-light">/);++ assert.match(page.text, /<meta name="robots" content="index, follow">/);++ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const posting = ld.find((x) => x['@type'] === 'BlogPosting');++ assert.ok(posting, 'BlogPosting JSON-LD');++ assert.strictEqual(posting.headline, 'First light');++ assert.strictEqual(posting.url, 'https://openvibe.blog/@alice/first-light');++ assert.strictEqual(posting.author[0].name, 'Alice Writer');++ assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());++ assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');++ assert.strictEqual(posting.aggregateRating, undefined);++ assert.strictEqual(t.events('blog.post.published').length, 1);++ const idx = t.events('blog.index_document.upserted');++ assert.strictEqual(idx.length, 1);++ assert.strictEqual(idx[0].payload.visibility, 'public');++ assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');++ assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);++ for (const e of t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);++ });++++ await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {++ const rss = await t.get('/@alice/feed.xml');++ assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe\.blog,2026:post\/pst_/);++ assert.match((await t.get('/@alice/atom.xml')).text, /<link rel="alternate" type="text\/html" href="https:\/\/openvibe\.blog\/@alice\/first-light"\/>/);++ const jf = (await t.get('/@alice/feed.json')).json();++ assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@alice/first-light');++ assert.match((await t.get('/sitemaps/posts.xml')).text, /@alice\/first-light/);++ assert.match((await t.get('/sitemaps/blogs.xml')).text, /https:\/\/openvibe\.blog\/@alice</);++ assert.match((await t.get('/@alice/tags/astronomy')).text, /First light/);++ assert.match((await t.get('/tags/astronomy')).text, /First light/);++ assert.match((await t.get('/@alice/categories/science')).text, /First light/, 'parent category includes children');++ assert.match((await t.get('/@alice/authors/alice')).text, /First light/);++ assert.match((await t.get('/@alice/first-light.json')).text, /"body_markdown"/);++ });++++ await check('a revision after publication does not change what readers see until it is published', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });++ assert.strictEqual(r.json().revision, 3);++ assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);++ assert.strictEqual(t.events('blog.post.updated').length, 0);++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });++ assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);++ assert.strictEqual(t.events('blog.post.updated').length, 1);++ assert.strictEqual(t.events('blog.index_document.upserted').length, 2);++ });++++ await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {++ const d = (await t.get(`/api/v1/posts/${post.id}/diff?from=1&to=3&mode=word`, { as: alice })).json().diff;++ assert.ok(d.fields.some((f) => f.field === 'title'));++ const rv = await t.get(`/api/v1/posts/${post.id}/revert`, { as: alice, json: { to_revision: 1, expected_revision: 3 } });++ assert.strictEqual(rv.status, 201, rv.text);++ assert.strictEqual(rv.json().revision, 4);++ const revs = (await t.get(`/api/v1/posts/${post.id}/revisions`, { as: alice })).json().revisions;++ assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);++ assert.strictEqual(revs[0].kind, 'revert');++ assert.strictEqual(revs[0].revertedTo, 1);++ assert.strictEqual(t.ctx.store.revisions.get(post.id, 4).content, t.ctx.store.revisions.get(post.id, 1).content);++ });++++ await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/unpublish`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual((await t.get('/@alice/first-light')).status, 404);++ assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);++ assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);++ const del = t.events('blog.index_document.deleted');++ assert.strictEqual(del.length, 1);++ assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);++ assert.strictEqual(t.events('blog.post.unpublished').length, 1);++ });++++ await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 4 } });++ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(r.status, 200, r.text);++ const page = await t.get('/@alice/first-light');++ assert.strictEqual(page.status, 410);++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(t.events('blog.post.deleted').length, 1);++ assert.strictEqual(t.ctx.store.revisions.list(post.id).length, 4);++ const last = t.events(/index_document/).pop();++ assert.strictEqual(last.event_type, 'blog.index_document.deleted');++ });++++ await check('a stranger cannot write on someone else’s blog', async () => {++ const bob = t.network.addUser('bob');++ const r = await t.get('/api/v1/blogs/alice/posts', { as: bob, json: { title: 'Intrusion', body: 'x' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++/**++ * The whole write → publish → comment journey with plain HTML forms (what a browser without++ * JavaScript does), Network SSO cookies and form tokens; comments are Community threads that are++ * referenced, never copied.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/) || [])[1];++++(async () => {++ const t = await boot();++ const eve = t.network.addUser('eve', { display_name: 'Eve' });++ const csrf = t.csrf(eve);++ let postId;++++ await check('the editor sends a signed-out browser to Network sign-in', async () => {++ const r = await t.get('/write');++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), '/auth/login?next=%2Fwrite');++ const login = await t.get('/auth/login?next=/write');++ assert.strictEqual(login.status, 302);++ assert.match(login.headers.get('location'), /\/oauth\/authorize\?.*client_id=blog/);++ });++++ await check('first use: the start form creates the member blog', async () => {++ const home = await t.get('/write', { as: eve });++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /<form method="post" action="\/write\/start">/);++ assert.strictEqual(home.headers.get('cache-control'), 'private, no-store');++ assert.match(home.headers.get('x-robots-tag'), /noindex/);++ const r = await t.get('/write/start', { as: eve, form: { _csrf: csrf, handle: 'eve', title: 'Eve’s notes' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location').split('?')[0], '/write/@eve');++ });++++ await check('a form without the token is refused', async () => {++ const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n, 0);++ });++++ await check('new draft, edit and publish with forms', async () => {++ const form = await t.get('/write/@eve/new', { as: eve });++ assert.match(form.text, /<textarea id="body" name="body"/);++ let r = await t.get('/write/@eve/new', { as: eve, form: { _csrf: csrf, title: 'Notes without script', body: `# Heading\n\nPlain *text*. ${LONG}`, tags: 'nojs', series: 'Field notes', seriesPosition: '1', visibility: 'public', allowComments: '1' } });++ assert.strictEqual(r.status, 303, r.text);++ postId = idFrom(r.headers.get('location'));++ assert.ok(postId);++ const edit = await t.get(`/write/posts/${postId}`, { as: eve });++ assert.match(edit.text, /name="expectedRevision" value="1"/);++ assert.match(edit.text, /Search engines: <strong>noindex, nofollow<\/strong>|Search engines: indexable/);++ r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Notes without script', body: `# Heading\n\nPlain *text*, edited. ${LONG}`, tags: 'nojs', series: 'Field notes', seriesPosition: '1', visibility: 'public', allowComments: '1' } });++ assert.strictEqual(r.status, 303, r.text);++ assert.match(decodeURIComponent(r.headers.get('location')), /revision 2/);++ r = await t.get(`/write/posts/${postId}/publish`, { as: eve, form: { _csrf: csrf, revision: '2' } });++ assert.strictEqual(r.status, 303);++ const page = await t.get('/@eve/notes-without-script');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Plain <em>text<\/em>, edited\./);++ assert.match(page.text, /Part 1 of the series <a href="\/@eve\/series\/field-notes">Field notes<\/a>/);++ assert.match((await t.get('/@eve/series/field-notes')).text, /Notes without script/);++ });++++ await check('a stale form (someone saved first) is a clear refusal, not a lost edit', async () => {++ const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });++ assert.strictEqual(r.status, 412);++ assert.match(r.text, /Someone saved revision 2/);++ assert.strictEqual(t.ctx.store.revisions.head(postId).fields.title, 'Notes without script');++ });++++ await check('history, diff and preview are plain pages', async () => {++ const h = await t.get(`/write/posts/${postId}/revisions?from=1&to=2`, { as: eve });++ assert.match(h.text, /<ins>/);++ const p = await t.get(`/write/posts/${postId}/preview`, { as: eve });++ assert.strictEqual(p.status, 200);++ assert.strictEqual(p.headers.get('cache-control'), 'private, no-store');++ assert.match(p.text, /<meta name="robots" content="noindex, nofollow">/);++ });++++ await check('the post page shows the Community thread and takes a comment by form', async () => {++ const page = await t.get('/@eve/notes-without-script', { as: eve });++ assert.match(page.text, /No comments yet\./);++ assert.match(page.text, /<form method="post" action="\/@eve\/notes-without-script\/comments"/);++ const r = await t.get('/@eve/notes-without-script/comments', { as: eve, form: { _csrf: csrf, message: 'First!' } });++ assert.strictEqual(r.status, 303, r.text);++ const again = await t.get('/@eve/notes-without-script');++ assert.match(again.text, /First!/);++ const thread = [...t.community.threads.values()][0];++ assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });++ const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));++ assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');++ const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('blog_post_discussion_refs')").all().map((c) => c.name);++ assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');++ });++++ await check('Community down: the page still serves and says comments are unavailable', async () => {++ t.community.setDown(true);++ const page = await t.get('/@eve/notes-without-script');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Comments could not be loaded/);++ t.community.setDown(false);++ });++++ await check('a post that stops being public: its thread is hidden, no thread for non-public posts', async () => {++ await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, visibility: 'private' } });++ await new Promise((r) => setTimeout(r, 100));++ assert.strictEqual([...t.community.threads.values()][0].visibility, 'hidden');++ const page = await t.get('/@eve/notes-without-script', { as: eve });++ assert.match(page.text, /Comments are available on public posts only\./);++ });++++ await check('delete needs the confirmation box, then answers 410', async () => {++ let r = await t.get(`/write/posts/${postId}/delete`, { as: eve, form: { _csrf: csrf } });++ assert.strictEqual(r.status, 422);++ r = await t.get(`/write/posts/${postId}/delete`, { as: eve, form: { _csrf: csrf, confirm: '1' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual((await t.get('/@eve/notes-without-script')).status, 410);++ });++++ await check('settings: theme preset, feed switches and members', async () => {++ const mallory = t.network.addUser('mallory');++ await t.get('/write', { as: mallory }); // signs in once: now known by username++ let r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });++ assert.strictEqual(r.status, 303, r.text);++ const blog = t.ctx.blogs.byHandle('eve');++ assert.strictEqual(blog.theme, 'paper');++ assert.strictEqual(t.ctx.blogs.feedSettings(blog).json, 0);++ assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);++ assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);++ r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(t.ctx.blogs.membership(blog, mallory.subject).role, 'author');++ const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });++ assert.strictEqual(bad.status, 422);++ const notOwner = await t.get('/write/@eve/settings', { as: mallory });++ assert.strictEqual(notOwner.status, 404);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * Private, members-only (VIP) and unlisted posts never leak through cache headers, Search,++ * feeds or sitemaps — and a visibility change converges everywhere.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++function assertPrivate(r, what) {++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', `${what}: Cache-Control`);++ assert.match(r.headers.get('vary') || '', /Cookie/i, `${what}: Vary Cookie`);++ assert.match(r.headers.get('vary') || '', /Authorization/i, `${what}: Vary Authorization`);++}++++(async () => {++ const vipMember = { subject: null };++ const t = await boot({ entitlementCheck: async ({ subject, key }) => subject === vipMember.subject && key === 'vip:carol' });++ const carol = t.network.addUser('carol');++ const reader = t.network.addUser('reader');++ const vip = t.network.addUser('vipfan');++ vipMember.subject = vip.subject;++ await t.get('/api/v1/blogs', { as: carol, json: {} });++++ const make = async (title, visibility, extra = {}) => {++ const p = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${title} SECRET-${visibility} ${LONG}`, visibility, ...extra } })).json().post;++ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });++ return p;++ };++ const pub = await make('Open post', 'public');++ const mem = await make('Members post', 'members', { entitlement_key: 'vip:carol' });++ const priv = await make('Private post', 'private');++ const unl = await make('Unlisted post', 'unlisted');++++ await check('a public post for an anonymous reader is shared-cacheable and varies on Cookie/Authorization', async () => {++ const r = await t.get('/@carol/open-post');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control'), /^public, max-age=60/);++ assert.match(r.headers.get('vary'), /Cookie/);++ assert.match(r.headers.get('vary'), /Authorization/);++ });++++ await check('the same public post for a signed-in reader is private, no-store', async () => {++ assertPrivate(await t.get('/@carol/open-post', { as: reader }), 'signed-in view');++ });++++ await check('members-only: anonymous → 403 without the text; non-entitled member of the network → 403; no-store', async () => {++ const a = await t.get('/@carol/members-post');++ assert.strictEqual(a.status, 403);++ assert.doesNotMatch(a.text, /SECRET-members/);++ assert.doesNotMatch(a.text, /Members post/, 'not even the title');++ assertPrivate(a, 'members 403');++ assert.match(a.headers.get('x-robots-tag'), /noindex/);++ const r = await t.get('/@carol/members-post', { as: reader });++ assert.strictEqual(r.status, 403);++ const j = await t.get('/@carol/members-post.json');++ assert.strictEqual(j.status, 403);++ assert.doesNotMatch(j.text, /SECRET/);++ });++++ await check('members-only: the entitled viewer (entitlement seam) and the blog owner can read it — privately', async () => {++ const v = await t.get('/@carol/members-post', { as: vip });++ assert.strictEqual(v.status, 200);++ assert.match(v.text, /SECRET-members/);++ assertPrivate(v, 'entitled view');++ assert.match(v.text, /<meta name="robots" content="noindex, nofollow">/);++ const o = await t.get('/@carol/members-post', { as: carol });++ assert.strictEqual(o.status, 200);++ assertPrivate(o, 'owner view');++ });++++ await check('private: 404 to everyone but the blog’s members; unlisted: readable by link but never cached publicly', async () => {++ assert.strictEqual((await t.get('/@carol/private-post')).status, 404);++ assert.strictEqual((await t.get('/@carol/private-post', { as: vip })).status, 404);++ assert.strictEqual((await t.get('/@carol/private-post', { as: carol })).status, 200);++ const u = await t.get('/@carol/unlisted-post');++ assert.strictEqual(u.status, 200);++ assertPrivate(u, 'unlisted');++ assert.match(u.text, /<meta name="robots" content="noindex, nofollow">/);++ });++++ await check('feeds, sitemaps, blog index and tag pages carry only the public post', async () => {++ for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/sitemap.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed']) {++ const r = await t.get(p);++ assert.doesNotMatch(r.text, /SECRET-(members|private|unlisted)|members-post|private-post|unlisted-post/, p);++ }++ assert.match((await t.get('/@carol/feed.xml')).text, /open-post/);++ // Even an entitled or owning reader gets the same public-only feed (feeds never vary by viewer).++ assert.doesNotMatch((await t.get('/@carol/feed.xml', { as: carol })).text, /members-post/);++ });++++ await check('Search receives only the public post; the others were never sent', async () => {++ const ids = t.events('blog.index_document.upserted').map((e) => e.payload.id);++ assert.deepStrictEqual(ids, [pub.id]);++ const published = t.events('blog.post.published');++ assert.strictEqual(published.length, 4);++ for (const e of published) {++ if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');++ else assert.strictEqual(e.visibility, 'internal', 'non-public publication events are internal');++ assert.strictEqual(JSON.stringify(e.payload).includes('SECRET'), false, 'no body in product events');++ }++ });++++ await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {++ const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });++ assert.strictEqual(r.status, 200, r.text);++ const del = t.events('blog.index_document.deleted');++ assert.strictEqual(del.length, 1);++ assert.strictEqual(del[0].payload.id, pub.id);++ const up = t.events('blog.index_document.upserted').find((e) => e.payload.id === pub.id);++ assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');++ assert.strictEqual(t.events('blog.post.updated').length, 1);++ const a = await t.get('/@carol/open-post');++ assert.strictEqual(a.status, 403);++ assertPrivate(a, 'after privatising');++ assert.doesNotMatch((await t.get('/@carol/feed.xml')).text, /open-post/);++ assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /open-post/);++ });++++ await check('members → public again: re-indexed with a higher revision', async () => {++ await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });++ const ups = t.events('blog.index_document.upserted').filter((e) => e.payload.id === pub.id);++ const del = t.events('blog.index_document.deleted')[0];++ assert.strictEqual(ups.length, 2);++ assert.ok(ups[1].payload.revision > del.payload.revision);++ });++++ await check('without an entitlement service the check fails closed', async () => {++ const { createEntitlementChecker } = require('../server/domain/access');++ const none = createEntitlementChecker({ provider: 'none' });++ assert.strictEqual(await none.has(vip.subject, 'vip:carol'), false);++ const unknown = createEntitlementChecker({ provider: 'vip-service-that-does-not-exist' });++ assert.strictEqual(await unknown.has(vip.subject, 'vip:carol'), false);++ const throwing = createEntitlementChecker({ check: async () => { throw new Error('down'); } });++ assert.strictEqual(await throwing.has(vip.subject, 'vip:carol'), false);++ });++++ await check('API reads of non-public posts are private and refused to outsiders', async () => {++ const r = await t.get(`/api/v1/posts/${priv.id}`, { as: reader });++ assert.strictEqual(r.status, 404);++ assertPrivate(r, 'api');++ const m = await t.get(`/api/v1/posts/${mem.id}`);++ assert.strictEqual(m.status, 403);++ assert.doesNotMatch(m.text, /SECRET/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,44 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite++ * databases and in-process mocks of OpenVibe.Network, Community, Media and Events; none needs the++ * network or a running site.++ *++ * npm test # everything++ * npm test -- schedule # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);++ if (process.env.VERBOSE) console.log(r.output);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * Scheduled publication is idempotent across worker restarts: a worker that applies the publish++ * and dies before recording the job as done leaves the lease to expire; after a restart on the same++ * database the job re-runs, changes nothing and emits nothing twice. Scheduling twice is one job.++ * A job that cannot succeed ends in blog.schedule.failed and the post returns to draft.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++const MIN = 60 * 1000;++++(async () => {++ const t = await boot();++ const ann = t.network.addUser('ann');++ await t.get('/api/v1/blogs', { as: ann, json: {} });++ const mk = async (title) => (await t.get('/api/v1/blogs/ann/posts', { as: ann, json: { title, body: LONG } })).json().post;++ const at = () => new Date(t.clock.now() + 10 * MIN).toISOString();++++ const p1 = await mk('Scheduled one');++++ await check('scheduling is idempotent: the same post, time and revision is one job', async () => {++ const when = at();++ const a = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });++ assert.strictEqual(a.status, 201, a.text);++ const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });++ assert.strictEqual(b.json().created, false);++ assert.strictEqual(b.json().job.id, a.json().job.id);++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id).n, 1, 'charter view blog_schedules');++ assert.strictEqual(b.json().post.state, 'scheduled');++ assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');++ assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);++ });++++ await check('nothing runs before its time', async () => {++ const s = await t.ctx.worker.scheduleTick();++ assert.strictEqual(s.done.length, 0);++ });++++ await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {++ t.clock.advance(11 * MIN);++ // Worker A: claim the job and apply its effect, then "crash" (no complete()).++ const [job] = t.ctx.store.scheduler.claim({ worker: 'worker-A' });++ assert.ok(job, 'claimed');++ t.ctx.posts.applyPublish(job.entityId, job.revision, null);++ assert.strictEqual(t.events('blog.post.published').length, 1);++ const idxBefore = t.events('blog.index_document.upserted').length;++ assert.strictEqual(idxBefore, 1);++++ await t.restart(); // same database file, new process state++ // The lease (60 s) has not expired yet: nobody may take it.++ let s = await t.ctx.worker.scheduleTick();++ assert.strictEqual(s.done.length, 0);++ t.clock.advance(2 * MIN);++ s = await t.ctx.worker.scheduleTick();++ assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');++ assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');++ assert.strictEqual(t.events('blog.post.published').length, 1, 'exactly one published event');++ assert.strictEqual(t.events('blog.index_document.upserted').length, 1, 'exactly one Search upsert');++ const page = await t.get('/@ann/scheduled-one');++ assert.strictEqual(page.status, 200);++ s = await t.ctx.worker.scheduleTick();++ assert.strictEqual(s.done.length + s.failed.length, 0, 'done jobs never run again');++ });++++ await check('a normal scheduled publish through the worker', async () => {++ const p2 = await mk('Scheduled two');++ await t.get(`/api/v1/posts/${p2.id}/schedule`, { as: ann, json: { at: at() } });++ t.clock.advance(11 * MIN);++ const s = await t.ctx.worker.scheduleTick();++ assert.strictEqual(s.done.length, 1);++ assert.strictEqual(t.ctx.posts.get(p2.id).state, 'published');++ assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);++ });++++ await check('publishing by hand cancels the pending schedule', async () => {++ const p3 = await mk('Scheduled three');++ await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });++ await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });++ const jobs = t.ctx.store.scheduler.jobs(p3.id);++ assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);++ });++++ await check('a job that cannot succeed fails after its retries: blog.schedule.failed, back to draft', async () => {++ const p4 = await mk('Doomed');++ await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });++ // The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.++ t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });++ t.clock.advance(11 * MIN);++ for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }++ const failed = t.events('blog.schedule.failed');++ assert.strictEqual(failed.length, 1, JSON.stringify(t.ctx.store.scheduler.jobs(p4.id)));++ assert.strictEqual(failed[0].subject.id, p4.id);++ assert.match(failed[0].payload.error, /revision/i);++ assert.strictEqual(t.ctx.posts.get(p4.id).state, 'draft');++ const ready = (await t.get('/api/ready')).json();++ assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');++ });++++ await check('a time in the past is refused', async () => {++ const p5 = await mk('Too late');++ const r = await t.get(`/api/v1/posts/${p5.id}/schedule`, { as: ann, json: { at: new Date(t.clock.now() - MIN).toISOString() } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'schedule.in_past');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * The seed post (verbatim quotes with citations, a draft until a named person reviews it) and the++ * operator surfaces: health, readiness, release, robots, llms.txt, sitemap index, legal pages.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');++++(async () => {++ const t = await boot();++ const facts = loadFacts();++++ await check('the facts file: every item is an OpenVibers GitHub source with a verbatim quote; no pricing copy', async () => {++ assert.ok(facts.items.length >= 5);++ const raw = fs.readFileSync(path.join(__dirname, '..', 'seed', 'week-2026-09-16.json'), 'utf8');++ assert.doesNotMatch(raw, /\bfree\b|\$0/i);++ for (const it of facts.items) assert.match(it.url, /^https:\/\/github\.com\/OpenVibers\//);++ });++++ await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {++ const out = seedOfficialPost(t.ctx, {});++ assert.strictEqual(out.created, true);++ assert.strictEqual(out.post.state, 'draft');++ assert.strictEqual(out.post.author_subject, t.official.subject);++ const rev = t.ctx.store.revisions.head(out.post.id);++ for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);++ const cites = t.ctx.store.citations.forRevision(out.post.id, rev.number);++ assert.strictEqual(cites.length, facts.items.length);++ assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));++ assert.strictEqual(rev.meta.authorship.mode, 'imported');++ assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);++ assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');++ assert.strictEqual(t.events(/index_document/).length, 0);++ assert.strictEqual(seedOfficialPost(t.ctx, {}).created, false, 'idempotent');++ });++++ await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {++ assert.throws(() => seedOfficialPost(t.ctx, { publish: true }), /reviewer/);++ const reviewer = t.network.addUser('reviewer');++ const out = seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });++ assert.strictEqual(out.published, true);++ assert.strictEqual(out.review.reviewer, reviewer.subject);++ const page = await t.get(`/@openvibe/${SLUG}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h2>Sources<\/h2>/);++ assert.match(page.text, /Imported/);++ assert.match((await t.get('/')).text, /Released in the OpenVibe repositories/);++ assert.match((await t.get('/feed.xml')).text, /Released in the OpenVibe repositories/);++ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'BlogPosting');++ assert.strictEqual(ld.publisher.name, 'OpenVibe');++ assert.strictEqual(ld.citation.length, facts.items.length);++ });++++ await check('health, readiness, release manifest', async () => {++ assert.strictEqual((await t.get('/api/health')).json().status, 'ok');++ const ready = await t.get('/api/ready');++ assert.strictEqual(ready.status, 200);++ const body = ready.json();++ assert.strictEqual(body.checks.db.status, 'ok');++ assert.strictEqual(body.checks.network_jwks.status, 'ok');++ assert.ok(body.degraded.includes('events_relay'), 'the relay is honestly reported off in tests');++ const rel = await t.get('/release.json');++ assert.strictEqual(rel.status, 200);++ assert.strictEqual(rel.json().service, 'blog');++ });++++ await check('robots.txt names the sitemap and the automated-consumer policy; llms.txt orients machines', async () => {++ const robots = (await t.get('/robots.txt')).text;++ assert.match(robots, /Sitemap: https:\/\/openvibe\.blog\/sitemap\.xml/);++ assert.match(robots, /Disallow: \/write/);++ assert.match(robots, /automated-consumer policy/);++ const llms = (await t.get('/llms.txt')).text;++ assert.match(llms, /^# OpenVibe\.Blog/);++ assert.match(llms, /\.json/);++ const idx = (await t.get('/sitemap.xml')).text;++ assert.match(idx, /<sitemapindex/);++ assert.match(idx, /sitemaps\/posts\.xml/);++ });++++ await check('legal pages and 404s', async () => {++ assert.strictEqual((await t.get('/terms')).status, 200);++ const nf = await t.get('/@nobody-here');++ assert.strictEqual(nf.status, 404);++ assert.strictEqual(nf.headers.get('cache-control'), 'private, no-store');++ });++++ await check('/metrics answers loopback callers only through the shared instrument', async () => {++ const r = await t.get('/metrics');++ assert.ok([200, 404].includes(r.status));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * Canonical URLs and feed identities stay stable across slug changes (old paths 301, chains++ * collapse), and a deleted Media object turns into an explicit broken-asset state — never a++ * silently missing image, and never because Media was merely down.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++const MED_A = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W3';++const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';++++(async () => {++ const t = await boot();++ const dan = t.network.addUser('dan');++ await t.get('/api/v1/blogs', { as: dan, json: {} });++ const post = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Old name', body: `Intro\n\n[[media:${MED_A}]]\n\n${LONG}` } })).json().post;++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: dan, json: {} });++ const feedIdBefore = (await t.get('/@dan/feed.json')).json().items[0].id;++++ await check('slug change: the old URL answers 301 to the new canonical one', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'new-name' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().post.url, 'https://openvibe.blog/@dan/new-name');++ const old = await t.get('/@dan/old-name');++ assert.strictEqual(old.status, 301);++ assert.strictEqual(old.headers.get('location'), '/@dan/new-name');++ assert.strictEqual((await t.get('/@dan/old-name.json')).headers.get('location'), '/@dan/new-name.json');++ const page = await t.get('/@dan/new-name');++ assert.match(page.text, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@dan\/new-name">/);++ assert.match(page.text, /"url":"https:\/\/openvibe\.blog\/@dan\/new-name"/);++ });++++ await check('the feed item id is stable and the feed links the new canonical URL', async () => {++ const jf = (await t.get('/@dan/feed.json')).json();++ assert.strictEqual(jf.items[0].id, feedIdBefore);++ assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');++ assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));++ assert.strictEqual(t.events('blog.post.updated').length, 1, 'a canonical change of a published post is an update');++ const docs = t.events('blog.index_document.upserted');++ assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');++ });++++ await check('a second rename collapses the chain: every old path goes straight to the newest', async () => {++ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });++ assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');++ assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');++ assert.deepStrictEqual(t.ctx.store.redirects.history(post.id).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);++ });++++ await check('a new post may take a freed slug; the redirect then yields to it', async () => {++ const p2 = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Old name', body: LONG } })).json().post;++ assert.strictEqual(p2.slug, 'old-name');++ await t.get(`/api/v1/posts/${p2.id}/publish`, { as: dan, json: {} });++ assert.strictEqual((await t.get('/@dan/old-name')).status, 200);++ });++++ await check('a redirect never reveals the new slug of a post the reader may not see', async () => {++ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { visibility: 'private' } });++ const r = await t.get('/@dan/new-name');++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.headers.get('location'), null);++ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { visibility: 'public' } });++ });++++ await check('attached media renders as a figure while Media has it', async () => {++ const a = await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: MED_A, alt: 'A comet' } });++ assert.strictEqual(a.status, 201, a.text);++ await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: MED_B, role: 'cover', alt: 'Cover' } });++ const bad = await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: 'https://evil.example/x.png' } });++ assert.strictEqual(bad.status, 422);++ t.media.objects.set(MED_A, 'ready');++ t.media.objects.set(MED_B, 'ready');++ await t.ctx.worker.verifyMedia();++ const page = await t.get('/@dan/newest-name');++ assert.match(page.text, new RegExp(`<img src="https://openvibe.media/o/${MED_A}" alt="A comet"`));++ assert.match(page.text, /data-state="available"/);++ });++++ await check('Media down: nothing changes (check_failed), the image is not declared missing', async () => {++ t.media.setDown(true);++ const out = await t.ctx.worker.verifyMedia();++ assert.ok(out[0].results.every((r) => r.outcome === 'check_failed'));++ t.media.setDown(false);++ assert.doesNotMatch((await t.get('/@dan/newest-name')).text, /no longer available/);++ });++++ await check('Media deleted the object: an explicit broken-asset state in the page, JSON, feed and editor', async () => {++ t.media.objects.set(MED_A, 'deleted');++ t.media.objects.set(MED_B, 'gone');++ const out = await t.ctx.worker.verifyMedia();++ assert.deepStrictEqual(out[0].results.map((r) => `${r.outcome}:${r.reason}`).sort(), ['broken:deleted', 'broken:not_found']);++ const page = await t.get('/@dan/newest-name');++ assert.match(page.text, new RegExp(`data-media-id="${MED_A}" data-state="broken"`));++ assert.match(page.text, /This media is no longer available\./);++ assert.doesNotMatch(page.text, new RegExp(`<img src="https://openvibe.media/o/${MED_A}"`));++ assert.doesNotMatch(page.text, /og:image/, 'a broken cover is not advertised');++ const json = (await t.get('/@dan/newest-name.json')).json();++ const m = json.media.find((x) => x.media_id === MED_A);++ assert.strictEqual(m.state, 'broken');++ assert.strictEqual(m.broken_reason, 'deleted');++ assert.strictEqual(m.url, null);++ assert.match((await t.get('/@dan/feed.json')).text, /no longer available/);++ const editor = await t.get(`/write/posts/${post.id}`, { as: dan });++ assert.match(editor.text, /unavailable \(deleted\)/);++ });++++ await check('an id in the text that is not attached says so', async () => {++ const p = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Dangling', body: `[[media:${MED_B}]]\n\n${LONG}` } })).json().post;++ await t.get(`/api/v1/posts/${p.id}/publish`, { as: dan, json: {} });++ assert.match((await t.get('/@dan/dangling')).text, /data-state="not_attached"/);++ });++++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,1562 @@
@@ -0,0 +1,1562 @@
+{
+ "name": "openvibe-blog",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-blog",
+ "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.13.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.13.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
+ "integrity": "sha512-MeIGJYHQsbr6ISeEErduUw/PD52X5IBclH2ht/G/t4lSX5X4o2AwcZYLEzOZrcbaXQyplgC7r86RCGFvi1ifUA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-publishing": {
+ "version": "0.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
+ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "better-sqlite3": ">=11"
+ },
+ "peerDependenciesMeta": {
+ "better-sqlite3": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.3.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
+ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+{
+ "name": "openvibe-blog",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Blog — the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "seed": "node scripts/seed.js"
+ },
+ "keywords": ["openvibe", "blog", "publishing", "feeds"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Blog",
+ "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.13.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
+ },
+ "devDependencies": {
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+/* OpenVibe.Blog — readable, theme-token driven (openvibe-shared presets). No JavaScript needed. */
+:root { color-scheme: dark; }
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 17px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--accent-light, #60a5fa); }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
+.account-bar { max-width: 760px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.page { max-width: 760px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
+.blog-surface { background: var(--bg-primary); color: var(--text-primary); border-radius: 12px; }
+body.editor .page { max-width: 900px; }
+h1 { font-size: 2.1rem; line-height: 1.2; margin: 1.2rem 0 .6rem; }
+h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
+.lede { color: var(--text-secondary, #96a7c2); font-size: 1.1rem; }
+.meta, .byline, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; }
+.badge { display: inline-block; font-size: .75rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary); vertical-align: middle; }
+.post-list, .comment-list, .replies { list-style: none; padding: 0; }
+.post-item { padding: 14px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.post-item-title { margin: 0 0 4px; font-size: 1.3rem; }
+.post-item-title a { color: var(--text-primary); text-decoration: none; }
+.post-item-title a:hover { text-decoration: underline; }
+.summary { margin: 6px 0; }
+.tags a, .categories a { margin-right: 6px; }
+.post-body img, .ov-media img { max-width: 100%; height: auto; border-radius: 8px; }
+.post-body pre { overflow-x: auto; background: var(--bg-tertiary, #172238); padding: 12px; border-radius: 8px; }
+.post-body blockquote { border-left: 3px solid var(--accent, #3b82f6); margin: 0; padding-left: 14px; color: var(--text-secondary); }
+.ov-media { margin: 1.2rem 0; }
+.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
+.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }
+.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
+.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }
+.sources ol { padding-left: 1.2rem; }
+.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
+.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
+.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
+.comments { margin-top: 40px; border-top: 1px solid var(--border, #1f2d47); }
+.comment { padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.replies { margin-left: 22px; }
+.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
+.notice.error { border-color: var(--danger, #ef4444); }
+.card { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 16px; margin: 16px 0; background: var(--bg-card, #131c2e); }
+label { display: block; margin: 10px 0 4px; font-weight: 600; font-size: .95rem; }
+label input[type=checkbox] { margin-right: 6px; }
+input, textarea, select { width: 100%; font: inherit; color: var(--text-primary); background: var(--bg-input, #0d1424); border: 1px solid var(--border-light, #2c3d5c); border-radius: 6px; padding: 8px 10px; }
+input[type=checkbox] { width: auto; }
+textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; }
+button, .button { display: inline-block; margin-top: 10px; font: inherit; font-weight: 600; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); border: 0; border-radius: 6px; padding: 8px 16px; cursor: pointer; text-decoration: none; }
+button.danger { background: var(--danger, #ef4444); }
+form.inline { display: inline; }
+form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
+.actions form { margin: 10px 0; }
+table.posts { width: 100%; border-collapse: collapse; }
+table.posts th, table.posts td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }
+.ov-diff ins { background: rgba(34,197,94,.25); text-decoration: none; }
+.ov-diff del { background: rgba(239,68,68,.25); }
+.ov-diff { white-space: pre-wrap; }
+@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
+@media (max-width: 600px) { h1 { font-size: 1.7rem; } body { font-size: 16px; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Seed the official blog with its first post (a DRAFT built from verbatim quotes; see server/seed.js).
+ *
+ * npm run seed # draft only (author: first BLOG_OFFICIAL_OWNERS)
+ * npm run seed -- --author usr_… # a different accountable author
+ * npm run seed -- --publish --reviewer usr_… # a person reviewed it: record that and publish
+ *
+ * Uses the same database and outbox as the service (BLOG_DB_PATH); events wait in event_outbox and
+ * are relayed by the running service.
+ */
+const { createApp } = require('../server/app');
+const { seedOfficialPost } = require('../server/seed');
+
+const args = process.argv.slice(2);
+const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
+
+const { ctx } = createApp();
+try {
+ const out = seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });
+ console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(ctx.blogs.get(out.post.blog_id), out.post)}`);
+ if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);
+ if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');
+} catch (err) {
+ console.error(`[seed] ${err.message}`);
+ process.exitCode = 1;
+} finally {
+ ctx.store.close();
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+{
+ "week": {
+ "from": "2026-09-16",
+ "to": "2026-09-22"
+ },
+ "collected_at": "2026-09-23T02:20:56+00:00",
+ "items": [
+ {
+ "repo": "OpenVibe.Billing",
+ "heading": "OpenVibe.Billing (alpha, shadow)",
+ "quote": "OpenVibe.Billing W8: balanced append-only journal, provider receipts, subscriptions, payouts, freeze, reconciliation and the Live import (ADR-012)",
+ "kind": "commit",
+ "ref": "ab5ae8197a80f20df0723f4ef83aeb310072f302",
+ "date": "2026-09-22T18:18:02-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Billing/commit/ab5ae8197a80f20df0723f4ef83aeb310072f302"
+ },
+ {
+ "repo": "OpenVibe.Chat",
+ "heading": "OpenVibe.Chat service",
+ "quote": "Chat service: Live's chat behind a service boundary (roadmap Wave 6)",
+ "kind": "commit",
+ "ref": "abf1e4b40ab6c0dd478bd879bbd28436d7404c0c",
+ "date": "2026-09-22T18:53:50-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Chat/commit/abf1e4b40ab6c0dd478bd879bbd28436d7404c0c"
+ },
+ {
+ "repo": "OpenVibe.Community",
+ "heading": "Community forum and comments",
+ "quote": "Wave 5: typed comment threads, forum (spaces/threads/posts), Discord relay, Pulse",
+ "kind": "commit",
+ "ref": "139aedd004c39575bd4832b9dcbfbeef41c61fa5",
+ "date": "2026-09-22T18:06:24-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Community/commit/139aedd004c39575bd4832b9dcbfbeef41c61fa5"
+ },
+ {
+ "repo": "OpenVibe.Contracts",
+ "heading": "OpenVibe.Contracts first release",
+ "quote": "v0.1.0: first contracts (subject, legacy identity map, service-token claims, entity ref, problem, service manifest, capability, event envelope, media ref)",
+ "kind": "tag",
+ "ref": "bae88d185ab402915fad1c434ca11031adf63af8",
+ "date": "2026-09-22T14:08:20-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Contracts/commit/bae88d185ab402915fad1c434ca11031adf63af8",
+ "tag": "v0.1.0"
+ },
+ {
+ "repo": "OpenVibe.Events",
+ "heading": "OpenVibe.Events service",
+ "quote": "Events service: durable store, publish API, signed delivery with retry/DLQ/replay, SSE realtime gateway, client library",
+ "kind": "commit",
+ "ref": "f8c2e2418938498664fafbd6ce096faaf45284c4",
+ "date": "2026-09-22T17:52:23-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Events/commit/f8c2e2418938498664fafbd6ce096faaf45284c4"
+ },
+ {
+ "repo": "OpenVibe.Live",
+ "heading": "Live release prompts",
+ "quote": "GET /release.json; openvibe-shared v1.2.1 (the navbar loads release-watch.js: open tabs are prompted after a deploy and never reloaded while watching, broadcasting or typing)",
+ "kind": "commit",
+ "ref": "829ac9fb35df92d98eb79ea538f5d661690984a3",
+ "date": "2026-09-22T18:52:38-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Live/commit/829ac9fb35df92d98eb79ea538f5d661690984a3"
+ },
+ {
+ "repo": "OpenVibe.Media",
+ "heading": "Media canonical object model",
+ "quote": "Canonical object model (Wave 4): media_objects with locations, relationships, variants, jobs, retention holds and the public-size invariant; vods/clips/files/screenshot pastes become projections via object_id",
+ "kind": "commit",
+ "ref": "cced10f81e529c76346d6578c8b26a5350f744be",
+ "date": "2026-09-22T18:01:01-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Media/commit/cced10f81e529c76346d6578c8b26a5350f744be"
+ },
+ {
+ "repo": "OpenVibe.Network",
+ "heading": "Network sign-in everywhere",
+ "quote": "Verify-email page, sign-in everywhere, cross-site history, linked services for every site, navbar brand + modular menus",
+ "kind": "commit",
+ "ref": "d8b7f65f015ced051ed64a8269252395f6bed320",
+ "date": "2026-09-17T20:08:00-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/d8b7f65f015ced051ed64a8269252395f6bed320"
+ },
+ {
+ "repo": "OpenVibe.Publishing",
+ "heading": "openvibe-publishing 0.1.0",
+ "quote": "openvibe-publishing 0.1.0: shared publishing packages (revisions, schedule, taxonomy, citations, media, discussion, seo gate, authorship, index hooks, ssr) with the two-products exit proof",
+ "kind": "tag",
+ "ref": "6b4f426badb8f9d2a0dc85fbd2fbdc4bae066354",
+ "date": "2026-09-22T18:38:23-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Publishing/commit/6b4f426badb8f9d2a0dc85fbd2fbdc4bae066354",
+ "tag": "v0.1.0"
+ },
+ {
+ "repo": "OpenVibe.SDK",
+ "heading": "openvibe-sdk 0.1.0",
+ "quote": "openvibe-sdk 0.1.0: core client, auth, registry, identity, modules, events, realtime, media, community and a mock platform",
+ "kind": "tag",
+ "ref": "c5d6d281c86e0aa00eafcb6630936d21225bbfdb",
+ "date": "2026-09-22T18:20:42-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.SDK/commit/c5d6d281c86e0aa00eafcb6630936d21225bbfdb",
+ "tag": "v0.1.0"
+ },
+ {
+ "repo": "OpenVibe.Search",
+ "heading": "OpenVibe.Search service",
+ "quote": "Search service: index documents with revision ordering and tombstones, event inbox, permission-aware FTS5 query API, removal events through an outbox",
+ "kind": "commit",
+ "ref": "a9d48430eb8d019948b3b912f4440045307c52ec",
+ "date": "2026-09-22T18:32:49-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Search/commit/a9d48430eb8d019948b3b912f4440045307c52ec"
+ },
+ {
+ "repo": "OpenVibe.Shared",
+ "heading": "openvibe-shared 1.0.0",
+ "quote": "First release from its own repository.",
+ "kind": "changelog",
+ "ref": "CHANGELOG.md#1.0.0 — 2026-09-22",
+ "date": "2026-09-22",
+ "url": "https://github.com/OpenVibers/OpenVibe.Shared/blob/11735628f73026823ebfe40c45a35f2e23b8ba3d/CHANGELOG.md"
+ },
+ {
+ "repo": "OpenVibe.Tools",
+ "heading": "Tools durable jobs",
+ "quote": "Durable jobs on Img, Audio and Docs: /api/v1/jobs with SSE progress, cancel, reattach, idempotency keys and owner scoping; results can be Media objects",
+ "kind": "commit",
+ "ref": "7b76c41792688d089ff826f4273efe4dd82c2350",
+ "date": "2026-09-22T18:42:49-07:00",
+ "url": "https://github.com/OpenVibers/OpenVibe.Tools/commit/7b76c41792688d089ff826f4273efe4dd82c2350"
+ }
+ ],
+ "excluded_notes": "Dates are committer dates in -07:00; the 2026-09-22 evening commits are 2026-09-23 in UTC. CHANGELOG entries dated 2026-09-23 (openvibe-shared 1.1.0-1.3.0, openvibe-sdk 0.2.0-0.2.2) were left out as outside the week. No deployment claims: STATUS.json says \"deployed\": false for Chat, Events, Search and Sources; Billing says \"authoritative\": false (\"neither is applied or executed, so Live stays authoritative.\"); Host Stage A is \"not installed on the production host\". Conflicting signals left out: Live commit 9f4f1da (\"Wave 6 cutover, 2026-09-23 02:03 UTC\") and Contracts ADR-010 (\"executed 2026-09-23\") vs Chat STATUS \"deployed\": false; Sites 7b7c917 (\"OpenVibe.Events serves it\") vs Events STATUS \"deployed\": false. Also left out for length: OpenVibe.Sources service (alpha W14, deployed false), OpenVibe.Host Stage A CLI, OpenVibe.Games Wave 12 platform commit (very long subject), Community paste authority cutover (786e6f8), Network canonical subject ids / ecosystem registry / FedCM, Tools' twelve single-purpose tools, Live observability, Contracts v0.2.0-v0.14.0 tags, Sites placeholders, and charter-only placeholder repos (OpenRe.Stream, AI, Tips, VIP, Wiki; Realtime closed by ADR-005). Tag items carry an extra \"tag\" field; their quote is the tagged commit's subject."
+}
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+
+/**
+ * OpenVibe.Blog — Express app factory. server/index.js listens and starts the worker; tests build
+ * their own instance with a temp database, an injectable clock and mock neighbours.
+ *
+ * Pages (server-rendered, http/public.js) Editor (forms, http/editor.js) API (http/api.js)
+ * Discovery (robots, llms, sitemaps) /auth/* (Network SSO) /api/health, /api/ready,
+ * /release.json, /metrics
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createAuthClient, createAuthRoutes } = require('./auth/sso');
+const { createViewerResolver } = require('./auth/viewer');
+const access = require('./domain/access');
+const { createBlogs } = require('./domain/blogs');
+const { createPublication } = require('./domain/publication');
+const { createPosts } = require('./domain/posts');
+const { createReading } = require('./domain/reading');
+const { createEffects } = require('./domain/effects');
+const { createPeople } = require('./clients/network');
+const { createCommunity } = require('./clients/community');
+const { createMedia } = require('./clients/media');
+const { createBlogOutbox } = require('./events/outbox');
+const { createPublicRoutes } = require('./http/public');
+const { createEditorRoutes } = require('./http/editor');
+const { createApi } = require('./http/api');
+const { createDiscoveryRoutes } = require('./http/discovery');
+const { createBlogReadiness } = 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),
+ * entitlementCheck ({ subject, key }) → bool, log
+ */
+function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+
+ const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
+ const blogs = createBlogs({ store, config });
+ const publication = createPublication({ store, config, outbox });
+ const posts = createPosts({ store, blogs, publication, access, outbox, log });
+ const people = createPeople({ store, config, fetchImpl });
+ const community = createCommunity({ store, config, fetchImpl });
+ const media = createMedia({ config, fetchImpl });
+ const reading = createReading({ store, blogs, posts, publication, people, media });
+ const effects = createEffects({ outbox, community });
+ const entitlements = access.createEntitlementChecker({ provider: config.entitlements.provider, check: opts.entitlementCheck });
+ const auth = opts.auth || createAuthClient(config);
+ const viewers = createViewerResolver({ auth, config, people });
+ const worker = createWorker({ config, store, posts, media, outbox, log });
+ blogs.ensureOfficial();
+
+ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, auth, viewers, access, worker };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'blog', root: path.join(__dirname, '..') });
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', 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'],
+ // Media objects are served by openvibe.media (which may redirect to object storage).
+ imgSrc: ["'self'", 'data:', 'https:'],
+ mediaSrc: ["'self'", 'https:'],
+ connectSrc: ["'self'", 'https://openvibe.network'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'self'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));
+ app.get('/release.json', release.handler);
+ const readiness = createBlogReadiness({ store, auth, outbox, release: release.release });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', createAuthRoutes(config, auth));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'blog', service: 'blog', host: 'openvibe.blog', name: 'OpenVibe.Blog', profile: 'ugc' })); }
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
+
+ // ── Discovery, editor, public pages ─────────────────────
+ app.use(createDiscoveryRoutes(ctx));
+ const publicRoutes = createPublicRoutes(ctx);
+ ctx.publicRoutes = publicRoutes;
+ app.use('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));
+ app.use(publicRoutes.router);
+ app.use((req, res) => publicRoutes.notFound(req, res));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[Blog]', err && err.stack ? err.stack : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', 'private, no-store');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * Capability checks for service tokens (audience openvibe.blog), including the ids Blog introduces
+ * before the contracts library knows them.
+ *
+ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
+ * manifests yet. Blog'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: they are judged by their blog
+ * membership (domain/access.js). A service token is judged by its capability AND by the membership
+ * of the person it acts for (X-OV-Subject).
+ */
+const { capabilities } = require('openvibe-contracts');
+
+const CAPABILITIES = Object.freeze({
+ BLOG_CREATE: 'blog.blog.create', // the charter's blog.create, as a 3-segment id
+ BLOG_CONFIGURE: 'blog.blog.configure', // title, description, language, feed settings
+ THEME_SET: 'blog.theme.set',
+ MEMBER_MANAGE: 'blog.member.manage',
+ POST_CREATE: 'blog.post.create',
+ POST_READ: 'blog.post.read',
+ POST_UPDATE: 'blog.post.update',
+ POST_PUBLISH: 'blog.post.publish',
+ POST_SCHEDULE: 'blog.post.schedule',
+ POST_UNPUBLISH: 'blog.post.unpublish',
+ POST_DELETE: 'blog.post.delete',
+ FEED_READ: 'blog.feed.read',
+});
+const PROPOSED = new Set(Object.values(CAPABILITIES));
+
+/** → { allowed, code, reason } like capabilities.check(). */
+function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
+ return capabilities.check(claims, capabilityId);
+}
+
+module.exports = { CAPABILITIES, PROPOSED, checkCapability };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+/**
+ * Form tokens for the no-JavaScript editor and comment forms (CSRF). The session cookie is
+ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
+ * BLOG_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(`blog-form:${viewer.subject}`).digest('base64url').slice(0, 32);
+}
+
+function checkCsrf(config, viewer, token) {
+ const expected = csrfToken(config, viewer);
+ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
+}
+
+module.exports = { csrfToken, checkCsrf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,420 @@
@@ -0,0 +1,420 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// openvibe.blog — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
+//
+// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
+// Blog's client id: registered in the Network as OAuth client `blog`, redirect
+// https://openvibe.blog/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.blog):
+// 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(`[Blog] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
+ return client.publicKey;
+ }
+ } catch (err) {
+ console.warn(`[Blog 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('[Blog 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('[Blog 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('[Blog auth] Refresh failed:', err.message);
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
+function optionalAuth(auth) {
+ return async (req, _res, next) => {
+ const token = extractToken(req);
+ if (token) {
+ const claims = await auth.verify(token);
+ if (claims) { req.user = claimsToUser(claims); req.token = token; }
+ }
+ next();
+ };
+}
+
+module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * Who is calling, resolved once per request into `req.viewer`:
+ *
+ * { kind: 'anonymous', subject: null, staff: false }
+ * { kind: 'user', subject: 'usr_…'|null, staff, user, token }
+ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the
+ * token's subject_id claim. role 'admin' makes the viewer staff. X-OV-* headers are ignored.
+ * { 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.blog.
+ * X-OV-Subject names the person it acts for; X-OV-Origin: ai marks AI output (OpenVibe.AI's
+ * blog.draft_post), which is never attributed to that person as author — it is AI-generated,
+ * starts as a draft and needs a person's review before it can be published.
+ *
+ * Identity never comes from a request body or query. A request presenting a service token is judged
+ * on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.
+ */
+const contracts = require('openvibe-contracts');
+const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
+const { checkCapability } = require('./capabilities');
+
+const { ids, serviceAuth, http } = contracts;
+const STAFF_ROLES = new Set(['admin']);
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const AUDIENCE = 'openvibe.blog';
+
+class ViewerError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
+
+function createViewerResolver({ auth, config, people }) {
+ async function fromServiceToken(req, token) {
+ const publicKey = await auth.ensureKey();
+ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
+ if (!r.ok) throw new ViewerError(401, r.code, r.reason);
+ const originHeader = req.get('x-ov-origin');
+ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
+ const subjectHeader = req.get('x-ov-subject');
+ let subject = null;
+ if (subjectHeader) {
+ if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
+ subject = subjectHeader;
+ }
+ return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };
+ }
+
+ async function fromUserToken(token) {
+ const claims = await auth.verify(token);
+ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user: claimsToUser(claims), token };
+ }
+
+ /** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */
+ async function resolve(req, opts = {}) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodeJwtPayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ if (opts.services === false) return ANONYMOUS;
+ return fromServiceToken(req, token);
+ }
+ }
+ const token = extractToken(req);
+ if (!token) return ANONYMOUS;
+ return (await fromUserToken(token)) || ANONYMOUS;
+ }
+
+ function middleware(opts = {}) {
+ return async (req, res, next) => {
+ try {
+ req.viewer = await resolve(req, opts);
+ next();
+ } catch (err) {
+ if (!(err instanceof ViewerError)) return next(err);
+ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ };
+ }
+
+ return { resolve, middleware };
+}
+
+/**
+ * Route guard: a service token must hold `cap`; browsers and anonymous callers pass here and are
+ * judged by membership in the domain layer.
+ */
+function guard(cap) {
+ return (req, res, next) => {
+ const v = req.viewer;
+ if (!v || v.kind !== 'service') return next();
+ const c = checkCapability(v.claims, cap);
+ if (c.allowed) return next();
+ return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+ };
+}
+
+module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * Comments are OpenVibe.Community threads, referenced — never copied. A public post's thread is the
+ * one Community resolves for EntityRef { service: 'blog', type: 'post', id } (openvibe-publishing/
+ * discussion stores only the thread id, in blog_post_discussion_refs). Pages read the thread from
+ * Community on render, so moderation and deletion there are always what readers see; a failure is
+ * shown as "comments are unavailable", never as an empty or invented thread.
+ *
+ * Only PUBLIC posts get threads: a Community thread is readable by anyone who has its id, so a
+ * members-only or private post must not have one.
+ *
+ * Calls with Blog's service token (audience openvibe.community):
+ * community.comment.write resolve a thread; comment as the signed-in member (X-OV-Subject)
+ * community.comment.moderate (optional) hide the thread of a post that was deleted or made non-public
+ */
+const { serviceAuth, http } = require('openvibe-contracts');
+const { createDiscussionClient } = require('openvibe-publishing/discussion');
+
+function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
+ const base = config.community.internalUrl;
+ const enabled = Boolean(config.oauth.clientSecret && base);
+ const tokenClient = (scope) => serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.community', scope, fetchImpl,
+ });
+ const writeTokens = enabled ? tokenClient('community.comment.write') : null;
+ const modTokens = enabled ? tokenClient('community.comment.moderate') : null;
+ const discussion = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;
+
+ const refFor = (post, label) => ({ service: 'blog', type: 'post', id: post.id, ...(label ? { label: String(label).slice(0, 200) } : {}) });
+
+ async function call(method, path, { tokens, subject, body, ctx, timeoutMs = 4000 } = {}) {
+ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };
+ if (tokens) Object.assign(headers, await tokens.authHeaders());
+ if (subject) headers['X-OV-Subject'] = subject;
+ if (body) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
+ const data = await res.json().catch(() => null);
+ if (res.status === 401 && tokens && tokens.invalidate) tokens.invalidate();
+ if (!res.ok) {
+ const err = new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);
+ err.status = res.status;
+ err.code = data && data.code;
+ throw err;
+ }
+ return data;
+ }
+
+ return {
+ enabled,
+ publicUrl: config.community.publicUrl,
+
+ /** The stored thread id, or resolve it once through Community. Public posts only. */
+ async threadFor(post, label, ctx) {
+ if (post.visibility !== 'public') return null;
+ const known = store.discussion.get(post.id);
+ if (known) return known.threadId;
+ if (!discussion) return null;
+ const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
+ return out.threadId;
+ },
+
+ /** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
+ async readThread(threadId, { after, ctx } = {}) {
+ const qs = after ? `?after=${encodeURIComponent(after)}` : '';
+ return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
+ },
+
+ /** Comment as the signed-in member. */
+ async comment(threadId, subject, { message, parentId } = {}, ctx) {
+ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
+ return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
+ tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
+ });
+ },
+
+ /**
+ * Best effort: hide a post's thread when the post is deleted or stops being public, show it
+ * again when it is public again. Needs community.comment.moderate.
+ */
+ async setThreadVisibility(post, visibility, ctx) {
+ const known = store.discussion.get(post.id);
+ if (!known || !modTokens) return false;
+ try {
+ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
+ return true;
+ } catch (err) {
+ console.warn(`[Blog] could not set the comment thread of ${post.id} to ${visibility}: ${err.message}`);
+ return false;
+ }
+ },
+ };
+}
+
+module.exports = { createCommunity };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * OpenVibe.Media: attachments are Media object ids (med_…); the bytes stay in Media.
+ *
+ * urlFor(mediaId) the public delivery URL (GET /o/:id on the Media host)
+ * resolve(mediaId) for openvibe-publishing/media verify(): asks Media's object API
+ * (GET /api/v2/<app>/objects/:id, capability media.object.read for the
+ * namespace BLOG_MEDIA_APP, audience openvibe.media) whether the object exists:
+ * 200 ready/uploading → { exists: true }
+ * 200 lifecycle deleted → { exists: false, reason: 'deleted' }
+ * 410 → { exists: false, reason: 'deleted' }
+ * 404 → { exists: false, reason: 'not_found' }
+ * anything else (401/403 grants, 5xx, timeouts) THROWS: an outage or a
+ * missing grant is not evidence the object is gone (check_failed)
+ *
+ * Media does not publish a deletion event yet, so Blog's worker re-verifies the attachments of
+ * published posts every BLOG_MEDIA_VERIFY_INTERVAL_MS; a broken attachment renders as an explicit
+ * "media unavailable" placeholder, never as a silently missing image.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createMedia({ config, fetchImpl = globalThis.fetch }) {
+ const enabled = Boolean(config.oauth.clientSecret && config.media.internalUrl);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.media', scope: 'media.object.read', fetchImpl,
+ }) : null;
+
+ return {
+ enabled,
+ urlFor: (mediaId) => `${config.media.publicUrl}/o/${encodeURIComponent(mediaId)}`,
+
+ async resolve(mediaId) {
+ if (!tokens) throw new Error('Media verification is not configured');
+ const res = await fetchImpl(`${config.media.internalUrl}/api/v2/${encodeURIComponent(config.media.app)}/objects/${encodeURIComponent(mediaId)}`, {
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
+ signal: AbortSignal.timeout(5000),
+ });
+ if (res.status === 404) return { exists: false, reason: 'not_found' };
+ if (res.status === 410) return { exists: false, reason: 'deleted' };
+ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
+ if (!res.ok) throw new Error(`Media answered ${res.status}`);
+ const data = await res.json().catch(() => null);
+ const obj = data && (data.object || data);
+ if (obj && obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
+ return { exists: true };
+ },
+ };
+}
+
+module.exports = { createMedia };
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+
+/**
+ * Who a subject is, for display (author bylines, author pages, member lists).
+ *
+ * Blog stores people as Network subject ids (usr_…) only. Names come from the signed-in member's
+ * own token claims and, for everyone else, from the Network's resolve-batch endpoint
+ * (capability identity.subject.resolve, a client-credentials token for audience openvibe.network).
+ * They are cached in subject_projections; the cache is never authority, and an unresolvable
+ * subject is shown as "a member", never as a made-up name.
+ */
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+const TTL_MS = 6 * 60 * 60 * 1000;
+const WAIT_MS = 2500;
+
+function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
+ const { db } = store;
+ const enabled = Boolean(config.oauth.clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
+ }) : null;
+ const q = {
+ get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
+ byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
+ put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
+ avatar_url = excluded.avatar_url, refreshed_at = excluded.refreshed_at`),
+ };
+ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
+
+ function remember(subject, p) {
+ if (!ids.isSubjectId('user', subject) || !p) return;
+ q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
+ }
+
+ async function resolve(subjects) {
+ if (!tokens || !subjects.length) return;
+ const res = await fetchImpl(`${config.networkInternalUrl}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ subject_ids: subjects.slice(0, 500) }),
+ signal: AbortSignal.timeout(8000),
+ });
+ if (res.status === 401) tokens.invalidate && tokens.invalidate();
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
+ for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);
+ }
+
+ const shape = (row, subject) => (row
+ ? { subject: row.subject, username: row.username, name: row.display_name || row.username || 'a member', avatarUrl: row.avatar_url, known: Boolean(row.username || row.display_name) }
+ : { subject, username: null, name: 'a member', avatarUrl: null, known: false });
+
+ return {
+ enabled,
+ /** From a verified sign-in: the member's own claims are a fresh projection. */
+ rememberClaims(subject, claims) { remember(subject, claims); },
+
+ /** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
+ async many(subjects) {
+ const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
+ const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
+ if (missing.length && tokens) {
+ let timer;
+ await Promise.race([
+ resolve(missing).catch((err) => console.warn('[Blog] identity resolve failed:', err.message)),
+ new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
+ ]);
+ clearTimeout(timer);
+ }
+ return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));
+ },
+
+ async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
+
+ /** The subject a username belongs to, from people Blog has seen. */
+ byUsername(username) {
+ const row = q.byUsername.get(String(username || '').toLowerCase());
+ return row ? shape(row, row.subject) : null;
+ },
+
+ cached: (subject) => shape(q.get.get(subject), subject),
+ };
+}
+
+module.exports = { createPeople };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * OpenVibe.Blog configuration. Every value comes from the environment (production:
+ * /etc/openvibe/blog.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, 4810);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.blog' : `http://localhost:${port}`));
+
+ return {
+ service: 'blog',
+ 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.BLOG_DB_PATH || './data/blog.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 || 'blog',
+ 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 (forms opened
+ // before a restart must be re-submitted).
+ formSecret: env.BLOG_FORM_SECRET || '',
+
+ // The official blog (served at /). Its owners are Network subjects (usr_…); Network
+ // admins may also edit it (staff).
+ official: {
+ handle: 'openvibe',
+ title: env.BLOG_OFFICIAL_TITLE || 'The OpenVibe blog',
+ description: env.BLOG_OFFICIAL_DESCRIPTION || 'News and release notes from the OpenVibe network.',
+ owners: list(env.BLOG_OFFICIAL_OWNERS),
+ },
+
+ // OpenVibe.Media: attachments are Media object ids; bytes are served by Media.
+ media: {
+ publicUrl: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
+ internalUrl: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
+ // The Media tenant (namespace) Blog's objects live in; media.object.read is granted for it.
+ app: env.BLOG_MEDIA_APP || 'blog',
+ verifyIntervalMs: int(env.BLOG_MEDIA_VERIFY_INTERVAL_MS, 10 * 60 * 1000),
+ },
+
+ // OpenVibe.Community: comment threads (referenced by id, never copied).
+ community: {
+ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
+ internalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),
+ },
+
+ // OpenVibe.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),
+ },
+
+ // Scheduled publication worker.
+ worker: {
+ enabled: env.BLOG_WORKER !== 'off',
+ intervalMs: int(env.BLOG_SCHEDULE_INTERVAL_MS, 15000),
+ id: env.BLOG_WORKER_ID || `blog-${process.pid}`,
+ },
+
+ // Entitlement checks for members-only (VIP) posts. 'none' (default): no entitlement service
+ // exists yet, so the check fails closed — only the post's blog members and staff can read.
+ entitlements: { provider: env.BLOG_ENTITLEMENTS_PROVIDER || 'none' },
+
+ // Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
+ apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media'),
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * Blog's own SQLite database: created on boot, idempotently. Nothing here is shared with another
+ * service; the publishing packages create their tables inside this database with Blog's prefixes.
+ *
+ * The ten charter tables (roadmap §15.13):
+ *
+ * blogs Blog-owned official + member blogs, owner = Network subject
+ * blog_memberships Blog-owned owner | editor | author per blog
+ * blog_posts Blog-owned publication state: slug, state, visibility, published revision
+ * blog_post_revisions package openvibe-publishing/revisions, prefix blog_post (immutable)
+ * blog_series Blog-owned ordered series per blog
+ * blog_taxonomy view over blog_terms (openvibe-publishing/taxonomy, prefix blog)
+ * blog_post_terms view over blog_term_links (same package)
+ * blog_schedules view over blog_schedule_jobs (openvibe-publishing/schedule, prefix blog)
+ * blog_redirects package openvibe-publishing/seo redirect store, prefix blog
+ * blog_feed_settings Blog-owned which feeds a blog offers, item count, full text or summary
+ *
+ * The three views keep the charter's names readable (`SELECT * FROM blog_schedules`) without a
+ * second copy of the package's state: the package tables are the only truth.
+ *
+ * Also here: the package's companions (blog_post_drafts, blog_post_revision_purges,
+ * blog_post_citations, blog_post_attachments, blog_post_reviews, blog_post_discussion_refs,
+ * blog_index_revisions), the SDK's event_outbox, and subject_projections (a display cache of
+ * Network names, never authority).
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { createRevisionStore } = require('openvibe-publishing/revisions');
+const { createCitationStore } = require('openvibe-publishing/citations');
+const { createTaxonomy } = require('openvibe-publishing/taxonomy');
+const { createScheduler } = require('openvibe-publishing/schedule');
+const { createAttachmentStore } = require('openvibe-publishing/media');
+const { createDiscussionRefs } = require('openvibe-publishing/discussion');
+const { createReviewLog } = require('openvibe-publishing/authorship');
+const { createRedirectStore } = require('openvibe-publishing/seo');
+const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS blogs (
+ id TEXT PRIMARY KEY, -- blg_<ULID>
+ kind TEXT NOT NULL CHECK (kind IN ('official','member')),
+ handle TEXT NOT NULL UNIQUE, -- /@handle; the official blog is 'openvibe' and served at /
+ owner_subject TEXT, -- usr_… (NULL for the official blog: its owners are memberships)
+ title TEXT NOT NULL,
+ description TEXT,
+ theme TEXT NOT NULL DEFAULT 'vibe', -- an openvibe-shared theme preset slug
+ language TEXT NOT NULL DEFAULT 'en',
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended')),
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS blogs_owner ON blogs (owner_subject) WHERE kind = 'member';
+
+CREATE TABLE IF NOT EXISTS blog_memberships (
+ blog_id TEXT NOT NULL REFERENCES blogs(id),
+ subject TEXT NOT NULL, -- usr_…
+ role TEXT NOT NULL CHECK (role IN ('owner','editor','author')),
+ added_by TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (blog_id, subject)
+);
+CREATE INDEX IF NOT EXISTS blog_memberships_subject ON blog_memberships (subject);
+
+CREATE TABLE IF NOT EXISTS blog_series (
+ id TEXT PRIMARY KEY, -- ser_<ULID>
+ blog_id TEXT NOT NULL REFERENCES blogs(id),
+ slug TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE (blog_id, slug)
+);
+
+CREATE TABLE IF NOT EXISTS blog_posts (
+ id TEXT PRIMARY KEY, -- pst_<ULID>
+ blog_id TEXT NOT NULL REFERENCES blogs(id),
+ slug TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'draft'
+ CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
+ visibility TEXT NOT NULL DEFAULT 'public'
+ CHECK (visibility IN ('public','unlisted','members','private')),
+ entitlement_key TEXT, -- members (VIP) posts: the entitlement that may read it
+ author_subject TEXT NOT NULL, -- usr_… accountable for the post
+ series_id TEXT REFERENCES blog_series(id),
+ series_position INTEGER,
+ published_revision INTEGER, -- which revision readers see (NULL: never published)
+ first_published_at INTEGER,
+ published_at INTEGER, -- the latest publication of published_revision
+ allow_comments INTEGER NOT NULL DEFAULT 1,
+ noindex INTEGER NOT NULL DEFAULT 0, -- the author asked search engines not to index it
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ deleted_at INTEGER,
+ CHECK (visibility <> 'members' OR entitlement_key IS NOT NULL)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS blog_posts_slug ON blog_posts (blog_id, slug) WHERE state <> 'deleted';
+CREATE INDEX IF NOT EXISTS blog_posts_listing ON blog_posts (blog_id, state, visibility, published_at);
+CREATE INDEX IF NOT EXISTS blog_posts_author ON blog_posts (author_subject, state);
+CREATE INDEX IF NOT EXISTS blog_posts_series ON blog_posts (series_id, series_position);
+
+CREATE TABLE IF NOT EXISTS blog_feed_settings (
+ blog_id TEXT PRIMARY KEY REFERENCES blogs(id),
+ rss INTEGER NOT NULL DEFAULT 1,
+ atom INTEGER NOT NULL DEFAULT 1,
+ json INTEGER NOT NULL DEFAULT 1,
+ item_count INTEGER NOT NULL DEFAULT 20 CHECK (item_count BETWEEN 1 AND 50),
+ full_content INTEGER NOT NULL DEFAULT 1,
+ updated_at INTEGER NOT NULL
+);
+
+-- Display cache of Network names for subjects (from sign-in claims or identity.subject.resolve).
+CREATE TABLE IF NOT EXISTS subject_projections (
+ subject TEXT PRIMARY KEY,
+ username TEXT,
+ display_name TEXT,
+ avatar_url TEXT,
+ refreshed_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS subject_projections_username ON subject_projections (username);
+`;
+
+// Charter names over the package tables (created after the stores, which create the tables).
+const VIEWS = `
+CREATE VIEW IF NOT EXISTS blog_taxonomy AS
+ SELECT t.id, CASE WHEN t.vocabulary = 'tag' THEN 'tag' ELSE 'category' END AS kind,
+ b.id AS blog_id, t.vocabulary, t.slug, t.name, t.parent_id, t.description, t.created_at
+ FROM blog_terms t
+ LEFT JOIN blogs b ON t.vocabulary = 'category_' || lower(substr(b.id, 5));
+CREATE VIEW IF NOT EXISTS blog_post_terms AS
+ SELECT l.entity_id AS post_id, l.term_id, t.vocabulary, l.position, l.created_at
+ FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id;
+CREATE VIEW IF NOT EXISTS blog_schedules AS
+ SELECT id, idem_key, entity_id AS post_id, action, revision, run_at, status, attempts,
+ lease_owner, lease_until, last_error, result, created_at, updated_at
+ FROM blog_schedule_jobs;
+`;
+
+/**
+ * Open (or create) the database and every store on it.
+ * opts.now — injectable clock (epoch ms) shared by the stores, so tests and replays are deterministic.
+ */
+function openStore(dbPath, { now = () => Date.now(), leaseMs = 60000 } = {}) {
+ 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);
+
+ const revisions = createRevisionStore(db, { prefix: 'blog_post', now });
+ const store = {
+ db,
+ now,
+ revisions,
+ citations: createCitationStore(db, { prefix: 'blog_post', now, revisions }),
+ taxonomy: createTaxonomy(db, { prefix: 'blog', now }),
+ // Few attempts: a scheduled publish that cannot happen should say so soon (blog.schedule.failed).
+ scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),
+ attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),
+ discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),
+ reviews: createReviewLog(db, { prefix: 'blog_post', now }),
+ redirects: createRedirectStore(db, { prefix: 'blog', now }),
+ sequencer: createIndexSequencer(db, { prefix: 'blog', now }),
+ tx: (fn) => db.transaction(fn)(),
+ close: () => db.close(),
+ };
+ db.exec(VIEWS);
+ return store;
+}
+
+const CHARTER_TABLES = ['blogs', 'blog_memberships', 'blog_posts', 'blog_post_revisions', 'blog_series',
+ 'blog_taxonomy', 'blog_post_terms', 'blog_schedules', 'blog_redirects', 'blog_feed_settings'];
+
+module.exports = { openStore, CHARTER_TABLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+
+/**
+ * Who may read and write what. Pure decisions over (viewer, blog, post, membership); the routes
+ * resolve the viewer (auth/viewer.js) and the data, then ask here.
+ *
+ * Viewers:
+ * { kind: 'anonymous' }
+ * { kind: 'user', subject: 'usr_…'|null, staff, user } a browser or a Bearer user JWT
+ * { kind: 'service', service: 'svc:…', subject, origin, claims } a Network service token; it acts
+ * for the person in X-OV-Subject
+ * The acting person is `viewer.subject`. Staff = a Network admin (role claim) on a user token.
+ *
+ * Roles per blog (blog_memberships): owner > editor > author.
+ * owner everything on the blog, including members, settings, theme and feeds
+ * editor every post of the blog (write, publish, schedule, unpublish, delete)
+ * author their own posts only
+ * Staff: owner rights on the official blog; unpublish/delete (moderation) on every blog.
+ *
+ * Reading (visibility of a post):
+ * public everyone once published
+ * unlisted everyone with the link once published; never listed, fed, mapped or indexed
+ * members the blog's members, staff, and viewers the entitlement check admits (VIP). No
+ * entitlement service exists yet: the default checker admits nobody (fails closed)
+ * private the blog's members and staff
+ * Anything not published (draft, scheduled, unpublished) is readable by the blog's members
+ * (authors: their own posts) and staff only. Deleted posts are readable by nobody.
+ */
+
+const ROLE_RANK = { author: 1, editor: 2, owner: 3 };
+
+function roleOf(store, blogId, subject) {
+ if (!subject) return null;
+ const row = store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);
+ return row ? row.role : null;
+}
+
+/** Effective role of the viewer on a blog, staff included ('owner' on the official blog). */
+function effectiveRole(store, viewer, blog) {
+ const role = roleOf(store, blog.id, viewer && viewer.subject);
+ if (viewer && viewer.staff && blog.kind === 'official') return 'owner';
+ return role;
+}
+
+const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[min];
+
+function isStaff(viewer) { return Boolean(viewer && viewer.staff); }
+
+/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */
+function memberCanSee(store, viewer, blog, post) {
+ const role = effectiveRole(store, viewer, blog);
+ if (!role) return false;
+ if (atLeast(role, 'editor')) return true;
+ return post.author_subject === viewer.subject;
+}
+
+/**
+ * The entitlement seam for members-only (VIP) posts.
+ * check({ subject, key }) → true | false (never throws: an error counts as "no")
+ * 'none' — no entitlement service exists yet, so nobody outside the blog's members and staff is
+ * admitted. A cached badge or a client claim is never authorization.
+ */
+function createEntitlementChecker({ provider = 'none', check = null } = {}) {
+ if (typeof check === 'function') {
+ return {
+ provider: 'custom',
+ async has(subject, key) {
+ if (!subject || !key) return false;
+ try { return (await check({ subject, key })) === true; } catch { return false; }
+ },
+ };
+ }
+ if (provider !== 'none') {
+ // A provider name we do not implement must not silently admit anyone.
+ console.warn(`[Blog] BLOG_ENTITLEMENTS_PROVIDER=${provider} is not implemented; members-only posts stay closed`);
+ }
+ return { provider: 'none', async has() { return false; } };
+}
+
+/**
+ * Read decision for one post. → { allowed, status, reason }
+ * status 404 hides existence (drafts, private, deleted); 403 says "members only".
+ */
+async function canReadPost(store, viewer, blog, post, entitlements) {
+ if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
+ if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
+ const member = memberCanSee(store, viewer, blog, post) || isStaff(viewer);
+ if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
+ if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
+ if (member) return { allowed: true, status: 200, reason: 'member' };
+ if (post.visibility === 'private') return { allowed: false, status: 404, reason: 'private' };
+ // members (VIP): the entitlement check, failing closed.
+ if (viewer && viewer.subject && post.entitlement_key && await entitlements.has(viewer.subject, post.entitlement_key)) {
+ return { allowed: true, status: 200, reason: 'entitled' };
+ }
+ return { allowed: false, status: 403, reason: 'members_only' };
+}
+
+/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */
+function canWrite(store, viewer, blog, action, post = null) {
+ if (!viewer || !viewer.subject) return false;
+ if (blog.status !== 'active' && !isStaff(viewer)) return false;
+ const role = effectiveRole(store, viewer, blog);
+ switch (action) {
+ case 'create': return atLeast(role, 'author');
+ case 'edit':
+ case 'publish':
+ if (atLeast(role, 'editor')) return true;
+ return role === 'author' && post && post.author_subject === viewer.subject;
+ case 'delete':
+ case 'unpublish':
+ if (isStaff(viewer)) return true; // moderation on any blog
+ if (atLeast(role, 'editor')) return true;
+ return role === 'author' && post && post.author_subject === viewer.subject;
+ case 'configure':
+ case 'members':
+ return atLeast(role, 'owner');
+ default:
+ return false;
+ }
+}
+
+module.exports = { ROLE_RANK, roleOf, effectiveRole, atLeast, isStaff, memberCanSee, canReadPost, canWrite, createEntitlementChecker };
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
+'use strict';
+
+/**
+ * Blogs, memberships, series, feed settings and themes.
+ *
+ * - The official blog (handle 'openvibe', served at /) exists from the first boot; its owners are
+ * the Network subjects named in BLOG_OFFICIAL_OWNERS, and Network admins act as its owners.
+ * - Every member gets a blog on first use (ensureMemberBlog): owner = the member's Network subject,
+ * handle = their Network username when it is free and valid. One member blog per subject.
+ * - Themes are a small set of openvibe-shared token presets; there is no site-local theme engine.
+ */
+const { ids } = require('openvibe-contracts');
+const { BUILTIN_THEMES } = require('openvibe-shared/builtin-themes');
+const { slugify } = require('openvibe-publishing/taxonomy');
+const { ApiError } = require('../http/errors');
+
+const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{1,39}$/;
+// Paths the site uses, and names that would impersonate the network.
+const RESERVED = new Set(['openvibe', 'official', 'admin', 'staff', 'api', 'auth', 'write', 'tags', 'authors', 'series',
+ 'categories', 'feed', 'feeds', 'sitemap', 'sitemaps', 'robots', 'llms', 'release', 'metrics', 'assets', 'css', 'js',
+ 'network', 'blog', 'blogs', 'help', 'support', 'security', 'terms', 'privacy', 'dmca', 'new', 'settings', 'me']);
+
+// The presets a blog may choose: a readable handful from openvibe-shared's built-in themes.
+const THEME_PRESETS = ['vibe', 'paper', 'slate', 'sand', 'nord', 'high-contrast'];
+const THEMES = Object.fromEntries(BUILTIN_THEMES.filter((t) => THEME_PRESETS.includes(t.id)).map((t) => [t.id, t]));
+const ROLES = ['owner', 'editor', 'author'];
+
+const newBlogId = (now) => `blg_${ids.ulid(now)}`;
+const newSeriesId = (now) => `ser_${ids.ulid(now)}`;
+
+function validHandle(h) { return typeof h === 'string' && HANDLE_RE.test(h) && !RESERVED.has(h); }
+
+function createBlogs({ store, config }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM blogs WHERE id = ?'),
+ byHandle: db.prepare('SELECT * FROM blogs WHERE handle = ?'),
+ byOwner: db.prepare("SELECT * FROM blogs WHERE owner_subject = ? AND kind = 'member'"),
+ official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),
+ insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)
+ VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),
+ feedDefaults: db.prepare('INSERT OR IGNORE INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?)'),
+ member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),
+ members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),
+ putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT (blog_id, subject) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`),
+ dropMember: db.prepare('DELETE FROM blog_memberships WHERE blog_id = ? AND subject = ?'),
+ ownerCount: db.prepare("SELECT COUNT(*) AS n FROM blog_memberships WHERE blog_id = ? AND role = 'owner'"),
+ blogsOf: db.prepare(`SELECT b.*, m.role FROM blog_memberships m JOIN blogs b ON b.id = m.blog_id WHERE m.subject = ? ORDER BY b.kind DESC, b.created_at`),
+ feed: db.prepare('SELECT * FROM blog_feed_settings WHERE blog_id = ?'),
+ series: db.prepare('SELECT * FROM blog_series WHERE blog_id = ? ORDER BY title'),
+ seriesBySlug: db.prepare('SELECT * FROM blog_series WHERE blog_id = ? AND slug = ?'),
+ seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),
+ };
+
+ function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {
+ const now = store.now();
+ const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };
+ q.insert.run(row);
+ q.feedDefaults.run(row.id, now);
+ if (owner) q.putMember.run(row.id, owner, 'owner', owner, now, now);
+ return q.byId.get(row.id);
+ }
+
+ const api = {
+ THEMES, THEME_PRESETS, ROLES, validHandle,
+
+ get: (id) => q.byId.get(id) || null,
+ byHandle: (handle) => q.byHandle.get(String(handle || '').toLowerCase()) || null,
+ ownedBy: (subject) => (subject ? q.byOwner.get(subject) || null : null),
+ blogsOf: (subject) => (subject ? q.blogsOf.all(subject) : []),
+
+ /** The official blog, created on first boot; config owners become owner memberships. */
+ ensureOfficial() {
+ return store.tx(() => {
+ let blog = q.official.get();
+ if (!blog) {
+ blog = create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });
+ }
+ const now = store.now();
+ for (const s of config.official.owners) {
+ if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }
+ if (!q.member.get(blog.id, s)) q.putMember.run(blog.id, s, 'owner', 'config', now, now);
+ }
+ return blog;
+ });
+ },
+
+ official: () => q.official.get() || null,
+
+ /**
+ * The member's own blog, created on first use. Handle: the requested one, else the Network
+ * username. A taken or reserved handle is a 409 the member resolves by choosing another.
+ */
+ ensureMemberBlog({ subject, username, handle, title, description } = {}) {
+ if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');
+ return store.tx(() => {
+ const existing = q.byOwner.get(subject);
+ if (existing) return { blog: existing, created: false };
+ const h = String(handle || username || '').toLowerCase();
+ if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);
+ if (q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);
+ const blog = create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });
+ return { blog, created: true };
+ });
+ },
+
+ update(blog, { title, description, language } = {}) {
+ const sets = [];
+ const vals = [];
+ if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }
+ if (description != null) { sets.push('description = ?'); vals.push(String(description).trim().slice(0, 500) || null); }
+ if (language != null) {
+ if (!/^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(String(language))) throw new ApiError(422, 'blog.invalid_language', 'language must be a BCP 47 tag, e.g. en or pt-BR');
+ sets.push('language = ?'); vals.push(String(language));
+ }
+ if (!sets.length) return blog;
+ db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);
+ return q.byId.get(blog.id);
+ },
+
+ setTheme(blog, theme) {
+ if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);
+ db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);
+ return q.byId.get(blog.id);
+ },
+
+ /** CSS custom properties of the blog's preset, for its content surface. */
+ themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },
+
+ feedSettings(blog) { return q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },
+
+ setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {
+ const cur = api.feedSettings(blog);
+ const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
+ const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);
+ if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');
+ db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,
+ item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`)
+ .run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());
+ return api.feedSettings(blog);
+ },
+
+ members: (blog) => q.members.all(blog.id),
+ membership: (blog, subject) => q.member.get(blog.id, subject) || null,
+
+ setMember(blog, subject, role, addedBy) {
+ if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');
+ if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);
+ return store.tx(() => {
+ const cur = q.member.get(blog.id, subject);
+ if (cur && cur.role === 'owner' && role !== 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') {
+ throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
+ }
+ const now = store.now();
+ q.putMember.run(blog.id, subject, role, addedBy || null, now, now);
+ return q.member.get(blog.id, subject);
+ });
+ },
+
+ removeMember(blog, subject) {
+ return store.tx(() => {
+ const cur = q.member.get(blog.id, subject);
+ if (!cur) return false;
+ if (cur.role === 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
+ q.dropMember.run(blog.id, subject);
+ return true;
+ });
+ },
+
+ series: (blog) => q.series.all(blog.id),
+ seriesBySlug: (blog, slug) => q.seriesBySlug.get(blog.id, slug) || null,
+ seriesById: (id) => (id ? q.seriesById.get(id) || null : null),
+
+ /** Get-or-create a series by title. */
+ ensureSeries(blog, title, description = null) {
+ const t = String(title || '').trim().slice(0, 120);
+ if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');
+ const slug = slugify(t);
+ return store.tx(() => {
+ const cur = q.seriesBySlug.get(blog.id, slug);
+ if (cur) return cur;
+ const now = store.now();
+ const id = newSeriesId(now);
+ db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(id, blog.id, slug, t, description, now, now);
+ return q.seriesById.get(id);
+ });
+ },
+
+ /** The taxonomy vocabulary that holds this blog's categories (tags are network-wide). */
+ categoryVocabulary: (blog) => `category_${blog.id.slice(4).toLowerCase()}`,
+ };
+ return api;
+}
+
+module.exports = { createBlogs, THEME_PRESETS, RESERVED, HANDLE_RE, validHandle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * What happens after a write has committed (never inside the transaction):
+ * - wake the outbox relay so events leave promptly;
+ * - keep the Community comment thread in step with the post: hidden when the post is deleted or
+ * stops being public, shown again when it is public again (best effort; needs
+ * community.comment.moderate). A thread is never created for a non-public post.
+ */
+function createEffects({ outbox, community }) {
+ return {
+ after(before, post, ctx) {
+ outbox.kick();
+ if (!post || !before) return;
+ const wasPublic = before.visibility === 'public' && before.state !== 'deleted';
+ const isPublic = post.visibility === 'public' && post.state !== 'deleted';
+ if (wasPublic && !isPublic) community.setThreadVisibility(post, 'hidden', ctx).catch(() => {});
+ else if (!wasPublic && isPublic) community.setThreadVisibility(post, 'public', ctx).catch(() => {});
+ },
+ };
+}
+
+module.exports = { createEffects };
new file mode 100644
new file mode 100644
@@ -0,0 +1,505 @@
@@ -0,0 +1,505 @@
+'use strict';
+
+/**
+ * Posts: drafts, immutable revisions, publication, scheduling, unpublish, delete, slug changes,
+ * taxonomy, series, citations, media attachments, and the human review AI drafts need.
+ *
+ * Every state change runs in one SQLite transaction together with its events (SDK outbox) and its
+ * Search document (index-hooks), so the event exists if and only if the change committed.
+ *
+ * Content lives in blog_post_revisions (openvibe-publishing/revisions): content = Markdown body,
+ * fields = { title, summary }, meta = { authorship } (openvibe-publishing/authorship record).
+ * blog_posts.published_revision says which revision readers see; editing a published post adds a
+ * revision without changing what readers see until that revision is published.
+ *
+ * Scheduled publication (openvibe-publishing/schedule) applies the idempotent effect "revision N is
+ * the published one": a worker that dies after applying it and before recording the job as done
+ * re-runs it after the lease expires, and the second run changes nothing and emits nothing.
+ */
+const { ids } = require('openvibe-contracts');
+const { slugify } = require('openvibe-publishing/taxonomy');
+const authorship = require('openvibe-publishing/authorship');
+const { isMediaId } = require('openvibe-publishing/media');
+const { ApiError } = require('../http/errors');
+const { actorRef } = require('./publication');
+
+const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];
+const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
+const KEY_RE = /^[a-z][a-z0-9_.:-]{0,127}$/;
+const MAX_BODY = 200000;
+const MAX_TAGS = 20;
+const MEDIA_ROLES = ['cover', 'inline', 'gallery'];
+
+const newPostId = (now) => `pst_${ids.ulid(now)}`;
+
+const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
+
+function listOf(v) {
+ if (v == null || v === '') return [];
+ const arr = Array.isArray(v) ? v : String(v).split(',');
+ return arr.map((s) => String(s).replace(/\s+/g, ' ').trim()).filter(Boolean);
+}
+
+function createPosts({ store, blogs, publication, access, outbox, log = console }) {
+ const { db } = store;
+ const q = {
+ byId: db.prepare('SELECT * FROM blog_posts WHERE id = ?'),
+ bySlug: db.prepare("SELECT * FROM blog_posts WHERE blog_id = ? AND slug = ? AND state <> 'deleted'"),
+ deletedBySlug: db.prepare("SELECT * FROM blog_posts WHERE blog_id = ? AND slug = ? AND state = 'deleted' ORDER BY deleted_at DESC LIMIT 1"),
+ slugTaken: db.prepare("SELECT id FROM blog_posts WHERE blog_id = ? AND slug = ? AND state <> 'deleted'"),
+ insert: db.prepare(`INSERT INTO blog_posts (id, blog_id, slug, state, visibility, entitlement_key, author_subject, series_id, series_position,
+ allow_comments, noindex, created_at, updated_at)
+ VALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,
+ @allow_comments, @noindex, @now, @now)`),
+ };
+ const blogOf = (post) => blogs.get(post.blog_id);
+
+ // ── Input normalisation ─────────────────────────────────
+
+ function title(v) {
+ const t = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
+ if (!t) throw new ApiError(422, 'post.invalid_title', 'A post needs a title');
+ if (t.length > 200) throw new ApiError(422, 'post.invalid_title', 'A title is at most 200 characters');
+ return t;
+ }
+ function body(v) {
+ const b = String(v == null ? '' : v).replace(/\r\n/g, '\n');
+ if (b.length > MAX_BODY) throw new ApiError(413, 'post.too_long', `A post is at most ${MAX_BODY} characters`);
+ return b;
+ }
+ function summary(v) {
+ if (v == null) return null;
+ const s = String(v).replace(/\s+/g, ' ').trim().slice(0, 500);
+ return s || null;
+ }
+ function visibilityOf(blog, v, key) {
+ const vis = v == null || v === '' ? 'public' : String(v);
+ if (!VISIBILITIES.includes(vis)) throw new ApiError(422, 'post.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
+ if (vis !== 'members') return { visibility: vis, entitlement_key: null };
+ const k = String(key || `blog:${blog.handle}:members`).trim().toLowerCase();
+ if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');
+ return { visibility: vis, entitlement_key: k };
+ }
+ function slugFor(blog, wanted, postId = null) {
+ let base;
+ try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }
+ if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');
+ for (let i = 1; i < 1000; i++) {
+ const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;
+ const hit = q.slugTaken.get(blog.id, s);
+ if (!hit || hit.id === postId) return s;
+ }
+ throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');
+ }
+
+ /** The acting person (usr_) or refuse. */
+ function actingSubject(viewer) {
+ if (!viewer || !viewer.subject) throw new ApiError(403, 'subject.required', 'This needs a signed-in member (or a service acting for one with X-OV-Subject)');
+ return viewer.subject;
+ }
+
+ /** The authorship record of a new revision, from who is writing and what came before. */
+ function authorshipFor(viewer, input, previous) {
+ const subject = viewer.subject;
+ const a = input.authorship || {};
+ if (viewer.kind === 'service' && viewer.origin === 'ai') {
+ // OpenVibe.AI output (blog.draft_post): AI-generated, draft + noindex until a person reviews it.
+ return authorship.record({ mode: 'ai', workflow: a.workflow, stubProvider: Boolean(a.stubProvider || a.stub_provider), source: a.source || null });
+ }
+ if (a.mode === 'hybrid' || (previous && (previous.mode === 'ai' || previous.mode === 'hybrid'))) {
+ const workflow = a.workflow || (previous && previous.workflow);
+ return authorship.record({ mode: 'hybrid', authors: [subject], workflow });
+ }
+ if (a.mode === 'imported') {
+ return authorship.record({ mode: 'imported', authors: [], importedFrom: a.importedFrom || a.imported_from });
+ }
+ return authorship.record({ mode: 'human', authors: [subject] });
+ }
+
+ function authorLabel(viewer) {
+ if (viewer.kind === 'service') return viewer.origin === 'ai' ? `${viewer.service} (ai)` : `${viewer.service} for ${viewer.subject}`;
+ return viewer.subject;
+ }
+
+ function setTerms(blog, post, input) {
+ if (input.tags !== undefined) {
+ const tags = listOf(input.tags);
+ if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);
+ for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');
+ store.taxonomy.setTerms(post.id, 'tag', tags);
+ }
+ if (input.categories !== undefined) {
+ const vocab = blogs.categoryVocabulary(blog);
+ const termIds = [];
+ // "Parent > Child" makes a nested category (breadcrumbs, parent pages include children).
+ for (const pathText of listOf(input.categories).slice(0, 10)) {
+ let parentId = null;
+ for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {
+ const term = store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });
+ parentId = term.id;
+ }
+ if (parentId != null) termIds.push(parentId);
+ }
+ store.taxonomy.setTerms(post.id, vocab, termIds);
+ }
+ }
+
+ function seriesOf(blog, input, current) {
+ if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;
+ const v = input.seriesId ?? input.series_id ?? input.series;
+ if (v == null || v === '') return { series_id: null, series_position: null };
+ let series = typeof v === 'string' && v.startsWith('ser_') ? blogs.seriesById(v) : blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);
+ if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');
+ const posRaw = input.seriesPosition ?? input.series_position;
+ const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);
+ if (pos != null && (!Number.isInteger(pos) || pos < 1 || pos > 10000)) throw new ApiError(422, 'post.invalid_series_position', 'series position is a whole number from 1');
+ return { series_id: series.id, series_position: pos };
+ }
+
+ function attachCitations(post, revision, list) {
+ if (!Array.isArray(list) || !list.length) return;
+ store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({
+ url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,
+ retrievedAt: c.retrievedAt || c.retrieved_at || null,
+ quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,
+ licenseNote: c.licenseNote || c.license_note || null,
+ })));
+ }
+
+ // ── Reads ───────────────────────────────────────────────
+
+ const api = {
+ VISIBILITIES, MEDIA_ROLES,
+
+ get: (id) => q.byId.get(String(id || '')) || null,
+ bySlug: (blog, slug) => q.bySlug.get(blog.id, String(slug || '')) || null,
+ deletedBySlug: (blog, slug) => q.deletedBySlug.get(blog.id, String(slug || '')) || null,
+ blogOf,
+
+ /** Must exist and not be deleted, else 404. */
+ mustGet(id) {
+ const p = api.get(id);
+ if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
+ return p;
+ },
+
+ /**
+ * Published posts for listings. `restricted` = also members/private posts (the viewer is a
+ * member of the blog or staff). Unlisted posts are never listed.
+ * filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset
+ */
+ listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
+ const where = ["p.state = 'published'"];
+ const args = [];
+ if (restricted) where.push("p.visibility IN ('public','members','private')");
+ else where.push("p.visibility = 'public'");
+ if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }
+ if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
+ if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
+ if (termIds) {
+ if (!termIds.length) return { total: 0, posts: [] };
+ where.push(`p.id IN (SELECT entity_id FROM blog_term_links WHERE term_id IN (${termIds.map(() => '?').join(',')}))`);
+ args.push(...termIds);
+ }
+ where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");
+ const w = where.join(' AND ');
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args).n;
+ const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';
+ const posts = db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);
+ return { total, posts };
+ },
+
+ /** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */
+ listForDashboard(blog, viewer) {
+ const role = access.effectiveRole(store, viewer, blog);
+ if (!role && !access.isStaff(viewer)) return [];
+ const own = role === 'author' ? ' AND author_subject = ?' : '';
+ const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];
+ return db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);
+ },
+
+ head: (post) => store.revisions.head(post.id),
+ revision: (post, n) => store.revisions.get(post.id, n),
+ revisions: (post, opts) => store.revisions.list(post.id, opts),
+ diff: (post, from, to, mode) => store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
+ pendingJobs: (post) => store.scheduler.jobs(post.id).filter((j) => j.status === 'pending' || j.status === 'running'),
+ attachments: (post) => store.attachments.list(post.id),
+ citations: (post, rev) => (rev ? store.citations.forRevision(post.id, rev) : []),
+
+ // ── Writes ──────────────────────────────────────────
+
+ /** A new draft (revision 1). Returns { post, revision }. */
+ create(viewer, blog, input = {}, { traceparent } = {}) {
+ const subject = actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
+ const t = title(input.title);
+ const content = body(input.body);
+ const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
+ const rec = authorshipFor(viewer, input, null);
+ return store.tx(() => {
+ const now = store.now();
+ const id = newPostId(now);
+ const slug = slugFor(blog, input.slug || t);
+ store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path
+ const ser = seriesOf(blog, input, { series_id: null, series_position: null });
+ q.insert.run({
+ id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,
+ allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),
+ noindex: truthy(input.noindex) ? 1 : 0, now,
+ });
+ const { revision } = store.revisions.create({
+ entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },
+ meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
+ });
+ attachCitations({ id }, revision.number, input.citations);
+ const post = q.byId.get(id);
+ setTerms(blog, post, input);
+ outbox.emit({
+ event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
+ subject: { type: 'post', id, revision: revision.number },
+ payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },
+ }, { traceparent });
+ return { post: q.byId.get(id), revision };
+ });
+ },
+
+ /**
+ * Edit: content (title/body/summary) becomes a new revision (expectedRevision required, 412
+ * on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug
+ * change leaves a 301 from the old path.
+ */
+ update(viewer, post, input = {}, { traceparent } = {}) {
+ actingSubject(viewer);
+ const blog = blogOf(post);
+ if (!access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
+ return store.tx(() => {
+ const before = publication.snapshot(blog, post);
+ const head = store.revisions.head(post.id);
+ let revision = head;
+ let created = false;
+ const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;
+ if (contentChange) {
+ const expected = input.expectedRevision ?? input.expected_revision;
+ if (expected == null || expected === '') throw new ApiError(428, 'revision.expected_required', 'Send expectedRevision (the revision you edited) so concurrent edits are not lost');
+ const fields = {
+ title: input.title !== undefined ? title(input.title) : head.fields.title,
+ summary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),
+ };
+ const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
+ const out = store.revisions.create({
+ entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,
+ fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
+ });
+ revision = out.revision;
+ created = out.created;
+ if (created) attachCitations(post, revision.number, input.citations);
+ }
+ const sets = {};
+ if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {
+ const slug = slugFor(blog, input.slug, post.id);
+ if (slug !== post.slug) {
+ store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });
+ sets.slug = slug;
+ }
+ }
+ if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {
+ Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));
+ }
+ Object.assign(sets, seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));
+ if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;
+ if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;
+ const keys = Object.keys(sets);
+ db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
+ .run({ ...sets, now: store.now(), id: post.id });
+ setTerms(blog, post, input);
+ const { post: after } = publication.afterChange(before, post.id, { actor: viewer, traceparent });
+ return { post: after, revision, created };
+ });
+ },
+
+ /** The idempotent effect: revision N is the published one. Returns { changed, post }. */
+ applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {
+ return store.tx(() => {
+ const post = q.byId.get(postId);
+ if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
+ const rev = revisionNumber ? store.revisions.get(post.id, revisionNumber) : store.revisions.head(post.id);
+ if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
+ if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
+ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
+ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
+ const before = publication.snapshot(blogOf(post), post);
+ const now = store.now();
+ db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
+ published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
+ const out = publication.afterChange(before, post.id, { actor, traceparent });
+ return { changed: true, post: out.post };
+ });
+ },
+
+ applyUnpublish(postId, actor, { traceparent } = {}) {
+ return store.tx(() => {
+ const post = q.byId.get(postId);
+ if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
+ if (post.state !== 'published') return { changed: false, post };
+ const before = publication.snapshot(blogOf(post), post);
+ db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ return { changed: true, post: publication.afterChange(before, post.id, { actor, traceparent }).post };
+ });
+ },
+
+ publish(viewer, post, { revision } = {}, ctx = {}) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');
+ const n = revision == null || revision === '' ? null : parseInt(revision, 10);
+ return store.tx(() => {
+ store.scheduler.cancelPending(post.id, 'publish');
+ return api.applyPublish(post.id, n, viewer, ctx);
+ });
+ },
+
+ unpublish(viewer, post, ctx = {}) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
+ return store.tx(() => {
+ store.scheduler.cancelPending(post.id);
+ const cur = q.byId.get(post.id);
+ if (cur.state === 'scheduled') {
+ db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ return { changed: true, post: q.byId.get(post.id) };
+ }
+ return api.applyUnpublish(post.id, viewer, ctx);
+ });
+ },
+
+ /**
+ * Schedule a publish (of revision N, default the head) or an unpublish at a future time.
+ * Idempotent: the same (post, action, time, revision) is one job.
+ */
+ schedule(viewer, post, { at, revision, action = 'publish' } = {}) {
+ actingSubject(viewer);
+ const blog = blogOf(post);
+ if (!access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');
+ if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');
+ const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);
+ if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');
+ if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');
+ const rev = action === 'publish' ? (revision ? store.revisions.get(post.id, parseInt(revision, 10)) : store.revisions.head(post.id)) : null;
+ if (action === 'publish') {
+ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
+ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
+ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
+ }
+ return store.tx(() => {
+ const { job, created } = store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
+ const cur = q.byId.get(post.id);
+ if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {
+ db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ }
+ return { job, created, post: q.byId.get(post.id) };
+ });
+ },
+
+ cancelSchedule(viewer, post) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');
+ return store.tx(() => {
+ const n = store.scheduler.cancelPending(post.id);
+ const cur = q.byId.get(post.id);
+ if (cur.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ return { cancelled: n, post: q.byId.get(post.id) };
+ });
+ },
+
+ /**
+ * Worker step: claim and run due jobs. A failed job (after its retries) emits
+ * blog.schedule.failed and returns a scheduled post to draft.
+ */
+ async runScheduled(worker) {
+ const summary = await store.scheduler.runDue({
+ worker,
+ handler: (job) => (job.action === 'publish'
+ ? api.applyPublish(job.entityId, job.revision, null).changed
+ : api.applyUnpublish(job.entityId, null).changed),
+ });
+ for (const job of summary.failed) {
+ store.tx(() => {
+ const post = q.byId.get(job.entityId);
+ if (post && post.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ outbox.emit({
+ event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',
+ subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },
+ payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },
+ });
+ });
+ log.warn(`[Blog] scheduled ${job.action} of ${job.entityId} failed: ${job.lastError}`);
+ }
+ return summary;
+ },
+
+ /** Soft delete: readers get 410, Search a tombstone, revisions are kept. */
+ remove(viewer, post, ctx = {}) {
+ actingSubject(viewer);
+ const blog = blogOf(post);
+ if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
+ return store.tx(() => {
+ store.scheduler.cancelPending(post.id);
+ const before = publication.snapshot(blog, q.byId.get(post.id));
+ const now = store.now();
+ db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
+ return publication.afterChange(before, post.id, { actor: viewer, ...ctx });
+ });
+ },
+
+ /** Revert = a new revision copying revision N (content, fields and its authorship). */
+ revert(viewer, post, { toRevision, expectedRevision } = {}) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ const target = store.revisions.get(post.id, parseInt(toRevision, 10));
+ if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);
+ return store.tx(() => {
+ const { revision } = store.revisions.revert({
+ entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),
+ author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },
+ });
+ store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });
+ db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);
+ return { revision, post: q.byId.get(post.id) };
+ });
+ },
+
+ /** A person's review of one revision (what lets an AI draft be published and indexed). */
+ review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
+ if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
+ const blog = blogOf(post);
+ if (!access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
+ const n = parseInt(revision, 10);
+ if (!store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
+ return store.tx(() => {
+ const before = publication.snapshot(blog, post);
+ const row = store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
+ publication.afterChange(before, post.id, { actor: viewer, ...ctx });
+ return row;
+ });
+ },
+
+ attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ const id = String(mediaId || '').trim();
+ if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');
+ if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);
+ const pos = parseInt(position, 10);
+ return store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
+ },
+
+ detach(viewer, post, attachmentId) {
+ actingSubject(viewer);
+ if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ return store.attachments.detach(post.id, attachmentId);
+ },
+ };
+ return api;
+}
+
+module.exports = { createPosts, VISIBILITIES, MEDIA_ROLES, listOf, truthy };
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+
+/**
+ * Publication mechanics shared by the routes, the worker and the seed:
+ * - canonical paths and URLs
+ * - the indexability gate (openvibe-publishing/seo) with Blog's editorial policy
+ * - the Search document and the publication events (openvibe-publishing/index-hooks), enqueued
+ * in the same transaction as the change through the SDK outbox
+ *
+ * Search receives ONLY published, public, listable posts. Everything else (drafts, scheduled,
+ * unlisted, members/VIP, private, unpublished, deleted) is a tombstone, so a visibility change or
+ * an unpublish removes the old copy; a post that was never indexed gets no tombstone.
+ */
+const seo = require('openvibe-publishing/seo');
+const ssr = require('openvibe-publishing/ssr');
+const hooks = require('openvibe-publishing/index-hooks');
+const authorship = require('openvibe-publishing/authorship');
+
+// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is
+// served and listed in feeds but not offered to search engines (reason "thin").
+const POLICY = Object.freeze({ minWords: 80, requireSources: false });
+const OWNER = 'blog';
+
+/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
+const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
+
+function createPublication({ store, config, outbox }) {
+ const { db } = store;
+ const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
+ const membersOf = db.prepare('SELECT subject FROM blog_memberships WHERE blog_id = ? ORDER BY subject');
+
+ const blogPath = (blog) => (blog.kind === 'official' ? '/' : `/@${blog.handle}`);
+ const postPath = (blog, post) => `/@${blog.handle}/${post.slug}`;
+ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
+ const postUrl = (blog, post) => abs(postPath(blog, post));
+ const feedId = (post) => `tag:openvibe.blog,2026:post/${post.id}`; // stable across slug changes
+
+ function authorshipOf(rev) {
+ return (rev && rev.meta && rev.meta.authorship) || null;
+ }
+
+ function reviewOf(post, rev) {
+ return rev ? store.reviews.latest(post.id, rev.number) : null;
+ }
+
+ /** The gate's decision for a post at one revision (the published one by default). */
+ function decide(blog, post, rev, { state } = {}) {
+ const rec = authorshipOf(rev);
+ const facts = {
+ state: state || post.state,
+ visibility: GATE_VISIBILITY[post.visibility],
+ canonicalUrl: postUrl(blog, post),
+ text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
+ noindex: Boolean(post.noindex),
+ };
+ if (rec) Object.assign(facts, authorship.gateFacts(rec, reviewOf(post, rev)));
+ return seo.evaluate(facts, { policy: POLICY, now: store.now() });
+ }
+
+ /** The revision readers see (published) or, for a never-published post, the head. */
+ function visibleRevision(post) {
+ return post.published_revision ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
+ }
+
+ function aclFor(blog, post) {
+ const subjects = [...new Set([...membersOf.all(blog.id).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));
+ if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };
+ if (post.visibility === 'private') return { subjects };
+ return {};
+ }
+
+ function termsOf(post) {
+ const terms = store.taxonomy.termsFor(post.id);
+ return {
+ tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),
+ categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),
+ };
+ }
+
+ /**
+ * The index document as it would describe this post (real visibility, for the product events),
+ * or a tombstone when it is not published.
+ */
+ function documentFor(blog, post, { forSearch }) {
+ const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
+ const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };
+ if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(blog, post, rev) : null };
+ const decision = decide(blog, post, rev);
+ if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };
+ const { tags, categories } = termsOf(post);
+ const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
+ const rec = authorshipOf(rev);
+ const acl = aclFor(blog, post);
+ const doc = hooks.buildIndexDocument({
+ ...identity,
+ state: 'published',
+ visibility: GATE_VISIBILITY[post.visibility],
+ acl,
+ includeUnlisted: !forSearch,
+ canonicalUrl: postUrl(blog, post),
+ title: rev.fields.title,
+ summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),
+ body: ssr.markdownToText(rev.content),
+ facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },
+ authorship: rec,
+ citations: store.citations.forRevision(post.id, rev.number),
+ decision,
+ publishedAt: post.first_published_at,
+ updatedAt: rev.createdAt,
+ language: blog.language,
+ });
+ return { doc, decision };
+ }
+
+ /** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
+ function syncIndex(blog, post, { traceparent } = {}) {
+ const { doc } = documentFor(blog, post, { forSearch: true });
+ const prev = store.sequencer.current(OWNER, 'post', post.id);
+ if (doc.deleted && prev == null) return null; // never indexed: nothing to remove
+ const stamped = store.sequencer.stamp(doc);
+ if (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send
+ return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ }
+
+ /** Snapshot for actionFor(before, after). */
+ function snapshot(blog, post) {
+ return post ? { state: post.state, visibility: post.visibility, revision: post.published_revision, url: postUrl(blog, post) } : null;
+ }
+
+ /**
+ * Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),
+ * and re-sync Search. Inside the caller's transaction, after the row changed.
+ */
+ function afterChange(before, postId, { actor, traceparent } = {}) {
+ const post = db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);
+ const blog = blogById.get(post.blog_id);
+ const after = snapshot(blog, post);
+ let action = hooks.actionFor(before, after);
+ if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';
+ let event = null;
+ if (action) {
+ const { doc, decision } = documentFor(blog, post, { forSearch: false });
+ event = outbox.emit(hooks.publicationEvent({
+ product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,
+ actor: actorRef(actor), document: doc, decision, now: store.now(),
+ extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
+ }), { traceparent });
+ }
+ syncIndex(blog, post, { traceparent });
+ return { action, event, post, blog };
+ }
+
+ return {
+ POLICY, OWNER, GATE_VISIBILITY,
+ blogPath, postPath, postUrl, abs, feedId,
+ decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, termsOf,
+ };
+}
+
+/** Event actor from a viewer (or 'svc:blog' for the worker). */
+function actorRef(actor) {
+ if (!actor) return { type: 'service', id: 'blog' };
+ if (typeof actor === 'string') return hooks.subjectRef(actor);
+ if (actor.kind === 'user' && actor.subject) return { type: 'user', id: actor.subject };
+ if (actor.kind === 'service') {
+ if (actor.subject && actor.origin !== 'ai') return { type: 'user', id: actor.subject };
+ return { type: 'service', id: String(actor.service || '').replace(/^svc:/, '') || 'unknown' };
+ }
+ if (actor.type && actor.id) return { type: actor.type, id: actor.id };
+ return { type: 'service', id: 'blog' };
+}
+
+module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+
+/**
+ * Read models shared by the pages, the JSON representations, the feeds and the sitemaps: URLs for
+ * every collection, list items with their authors and tags, feed items, and the category tree.
+ * Nothing here decides access — callers pass rows they may show.
+ */
+const seo = require('openvibe-publishing/seo');
+const ssr = require('openvibe-publishing/ssr');
+const authorship = require('openvibe-publishing/authorship');
+const { renderBody } = require('../render/pages');
+
+function createReading({ store, blogs, posts: postsApi, publication, people, media }) {
+ const { db } = store;
+
+ const blogPath = (blog) => publication.blogPath(blog);
+ const base = (blog) => `/@${blog.handle}`;
+ const urls = {
+ blog: blogPath,
+ tag: (tag, blog = null) => (blog ? `${base(blog)}/tags/${tag.slug}` : `/tags/${tag.slug}`),
+ category: (blog, term) => `${base(blog)}/categories/${term.slug}`,
+ series: (blog, s) => `${base(blog)}/series/${s.slug}`,
+ author: (person, blog = null) => `${blog ? base(blog) : ''}/authors/${encodeURIComponent(person.username || person.subject)}`,
+ feed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,
+ };
+
+ function feedsOf(blog) {
+ const s = blogs.feedSettings(blog);
+ const out = [];
+ if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });
+ if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });
+ if (s.json) out.push({ type: 'json', href: urls.feed(blog, 'json'), title: `${blog.title} (JSON Feed)`, label: 'JSON Feed', mime: 'application/feed+json' });
+ return out;
+ }
+
+ function termsView(post, blog, { networkTags = false } = {}) {
+ const terms = store.taxonomy.termsFor(post.id);
+ return {
+ tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),
+ categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),
+ };
+ }
+
+ /** List items for rows of published posts (possibly from several blogs). */
+ async function listItems(rows, { perBlogLinks = true } = {}) {
+ const who = await people.many(rows.map((p) => p.author_subject));
+ return rows.map((post) => {
+ const blog = blogs.get(post.blog_id);
+ const rev = store.revisions.get(post.id, post.published_revision);
+ const person = who.get(post.author_subject);
+ const { tags } = termsView(post, blog, { networkTags: !perBlogLinks });
+ return {
+ post, rev, blog,
+ url: publication.postPath(blog, post),
+ author: person ? { name: person.name, url: urls.author(person, blog) } : null,
+ tags,
+ badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
+ };
+ }).filter((it) => it.rev);
+ }
+
+ function categoriesTree(blog) {
+ return store.taxonomy.tree(blogs.categoryVocabulary(blog));
+ }
+
+ /** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */
+ async function feedItems(blog, { limit, fullContent }) {
+ const { posts: rows } = postsApi.listPublished({ blogId: blog.id, limit, restricted: false });
+ const who = await people.many(rows.map((p) => p.author_subject));
+ return rows.map((post) => {
+ const rev = store.revisions.get(post.id, post.published_revision);
+ if (!rev) return null;
+ const decision = publication.decide(blog, post, rev);
+ const person = who.get(post.author_subject);
+ const atts = store.attachments.list(post.id);
+ const cover = atts.find((a) => a.role === 'cover' && !a.broken);
+ const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
+ return {
+ id: publication.feedId(post),
+ url: publication.postUrl(blog, post),
+ title: rev.fields.title,
+ summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),
+ contentHtml: fullContent ? renderBody(rev.content, atts, { urlFor: media.urlFor, rel }) : null,
+ published: post.first_published_at,
+ updated: rev.createdAt,
+ decision,
+ authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
+ tags: termsView(post, blog).tags.map((t) => t.name),
+ image: cover ? media.urlFor(cover.mediaId) : null,
+ };
+ }).filter(Boolean);
+ }
+
+ /** The machine-readable representation of a post (same content as the page). */
+ function postJson({ blog, post, rev, person, decision }) {
+ const rec = publication.authorshipOf(rev);
+ const review = publication.reviewOf(post, rev);
+ const { tags, categories } = termsView(post, blog);
+ const series = blogs.seriesById(post.series_id);
+ return {
+ id: post.id,
+ url: publication.postUrl(blog, post),
+ blog: { id: blog.id, handle: blog.handle, title: blog.title, url: publication.abs(blogPath(blog)) },
+ title: rev.fields.title,
+ summary: rev.fields.summary || null,
+ body_markdown: rev.content,
+ revision: rev.number,
+ state: post.state,
+ visibility: post.visibility,
+ published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
+ revised_at: rev.createdAt,
+ author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,
+ authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, reviewed: authorship.isReviewed(rec, review), disclosure: authorship.disclosure(rec, review) } : null,
+ tags: tags.map((t) => t.name),
+ categories: categories.map((c) => c.name),
+ series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
+ media: store.attachments.list(post.id).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),
+ citations: store.citations.forRevision(post.id, rev.number).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),
+ indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },
+ };
+ }
+
+ /** BlogPosting JSON-LD from real fields only (missing → omitted). */
+ function postJsonLd({ blog, post, rev, person, tags, citations, attachments }) {
+ const url = publication.postUrl(blog, post);
+ const cover = attachments.find((a) => a.role === 'cover' && !a.broken);
+ return seo.structuredData.article({
+ type: 'BlogPosting',
+ headline: rev.fields.title,
+ url,
+ description: rev.fields.summary || ssr.markdownToText(rev.content, 200),
+ datePublished: post.first_published_at || null,
+ dateModified: post.published_revision ? rev.createdAt : null,
+ authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
+ publisher: blog.kind === 'official' ? { name: 'OpenVibe', url: 'https://openvibe.network' } : null,
+ image: cover ? media.urlFor(cover.mediaId) : null,
+ keywords: tags.map((t) => t.name),
+ inLanguage: blog.language,
+ citations: citations.filter((c) => c.url).map((c) => ({ url: c.url, title: c.title })),
+ wordCount: ssr.wordCount(ssr.markdownToText(rev.content)),
+ });
+ }
+
+ return { urls, feedsOf, termsView, listItems, categoriesTree, feedItems, postJson, postJsonLd, db };
+}
+
+module.exports = { createReading };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * Blog → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * blog.post.created a post (draft) exists
+ * blog.post.published|updated|unpublished|deleted
+ * publication changes (openvibe-publishing/index-hooks
+ * publicationEvent: canonical URL, state, indexability; public
+ * only for public, listable posts)
+ * blog.schedule.failed a scheduled publish/unpublish gave up after its retries
+ * blog.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
+ * indexEvent), consumed by Search's '*.index_document.*'
+ * subscription
+ *
+ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
+ * if its change committed. The relay publishes with Blog'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 createBlogOutbox({ 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: 'blog' });
+ 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('[Blog] 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 = { createBlogOutbox };
new file mode 100644
new file mode 100644
@@ -0,0 +1,284 @@
@@ -0,0 +1,284 @@
+'use strict';
+
+/**
+ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.blog, one
+ * capability per route, acting for the person in X-OV-Subject) and for browsers or apps with a
+ * Network user JWT (judged by blog membership). Errors are problem+json.
+ *
+ * GET /blogs/:handle public
+ * POST /blogs blog.blog.create get-or-create the acting member's blog
+ * PATCH /blogs/:handle blog.blog.configure title, description, language, feeds
+ * PUT /blogs/:handle/theme blog.theme.set
+ * GET /blogs/:handle/members blog.member.manage
+ * PUT /blogs/:handle/members/:subject blog.member.manage { role }
+ * DELETE /blogs/:handle/members/:subject blog.member.manage
+ * GET /blogs/:handle/posts public list; ?all=1 drafts too (members; services: blog.post.read)
+ * POST /blogs/:handle/posts blog.post.create (X-OV-Origin: ai → an AI draft that needs review)
+ * GET /blogs/:handle/feed blog.feed.read the blog's JSON Feed (public posts only)
+ * GET /posts/:id blog.post.read (readers of published posts need nothing)
+ * PATCH /posts/:id blog.post.update content → new revision (expected_revision)
+ * POST /posts/:id/publish blog.post.publish { revision? }
+ * POST /posts/:id/schedule blog.post.schedule { at, revision?, action? }
+ * DELETE /posts/:id/schedule blog.post.schedule
+ * POST /posts/:id/unpublish blog.post.unpublish
+ * DELETE /posts/:id blog.post.delete
+ * GET /posts/:id/revisions[/:n], /posts/:id/diff?from&to&mode blog.post.read
+ * POST /posts/:id/revert blog.post.update { to_revision, expected_revision }
+ * POST /posts/:id/reviews people only { revision, decision, note? }
+ * POST /posts/:id/attachments blog.post.update { media_id, role, alt, caption, position }
+ * DELETE /posts/:id/attachments/:aid blog.post.update
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const seo = require('openvibe-publishing/seo');
+const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
+const { guard } = require('../auth/viewer');
+const { checkCapability } = require('../auth/capabilities');
+
+function cors(origins) {
+ const allowed = new Set(origins);
+ return (req, res, next) => {
+ const origin = req.get('origin');
+ if (origin && allowed.has(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.vary('Origin');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
+ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ next();
+ };
+}
+
+function createApi(ctx) {
+ const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people } = ctx;
+ const router = express.Router();
+ router.use(cors(config.apiCorsOrigins));
+ router.use(viewers.middleware());
+ router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
+
+ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
+
+ function mustBlog(req) {
+ const blog = blogs.byHandle(req.params.handle);
+ if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');
+ return blog;
+ }
+ const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
+
+ function blogDto(blog, { full = false } = {}) {
+ const out = {
+ id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,
+ url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,
+ feeds: reading.feedsOf(blog).map((f) => ({ type: f.type, url: publication.abs(f.href) })),
+ };
+ if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: blogs.feedSettings(blog) });
+ return out;
+ }
+
+ function postDto(post, { full = false } = {}) {
+ const blog = posts.blogOf(post);
+ const rev = full ? posts.head(post) : posts.revision(post, post.published_revision);
+ const shown = post.published_revision ? posts.revision(post, post.published_revision) : rev;
+ const decision = shown ? publication.decide(blog, post, shown) : null;
+ const { tags, categories } = publication.termsOf(post);
+ const series = blogs.seriesById(post.series_id);
+ const out = {
+ id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),
+ state: post.state, visibility: post.visibility, author_subject: post.author_subject,
+ title: rev ? rev.fields.title : null, summary: rev ? rev.fields.summary || null : null,
+ revision: rev ? rev.number : null, published_revision: post.published_revision,
+ first_published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
+ published_at: post.published_at ? new Date(post.published_at).toISOString() : null,
+ tags, categories, series: series ? { id: series.id, slug: series.slug, title: series.title, position: post.series_position } : null,
+ allow_comments: Boolean(post.allow_comments), noindex: Boolean(post.noindex),
+ indexability: decision ? { indexable: decision.indexable, robots: decision.robots, reasons: decision.reasons } : null,
+ };
+ if (full) {
+ const rec = publication.authorshipOf(rev);
+ Object.assign(out, {
+ entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
+ authorship: rec, review: rev ? publication.reviewOf(post, rev) : null,
+ scheduled: posts.pendingJobs(post), attachments: posts.attachments(post),
+ citations: rev ? posts.citations(post, rev.number) : [],
+ });
+ }
+ return out;
+ }
+
+ function mustPost(req, cap) {
+ const post = posts.mustGet(req.params.id);
+ return { post, blog: posts.blogOf(post), cap };
+ }
+
+ function after(before, postId, req) {
+ effects.after(before, posts.get(postId), req.ov);
+ }
+
+ // ── Blogs ───────────────────────────────────────────────
+
+ router.get('/blogs/:handle', run((req) => {
+ const blog = mustBlog(req);
+ return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };
+ }));
+
+ router.post('/blogs', guard('blog.blog.create'), jsonBody, run((req) => {
+ const b = req.body || {};
+ const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
+ const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
+ return { blog: blogDto(blog, { full: true }), created };
+ }, (out) => (out.created ? 201 : 200)));
+
+ router.patch('/blogs/:handle', guard('blog.blog.configure'), jsonBody, run((req) => {
+ const blog = mustBlog(req);
+ if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
+ const b = req.body || {};
+ const updated = store.tx(() => {
+ const u = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
+ if (b.feeds) blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });
+ return blogs.get(u.id);
+ });
+ return { blog: blogDto(updated, { full: true }) };
+ }));
+
+ router.put('/blogs/:handle/theme', guard('blog.theme.set'), jsonBody, run((req) => {
+ const blog = mustBlog(req);
+ if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
+ return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
+ }));
+
+ router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {
+ const blog = mustBlog(req);
+ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');
+ const list = blogs.members(blog);
+ const who = await people.many(list.map((m) => m.subject));
+ return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
+ }));
+
+ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), jsonBody, run((req) => {
+ const blog = mustBlog(req);
+ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
+ return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
+ }));
+
+ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), run((req) => {
+ const blog = mustBlog(req);
+ if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
+ return { removed: blogs.removeMember(blog, req.params.subject) };
+ }));
+
+ router.get('/blogs/:handle/posts', run((req) => {
+ const blog = mustBlog(req);
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+ if (req.query.all === '1') {
+ if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');
+ if (!isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
+ return { posts: posts.listForDashboard(blog, req.viewer).slice(offset, offset + limit).map((p) => postDto(p, { full: false })) };
+ }
+ const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted: isMember(blog, req.viewer), limit, offset });
+ return { total, posts: rows.map((p) => postDto(p)) };
+ }));
+
+ router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {
+ const blog = mustBlog(req);
+ const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
+ after(null, post.id, req);
+ return { post: postDto(post, { full: true }), revision: revision.number };
+ }, 201));
+
+ router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {
+ const blog = mustBlog(req);
+ const s = blogs.feedSettings(blog);
+ const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
+ res.set('Cache-Control', 'public, max-age=300');
+ res.removeHeader('X-Robots-Tag');
+ return seo.jsonFeed({ title: blog.title, link: publication.abs(publication.blogPath(blog)), feedUrl: publication.abs(reading.urls.feed(blog, 'json')), description: blog.description || undefined }, items);
+ }));
+
+ // ── Posts ───────────────────────────────────────────────
+
+ router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
+ const { post, blog } = mustPost(req);
+ if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };
+ const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
+ if (!d.allowed) throw new ApiError(d.status, d.status === 403 ? 'post.members_only' : 'post.not_found', d.status === 403 ? 'Members only' : 'No such post');
+ return { post: postDto(post) };
+ }));
+
+ const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => {
+ const { post } = mustPost(req);
+ const before = { ...post };
+ const out = await fn(req, post, req.body || {});
+ after(before, post.id, req);
+ return out;
+ }, status));
+
+ write('patch', '/posts/:id', guard('blog.post.update'), (req, post, b) => {
+ const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
+ const out = posts.update(req.viewer, post, input, tp(req));
+ return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
+ });
+ write('post', '/posts/:id/publish', guard('blog.post.publish'), (req, post, b) => {
+ const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
+ return { post: postDto(out.post, { full: true }), changed: out.changed };
+ });
+ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post, b) => {
+ const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
+ return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };
+ }, 201);
+ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post) => {
+ const out = posts.cancelSchedule(req.viewer, post);
+ return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };
+ });
+ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), (req, post) => {
+ const out = posts.unpublish(req.viewer, post, tp(req));
+ return { post: postDto(out.post, { full: true }), changed: out.changed };
+ });
+ write('delete', '/posts/:id', guard('blog.post.delete'), (req, post) => {
+ posts.remove(req.viewer, post, tp(req));
+ return { deleted: true, id: post.id };
+ });
+ write('post', '/posts/:id/revert', guard('blog.post.update'), (req, post, b) => {
+ const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
+ return { revision: out.revision.number, post: postDto(out.post, { full: true }) };
+ }, 201);
+ router.post('/posts/:id/reviews', jsonBody, run(async (req) => {
+ const { post } = mustPost(req);
+ const b = req.body || {};
+ const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
+ after({ ...post }, post.id, req);
+ return { review };
+ }, 201));
+ write('post', '/posts/:id/attachments', guard('blog.post.update'), (req, post, b) => ({
+ attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
+ }), 201);
+ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));
+
+ function readable(req) {
+ const { post, blog } = mustPost(req);
+ if (!(access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');
+ return post;
+ }
+ router.get('/posts/:id/revisions', guard('blog.post.read'), run((req) => {
+ const post = readable(req);
+ return { revisions: posts.revisions(post, { limit: req.query.limit, before: req.query.before }).map(({ content, ...r }) => ({ ...r, length: content.length })) };
+ }));
+ router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run((req) => {
+ const post = readable(req);
+ const rev = posts.revision(post, parseInt(req.params.n, 10));
+ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
+ return { revision: rev, citations: posts.citations(post, rev.number) };
+ }));
+ router.get('/posts/:id/diff', guard('blog.post.read'), run((req) => {
+ const post = readable(req);
+ return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
+ }));
+
+ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
+ return router;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):
+ *
+ * GET /robots.txt sitemap location + explicit automated-consumer policy
+ * GET /llms.txt orientation for language models (what the site is, entry points)
+ * GET /sitemap.xml sitemap index over the two sections below
+ * GET /sitemaps/posts.xml published, public, INDEXABLE posts only (the gate decides), lastmod
+ * = the published revision's real time
+ * GET /sitemaps/blogs.xml blog front pages and series pages that have indexable posts
+ *
+ * Built from the database on every request, never from the viewer: drafts, scheduled, unlisted,
+ * members/VIP, private, unpublished, deleted and noindex posts can never appear.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const sharedSeo = require('openvibe-shared/seo');
+
+function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
+ const router = express.Router();
+ const { db } = store;
+ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
+
+ const publicPosts = () => db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
+ WHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'
+ ORDER BY p.published_at DESC LIMIT 50000`).all();
+
+ function postEntries() {
+ return publicPosts().map((post) => {
+ const blog = blogs.get(post.blog_id);
+ const rev = store.revisions.get(post.id, post.published_revision);
+ return { post, blog, rev, decision: publication.decide(blog, post, rev) };
+ });
+ }
+
+ const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
+
+ router.get('/robots.txt', (_req, res) => {
+ const body = [
+ '# openvibe.blog automated-consumer policy: search engines and AI crawlers are welcome to read',
+ '# public posts, feeds and sitemaps. Drafts, the editor, sign-in and the API are not for crawling.',
+ '# Pages decide their own indexability (meta robots / X-Robots-Tag); a Disallow is not a noindex.',
+ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/write', '/auth/', '/api/'] }),
+ ].join('\n');
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
+ });
+
+ router.get('/llms.txt', (_req, res) => {
+ const official = blogs.official();
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
+ name: 'OpenVibe.Blog',
+ summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
+ details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: official ? official.title : 'The OpenVibe blog', url: abs('/'), note: 'official announcements and release notes' },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ ] },
+ { title: 'Feeds', links: [
+ { title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') },
+ { title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },
+ ] },
+ { title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },
+ ],
+ }));
+ });
+
+ router.get('/sitemap.xml', (_req, res) => {
+ const entries = postEntries().filter((e) => e.decision.indexable);
+ const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
+ xml(res, seo.sitemapIndex([
+ { loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },
+ { loc: abs('/sitemaps/blogs.xml'), ...(newest ? { lastmod: newest } : {}) },
+ ]));
+ });
+
+ router.get('/sitemaps/posts.xml', (_req, res) => {
+ const out = seo.sitemap(postEntries().map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));
+ xml(res, out.files[0]);
+ });
+
+ router.get('/sitemaps/blogs.xml', (_req, res) => {
+ const byBlog = new Map();
+ const bySeries = new Map();
+ for (const e of postEntries()) {
+ if (!e.decision.indexable) continue;
+ const t = e.rev.createdAt;
+ if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });
+ if (e.post.series_id && (!bySeries.has(e.post.series_id) || bySeries.get(e.post.series_id).lastmod < t)) bySeries.set(e.post.series_id, { blog: e.blog, lastmod: t });
+ }
+ const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });
+ const entries = [];
+ for (const { blog, lastmod } of byBlog.values()) {
+ const path = publication.blogPath(blog);
+ entries.push({ loc: abs(path), lastmod, decision: listing(path) });
+ }
+ for (const [id, { blog, lastmod }] of bySeries) {
+ const s = blogs.seriesById(id);
+ if (!s) continue;
+ const path = reading.urls.series(blog, s);
+ entries.push({ loc: abs(path), lastmod, decision: listing(path) });
+ }
+ xml(res, seo.sitemap(entries).files[0]);
+ });
+
+ return router;
+}
+
+module.exports = { createDiscoveryRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+'use strict';
+
+/**
+ * The editor as plain HTML forms (no JavaScript needed). Signed-in members only (Network SSO);
+ * every POST carries the form token. Every response is private, no-store and noindex.
+ *
+ * GET /write your blogs; start your blog
+ * POST /write/start create your member blog (first use)
+ * GET /write/@:handle the blog's posts (drafts included)
+ * GET /write/@:handle/new, POST …/new a new draft
+ * GET /write/posts/:id, POST … edit (a new revision; 412 when someone saved first)
+ * POST /write/posts/:id/{publish,schedule,unschedule,unpublish,delete,review,revert}
+ * POST /write/posts/:id/media, …/media/:aid/remove
+ * GET /write/posts/:id/revisions history and diffs GET /write/posts/:id/preview
+ * GET|POST /write/@:handle/settings title, theme, feeds POST /write/@:handle/members[/remove]
+ */
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const authorship = require('openvibe-publishing/authorship');
+const { escapeHtml: esc } = require('openvibe-publishing/ssr');
+const { renderPage } = require('../render/layout');
+const editor = require('../render/editor');
+const { csrfToken, checkCsrf } = require('../auth/forms');
+const { asApiError } = require('./errors');
+
+/** Checkbox + hidden fallback arrive as ['1','0']; a text field repeated keeps its last value. */
+const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
+
+function formInput(body) {
+ const b = {};
+ for (const [k, v] of Object.entries(body || {})) b[k] = one(v);
+ return b;
+}
+
+function createEditorRoutes(ctx) {
+ const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes } = ctx;
+ const router = express.Router();
+ const form = express.urlencoded({ extended: false, limit: '600kb' });
+ router.use(viewers.middleware({ services: false }));
+ router.use((req, res, next) => {
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Robots-Tag', 'noindex, nofollow');
+ res.vary('Cookie');
+ if (req.viewer.kind !== 'user') {
+ if (req.method === 'GET') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ return publicRoutes.messagePage(req, res, 401, 'Sign in first', 'Your session ended. Sign in and try again.', { href: `/auth/login?next=${encodeURIComponent('/write')}`, label: 'Sign in' });
+ }
+ if (!req.viewer.subject) return publicRoutes.messagePage(req, res, 403, 'Account not ready', 'Your OpenVibe sign-in did not include an account id. Sign out and in again.');
+ next();
+ });
+ router.post('*', form, (req, res, next) => {
+ if (!checkCsrf(config, req.viewer, req.body && one(req.body._csrf))) return publicRoutes.messagePage(req, res, 403, 'Form expired', 'Reload the page and submit it again.');
+ next();
+ });
+
+ const wrap = (fn) => async (req, res, next) => {
+ try { await fn(req, res, next); } catch (err) {
+ const e = asApiError(err);
+ if (!e) return next(err);
+ const text = e.code === 'revision.conflict'
+ ? `Someone saved revision ${e.extra && e.extra.current} while you were editing. Your text was not saved: open the post again, re-apply your change and save.`
+ : e.message;
+ publicRoutes.messagePage(req, res, e.status, 'That did not work', text, { href: req.get('referer') && /^\//.test(new URL(req.get('referer'), config.baseUrl).pathname) ? new URL(req.get('referer'), config.baseUrl).pathname : '/write', label: 'Back' });
+ }
+ };
+
+ function page(req, res, title, body, status = 200) {
+ const decision = publicRoutes.pageDecision(req.path, { indexable: false });
+ res.status(status).type('html').send(renderPage({ title, body, decision, viewer: req.viewer, config, path: req.originalUrl, bodyClass: 'editor' }));
+ }
+
+ const csrf = (req) => csrfToken(config, req.viewer);
+ const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);
+
+ function blogFor(req) {
+ const blog = blogs.byHandle(req.params.handle);
+ if (!blog) return null;
+ return access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;
+ }
+
+ function postFor(req) {
+ const post = posts.get(req.params.id);
+ if (!post || post.state === 'deleted') return null;
+ const blog = posts.blogOf(post);
+ if (!access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;
+ return { post, blog };
+ }
+
+ const notFound = (req, res) => publicRoutes.notFound(req, res);
+
+ // ── Home, first use ─────────────────────────────────────
+
+ router.get('/', wrap(async (req, res) => {
+ const mine = blogs.blogsOf(req.viewer.subject);
+ const official = blogs.official();
+ if (access.isStaff(req.viewer) && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
+ page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
+ }));
+
+ router.post('/start', wrap(async (req, res) => {
+ const b = formInput(req.body);
+ const { blog } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });
+ res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);
+ }));
+
+ // ── A blog ──────────────────────────────────────────────
+
+ router.get('/@:handle', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog) return notFound(req, res);
+ const list = posts.listForDashboard(blog, req.viewer);
+ const heads = new Map(list.map((p) => [p.id, (posts.head(p) || { fields: {} }).fields]));
+ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
+ }));
+
+ router.get('/@:handle/new', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
+ page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
+ }));
+
+ router.post('/@:handle/new', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog) return notFound(req, res);
+ const { post } = posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
+ effects.after(null, post, req.ov);
+ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);
+ }));
+
+ router.get('/@:handle/settings', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
+ const members = blogs.members(blog);
+ const who = await people.many(members.map((m) => m.subject));
+ page(req, res, 'Settings', editor.settingsPage({ blog, feed: blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));
+ }));
+
+ router.post('/@:handle/settings', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
+ const b = formInput(req.body);
+ store.tx(() => {
+ const updated = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
+ if (b.theme) blogs.setTheme(updated, b.theme);
+ blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });
+ });
+ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
+ }));
+
+ router.post('/@:handle/members', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
+ const b = formInput(req.body);
+ const who = String(b.member || '').trim().replace(/^@/, '');
+ const subject = ids.isSubjectId('user', who) ? who : (people.byUsername(who) || {}).subject;
+ if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });
+ blogs.setMember(blog, subject, b.role, req.viewer.subject);
+ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);
+ }));
+
+ router.post('/@:handle/members/remove', wrap(async (req, res) => {
+ const blog = blogFor(req);
+ if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
+ blogs.removeMember(blog, formInput(req.body).subject);
+ res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);
+ }));
+
+ // ── A post ──────────────────────────────────────────────
+
+ router.get('/posts/:id', wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found) return notFound(req, res);
+ const { post, blog } = found;
+ const head = posts.head(post);
+ const published = post.published_revision ? posts.revision(post, post.published_revision) : null;
+ const rec = publication.authorshipOf(head);
+ const review = publication.reviewOf(post, head);
+ const terms = publication.termsOf(post);
+ const decision = publication.decide(blog, { ...post, state: 'published' }, head);
+ const body = editor.editPage({
+ blog, post, head, published, decision, jobs: posts.pendingJobs(post), attachments: posts.attachments(post), csrf: csrf(req),
+ disclosure: rec ? authorship.disclosure(rec, review) : null,
+ needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
+ publicUrl: publication.postPath(blog, post),
+ canDelete: access.canWrite(store, req.viewer, blog, 'delete', post),
+ form: editor.postForm({ blog, post, head, terms, series: blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
+ });
+ page(req, res, `Edit: ${head.fields.title}`, body);
+ }));
+
+ router.post('/posts/:id', wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found) return notFound(req, res);
+ const before = { ...found.post };
+ const { post, revision, created } = posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
+ effects.after(before, post, req.ov);
+ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);
+ }));
+
+ const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);
+ const post = found ? found.post : posts.get(req.params.id);
+ if (!post || post.state === 'deleted') return notFound(req, res);
+ const before = { ...post };
+ const out = await fn(req, post, formInput(req.body));
+ const after = posts.get(post.id);
+ effects.after(before, after, req.ov);
+ if (name === 'delete') return res.redirect(303, `/write/@${posts.blogOf(post).handle}?saved=${encodeURIComponent('Post deleted.')}`);
+ res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);
+ }));
+
+ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
+ action('publish', (req, post, b) => posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));
+ action('schedule', (req, post, b) => {
+ // <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.
+ const raw = String(b.at || '');
+ const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;
+ return posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });
+ }, (out) => `Scheduled for ${out.job.runAt}.`);
+ action('unschedule', (req, post) => posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');
+ action('unpublish', (req, post) => posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');
+ action('delete', (req, post, b) => {
+ if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });
+ return posts.remove(req.viewer, post, tp(req));
+ }, 'Deleted.');
+ action('review', (req, post, b) => posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
+ action('revert', (req, post, b) => posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
+ action('media', (req, post, b) => posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
+ router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found) return notFound(req, res);
+ posts.detach(req.viewer, found.post, req.params.aid);
+ res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
+ }));
+
+ router.get('/posts/:id/revisions', wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found) return notFound(req, res);
+ const { post } = found;
+ const head = posts.head(post);
+ const from = parseInt(req.query.from, 10);
+ const to = parseInt(req.query.to, 10);
+ const diff = Number.isInteger(from) && Number.isInteger(to) ? posts.diff(post, from, to, 'word') : null;
+ page(req, res, 'History', editor.revisionsPage({ post, head, revisions: posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));
+ }));
+
+ router.get('/posts/:id/preview', wrap(async (req, res) => {
+ const found = postFor(req);
+ if (!found) return notFound(req, res);
+ const n = parseInt(req.query.revision, 10);
+ const rev = Number.isInteger(n) ? posts.revision(found.post, n) : posts.head(found.post);
+ if (!rev) return notFound(req, res);
+ return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });
+ }));
+
+ return router;
+}
+
+module.exports = { createEditorRoutes, formInput };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
+ * and the small request helpers every router shares.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
+class ApiError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.name = 'ApiError';
+ this.status = status;
+ this.code = code;
+ this.extra = extra || null;
+ }
+}
+
+/** Publishing package errors carry { status, code } too. */
+function asApiError(err) {
+ if (err instanceof ApiError) return err;
+ if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {
+ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
+ return new ApiError(err.status, err.code, err.message, extra);
+ }
+ if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {
+ return new ApiError(422, 'request.invalid', err.message);
+ }
+ return null;
+}
+
+/** Wrap a JSON handler: its return value is the body; errors become problems. */
+function run(fn, status = 200) {
+ return async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out === undefined || res.headersSent) return;
+ res.status(typeof status === 'function' ? status(out) : status).json(out);
+ } catch (err) {
+ if (res.headersSent) return;
+ const e = asApiError(err);
+ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
+ console.error('[Blog API]', err && err.stack ? err.stack : err);
+ contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ }
+ };
+}
+
+const jsonParser = express.json({ limit: '512kb' });
+/** JSON body parser whose syntax errors are problems too. */
+function jsonBody(req, res, next) {
+ jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
+}
+
+/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
+function privateNoStore(res) {
+ res.set('Cache-Control', 'private, no-store');
+ res.vary('Cookie');
+ res.vary('Authorization');
+}
+
+module.exports = { ApiError, asApiError, run, jsonBody, privateNoStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,366 @@
@@ -0,0 +1,366 @@
+'use strict';
+
+/**
+ * Public, server-rendered routes — useful without JavaScript:
+ *
+ * GET / the official blog GET /@:handle a member's blog
+ * GET /@:handle/:slug a post (…/:slug.json: the same post as data)
+ * POST /@:handle/:slug/comments comment (Community thread) as the signed-in member
+ * GET /@:handle/tags/:tag GET /@:handle/categories/:category
+ * GET /@:handle/series/:series GET /@:handle/authors/:who
+ * GET /tags/:tag, /authors/:who the same across every blog
+ *
+ * Caching (the rule that keeps private content out of shared caches):
+ * - every HTML/JSON page varies on Cookie and Authorization;
+ * - only a PUBLIC, PUBLISHED post, or a list of public posts, rendered for an ANONYMOUS visitor is
+ * `public, max-age=60`; everything else — signed-in views, drafts, previews, unlisted,
+ * members/VIP and private posts, refusals, 404/410 — is `private, no-store` + X-Robots-Tag.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const ssr = require('openvibe-publishing/ssr');
+const authorship = require('openvibe-publishing/authorship');
+const { renderPage } = require('../render/layout');
+const pages = require('../render/pages');
+const { csrfToken, checkCsrf } = require('../auth/forms');
+
+const PER_PAGE = 15;
+
+function createPublicRoutes(ctx) {
+ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers } = ctx;
+ const router = express.Router();
+ router.use(viewers.middleware({ services: false }));
+
+ // ── Helpers ─────────────────────────────────────────────
+
+ function cacheHeaders(res, { cacheable, robots }) {
+ res.vary('Cookie');
+ res.vary('Authorization');
+ if (cacheable) res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
+ else res.set('Cache-Control', 'private, no-store');
+ if (robots && robots !== 'index, follow') res.set('X-Robots-Tag', robots);
+ }
+
+ function send(req, res, status, page, { cacheable = false } = {}) {
+ cacheHeaders(res, { cacheable: cacheable && req.viewer.kind === 'anonymous' && status === 200, robots: page.decision.robots });
+ res.status(status).type('html').send(renderPage({ ...page, viewer: req.viewer, config, path: req.originalUrl }));
+ }
+
+ /** A decision for a page that is not a post (collections, messages). */
+ function pageDecision(path, { indexable = true, empty = false, query = [] } = {}) {
+ return seo.evaluate({
+ state: indexable ? 'published' : 'draft', visibility: indexable ? 'public' : 'private',
+ canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query }), wordCount: 0, noindex: empty,
+ }, { policy: { minWords: 0 }, now: store.now() });
+ }
+
+ function messagePage(req, res, status, heading, text, action) {
+ send(req, res, status, { title: heading, decision: pageDecision(req.path, { indexable: false }), body: pages.message({ heading, text, action }) });
+ }
+ const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });
+
+ const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
+
+ function pageNumber(req) {
+ const n = parseInt(req.query.page, 10);
+ return Number.isInteger(n) && n > 0 ? n : 1;
+ }
+
+ function activeBlog(req) {
+ const blog = blogs.byHandle(req.params.handle);
+ if (!blog) return null;
+ if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;
+ return blog;
+ }
+
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+
+ // ── Blog front pages ────────────────────────────────────
+
+ async function blogFront(req, res, blog) {
+ const page = pageNumber(req);
+ const restricted = isMember(blog, req.viewer);
+ const path = publication.blogPath(blog);
+ const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return notFound(req, res);
+ const items = await reading.listItems(rows);
+ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
+ const feeds = reading.feedsOf(blog);
+ send(req, res, 200, {
+ title: blog.title,
+ description: blog.description,
+ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
+ canonical,
+ feeds,
+ theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
+ lang: blog.language,
+ prev: pager.prev ? pager.prev.href : null,
+ next: pager.next ? pager.next.href : null,
+ jsonLd: [blog.kind === 'official'
+ ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }
+ : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
+ body: pages.blogIndex({
+ blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
+ series: blogs.series(blog), categories: reading.categoriesTree(blog),
+ canWrite: access.canWrite(store, req.viewer, blog, 'create'),
+ }),
+ }, { cacheable: !restricted });
+ }
+
+ router.get('/', wrap(async (req, res) => blogFront(req, res, blogs.official())));
+
+ router.get('/@:handle', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ if (!blog) return notFound(req, res);
+ if (blog.kind === 'official') return res.redirect(301, '/');
+ return blogFront(req, res, blog);
+ }));
+
+ // ── Feeds (public posts only, never viewer-dependent) ───
+
+ async function feed(req, res, blog, kind) {
+ const s = blogs.feedSettings(blog);
+ if (!s[kind]) return notFound(req, res);
+ const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
+ const link = publication.abs(publication.blogPath(blog));
+ const feedUrl = publication.abs(reading.urls.feed(blog, kind));
+ res.set('Cache-Control', 'public, max-age=300');
+ res.vary('Accept-Encoding');
+ if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: blog.title, link, description: blog.description || blog.title, feedUrl, language: blog.language }, items));
+ if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: blog.title, link, feedUrl, description: blog.description || undefined, language: blog.language }, items)));
+ const listed = items.filter((i) => i.decision.listable);
+ const updated = listed.length ? null : new Date(blog.updated_at).toISOString();
+ return res.type('application/atom+xml').send(seo.atomFeed({ title: blog.title, link, feedUrl, id: `tag:openvibe.blog,2026:blog/${blog.id}`, subtitle: blog.description || undefined, ...(updated ? { updated } : {}) }, items));
+ }
+ const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };
+ for (const [file, kind] of Object.entries(FEEDS)) {
+ router.get(`/${file}`, wrap((req, res) => feed(req, res, blogs.official(), kind)));
+ router.get(`/@:handle/${file}`, wrap((req, res) => {
+ const blog = activeBlog(req);
+ if (!blog) return notFound(req, res);
+ if (blog.kind === 'official') return res.redirect(301, `/${file}`);
+ return feed(req, res, blog, kind);
+ }));
+ }
+
+ // ── Collections ─────────────────────────────────────────
+
+ async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty }) {
+ const page = pageNumber(req);
+ const restricted = blog ? isMember(blog, req.viewer) : false;
+ const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return notFound(req, res);
+ const items = await reading.listItems(rows, { perBlogLinks: Boolean(blog) });
+ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
+ send(req, res, 200, {
+ title: heading, description: intro, canonical,
+ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
+ theme: blog ? { slug: blog.theme, vars: blogs.themeVars(blog) } : null,
+ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
+ jsonLd: [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))],
+ body: pages.collection({ heading, intro, breadcrumbs: crumbs, items, pager, empty }),
+ }, { cacheable: !restricted });
+ }
+
+ const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
+
+ router.get('/@:handle/tags/:tag', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ const tag = blog && store.taxonomy.bySlug('tag', req.params.tag);
+ if (!tag) return notFound(req, res);
+ return collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),
+ crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
+ }));
+
+ router.get('/tags/:tag', wrap(async (req, res) => {
+ const tag = store.taxonomy.bySlug('tag', req.params.tag);
+ if (!tag) return notFound(req, res);
+ return collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),
+ crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
+ }));
+
+ router.get('/@:handle/categories/:category', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ const term = blog && store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);
+ if (!term) return notFound(req, res);
+ const trail = store.taxonomy.ancestors(term.id);
+ const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];
+ return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
+ crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],
+ filter: { termIds: ids }, empty: 'No public posts in this category yet.' });
+ }));
+
+ router.get('/@:handle/series/:series', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ const series = blog && blogs.seriesBySlug(blog, req.params.series);
+ if (!series) return notFound(req, res);
+ return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
+ crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.' });
+ }));
+
+ async function personFor(who) {
+ if (/^usr_/.test(who)) return people.one(who);
+ return people.byUsername(who);
+ }
+
+ router.get('/@:handle/authors/:who', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ const person = blog && await personFor(req.params.who);
+ if (!person || !blogs.membership(blog, person.subject) && !posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 }).total) return notFound(req, res);
+ return collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),
+ crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
+ }));
+
+ router.get('/authors/:who', wrap(async (req, res) => {
+ const person = await personFor(req.params.who);
+ if (!person) return notFound(req, res);
+ return collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),
+ crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
+ }));
+
+ // ── Posts ───────────────────────────────────────────────
+
+ async function commentsFor(req, blog, post, rev) {
+ if (!post.allow_comments) return { state: 'disabled' };
+ if (post.visibility !== 'public' || post.state !== 'published') return { state: 'restricted' };
+ if (!community.enabled) return { state: 'off' };
+ try {
+ const threadId = await community.threadFor(post, rev.fields.title, req.ov);
+ if (!threadId) return { state: 'off' };
+ const data = await community.readThread(threadId, { after: req.query.comments_after, ctx: req.ov });
+ return { state: 'ok', thread: data.thread, comments: data.comments || [], nextCursor: data.next_cursor, communityUrl: community.publicUrl };
+ } catch (err) {
+ console.warn(`[Blog] comments for ${post.id} unavailable: ${err.message}`);
+ return { state: 'unavailable' };
+ }
+ }
+
+ function seriesView(blog, post) {
+ const series = blogs.seriesById(post.series_id);
+ if (!series) return null;
+ const { posts: rows } = posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
+ return {
+ title: series.title, url: reading.urls.series(blog, series), position: post.series_position,
+ items: rows.map((p) => ({ title: (store.revisions.get(p.id, p.published_revision) || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
+ };
+ }
+
+ /** Render one post (also used by the editor's preview with a chosen revision). */
+ async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {
+ const decision = preview ? publication.decide(blog, post, rev, { state: 'draft' }) : publication.decide(blog, post, rev);
+ const person = await people.one(post.author_subject);
+ const { tags, categories } = reading.termsView(post, blog);
+ const attachments = store.attachments.list(post.id);
+ const citations = store.citations.forRevision(post.id, rev.number);
+ const rec = publication.authorshipOf(rev);
+ const review = publication.reviewOf(post, rev);
+ const path = publication.postPath(blog, post);
+ const firstCategory = categories[0];
+ const crumbs = [blogCrumb(blog), ...(firstCategory ? store.taxonomy.ancestors(firstCategory.id).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
+ const comments = preview ? null : await commentsFor(req, blog, post, rev);
+ const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
+ const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
+ send(req, res, status, {
+ title: rev.fields.title,
+ description: rev.fields.summary || ssr.markdownToText(rev.content, 160),
+ decision,
+ canonical: publication.postUrl(blog, post),
+ type: 'article',
+ author: person.known ? person.name : undefined,
+ image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),
+ published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
+ modified: post.published_revision ? rev.createdAt : null,
+ feeds: reading.feedsOf(blog),
+ theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
+ lang: blog.language,
+ jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
+ body: pages.postPage({
+ blog, blogUrl: publication.blogPath(blog), post, rev,
+ author: { name: person.name, url: reading.urls.author(person, blog) },
+ tags, categories, series: seriesView(blog, post), attachments, citations,
+ disclosure: rec ? authorship.disclosure(rec, review) : null,
+ decisionNote: preview ? `preview · ${decision.robots}` : null,
+ comments, canEdit: access.canWrite(store, req.viewer, blog, 'edit', post),
+ urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,
+ postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
+ loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
+ }),
+ }, { cacheable });
+ }
+
+ /** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */
+ async function resolvePost(req, res, blog, slug) {
+ const post = posts.bySlug(blog, slug);
+ if (!post) {
+ const path = `/@${blog.handle}/${slug}`;
+ const r = store.redirects.resolve(path, {
+ currentPath: (id) => { const p = posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(blogs.get(p.blog_id), p) : null; },
+ });
+ if (r && r.status === 301) {
+ const target = posts.get(r.entityId);
+ const decision = await access.canReadPost(store, req.viewer, blogs.get(target.blog_id), target, entitlements);
+ if (decision.allowed) {
+ cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
+ res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
+ return null;
+ }
+ }
+ if ((r && r.status === 410) || posts.deletedBySlug(blog, slug)) {
+ messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });
+ return null;
+ }
+ notFound(req, res);
+ return null;
+ }
+ const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements);
+ if (!decision.allowed) {
+ if (decision.status === 403) {
+ messagePage(req, res, 403, 'Members only', 'This post is for members of this blog. Sign in with an account that has access.', { href: `/auth/login?next=${encodeURIComponent(req.originalUrl)}`, label: 'Sign in' });
+ } else notFound(req, res);
+ return null;
+ }
+ return post;
+ }
+
+ router.get('/@:handle/:slug', wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ if (!blog) return notFound(req, res);
+ const asJson = req.params.slug.endsWith('.json');
+ const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
+ const post = await resolvePost(req, res, blog, slug);
+ if (!post) return;
+ // Readers see the published revision; members looking at an unpublished post see its head.
+ const rev = post.state === 'published' ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
+ if (asJson) {
+ const decision = publication.decide(blog, post, rev);
+ cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });
+ return res.json(reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));
+ }
+ return renderPost(req, res, { blog, post, rev });
+ }));
+
+ router.post('/@:handle/:slug/comments', express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
+ const blog = activeBlog(req);
+ const post = blog && posts.bySlug(blog, req.params.slug);
+ if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
+ if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);
+ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
+ if (!post.allow_comments || !community.enabled) return messagePage(req, res, 409, 'Comments are off', 'This post does not take comments.');
+ const message = String((req.body && req.body.message) || '').trim();
+ if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);
+ try {
+ const rev = store.revisions.get(post.id, post.published_revision);
+ const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);
+ await community.comment(threadId, req.viewer.subject, { message }, req.ov);
+ } catch (err) {
+ return messagePage(req, res, err.status === 429 ? 429 : 502, 'Comment not posted', `OpenVibe.Community did not accept the comment: ${err.message}`);
+ }
+ return res.redirect(303, `${publication.postPath(blog, post)}#comments`);
+ }));
+
+ return { router, renderPost, notFound, messagePage, pageDecision, send };
+}
+
+module.exports = { createPublicRoutes, PER_PAGE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * OpenVibe.Blog — process entry. `node server/index.js`
+ * Listens on PORT (4810) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the
+ * client secret are set) and the worker (scheduled publication, media verification).
+ */
+const { createApp } = require('./app');
+
+const { app, ctx } = createApp();
+const { config } = ctx;
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
+});
+server.keepAliveTimeout = 65_000;
+ctx.outbox.start();
+ctx.worker.start();
+
+function shutdown(signal) {
+ console.log(`[Blog] ${signal}: closing`);
+ ctx.worker.stop();
+ server.close(async () => {
+ try { await ctx.outbox.stop(); } catch { /* best effort */ }
+ try { ctx.store.close(); } catch { /* already closed */ }
+ process.exit(0);
+ });
+ setTimeout(() => process.exit(0), 5000).unref();
+}
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * db required a real query on Blog's SQLite (the charter tables answer)
+ * network_jwks optional the Network signing key has loaded; without it pages and feeds still
+ * serve, but nobody can sign in and service tokens are refused (503)
+ * events_relay optional the outbox relay is configured and has no rejected rows; when it is
+ * off, events wait in event_outbox (Search and subscribers lag)
+ * scheduler optional no scheduled job has failed and none is overdue by more than 5 minutes
+ *
+ * Request metrics come from openvibe-shared/metrics in app.js; content counts are not metrics.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { CHARTER_TABLES } = require('./db');
+
+function createBlogReadiness({ store, auth, outbox, release = null }) {
+ const { db } = store;
+ return createReadiness({
+ service: 'blog',
+ 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: 'scheduler', required: false,
+ check: () => {
+ const failed = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000).n;
+ const overdue = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000).n;
+ if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;
+ return true;
+ },
+ },
+ ],
+ });
+}
+
+module.exports = { createBlogReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+
+/**
+ * The editor: plain HTML forms that work without JavaScript. Every form posts to the same origin
+ * with the per-session form token (_csrf); sign-in is Network SSO.
+ */
+const ssr = require('openvibe-publishing/ssr');
+const { time } = require('./pages');
+
+const { html: h, raw } = ssr;
+
+const STATE_LABEL = { draft: 'Draft', scheduled: 'Scheduled', published: 'Published', unpublished: 'Unpublished' };
+const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;
+
+function flash(msg) {
+ if (!msg) return '';
+ return h`<p class="notice ${msg.kind === 'error' ? 'error' : ''}" role="${msg.kind === 'error' ? 'alert' : 'status'}">${msg.text}</p>`;
+}
+
+function home({ viewer, blogs, csrf, suggestedHandle, message }) {
+ const own = blogs.find((b) => b.kind === 'member' && b.role === 'owner');
+ return String(h`<h1>Write</h1>
+${flash(message)}
+${blogs.length ? h`<h2>Your blogs</h2><ul class="blog-list">${blogs.map((b) => h`<li><a href="/write/@${b.handle}">${b.title}</a> <span class="meta">@${b.handle} · ${b.role}</span></li>`)}</ul>` : ''}
+${own ? '' : h`<section class="card"><h2>Start your blog</h2>
+<p>Your blog lives at <code>/@handle</code> on openvibe.blog and belongs to your OpenVibe account.</p>
+<form method="post" action="/write/start">${csrfField(csrf)}
+<label for="handle">Handle</label><input id="handle" name="handle" value="${suggestedHandle || ''}" pattern="[a-z0-9][a-z0-9_-]{1,39}" required>
+<label for="title">Blog title</label><input id="title" name="title" maxlength="120" value="${suggestedHandle ? `@${suggestedHandle}` : ''}">
+<button type="submit">Start my blog</button></form></section>`}
+<p class="meta">Signed in as ${viewer.user && (viewer.user.display_name || viewer.user.username) ? (viewer.user.display_name || viewer.user.username) : viewer.subject}.</p>`);
+}
+
+function blogDashboard({ blog, posts, heads, role, csrf, message, publicUrl }) {
+ return String(h`<p><a href="/write">← All your blogs</a></p>
+<h1>${blog.title}</h1>
+<p class="meta">@${blog.handle} · your role: ${role} · <a href="${publicUrl}">View the blog</a>${role === 'owner' ? h` · <a href="/write/@${blog.handle}/settings">Settings</a>` : ''}</p>
+${flash(message)}
+<p><a class="button" href="/write/@${blog.handle}/new">New post</a></p>
+${posts.length ? h`<table class="posts"><thead><tr><th>Title</th><th>State</th><th>Visibility</th><th>Updated</th></tr></thead><tbody>
+${posts.map((p) => h`<tr><td><a href="/write/posts/${p.id}">${(heads.get(p.id) || {}).title || p.slug}</a></td><td>${STATE_LABEL[p.state] || p.state}</td><td>${p.visibility}</td><td>${time(p.updated_at)}</td></tr>`)}
+</tbody></table>` : h`<p class="empty">No posts yet.</p>`}`);
+}
+
+function select(name, options, current) {
+ return h`<select id="${name}" name="${name}">${options.map(([v, label]) => (v === current ? h`<option value="${v}" selected>${label}</option>` : h`<option value="${v}">${label}</option>`))}</select>`;
+}
+
+/** New post and edit form (the same fields). */
+function postForm({ blog, post, head, terms, series, csrf, message, action }) {
+ const f = head ? head.fields : {};
+ const vis = post ? post.visibility : 'public';
+ return String(h`${flash(message)}
+<form method="post" action="${action}" class="post-form">${csrfField(csrf)}
+${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : ''}
+<label for="title">Title</label><input id="title" name="title" maxlength="200" required value="${f.title || ''}">
+<label for="summary">Summary <span class="meta">(optional, shown in lists and feeds)</span></label><input id="summary" name="summary" maxlength="500" value="${f.summary || ''}">
+<label for="body">Text <span class="meta">(Markdown; a line <code>[[media:med_…]]</code> places an attached Media object)</span></label>
+<textarea id="body" name="body" rows="22">${head ? head.content : ''}</textarea>
+${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value="${post.slug}" pattern="[a-z0-9][a-z0-9-]{0,79}"><p class="meta">Changing it keeps the old address working (301).</p>` : ''}
+<label for="tags">Tags <span class="meta">(comma-separated)</span></label><input id="tags" name="tags" value="${(terms.tags || []).join(', ')}">
+<label for="categories">Categories <span class="meta">(comma-separated; "Parent > Child" nests)</span></label><input id="categories" name="categories" value="${(terms.categories || []).join(', ')}">
+<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}">
+<label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}">
+<label for="visibility">Who can read it</label>
+${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (VIP entitlement)'], ['private', 'Private (this blog’s members)']], vis)}
+<label for="entitlementKey">Entitlement key for members-only posts</label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `blog:${blog.handle}:members`}">
+<p class="meta">Members-only posts are readable by this blog’s members and staff. Other readers need the entitlement; no entitlement service is connected yet, so nobody else is admitted.</p>
+<label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>
+<label><input type="checkbox" name="noindex" value="1"${raw(post && post.noindex ? ' checked' : '')}> Ask search engines not to index it</label>
+<input type="hidden" name="allowComments" value="0">
+<label for="message">Revision note <span class="meta">(optional)</span></label><input id="message" name="message" maxlength="200">
+<button type="submit">${post ? 'Save revision' : 'Save draft'}</button>
+</form>`);
+}
+
+function gateSummary(decision) {
+ if (!decision) return '';
+ if (decision.indexable) return h`<p class="meta">Search engines: indexable (${decision.robots}).</p>`;
+ return h`<p class="meta">Search engines: <strong>${decision.robots}</strong> — ${decision.reasons.map((r) => `${r.code}${r.detail ? ` (${r.detail})` : ''}`).join('; ')}.</p>`;
+}
+
+function editPage(o) {
+ const { blog, post, head, published, decision, jobs, attachments, csrf, disclosure, needsReview, publicUrl, canDelete } = o;
+ return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>
+<h1>${head.fields.title}</h1>
+<p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>
+${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
+${raw(gateSummary(decision))}
+${needsReview ? h`<section class="card"><h2>Review needed</h2><p>Revision ${head.number} is AI-generated. A person must review it before it can be published or indexed.</p>
+<form method="post" action="/write/posts/${post.id}/review">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">
+<label for="note">Note</label><input id="note" name="note" maxlength="500">
+<button name="decision" value="approved" type="submit">Approve revision ${head.number}</button> <button name="decision" value="rejected" type="submit">Reject</button></form></section>` : ''}
+<section class="card actions"><h2>Publish</h2>
+<form method="post" action="/write/posts/${post.id}/publish">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}"><button type="submit">Publish revision ${head.number} now</button></form>
+<form method="post" action="/write/posts/${post.id}/schedule">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">
+<label for="at">Publish revision ${head.number} at (UTC)</label><input id="at" name="at" type="datetime-local" required><button type="submit">Schedule</button></form>
+${jobs.length ? h`<p class="meta">Scheduled: ${jobs.map((j) => h`${j.action} of revision ${j.revision || '-'} at ${j.runAt} (${j.status}) `)}</p>
+<form method="post" action="/write/posts/${post.id}/unschedule">${csrfField(csrf)}<button type="submit">Cancel schedule</button></form>` : ''}
+${post.state === 'published' || post.state === 'scheduled' ? h`<form method="post" action="/write/posts/${post.id}/unpublish">${csrfField(csrf)}<button type="submit">Unpublish</button></form>` : ''}
+${canDelete ? h`<form method="post" action="/write/posts/${post.id}/delete">${csrfField(csrf)}<label><input type="checkbox" name="confirm" value="1" required> I want to delete this post</label><button type="submit" class="danger">Delete</button></form>` : ''}
+</section>
+<section class="card"><h2>Media</h2>
+${attachments.length ? h`<ul>${attachments.map((a) => h`<li><code>${a.mediaId}</code> · ${a.role}${a.broken ? h` · <strong>unavailable (${a.brokenReason})</strong>` : h` · ${a.state}`}${a.alt ? h` · alt: ${a.alt}` : ''}
+<form method="post" action="/write/posts/${post.id}/media/${a.id}/remove" class="inline">${csrfField(csrf)}<button type="submit">Remove</button></form></li>`)}</ul>` : h`<p class="empty">No media attached.</p>`}
+<form method="post" action="/write/posts/${post.id}/media">${csrfField(csrf)}
+<label for="mediaId">OpenVibe.Media object id</label><input id="mediaId" name="mediaId" placeholder="med_…" required pattern="med_[0-9A-HJKMNP-TV-Z]{26}">
+<label for="role">Role</label>${select('role', [['inline', 'Inline'], ['cover', 'Cover'], ['gallery', 'Gallery']], 'inline')}
+<label for="alt">Alt text</label><input id="alt" name="alt" maxlength="1000">
+<label for="caption">Caption</label><input id="caption" name="caption" maxlength="2000">
+<button type="submit">Attach</button></form></section>
+<h2>Edit</h2>
+${raw(o.form)}`);
+}
+
+function revisionsPage({ post, head, revisions, diff, from, to, csrf }) {
+ return String(h`<p><a href="/write/posts/${post.id}">← Back to the post</a></p>
+<h1>History of “${head.fields.title}”</h1>
+<form method="get" action="/write/posts/${post.id}/revisions" class="inline">
+<label>Compare <input name="from" type="number" min="1" value="${from || ''}"></label> <label>with <input name="to" type="number" min="1" value="${to || ''}"></label> <button type="submit">Diff</button></form>
+${diff ? h`<section><h2>Revision ${diff.from} → ${diff.to}</h2>${diff.fields.length ? h`<ul>${diff.fields.map((d) => h`<li>${d.field}: “${d.from}” → “${d.to}”</li>`)}</ul>` : ''}${raw(ssr.diffHtml(diff.content))}</section>` : ''}
+<ol class="revisions" reversed>${revisions.map((r) => h`<li><strong>Revision ${r.number}</strong> · ${r.kind}${r.revertedTo ? h` of ${r.revertedTo}` : ''} · ${time(Date.parse(r.createdAt))} · ${(r.meta && r.meta.authorship && r.meta.authorship.mode) || 'human'}${r.message ? h` · ${r.message}` : ''}
+${r.number !== head.number ? h`<form method="post" action="/write/posts/${post.id}/revert" class="inline">${csrfField(csrf)}<input type="hidden" name="toRevision" value="${r.number}"><input type="hidden" name="expectedRevision" value="${head.number}"><button type="submit">Restore as a new revision</button></form>` : ''}</li>`)}</ol>`);
+}
+
+function settingsPage({ blog, feed, members, people, themes, csrf, message }) {
+ return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>
+<h1>Settings</h1>
+${flash(message)}
+<form method="post" action="/write/@${blog.handle}/settings" class="card">${csrfField(csrf)}
+<h2>Blog</h2>
+<label for="title">Title</label><input id="title" name="title" maxlength="120" value="${blog.title}" required>
+<label for="description">Description</label><input id="description" name="description" maxlength="500" value="${blog.description || ''}">
+<label for="language">Language (BCP 47)</label><input id="language" name="language" value="${blog.language}">
+<h2>Theme</h2>
+${select('theme', themes.map((t) => [t.id, `${t.name} (${t.mode})`]), blog.theme)}
+<h2>Feeds</h2>
+<label><input type="checkbox" name="rss" value="1"${raw(feed.rss ? ' checked' : '')}> RSS</label>
+<label><input type="checkbox" name="atom" value="1"${raw(feed.atom ? ' checked' : '')}> Atom</label>
+<label><input type="checkbox" name="json" value="1"${raw(feed.json ? ' checked' : '')}> JSON Feed</label>
+<label for="itemCount">Items per feed</label><input id="itemCount" name="itemCount" type="number" min="1" max="50" value="${feed.item_count}">
+<label><input type="checkbox" name="fullContent" value="1"${raw(feed.full_content ? ' checked' : '')}> Full text in feeds (otherwise summaries)</label>
+<button type="submit">Save settings</button></form>
+<section class="card"><h2>Members</h2>
+<ul>${members.map((m) => { const p = people.get(m.subject); return h`<li>${p ? p.name : m.subject} <span class="meta">${p && p.username ? `@${p.username}` : m.subject}</span> · ${m.role}
+${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action="/write/@${blog.handle}/members/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="subject" value="${m.subject}"><button type="submit">Remove</button></form>` : ''}</li>`; })}</ul>
+<form method="post" action="/write/@${blog.handle}/members">${csrfField(csrf)}
+<label for="member">Username (someone who has signed in to OpenVibe.Blog) or subject id (usr_…)</label><input id="member" name="member" required>
+<label for="role">Role</label>${select('role', [['author', 'Author (own posts)'], ['editor', 'Editor (all posts)'], ['owner', 'Owner']], 'author')}
+<button type="submit">Add member</button></form></section>`);
+}
+
+module.exports = { home, blogDashboard, postForm, editPage, revisionsPage, settingsPage, gateSummary };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through this and is complete without JavaScript:
+ * - <head>: title, description, canonical and robots from the indexability gate's decision
+ * (openvibe-publishing/seo metaTags — there is no default that makes a page indexable),
+ * Open Graph/Twitter, JSON-LD, feed links, the shared app icon and critical canvas
+ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a
+ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
+ * - a blog's theme preset applied to its content surface only (openvibe-shared tokens)
+ */
+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.Blog';
+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)}`;
+
+/** Inline CSS custom properties for a theme preset (values come from openvibe-shared, escaped anyway). */
+function themeStyle(vars) {
+ return Object.entries(vars || {})
+ .filter(([k, v]) => /^--[a-z0-9-]+$/.test(k) && /^[#(),.%\w\s-]+$/.test(String(v)))
+ .map(([k, v]) => `${k}:${v}`).join(';');
+}
+
+/**
+ * o: title, description, decision (required), canonical, type ('website'|'article'), image,
+ * jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,
+ * theme { slug, vars }, published, modified, author, prev, next, bodyClass
+ */
+function renderPage(o) {
+ if (!o.decision) throw new TypeError('renderPage needs the gate decision');
+ const head = seo.metaTags({
+ title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
+ description: o.description || 'The official OpenVibe blog and a blog for every member.',
+ decision: o.decision,
+ canonical: o.canonical,
+ type: o.type || 'website',
+ siteName: SITE_NAME,
+ image: o.image || undefined,
+ author: o.author || undefined,
+ prev: o.prev || undefined,
+ next: o.next || undefined,
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ });
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const loginNext = encodeURIComponent(o.path || '/');
+ const nav = {
+ service: 'blog',
+ apiBase: NETWORK_URL,
+ links: [
+ { label: 'Blog', href: '/' },
+ { label: 'Write', href: '/write', icon: 'fa-pen' },
+ ],
+ 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 style = o.theme ? ` style="${esc(themeStyle(o.theme.vars))}" data-blog-theme="${esc(o.theme.slug)}"` : '';
+ const account = signedIn
+ ? `<a href="/write">Write</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ return `<!DOCTYPE html>
+<html lang="${esc(o.lang || 'en')}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+${head}
+${o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : ''}
+${o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : ''}
+${seo.feedLinks(o.feeds || [])}
+${appIcon.headTags({ site: 'blog' })}
+<link rel="stylesheet" href="${asset('css/blog.css')}">
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+</head>
+<body class="${esc(o.bodyClass || '')}">
+<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }] })}
+<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
+<main id="main" class="page blog-surface"${style}>
+${o.body || ''}
+</main>
+${chrome.footer({ service: 'blog', 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, themeStyle, SITE_NAME, NETWORK_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+
+/**
+ * Public pages (HTML bodies; layout.js wraps them). Everything a reader needs is in the HTML:
+ * post text, media figures (or an explicit "media unavailable" placeholder), sources, tags,
+ * series navigation, author links, feeds and the Community comment thread with a plain form.
+ * Values are escaped by ssr.html unless wrapped in raw(); Markdown goes through the package's
+ * safe renderer.
+ */
+const ssr = require('openvibe-publishing/ssr');
+const { figureHtml } = require('openvibe-publishing/media');
+
+const { html: h, raw, escapeHtml: esc } = ssr;
+const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;
+
+const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
+const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
+
+/**
+ * Markdown body with media: a line `[[media:med_…]]` places that attachment's <figure> there.
+ * Inline attachments that the body does not place follow the text; covers and galleries are
+ * rendered by the caller. A referenced id that is not attached renders an explicit notice.
+ */
+function renderBody(content, attachments, { urlFor, rel }) {
+ const byId = new Map(attachments.map((a) => [a.mediaId, a]));
+ const used = new Set();
+ const parts = String(content || '').split(new RegExp(MEDIA_LINE.source, 'm'));
+ let out = '';
+ parts.forEach((part, i) => {
+ if (i % 2 === 0) { out += ssr.renderMarkdown(part, { rel }); return; }
+ const att = byId.get(part);
+ if (att) { used.add(att.id); out += figureHtml(att, { urlFor }); } else {
+ out += `<figure class="ov-media ov-media-broken" data-media-id="${esc(part)}" data-state="not_attached"><div class="ov-media-missing" role="img" aria-label="Media not attached">This media is not attached to the post.</div></figure>`;
+ }
+ });
+ for (const a of attachments) if (a.role === 'inline' && !used.has(a.id)) out += figureHtml(a, { urlFor });
+ return out;
+}
+
+function postListItem(item) {
+ const { post, rev, url, author, tags, badge } = item;
+ return h`<li class="post-item">
+<h2 class="post-item-title"><a href="${url}">${rev.fields.title}</a>${badge ? h` <span class="badge">${badge}</span>` : ''}</h2>
+<p class="meta">${time(post.published_at)}${author ? h` · <a href="${author.url}">${author.name}</a>` : ''}</p>
+<p class="summary">${rev.fields.summary || ssr.markdownToText(rev.content, 220)}</p>
+${tags && tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}">#${t.name}</a> `)}</p>` : ''}
+</li>`;
+}
+
+function postList(items, empty = 'Nothing published here yet.') {
+ if (!items.length) return h`<p class="empty">${empty}</p>`;
+ return h`<ol class="post-list">${items.map(postListItem)}</ol>`;
+}
+
+function feedLinksHtml(feeds) {
+ if (!feeds.length) return '';
+ return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>`;
+}
+
+/** A blog's front page. */
+function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite }) {
+ return String(h`<header class="blog-header">
+<h1>${blog.title}</h1>
+${blog.description ? h`<p class="lede">${blog.description}</p>` : ''}
+${raw(feedLinksHtml(feeds))}
+${canWrite ? h`<p><a class="button" href="/write/@${blog.handle}">Write on this blog</a></p>` : ''}
+</header>
+<section aria-label="Posts">${postList(items)}</section>
+${raw(ssr.paginationHtml(pager))}
+${series.length ? h`<section class="side"><h2>Series</h2><ul>${series.map((s) => h`<li><a href="${blogUrl}/series/${s.slug}">${s.title}</a></li>`)}</ul></section>` : ''}
+${categories.length ? h`<section class="side"><h2>Categories</h2>${raw(categoryTree(categories, blogUrl))}</section>` : ''}`);
+}
+
+function categoryTree(nodes, blogUrl) {
+ if (!nodes.length) return '';
+ return `<ul>${nodes.map((n) => `<li><a href="${esc(`${blogUrl}/categories/${n.slug}`)}">${esc(n.name)}</a>${categoryTree(n.children || [], blogUrl)}</li>`).join('')}</ul>`;
+}
+
+/** Tag, category, series and author collections. */
+function collection({ heading, intro, breadcrumbs, items, pager, feeds = [], empty }) {
+ return String(h`${raw(ssr.breadcrumbsHtml(breadcrumbs || []))}
+<header class="blog-header"><h1>${heading}</h1>${intro ? h`<p class="lede">${intro}</p>` : ''}${raw(feedLinksHtml(feeds))}</header>
+<section aria-label="Posts">${postList(items, empty)}</section>
+${raw(ssr.paginationHtml(pager))}`);
+}
+
+function commentsSection(c, { postPath, csrf, signedIn, loginUrl }) {
+ const head = '<h2>Comments</h2>';
+ if (c.state === 'disabled') return `<section id="comments" class="comments">${head}<p class="empty">Comments are off for this post.</p></section>`;
+ if (c.state === 'restricted') return `<section id="comments" class="comments">${head}<p class="empty">Comments are available on public posts only.</p></section>`;
+ if (c.state === 'off') return `<section id="comments" class="comments">${head}<p class="empty">Comments are not connected on this server.</p></section>`;
+ if (c.state === 'unavailable') return `<section id="comments" class="comments">${head}<p class="notice" role="status">Comments could not be loaded from OpenVibe.Community right now. Reload later.</p></section>`;
+ const one = (m) => h`<li class="comment" id="comment-${m.id}">
+<p class="meta"><strong>${m.deleted ? '[deleted]' : (m.display_name || 'Anonymous')}</strong>${m.origin === 'ai' ? h` <span class="badge">AI</span>` : ''} · ${raw(ssr.timeTag(m.created_at, { label: dateLabel(m.created_at) }))}</p>
+${m.deleted ? h`<p class="empty">This comment was deleted.</p>` : raw(`<p>${esc(m.message).replace(/\n/g, '<br>')}</p>`)}
+${m.replies && m.replies.length ? h`<ol class="replies">${m.replies.map(one)}</ol>` : ''}
+</li>`;
+ const list = c.comments.length ? h`<ol class="comment-list">${c.comments.map(one)}</ol>` : h`<p class="empty">No comments yet.</p>`;
+ const more = c.nextCursor ? h`<p><a href="${postPath}?comments_after=${c.nextCursor}#comments">More comments</a></p>` : '';
+ const form = c.thread && c.thread.visibility === 'locked'
+ ? h`<p class="empty">This thread is locked.</p>`
+ : signedIn
+ ? h`<form method="post" action="${postPath}/comments" class="comment-form">
+<input type="hidden" name="_csrf" value="${csrf}">
+<label for="comment-message">Add a comment</label>
+<textarea id="comment-message" name="message" rows="4" maxlength="5000" required></textarea>
+<button type="submit">Post comment</button>
+</form>`
+ : h`<p><a href="${loginUrl}">Sign in with OpenVibe</a> to comment.</p>`;
+ return String(h`<section id="comments" class="comments">${raw(head)}
+<p class="meta">Comments are hosted by <a href="${c.communityUrl}">OpenVibe.Community</a>.</p>
+${list}${more}${form}</section>`);
+}
+
+/** One post. */
+function postPage(o) {
+ const { blog, blogUrl, post, rev, author, tags, categories, series, attachments, citations, disclosure, decisionNote, comments, canEdit, urlFor } = o;
+ const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
+ const cover = attachments.filter((a) => a.role === 'cover');
+ const gallery = attachments.filter((a) => a.role === 'gallery');
+ const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
+ const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;
+ return String(h`${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}
+<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">
+<header>
+<h1>${rev.fields.title}</h1>
+<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a>` : ''}${post.published_at ? h` · ${time(post.first_published_at || post.published_at)}` : ''}${updated ? h` · updated ${time(Date.parse(rev.createdAt))}` : ''}${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>
+${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
+${series ? h`<nav class="series" aria-label="Series"><p>Part ${series.position || '?'} of the series <a href="${series.url}">${series.title}</a></p><ol>${series.items.map((s) => (s.current ? h`<li aria-current="page">${s.title}</li>` : h`<li><a href="${s.url}">${s.title}</a></li>`))}</ol></nav>` : ''}
+${raw(cover.map((a) => figureHtml(a, { urlFor })).join(''))}
+</header>
+<div class="post-body">${raw(renderBody(rev.content, attachments, { urlFor, rel }))}</div>
+${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}
+${citations.length ? h`<section class="sources"><h2>Sources</h2><ol>${citations.map((c) => h`<li>${c.url ? h`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : (c.title || c.sourceItemId)}${c.quote && c.quote.text ? h` — “${c.quote.text}”` : ''}${c.retrievedAt ? h` (retrieved ${dateLabel(c.retrievedAt)})` : ''}</li>`)}</ol></section>` : ''}
+<footer class="post-footer">
+${tags.length ? h`<p class="tags">${tags.map((t) => h`<a href="${t.url}" rel="tag">#${t.name}</a> `)}</p>` : ''}
+${categories.length ? h`<p class="categories">Filed under ${categories.map((c) => h`<a href="${c.url}">${c.name}</a> `)}</p>` : ''}
+<p class="meta">Revision ${rev.number}${decisionNote ? h` · ${decisionNote}` : ''} · <a href="${o.jsonUrl}">JSON</a> · <a href="${blogUrl}">More from ${blog.title}</a>${canEdit ? h` · <a href="/write/posts/${post.id}">Edit</a>` : ''}</p>
+</footer>
+</article>
+${raw(comments ? commentsSection(comments, o) : '')}`);
+}
+
+function message({ heading, text, action }) {
+ return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
+}
+
+module.exports = { renderBody, blogIndex, collection, postPage, message, postList, categoryTree, time, dateLabel };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+
+/**
+ * The seed: the official blog's first post, "Released in the OpenVibe repositories", built ONLY
+ * from verbatim quotes of commit messages and release notes (seed/week-*.json, collected from the
+ * repositories' main branches with the commit, file and date of each quote). Every quote is
+ * attached as a citation (URL, retrieval time, the quoted text) to the revision that uses it.
+ *
+ * It is created as a DRAFT. Nothing marks it reviewed unless a person is named: with
+ * { reviewer: 'usr_…', publish: true } that person's approving review is recorded and the post is
+ * published. The seed never invents a reviewer, a date or a feature.
+ *
+ * Authorship: 'imported' — the text is quoted from the repositories (original author OpenVibers);
+ * the connecting sentences only say where each quote comes from.
+ */
+const fs = require('fs');
+const path = require('path');
+const { ids } = require('openvibe-contracts');
+
+const SLUG = 'released-in-the-openvibe-repositories-16-22-september-2026';
+
+function loadFacts(file = path.join(__dirname, '..', 'seed', 'week-2026-09-16.json')) {
+ const facts = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (!facts || !Array.isArray(facts.items) || !facts.items.length) throw new Error(`${file}: no items`);
+ for (const it of facts.items) {
+ for (const k of ['repo', 'quote', 'kind', 'ref', 'url']) if (!it[k]) throw new Error(`${file}: an item has no ${k}`);
+ if (!/^https:\/\/github\.com\/OpenVibers\//.test(it.url)) throw new Error(`${file}: ${it.url} is not an OpenVibers GitHub URL`);
+ }
+ return facts;
+}
+
+const kindLabel = (it) => (it.kind === 'tag' ? `tag ${it.tag}, commit ${it.ref.slice(0, 7)}` : it.kind === 'commit' ? `commit ${it.ref.slice(0, 7)}` : it.kind === 'changelog' ? `CHANGELOG, ${it.ref.split('#')[1] || it.ref}` : it.kind);
+
+function composeBody(facts) {
+ const lines = [
+ `This post quotes the commit messages and release notes that reached the main branch of the OpenVibers repositories between ${facts.week.from} and ${facts.week.to}. Each quote is copied as written and links to its source. A change listed here was released in its repository; this post does not say that it is running on an OpenVibe site.`,
+ '',
+ ];
+ let current = null;
+ for (const it of facts.items) {
+ if (it.repo !== current) { lines.push(`# ${it.repo}`, ''); current = it.repo; }
+ lines.push(`> ${it.quote}`, '', `Source: [${it.repo}, ${kindLabel(it)}](${it.url})`, '');
+ }
+ return lines.join('\n').trim() + '\n';
+}
+
+/**
+ * Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;
+ * default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).
+ */
+function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {
+ const { blogs, posts, store } = ctx;
+ const blog = blogs.ensureOfficial();
+ const owners = blogs.members(blog).filter((m) => m.role === 'owner').map((m) => m.subject);
+ const subject = author || owners[0];
+ if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
+ if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
+ const actor = { kind: 'user', subject, staff: true, user: {} };
+
+ let post = posts.bySlug(blog, SLUG);
+ let created = false;
+ if (!post) {
+ const out = posts.create(actor, blog, {
+ title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,
+ slug: SLUG,
+ summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,
+ body: composeBody(facts),
+ tags: ['release notes', 'openvibe'],
+ categories: ['Release notes'],
+ authorship: { mode: 'imported', importedFrom: { label: 'commit messages and release notes in the OpenVibers repositories (quoted verbatim)', originalAuthor: 'OpenVibers' } },
+ citations: facts.items.map((it) => ({ url: it.url, title: `${it.repo}: ${kindLabel(it)}`, retrievedAt: facts.collected_at, quote: { text: it.quote } })),
+ message: `Seed: ${facts.items.length} verbatim quotes collected ${facts.collected_at}`,
+ });
+ post = out.post;
+ created = true;
+ }
+ let review = null;
+ let published = false;
+ if (publish) {
+ const head = store.revisions.head(post.id);
+ review = posts.review({ kind: 'user', subject: reviewer, staff: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
+ published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;
+ }
+ return { post: posts.get(post.id), created, review, published };
+}
+
+module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * Background work in the Blog process (openvibe-shared has no job runner; this is two timers):
+ *
+ * scheduled publication every BLOG_SCHEDULE_INTERVAL_MS: claim due jobs (leased, so two
+ * processes never run the same job at once) and apply them; the effect
+ * is idempotent, so a job re-run after a crash changes nothing
+ * media verification every BLOG_MEDIA_VERIFY_INTERVAL_MS: ask OpenVibe.Media whether the
+ * objects attached to published posts still exist; gone → the explicit
+ * broken-asset state; an outage changes nothing (check_failed)
+ */
+function createWorker({ config, store, posts, media, outbox, log = console }) {
+ let scheduleTimer = null;
+ let mediaTimer = null;
+ let running = false;
+
+ async function scheduleTick() {
+ if (running) return null;
+ running = true;
+ try {
+ const summary = await posts.runScheduled(config.worker.id);
+ if (summary.done.length || summary.failed.length) outbox.kick();
+ return summary;
+ } catch (err) {
+ log.error('[Blog] schedule tick failed:', err.message);
+ return null;
+ } finally { running = false; }
+ }
+
+ /** Verify the attachments of published posts (at most `limit` posts per run). */
+ async function verifyMedia({ limit = 200 } = {}) {
+ if (!media.enabled) return [];
+ const ids = store.db.prepare(`SELECT DISTINCT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id
+ WHERE p.state = 'published' ORDER BY COALESCE(a.checked_at, 0) LIMIT ?`).all(limit).map((r) => r.id);
+ const out = [];
+ for (const id of ids) {
+ const results = await store.attachments.verify(id, { resolve: media.resolve });
+ out.push({ post: id, results });
+ }
+ return out;
+ }
+
+ return {
+ scheduleTick,
+ verifyMedia,
+ start() {
+ if (!config.worker.enabled) return;
+ scheduleTimer = setInterval(scheduleTick, config.worker.intervalMs);
+ scheduleTimer.unref();
+ if (media.enabled) {
+ mediaTimer = setInterval(() => verifyMedia().catch((err) => log.warn('[Blog] media verification failed:', err.message)), config.media.verifyIntervalMs);
+ mediaTimer.unref();
+ }
+ setTimeout(scheduleTick, 1000).unref();
+ },
+ stop() { clearInterval(scheduleTimer); clearInterval(mediaTimer); },
+ };
+}
+
+module.exports = { createWorker };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Service tokens: one capability per route (3-segment ids), the acting person in X-OV-Subject,
+ * membership still applies. OpenVibe.AI's blog.draft_post arrives as an AI-generated draft that is
+ * noindex and cannot be published or scheduled until a person reviews it.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };
+
+(async () => {
+ const t = await boot();
+ const fay = t.network.addUser('fay', { display_name: 'Fay' });
+ await t.get('/api/v1/blogs', { as: fay, json: {} });
+ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read']);
+ let draft;
+
+ await check('a service token without the capability is refused (problem+json)', async () => {
+ const tok = t.network.serviceToken('ai', ['blog.post.read']);
+ const r = await t.get('/api/v1/blogs/fay/posts', { as: tok, headers: { 'X-OV-Subject': fay.subject }, json: { title: 'x', body: 'y' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ });
+
+ await check('a token for another audience, or a forged one, is refused', async () => {
+ const wrong = t.network.signService({ sub: 'svc:ai', aud: ['openvibe.community'], cap: ['blog.post.create'] });
+ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts', { as: wrong, json: { title: 'x', body: 'y' } })).status, 401);
+ const forged = `${ai.split('.').slice(0, 2).join('.')}.AAAA`;
+ assert.strictEqual((await t.get('/api/v1/blogs/fay/posts', { as: forged, json: { title: 'x', body: 'y' } })).status, 401);
+ });
+
+ await check('a service must act for a member of the blog', async () => {
+ const r = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai' }, json: { title: 'x', body: 'y', authorship: { workflow: WORKFLOW } } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'subject.required');
+ const stranger = t.network.addUser('stranger');
+ const s = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': stranger.subject }, json: { title: 'x', body: 'y', authorship: { workflow: WORKFLOW } } });
+ assert.strictEqual(s.status, 403);
+ });
+
+ await check('blog.draft_post output becomes an AI-generated draft for the member who asked', async () => {
+ const r = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'An AI draft', body: LONG, authorship: { workflow: WORKFLOW } } });
+ assert.strictEqual(r.status, 201, r.text);
+ draft = r.json().post;
+ assert.strictEqual(draft.state, 'draft');
+ assert.strictEqual(draft.authorship.mode, 'ai');
+ assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');
+ assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');
+ const created = t.events('blog.post.created')[0];
+ assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
+ assert.strictEqual(created.payload.authorship, 'ai');
+ const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });
+ assert.strictEqual(noWorkflow.status, 400);
+ assert.strictEqual(noWorkflow.json().code, 'authorship.workflow_required');
+ });
+
+ await check('it cannot be published or scheduled before a person reviews it', async () => {
+ const p = await t.get(`/api/v1/posts/${draft.id}/publish`, { as: fay, json: {} });
+ assert.strictEqual(p.status, 409);
+ assert.strictEqual(p.json().code, 'post.review_required');
+ const s = await t.get(`/api/v1/posts/${draft.id}/schedule`, { as: fay, json: { at: new Date(t.clock.now() + 3600e3).toISOString() } });
+ assert.strictEqual(s.status, 409);
+ const editor = await t.get(`/write/posts/${draft.id}`, { as: fay });
+ assert.match(editor.text, /AI-generated/);
+ assert.match(editor.text, /Review needed/);
+ assert.match(editor.text, /ai_generated_unreviewed/);
+ });
+
+ await check('a service cannot review; the member can, then publish; the page discloses it', async () => {
+ const svc = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.publish']);
+ const bad = await t.get(`/api/v1/posts/${draft.id}/reviews`, { as: svc, headers: { 'X-OV-Subject': fay.subject }, json: { revision: 1, decision: 'approved' } });
+ assert.strictEqual(bad.status, 403);
+ const ok = await t.get(`/api/v1/posts/${draft.id}/reviews`, { as: fay, json: { revision: 1, decision: 'approved' } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ const p = await t.get(`/api/v1/posts/${draft.id}/publish`, { as: fay, json: {} });
+ assert.strictEqual(p.status, 200, p.text);
+ const page = await t.get('/@fay/an-ai-draft');
+ assert.match(page.text, /AI-generated/);
+ assert.match(page.text, /reviewed by a person/);
+ const doc = t.events('blog.index_document.upserted').pop().payload;
+ assert.strictEqual(doc.authorship, 'ai_generated');
+ assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));
+ });
+
+ await check('a person editing the AI text makes an AI-assisted (hybrid) revision', async () => {
+ const r = await t.get(`/api/v1/posts/${draft.id}`, { as: fay, method: 'PATCH', json: { expected_revision: 1, body: `${LONG} edited by Fay` } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().post.authorship.mode, 'hybrid');
+ assert.deepStrictEqual(r.json().post.authorship.authors, [fay.subject]);
+ });
+
+ await check('user tokens are judged by membership, not capabilities; staff may moderate any blog', async () => {
+ const admin = t.network.addUser('admin', { role: 'admin' });
+ const r = await t.get(`/api/v1/posts/${draft.id}/unpublish`, { as: admin, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ const edit = await t.get(`/api/v1/posts/${draft.id}`, { as: admin, method: 'PATCH', json: { expected_revision: 2, title: 'Staff rewrite' } });
+ assert.strictEqual(edit.status, 403, 'moderation is not authorship');
+ });
+
+ await check('theme and configuration go through their own capabilities', async () => {
+ const svc = t.network.serviceToken('network', ['blog.theme.set']);
+ const r = await t.get('/api/v1/blogs/fay/theme', { as: svc, method: 'PUT', headers: { 'X-OV-Subject': fay.subject }, json: { theme: 'nord' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().blog.theme, 'nord');
+ const c = await t.get('/api/v1/blogs/fay', { as: svc, method: 'PATCH', headers: { 'X-OV-Subject': fay.subject }, json: { title: 'No' } });
+ assert.strictEqual(c.status, 403);
+ });
+
+ await check('responses carry the request id and trace', async () => {
+ const r = await t.get('/api/v1/blogs/fay', { headers: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', 'X-OpenVibe-Request-Id': 'req_testtesttest' } });
+ assert.strictEqual(r.headers.get('x-openvibe-request-id'), 'req_testtesttest');
+ assert.match(r.headers.get('traceparent'), /^00-0af7651916cd43dd8448eb211c80319c-/);
+ const nf = await t.get('/api/v1/nothing', { headers: { 'X-OpenVibe-Request-Id': 'req_testtesttest' } });
+ assert.strictEqual(nf.json().request_id, 'req_testtesttest');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'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 do not collide with released ids.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { check, done } = require('./helpers/boot');
+const { PROPOSED } = require('../server/auth/capabilities');
+
+const DIR = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
+
+(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 blog', 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, 'blog');
+ 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 === 'blog', `${c.id} collides with a released capability`);
+ }
+ });
+
+ await check('the proposals are exactly the capabilities the code enforces', async () => {
+ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
+ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
+ });
+
+ await check('the service manifest proposal is a valid registry.service-manifest@1', async () => {
+ const v = contracts.validate('registry.service-manifest@1', manifest);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(manifest.id, 'blog');
+ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);
+ });
+
+ await check('every event type the code emits is declared', async () => {
+ const src = ['server/domain/posts.js', 'server/domain/publication.js'].map((f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8')).join('\n');
+ for (const m of src.matchAll(/event_type: '([a-z_.]+)'/g)) assert.ok(manifest.eventsProduced.includes(m[1]), m[1]);
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * Boots Blog 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, ... })
+ * t.clock.advance(ms) // the app's clock (scheduler, revisions, gate)
+ * t.restart() // a new app on the SAME database file (a worker restart)
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { startNetwork, startCommunity, startMedia } = require('./mocks');
+
+function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
+ let t = start;
+ return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const community = await startCommunity({ network });
+ const media = await startMedia({ network });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blog-test-'));
+ const dbPath = path.join(dir, 'blog.db');
+ const clock = opts.clock || makeClock();
+ const official = network.addUser('lead', { display_name: 'The Lead' });
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.blog', TRUST_PROXY: '1',
+ BLOG_DB_PATH: dbPath,
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'blog', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
+ OV_COMMUNITY_URL: 'https://openvibe.community', OV_COMMUNITY_INTERNAL_URL: community.url,
+ OV_MEDIA_URL: 'https://openvibe.media', OV_MEDIA_INTERNAL_URL: media.url,
+ BLOG_OFFICIAL_OWNERS: official.subject, BLOG_WORKER: 'off', BLOG_FORM_SECRET: 'test-form-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, entitlementCheck: opts.entitlementCheck });
+ 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); } };
+ }
+
+ /** Rows of event_outbox as parsed envelopes. */
+ function events(type = null) {
+ return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
+ }
+
+ const t = {
+ network, community, media, clock, dbPath, official, get, events,
+ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),
+ async restart() { await stop(); await start(); },
+ async close() { await stop(); await network.close(); await community.close(); await media.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, 6).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, makeClock };
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+/**
+ * In-process stand-ins for Blog's neighbours, with a real RS256 key pair:
+ * Network JWKS, /oauth/token (client_credentials → service tokens with the requested scope as
+ * capabilities), /internal/identity/resolve-batch (needs identity.subject.resolve)
+ * Community /api/v1/comments/threads/resolve, GET thread, POST comment, PUT visibility
+ * Media /api/v2/<app>/objects/:id → 200 | 404 | 410 by a table the test controls
+ * userToken()/serviceToken() mint the tokens browsers and services present to Blog.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ handler(req, raw, json, res);
+ });
+ });
+ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) }));
+ });
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const directory = new Map(); // subject → { username, display_name }
+ const grants = [];
+ let issuer = null;
+ const srv = await listen((req, raw, json) => {
+ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/oauth/token' && req.method === 'POST') {
+ let body = {};
+ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
+ else { try { body = JSON.parse(raw); } catch { /* */ } }
+ grants.push(body);
+ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
+ if (body.grant_type === 'client_credentials') {
+ let scope = body.scope;
+ if (scope && typeof scope === 'object') scope = Object.values(scope).join(' ');
+ const cap = String(scope || '').split(/\s+/).filter(Boolean);
+ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok || !(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied' });
+ const b = JSON.parse(raw || '{}');
+ const results = {};
+ for (const s of b.subject_ids || []) {
+ const u = directory.get(s);
+ results[s] = u ? { subject: { type: 'user', id: s }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
+ }
+ return json(200, { results });
+ }
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+ function signService({ sub, aud, cap, ns }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
+ }
+ function addUser(username, extra = {}) {
+ const subject = ids.newId('user');
+ const u = { subject, username, display_name: extra.display_name || username[0].toUpperCase() + username.slice(1), role: extra.role || 'user' };
+ directory.set(subject, u);
+ return u;
+ }
+ function userToken(u) {
+ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ }
+ function serviceToken(client, cap) {
+ return signService({ sub: `svc:${client}`, aud: ['openvibe.blog'], cap });
+ }
+ return { ...srv, publicPem, grants, directory, addUser, userToken, serviceToken, signService };
+}
+
+async function startCommunity({ network }) {
+ const threads = new Map(); // id → { ref, visibility, comments: [] }
+ const byRef = new Map();
+ const calls = [];
+ let down = false;
+ let seq = 0;
+ const srv = await listen((req, raw, json) => {
+ calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, auth: Boolean(req.headers.authorization) });
+ if (down) return json(503, { code: 'down' });
+ const auth = () => {
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.community' });
+ return v.ok ? v.claims : null;
+ };
+ if (req.url === '/api/v1/comments/threads/resolve' && req.method === 'POST') {
+ const c = auth();
+ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });
+ const { ref } = JSON.parse(raw);
+ const key = `${ref.service}:${ref.type}:${ref.id}`;
+ if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });
+ const id = ++seq;
+ threads.set(id, { ref, visibility: 'public', comments: [] });
+ byRef.set(key, id);
+ return json(201, { thread: { id, visibility: 'public' }, created: true });
+ }
+ let m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)(\?.*)?$/);
+ if (m && req.method === 'GET') {
+ const t = threads.get(Number(m[1]));
+ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });
+ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility, comment_count: t.comments.length }, comments: t.comments, next_cursor: null });
+ }
+ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/comments$/);
+ if (m && req.method === 'POST') {
+ const c = auth();
+ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });
+ const t = threads.get(Number(m[1]));
+ const body = JSON.parse(raw);
+ const who = network.directory.get(req.headers['x-ov-subject']);
+ const comment = { id: t.comments.length + 1, origin: 'user', display_name: who ? who.display_name : 'Someone', message: body.message, deleted: false, created_at: new Date().toISOString() };
+ t.comments.push(comment);
+ return json(201, { comment });
+ }
+ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
+ if (m && req.method === 'PUT') {
+ const c = auth();
+ if (!c || !(c.cap || []).includes('community.comment.moderate')) return json(403, { code: 'capability.denied' });
+ threads.get(Number(m[1])).visibility = JSON.parse(raw).visibility;
+ return json(200, { ok: true });
+ }
+ return json(404, { code: 'route.not_found' });
+ });
+ return { ...srv, threads, calls, setDown: (v) => { down = v; } };
+}
+
+async function startMedia({ network }) {
+ const objects = new Map(); // id → 'ready' | 'deleted' | 'gone' (404)
+ let down = false;
+ const srv = await listen((req, raw, json) => {
+ if (down) return json(503, { code: 'down' });
+ const m = req.url.match(/^\/api\/v2\/([a-z0-9-]+)\/objects\/(med_[0-9A-Z]+)$/);
+ if (!m) return json(404, { code: 'route.not_found' });
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media' });
+ if (!v.ok || !(v.claims.cap || []).includes('media.object.read')) return json(403, { code: 'capability.denied' });
+ const st = objects.get(m[2]);
+ if (!st || st === 'gone') return json(404, { code: 'media.object.not_found' });
+ if (st === 'deleted') return json(410, { code: 'media.object.deleted' });
+ return json(200, { id: m[2], lifecycle_status: 'ready' });
+ });
+ return { ...srv, objects, setDown: (v) => { down = v; } };
+}
+
+module.exports = { startNetwork, startCommunity, startMedia, listen };
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+/**
+ * Draft → edit → publish → revise → diff → revert → unpublish → delete, through the API with a
+ * member's Network JWT, and what readers, feeds, sitemaps, Search and subscribers see at each step.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { CHARTER_TABLES } = require('../server/db');
+
+const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });
+ let post;
+
+ await check('the ten charter tables exist (package tables and views included)', async () => {
+ const names = new Set(t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
+ for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);
+ for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);
+ });
+
+ await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {
+ const blog = t.ctx.blogs.official();
+ assert.strictEqual(blog.handle, 'openvibe');
+ assert.strictEqual(t.ctx.blogs.membership(blog, t.official.subject).role, 'owner');
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /The OpenVibe blog/);
+ });
+
+ await check('a member gets a blog on first use, owned by their Network subject', async () => {
+ const r = await t.get('/api/v1/blogs', { as: alice, json: {} });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json().blog;
+ assert.strictEqual(b.handle, 'alice');
+ assert.strictEqual(b.owner_subject, alice.subject);
+ const again = await t.get('/api/v1/blogs', { as: alice, json: {} });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().created, false);
+ });
+
+ await check('create a draft: revision 1, blog.post.created, nothing public, nothing sent to Search', async () => {
+ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'First light', body: `Hello **world**.\n\n${LONG}`, tags: 'Astronomy, night sky', categories: 'Science > Space' } });
+ assert.strictEqual(r.status, 201, r.text);
+ post = r.json().post;
+ assert.strictEqual(post.state, 'draft');
+ assert.strictEqual(post.revision, 1);
+ assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);
+ assert.strictEqual(t.events('blog.post.created').length, 1);
+ assert.strictEqual(t.events(/index_document/).length, 0);
+ assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
+ assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);
+ });
+
+ await check('edit: a new immutable revision; a stale expected_revision is a 412 and loses nothing', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 1, body: `Hello **stars**.\n\n${LONG}` } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().revision, 2);
+ const stale = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 1, body: 'lost update' } });
+ assert.strictEqual(stale.status, 412);
+ assert.strictEqual(stale.json().code, 'revision.conflict');
+ const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });
+ assert.strictEqual(missing.status, 428);
+ assert.throws(() => t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);
+ });
+
+ await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().post.published_revision, 2);
+ const page = await t.get('/@alice/first-light');
+ assert.strictEqual(page.status, 200);
+ const beforeScripts = page.text.split('<script src=')[0];
+ assert.match(page.text, /<h1>First light<\/h1>/);
+ assert.match(page.text, /Hello <strong>stars<\/strong>/);
+ assert.match(beforeScripts, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@alice\/first-light">/);
+ assert.match(page.text, /<meta name="robots" content="index, follow">/);
+ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const posting = ld.find((x) => x['@type'] === 'BlogPosting');
+ assert.ok(posting, 'BlogPosting JSON-LD');
+ assert.strictEqual(posting.headline, 'First light');
+ assert.strictEqual(posting.url, 'https://openvibe.blog/@alice/first-light');
+ assert.strictEqual(posting.author[0].name, 'Alice Writer');
+ assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());
+ assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');
+ assert.strictEqual(posting.aggregateRating, undefined);
+ assert.strictEqual(t.events('blog.post.published').length, 1);
+ const idx = t.events('blog.index_document.upserted');
+ assert.strictEqual(idx.length, 1);
+ assert.strictEqual(idx[0].payload.visibility, 'public');
+ assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');
+ assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);
+ for (const e of t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);
+ });
+
+ await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {
+ const rss = await t.get('/@alice/feed.xml');
+ assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe\.blog,2026:post\/pst_/);
+ assert.match((await t.get('/@alice/atom.xml')).text, /<link rel="alternate" type="text\/html" href="https:\/\/openvibe\.blog\/@alice\/first-light"\/>/);
+ const jf = (await t.get('/@alice/feed.json')).json();
+ assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@alice/first-light');
+ assert.match((await t.get('/sitemaps/posts.xml')).text, /@alice\/first-light/);
+ assert.match((await t.get('/sitemaps/blogs.xml')).text, /https:\/\/openvibe\.blog\/@alice</);
+ assert.match((await t.get('/@alice/tags/astronomy')).text, /First light/);
+ assert.match((await t.get('/tags/astronomy')).text, /First light/);
+ assert.match((await t.get('/@alice/categories/science')).text, /First light/, 'parent category includes children');
+ assert.match((await t.get('/@alice/authors/alice')).text, /First light/);
+ assert.match((await t.get('/@alice/first-light.json')).text, /"body_markdown"/);
+ });
+
+ await check('a revision after publication does not change what readers see until it is published', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });
+ assert.strictEqual(r.json().revision, 3);
+ assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);
+ assert.strictEqual(t.events('blog.post.updated').length, 0);
+ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
+ assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);
+ assert.strictEqual(t.events('blog.post.updated').length, 1);
+ assert.strictEqual(t.events('blog.index_document.upserted').length, 2);
+ });
+
+ await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {
+ const d = (await t.get(`/api/v1/posts/${post.id}/diff?from=1&to=3&mode=word`, { as: alice })).json().diff;
+ assert.ok(d.fields.some((f) => f.field === 'title'));
+ const rv = await t.get(`/api/v1/posts/${post.id}/revert`, { as: alice, json: { to_revision: 1, expected_revision: 3 } });
+ assert.strictEqual(rv.status, 201, rv.text);
+ assert.strictEqual(rv.json().revision, 4);
+ const revs = (await t.get(`/api/v1/posts/${post.id}/revisions`, { as: alice })).json().revisions;
+ assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);
+ assert.strictEqual(revs[0].kind, 'revert');
+ assert.strictEqual(revs[0].revertedTo, 1);
+ assert.strictEqual(t.ctx.store.revisions.get(post.id, 4).content, t.ctx.store.revisions.get(post.id, 1).content);
+ });
+
+ await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/unpublish`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
+ assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);
+ assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);
+ const del = t.events('blog.index_document.deleted');
+ assert.strictEqual(del.length, 1);
+ assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);
+ assert.strictEqual(t.events('blog.post.unpublished').length, 1);
+ });
+
+ await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {
+ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 4 } });
+ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(r.status, 200, r.text);
+ const page = await t.get('/@alice/first-light');
+ assert.strictEqual(page.status, 410);
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(t.events('blog.post.deleted').length, 1);
+ assert.strictEqual(t.ctx.store.revisions.list(post.id).length, 4);
+ const last = t.events(/index_document/).pop();
+ assert.strictEqual(last.event_type, 'blog.index_document.deleted');
+ });
+
+ await check('a stranger cannot write on someone else’s blog', async () => {
+ const bob = t.network.addUser('bob');
+ const r = await t.get('/api/v1/blogs/alice/posts', { as: bob, json: { title: 'Intrusion', body: 'x' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+/**
+ * The whole write → publish → comment journey with plain HTML forms (what a browser without
+ * JavaScript does), Network SSO cookies and form tokens; comments are Community threads that are
+ * referenced, never copied.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/) || [])[1];
+
+(async () => {
+ const t = await boot();
+ const eve = t.network.addUser('eve', { display_name: 'Eve' });
+ const csrf = t.csrf(eve);
+ let postId;
+
+ await check('the editor sends a signed-out browser to Network sign-in', async () => {
+ const r = await t.get('/write');
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), '/auth/login?next=%2Fwrite');
+ const login = await t.get('/auth/login?next=/write');
+ assert.strictEqual(login.status, 302);
+ assert.match(login.headers.get('location'), /\/oauth\/authorize\?.*client_id=blog/);
+ });
+
+ await check('first use: the start form creates the member blog', async () => {
+ const home = await t.get('/write', { as: eve });
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /<form method="post" action="\/write\/start">/);
+ assert.strictEqual(home.headers.get('cache-control'), 'private, no-store');
+ assert.match(home.headers.get('x-robots-tag'), /noindex/);
+ const r = await t.get('/write/start', { as: eve, form: { _csrf: csrf, handle: 'eve', title: 'Eve’s notes' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location').split('?')[0], '/write/@eve');
+ });
+
+ await check('a form without the token is refused', async () => {
+ const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n, 0);
+ });
+
+ await check('new draft, edit and publish with forms', async () => {
+ const form = await t.get('/write/@eve/new', { as: eve });
+ assert.match(form.text, /<textarea id="body" name="body"/);
+ let r = await t.get('/write/@eve/new', { as: eve, form: { _csrf: csrf, title: 'Notes without script', body: `# Heading\n\nPlain *text*. ${LONG}`, tags: 'nojs', series: 'Field notes', seriesPosition: '1', visibility: 'public', allowComments: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+ postId = idFrom(r.headers.get('location'));
+ assert.ok(postId);
+ const edit = await t.get(`/write/posts/${postId}`, { as: eve });
+ assert.match(edit.text, /name="expectedRevision" value="1"/);
+ assert.match(edit.text, /Search engines: <strong>noindex, nofollow<\/strong>|Search engines: indexable/);
+ r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Notes without script', body: `# Heading\n\nPlain *text*, edited. ${LONG}`, tags: 'nojs', series: 'Field notes', seriesPosition: '1', visibility: 'public', allowComments: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.match(decodeURIComponent(r.headers.get('location')), /revision 2/);
+ r = await t.get(`/write/posts/${postId}/publish`, { as: eve, form: { _csrf: csrf, revision: '2' } });
+ assert.strictEqual(r.status, 303);
+ const page = await t.get('/@eve/notes-without-script');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Plain <em>text<\/em>, edited\./);
+ assert.match(page.text, /Part 1 of the series <a href="\/@eve\/series\/field-notes">Field notes<\/a>/);
+ assert.match((await t.get('/@eve/series/field-notes')).text, /Notes without script/);
+ });
+
+ await check('a stale form (someone saved first) is a clear refusal, not a lost edit', async () => {
+ const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });
+ assert.strictEqual(r.status, 412);
+ assert.match(r.text, /Someone saved revision 2/);
+ assert.strictEqual(t.ctx.store.revisions.head(postId).fields.title, 'Notes without script');
+ });
+
+ await check('history, diff and preview are plain pages', async () => {
+ const h = await t.get(`/write/posts/${postId}/revisions?from=1&to=2`, { as: eve });
+ assert.match(h.text, /<ins>/);
+ const p = await t.get(`/write/posts/${postId}/preview`, { as: eve });
+ assert.strictEqual(p.status, 200);
+ assert.strictEqual(p.headers.get('cache-control'), 'private, no-store');
+ assert.match(p.text, /<meta name="robots" content="noindex, nofollow">/);
+ });
+
+ await check('the post page shows the Community thread and takes a comment by form', async () => {
+ const page = await t.get('/@eve/notes-without-script', { as: eve });
+ assert.match(page.text, /No comments yet\./);
+ assert.match(page.text, /<form method="post" action="\/@eve\/notes-without-script\/comments"/);
+ const r = await t.get('/@eve/notes-without-script/comments', { as: eve, form: { _csrf: csrf, message: 'First!' } });
+ assert.strictEqual(r.status, 303, r.text);
+ const again = await t.get('/@eve/notes-without-script');
+ assert.match(again.text, /First!/);
+ const thread = [...t.community.threads.values()][0];
+ assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });
+ const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));
+ assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');
+ const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('blog_post_discussion_refs')").all().map((c) => c.name);
+ assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');
+ });
+
+ await check('Community down: the page still serves and says comments are unavailable', async () => {
+ t.community.setDown(true);
+ const page = await t.get('/@eve/notes-without-script');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Comments could not be loaded/);
+ t.community.setDown(false);
+ });
+
+ await check('a post that stops being public: its thread is hidden, no thread for non-public posts', async () => {
+ await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, visibility: 'private' } });
+ await new Promise((r) => setTimeout(r, 100));
+ assert.strictEqual([...t.community.threads.values()][0].visibility, 'hidden');
+ const page = await t.get('/@eve/notes-without-script', { as: eve });
+ assert.match(page.text, /Comments are available on public posts only\./);
+ });
+
+ await check('delete needs the confirmation box, then answers 410', async () => {
+ let r = await t.get(`/write/posts/${postId}/delete`, { as: eve, form: { _csrf: csrf } });
+ assert.strictEqual(r.status, 422);
+ r = await t.get(`/write/posts/${postId}/delete`, { as: eve, form: { _csrf: csrf, confirm: '1' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual((await t.get('/@eve/notes-without-script')).status, 410);
+ });
+
+ await check('settings: theme preset, feed switches and members', async () => {
+ const mallory = t.network.addUser('mallory');
+ await t.get('/write', { as: mallory }); // signs in once: now known by username
+ let r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });
+ assert.strictEqual(r.status, 303, r.text);
+ const blog = t.ctx.blogs.byHandle('eve');
+ assert.strictEqual(blog.theme, 'paper');
+ assert.strictEqual(t.ctx.blogs.feedSettings(blog).json, 0);
+ assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);
+ assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);
+ r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(t.ctx.blogs.membership(blog, mallory.subject).role, 'author');
+ const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });
+ assert.strictEqual(bad.status, 422);
+ const notOwner = await t.get('/write/@eve/settings', { as: mallory });
+ assert.strictEqual(notOwner.status, 404);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * Private, members-only (VIP) and unlisted posts never leak through cache headers, Search,
+ * feeds or sitemaps — and a visibility change converges everywhere.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+function assertPrivate(r, what) {
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', `${what}: Cache-Control`);
+ assert.match(r.headers.get('vary') || '', /Cookie/i, `${what}: Vary Cookie`);
+ assert.match(r.headers.get('vary') || '', /Authorization/i, `${what}: Vary Authorization`);
+}
+
+(async () => {
+ const vipMember = { subject: null };
+ const t = await boot({ entitlementCheck: async ({ subject, key }) => subject === vipMember.subject && key === 'vip:carol' });
+ const carol = t.network.addUser('carol');
+ const reader = t.network.addUser('reader');
+ const vip = t.network.addUser('vipfan');
+ vipMember.subject = vip.subject;
+ await t.get('/api/v1/blogs', { as: carol, json: {} });
+
+ const make = async (title, visibility, extra = {}) => {
+ const p = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${title} SECRET-${visibility} ${LONG}`, visibility, ...extra } })).json().post;
+ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
+ return p;
+ };
+ const pub = await make('Open post', 'public');
+ const mem = await make('Members post', 'members', { entitlement_key: 'vip:carol' });
+ const priv = await make('Private post', 'private');
+ const unl = await make('Unlisted post', 'unlisted');
+
+ await check('a public post for an anonymous reader is shared-cacheable and varies on Cookie/Authorization', async () => {
+ const r = await t.get('/@carol/open-post');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
+ assert.match(r.headers.get('vary'), /Cookie/);
+ assert.match(r.headers.get('vary'), /Authorization/);
+ });
+
+ await check('the same public post for a signed-in reader is private, no-store', async () => {
+ assertPrivate(await t.get('/@carol/open-post', { as: reader }), 'signed-in view');
+ });
+
+ await check('members-only: anonymous → 403 without the text; non-entitled member of the network → 403; no-store', async () => {
+ const a = await t.get('/@carol/members-post');
+ assert.strictEqual(a.status, 403);
+ assert.doesNotMatch(a.text, /SECRET-members/);
+ assert.doesNotMatch(a.text, /Members post/, 'not even the title');
+ assertPrivate(a, 'members 403');
+ assert.match(a.headers.get('x-robots-tag'), /noindex/);
+ const r = await t.get('/@carol/members-post', { as: reader });
+ assert.strictEqual(r.status, 403);
+ const j = await t.get('/@carol/members-post.json');
+ assert.strictEqual(j.status, 403);
+ assert.doesNotMatch(j.text, /SECRET/);
+ });
+
+ await check('members-only: the entitled viewer (entitlement seam) and the blog owner can read it — privately', async () => {
+ const v = await t.get('/@carol/members-post', { as: vip });
+ assert.strictEqual(v.status, 200);
+ assert.match(v.text, /SECRET-members/);
+ assertPrivate(v, 'entitled view');
+ assert.match(v.text, /<meta name="robots" content="noindex, nofollow">/);
+ const o = await t.get('/@carol/members-post', { as: carol });
+ assert.strictEqual(o.status, 200);
+ assertPrivate(o, 'owner view');
+ });
+
+ await check('private: 404 to everyone but the blog’s members; unlisted: readable by link but never cached publicly', async () => {
+ assert.strictEqual((await t.get('/@carol/private-post')).status, 404);
+ assert.strictEqual((await t.get('/@carol/private-post', { as: vip })).status, 404);
+ assert.strictEqual((await t.get('/@carol/private-post', { as: carol })).status, 200);
+ const u = await t.get('/@carol/unlisted-post');
+ assert.strictEqual(u.status, 200);
+ assertPrivate(u, 'unlisted');
+ assert.match(u.text, /<meta name="robots" content="noindex, nofollow">/);
+ });
+
+ await check('feeds, sitemaps, blog index and tag pages carry only the public post', async () => {
+ for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/sitemap.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed']) {
+ const r = await t.get(p);
+ assert.doesNotMatch(r.text, /SECRET-(members|private|unlisted)|members-post|private-post|unlisted-post/, p);
+ }
+ assert.match((await t.get('/@carol/feed.xml')).text, /open-post/);
+ // Even an entitled or owning reader gets the same public-only feed (feeds never vary by viewer).
+ assert.doesNotMatch((await t.get('/@carol/feed.xml', { as: carol })).text, /members-post/);
+ });
+
+ await check('Search receives only the public post; the others were never sent', async () => {
+ const ids = t.events('blog.index_document.upserted').map((e) => e.payload.id);
+ assert.deepStrictEqual(ids, [pub.id]);
+ const published = t.events('blog.post.published');
+ assert.strictEqual(published.length, 4);
+ for (const e of published) {
+ if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');
+ else assert.strictEqual(e.visibility, 'internal', 'non-public publication events are internal');
+ assert.strictEqual(JSON.stringify(e.payload).includes('SECRET'), false, 'no body in product events');
+ }
+ });
+
+ await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {
+ const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const del = t.events('blog.index_document.deleted');
+ assert.strictEqual(del.length, 1);
+ assert.strictEqual(del[0].payload.id, pub.id);
+ const up = t.events('blog.index_document.upserted').find((e) => e.payload.id === pub.id);
+ assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');
+ assert.strictEqual(t.events('blog.post.updated').length, 1);
+ const a = await t.get('/@carol/open-post');
+ assert.strictEqual(a.status, 403);
+ assertPrivate(a, 'after privatising');
+ assert.doesNotMatch((await t.get('/@carol/feed.xml')).text, /open-post/);
+ assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /open-post/);
+ });
+
+ await check('members → public again: re-indexed with a higher revision', async () => {
+ await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });
+ const ups = t.events('blog.index_document.upserted').filter((e) => e.payload.id === pub.id);
+ const del = t.events('blog.index_document.deleted')[0];
+ assert.strictEqual(ups.length, 2);
+ assert.ok(ups[1].payload.revision > del.payload.revision);
+ });
+
+ await check('without an entitlement service the check fails closed', async () => {
+ const { createEntitlementChecker } = require('../server/domain/access');
+ const none = createEntitlementChecker({ provider: 'none' });
+ assert.strictEqual(await none.has(vip.subject, 'vip:carol'), false);
+ const unknown = createEntitlementChecker({ provider: 'vip-service-that-does-not-exist' });
+ assert.strictEqual(await unknown.has(vip.subject, 'vip:carol'), false);
+ const throwing = createEntitlementChecker({ check: async () => { throw new Error('down'); } });
+ assert.strictEqual(await throwing.has(vip.subject, 'vip:carol'), false);
+ });
+
+ await check('API reads of non-public posts are private and refused to outsiders', async () => {
+ const r = await t.get(`/api/v1/posts/${priv.id}`, { as: reader });
+ assert.strictEqual(r.status, 404);
+ assertPrivate(r, 'api');
+ const m = await t.get(`/api/v1/posts/${mem.id}`);
+ assert.strictEqual(m.status, 403);
+ assert.doesNotMatch(m.text, /SECRET/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
+ * databases and in-process mocks of OpenVibe.Network, Community, Media and Events; none needs the
+ * network or a running site.
+ *
+ * npm test # everything
+ * npm test -- schedule # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
+ if (process.env.VERBOSE) console.log(r.output);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * Scheduled publication is idempotent across worker restarts: a worker that applies the publish
+ * and dies before recording the job as done leaves the lease to expire; after a restart on the same
+ * database the job re-runs, changes nothing and emits nothing twice. Scheduling twice is one job.
+ * A job that cannot succeed ends in blog.schedule.failed and the post returns to draft.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+const MIN = 60 * 1000;
+
+(async () => {
+ const t = await boot();
+ const ann = t.network.addUser('ann');
+ await t.get('/api/v1/blogs', { as: ann, json: {} });
+ const mk = async (title) => (await t.get('/api/v1/blogs/ann/posts', { as: ann, json: { title, body: LONG } })).json().post;
+ const at = () => new Date(t.clock.now() + 10 * MIN).toISOString();
+
+ const p1 = await mk('Scheduled one');
+
+ await check('scheduling is idempotent: the same post, time and revision is one job', async () => {
+ const when = at();
+ const a = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });
+ assert.strictEqual(a.status, 201, a.text);
+ const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });
+ assert.strictEqual(b.json().created, false);
+ assert.strictEqual(b.json().job.id, a.json().job.id);
+ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id).n, 1, 'charter view blog_schedules');
+ assert.strictEqual(b.json().post.state, 'scheduled');
+ assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');
+ assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);
+ });
+
+ await check('nothing runs before its time', async () => {
+ const s = await t.ctx.worker.scheduleTick();
+ assert.strictEqual(s.done.length, 0);
+ });
+
+ await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {
+ t.clock.advance(11 * MIN);
+ // Worker A: claim the job and apply its effect, then "crash" (no complete()).
+ const [job] = t.ctx.store.scheduler.claim({ worker: 'worker-A' });
+ assert.ok(job, 'claimed');
+ t.ctx.posts.applyPublish(job.entityId, job.revision, null);
+ assert.strictEqual(t.events('blog.post.published').length, 1);
+ const idxBefore = t.events('blog.index_document.upserted').length;
+ assert.strictEqual(idxBefore, 1);
+
+ await t.restart(); // same database file, new process state
+ // The lease (60 s) has not expired yet: nobody may take it.
+ let s = await t.ctx.worker.scheduleTick();
+ assert.strictEqual(s.done.length, 0);
+ t.clock.advance(2 * MIN);
+ s = await t.ctx.worker.scheduleTick();
+ assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');
+ assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');
+ assert.strictEqual(t.events('blog.post.published').length, 1, 'exactly one published event');
+ assert.strictEqual(t.events('blog.index_document.upserted').length, 1, 'exactly one Search upsert');
+ const page = await t.get('/@ann/scheduled-one');
+ assert.strictEqual(page.status, 200);
+ s = await t.ctx.worker.scheduleTick();
+ assert.strictEqual(s.done.length + s.failed.length, 0, 'done jobs never run again');
+ });
+
+ await check('a normal scheduled publish through the worker', async () => {
+ const p2 = await mk('Scheduled two');
+ await t.get(`/api/v1/posts/${p2.id}/schedule`, { as: ann, json: { at: at() } });
+ t.clock.advance(11 * MIN);
+ const s = await t.ctx.worker.scheduleTick();
+ assert.strictEqual(s.done.length, 1);
+ assert.strictEqual(t.ctx.posts.get(p2.id).state, 'published');
+ assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);
+ });
+
+ await check('publishing by hand cancels the pending schedule', async () => {
+ const p3 = await mk('Scheduled three');
+ await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });
+ await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });
+ const jobs = t.ctx.store.scheduler.jobs(p3.id);
+ assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);
+ });
+
+ await check('a job that cannot succeed fails after its retries: blog.schedule.failed, back to draft', async () => {
+ const p4 = await mk('Doomed');
+ await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });
+ // The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.
+ t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });
+ t.clock.advance(11 * MIN);
+ for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }
+ const failed = t.events('blog.schedule.failed');
+ assert.strictEqual(failed.length, 1, JSON.stringify(t.ctx.store.scheduler.jobs(p4.id)));
+ assert.strictEqual(failed[0].subject.id, p4.id);
+ assert.match(failed[0].payload.error, /revision/i);
+ assert.strictEqual(t.ctx.posts.get(p4.id).state, 'draft');
+ const ready = (await t.get('/api/ready')).json();
+ assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');
+ });
+
+ await check('a time in the past is refused', async () => {
+ const p5 = await mk('Too late');
+ const r = await t.get(`/api/v1/posts/${p5.id}/schedule`, { as: ann, json: { at: new Date(t.clock.now() - MIN).toISOString() } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'schedule.in_past');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * The seed post (verbatim quotes with citations, a draft until a named person reviews it) and the
+ * operator surfaces: health, readiness, release, robots, llms.txt, sitemap index, legal pages.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');
+
+(async () => {
+ const t = await boot();
+ const facts = loadFacts();
+
+ await check('the facts file: every item is an OpenVibers GitHub source with a verbatim quote; no pricing copy', async () => {
+ assert.ok(facts.items.length >= 5);
+ const raw = fs.readFileSync(path.join(__dirname, '..', 'seed', 'week-2026-09-16.json'), 'utf8');
+ assert.doesNotMatch(raw, /\bfree\b|\$0/i);
+ for (const it of facts.items) assert.match(it.url, /^https:\/\/github\.com\/OpenVibers\//);
+ });
+
+ await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {
+ const out = seedOfficialPost(t.ctx, {});
+ assert.strictEqual(out.created, true);
+ assert.strictEqual(out.post.state, 'draft');
+ assert.strictEqual(out.post.author_subject, t.official.subject);
+ const rev = t.ctx.store.revisions.head(out.post.id);
+ for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);
+ const cites = t.ctx.store.citations.forRevision(out.post.id, rev.number);
+ assert.strictEqual(cites.length, facts.items.length);
+ assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));
+ assert.strictEqual(rev.meta.authorship.mode, 'imported');
+ assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);
+ assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');
+ assert.strictEqual(t.events(/index_document/).length, 0);
+ assert.strictEqual(seedOfficialPost(t.ctx, {}).created, false, 'idempotent');
+ });
+
+ await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {
+ assert.throws(() => seedOfficialPost(t.ctx, { publish: true }), /reviewer/);
+ const reviewer = t.network.addUser('reviewer');
+ const out = seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });
+ assert.strictEqual(out.published, true);
+ assert.strictEqual(out.review.reviewer, reviewer.subject);
+ const page = await t.get(`/@openvibe/${SLUG}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h2>Sources<\/h2>/);
+ assert.match(page.text, /Imported/);
+ assert.match((await t.get('/')).text, /Released in the OpenVibe repositories/);
+ assert.match((await t.get('/feed.xml')).text, /Released in the OpenVibe repositories/);
+ const ld = [...page.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'BlogPosting');
+ assert.strictEqual(ld.publisher.name, 'OpenVibe');
+ assert.strictEqual(ld.citation.length, facts.items.length);
+ });
+
+ await check('health, readiness, release manifest', async () => {
+ assert.strictEqual((await t.get('/api/health')).json().status, 'ok');
+ const ready = await t.get('/api/ready');
+ assert.strictEqual(ready.status, 200);
+ const body = ready.json();
+ assert.strictEqual(body.checks.db.status, 'ok');
+ assert.strictEqual(body.checks.network_jwks.status, 'ok');
+ assert.ok(body.degraded.includes('events_relay'), 'the relay is honestly reported off in tests');
+ const rel = await t.get('/release.json');
+ assert.strictEqual(rel.status, 200);
+ assert.strictEqual(rel.json().service, 'blog');
+ });
+
+ await check('robots.txt names the sitemap and the automated-consumer policy; llms.txt orients machines', async () => {
+ const robots = (await t.get('/robots.txt')).text;
+ assert.match(robots, /Sitemap: https:\/\/openvibe\.blog\/sitemap\.xml/);
+ assert.match(robots, /Disallow: \/write/);
+ assert.match(robots, /automated-consumer policy/);
+ const llms = (await t.get('/llms.txt')).text;
+ assert.match(llms, /^# OpenVibe\.Blog/);
+ assert.match(llms, /\.json/);
+ const idx = (await t.get('/sitemap.xml')).text;
+ assert.match(idx, /<sitemapindex/);
+ assert.match(idx, /sitemaps\/posts\.xml/);
+ });
+
+ await check('legal pages and 404s', async () => {
+ assert.strictEqual((await t.get('/terms')).status, 200);
+ const nf = await t.get('/@nobody-here');
+ assert.strictEqual(nf.status, 404);
+ assert.strictEqual(nf.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('/metrics answers loopback callers only through the shared instrument', async () => {
+ const r = await t.get('/metrics');
+ assert.ok([200, 404].includes(r.status));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * Canonical URLs and feed identities stay stable across slug changes (old paths 301, chains
+ * collapse), and a deleted Media object turns into an explicit broken-asset state — never a
+ * silently missing image, and never because Media was merely down.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+const MED_A = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W3';
+const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
+
+(async () => {
+ const t = await boot();
+ const dan = t.network.addUser('dan');
+ await t.get('/api/v1/blogs', { as: dan, json: {} });
+ const post = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Old name', body: `Intro\n\n[[media:${MED_A}]]\n\n${LONG}` } })).json().post;
+ await t.get(`/api/v1/posts/${post.id}/publish`, { as: dan, json: {} });
+ const feedIdBefore = (await t.get('/@dan/feed.json')).json().items[0].id;
+
+ await check('slug change: the old URL answers 301 to the new canonical one', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'new-name' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().post.url, 'https://openvibe.blog/@dan/new-name');
+ const old = await t.get('/@dan/old-name');
+ assert.strictEqual(old.status, 301);
+ assert.strictEqual(old.headers.get('location'), '/@dan/new-name');
+ assert.strictEqual((await t.get('/@dan/old-name.json')).headers.get('location'), '/@dan/new-name.json');
+ const page = await t.get('/@dan/new-name');
+ assert.match(page.text, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@dan\/new-name">/);
+ assert.match(page.text, /"url":"https:\/\/openvibe\.blog\/@dan\/new-name"/);
+ });
+
+ await check('the feed item id is stable and the feed links the new canonical URL', async () => {
+ const jf = (await t.get('/@dan/feed.json')).json();
+ assert.strictEqual(jf.items[0].id, feedIdBefore);
+ assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');
+ assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));
+ assert.strictEqual(t.events('blog.post.updated').length, 1, 'a canonical change of a published post is an update');
+ const docs = t.events('blog.index_document.upserted');
+ assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');
+ });
+
+ await check('a second rename collapses the chain: every old path goes straight to the newest', async () => {
+ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });
+ assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');
+ assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');
+ assert.deepStrictEqual(t.ctx.store.redirects.history(post.id).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);
+ });
+
+ await check('a new post may take a freed slug; the redirect then yields to it', async () => {
+ const p2 = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Old name', body: LONG } })).json().post;
+ assert.strictEqual(p2.slug, 'old-name');
+ await t.get(`/api/v1/posts/${p2.id}/publish`, { as: dan, json: {} });
+ assert.strictEqual((await t.get('/@dan/old-name')).status, 200);
+ });
+
+ await check('a redirect never reveals the new slug of a post the reader may not see', async () => {
+ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { visibility: 'private' } });
+ const r = await t.get('/@dan/new-name');
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.headers.get('location'), null);
+ await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { visibility: 'public' } });
+ });
+
+ await check('attached media renders as a figure while Media has it', async () => {
+ const a = await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: MED_A, alt: 'A comet' } });
+ assert.strictEqual(a.status, 201, a.text);
+ await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: MED_B, role: 'cover', alt: 'Cover' } });
+ const bad = await t.get(`/api/v1/posts/${post.id}/attachments`, { as: dan, json: { media_id: 'https://evil.example/x.png' } });
+ assert.strictEqual(bad.status, 422);
+ t.media.objects.set(MED_A, 'ready');
+ t.media.objects.set(MED_B, 'ready');
+ await t.ctx.worker.verifyMedia();
+ const page = await t.get('/@dan/newest-name');
+ assert.match(page.text, new RegExp(`<img src="https://openvibe.media/o/${MED_A}" alt="A comet"`));
+ assert.match(page.text, /data-state="available"/);
+ });
+
+ await check('Media down: nothing changes (check_failed), the image is not declared missing', async () => {
+ t.media.setDown(true);
+ const out = await t.ctx.worker.verifyMedia();
+ assert.ok(out[0].results.every((r) => r.outcome === 'check_failed'));
+ t.media.setDown(false);
+ assert.doesNotMatch((await t.get('/@dan/newest-name')).text, /no longer available/);
+ });
+
+ await check('Media deleted the object: an explicit broken-asset state in the page, JSON, feed and editor', async () => {
+ t.media.objects.set(MED_A, 'deleted');
+ t.media.objects.set(MED_B, 'gone');
+ const out = await t.ctx.worker.verifyMedia();
+ assert.deepStrictEqual(out[0].results.map((r) => `${r.outcome}:${r.reason}`).sort(), ['broken:deleted', 'broken:not_found']);
+ const page = await t.get('/@dan/newest-name');
+ assert.match(page.text, new RegExp(`data-media-id="${MED_A}" data-state="broken"`));
+ assert.match(page.text, /This media is no longer available\./);
+ assert.doesNotMatch(page.text, new RegExp(`<img src="https://openvibe.media/o/${MED_A}"`));
+ assert.doesNotMatch(page.text, /og:image/, 'a broken cover is not advertised');
+ const json = (await t.get('/@dan/newest-name.json')).json();
+ const m = json.media.find((x) => x.media_id === MED_A);
+ assert.strictEqual(m.state, 'broken');
+ assert.strictEqual(m.broken_reason, 'deleted');
+ assert.strictEqual(m.url, null);
+ assert.match((await t.get('/@dan/feed.json')).text, /no longer available/);
+ const editor = await t.get(`/write/posts/${post.id}`, { as: dan });
+ assert.match(editor.text, /unavailable \(deleted\)/);
+ });
+
+ await check('an id in the text that is not attached says so', async () => {
+ const p = (await t.get('/api/v1/blogs/dan/posts', { as: dan, json: { title: 'Dangling', body: `[[media:${MED_B}]]\n\n${LONG}` } })).json().post;
+ await t.get(`/api/v1/posts/${p.id}/publish`, { as: dan, json: {} });
+ assert.match((await t.get('/@dan/dangling')).text, /data-state="not_attached"/);
+ });
+
+ await t.close();
+ done();
+})();