openvibers/openvibe.blog Commits

OpenVibe.Blog service (Wave 16): official and member blogs on openvibe-publishing v0.2.0

OP OpenVibers committed 23 Sept 2026 · 8b248d562f · 46 files, +7306 −0

The ten charter tables in Blog's own SQLite (blogs, blog_memberships, blog_posts, blog_series,
blog_feed_settings owned by Blog; blog_post_revisions and blog_redirects from the packages;
blog_taxonomy, blog_post_terms and blog_schedules as views over the package tables). Drafts and
immutable revisions with 412 conflicts, diff and revert; publish, unpublish, soft delete (410);
scheduled publish/unpublish that is idempotent across worker restarts; tags, nested categories,
series, author pages; slug changes with collapsing 301s; Media attachments with an explicit
broken-asset state; openvibe-shared theme presets; Community comment threads by reference.

Server-rendered routes useful without JavaScript, RSS/Atom/JSON Feed per blog, sitemaps,
robots.txt, llms.txt and a .json twin of every post; the indexability gate decides robots, feeds,
sitemaps and Search. Private, members-only (entitlement seam failing closed) and unlisted posts
never reach feeds, sitemaps, Search or shared caches. Editor as plain HTML forms with Network SSO.
/api/v1 guarded by 3-segment capabilities for service tokens and by membership for user tokens;
AI drafts (X-OV-Origin: ai) need a person's review before publication. Events through the SDK
outbox: blog.post.*, blog.schedule.failed and blog.index_document.* for OpenVibe.Search.

Seed: the official blog's first post built only from verbatim, cited commit messages; a draft
until a named person reviews it. Tests on Node 22.22.1 with temp databases and in-process mocks.

Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..1478547
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..6810a5e
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/blog.css b/public/css/blog.css
nnew file mode 100644
iindex 0000000..7959197
---- /dev/null
++++ b/public/css/blog.css
@@@ -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; } }
ddiff --git a/scripts/seed.js b/scripts/seed.js
nnew file mode 100644
iindex 0000000..e2998fe
---- /dev/null
++++ b/scripts/seed.js
@@@ -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();
++}
ddiff --git a/seed/week-2026-09-16.json b/seed/week-2026-09-16.json
nnew file mode 100644
iindex 0000000..baf806a
---- /dev/null
++++ b/seed/week-2026-09-16.json
@@@ -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
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..86b4433
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
nnew file mode 100644
iindex 0000000..ce54e4f
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -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 };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..dc89e9a
---- /dev/null
++++ b/server/auth/forms.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++/**
++ * Form tokens for the no-JavaScript editor and comment forms (CSRF). The session cookie is
++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
++ * 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 };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..ffe1505
---- /dev/null
++++ b/server/auth/sso.js
@@@ -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 };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
nnew file mode 100644
iindex 0000000..7558353
---- /dev/null
++++ b/server/auth/viewer.js
@@@ -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 };
ddiff --git a/server/clients/community.js b/server/clients/community.js
nnew file mode 100644
iindex 0000000..855a7e8
---- /dev/null
++++ b/server/clients/community.js
@@@ -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 };
ddiff --git a/server/clients/media.js b/server/clients/media.js
nnew file mode 100644
iindex 0000000..76cf9a8
---- /dev/null
++++ b/server/clients/media.js
@@@ -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 };
ddiff --git a/server/clients/network.js b/server/clients/network.js
nnew file mode 100644
iindex 0000000..00b977e
---- /dev/null
++++ b/server/clients/network.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..6864ddb
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..38067e9
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/access.js b/server/domain/access.js
nnew file mode 100644
iindex 0000000..d73faea
---- /dev/null
++++ b/server/domain/access.js
@@@ -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 };
ddiff --git a/server/domain/blogs.js b/server/domain/blogs.js
nnew file mode 100644
iindex 0000000..a454f7e
---- /dev/null
++++ b/server/domain/blogs.js
@@@ -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 };
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
nnew file mode 100644
iindex 0000000..cc5eef6
---- /dev/null
++++ b/server/domain/effects.js
@@@ -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 };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
nnew file mode 100644
iindex 0000000..9cc72f0
---- /dev/null
++++ b/server/domain/posts.js
@@@ -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 };
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
nnew file mode 100644
iindex 0000000..c333897
---- /dev/null
++++ b/server/domain/publication.js
@@@ -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 };
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
nnew file mode 100644
iindex 0000000..cdf943c
---- /dev/null
++++ b/server/domain/reading.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..24aab16
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..bf8252c
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..79e1090
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/server/http/editor.js b/server/http/editor.js
nnew file mode 100644
iindex 0000000..8fb3238
---- /dev/null
++++ b/server/http/editor.js
@@@ -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 };
ddiff --git a/server/http/errors.js b/server/http/errors.js
nnew file mode 100644
iindex 0000000..9f71079
---- /dev/null
++++ b/server/http/errors.js
@@@ -0,0 +1,64 @@
++'use strict';
++
++/**
++ * Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
++ * and the small request helpers every router shares.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++
++/** A refusal with a stable problem code (e.g. 404 'post.not_found'). */
++class ApiError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.name = 'ApiError';
++ this.status = status;
++ this.code = code;
++ this.extra = extra || null;
++ }
++}
++
++/** Publishing package errors carry { status, code } too. */
++function asApiError(err) {
++ if (err instanceof ApiError) return err;
++ if (err && err.name === 'PublishingError' && Number.isInteger(err.status)) {
++ const extra = err.code === 'revision.conflict' ? { expected: err.expected, current: err.current } : null;
++ return new ApiError(err.status, err.code, err.message, extra);
++ }
++ if (err instanceof TypeError && err.message && !/Cannot read|is not a function|undefined/.test(err.message)) {
++ return new ApiError(422, 'request.invalid', err.message);
++ }
++ return null;
++}
++
++/** Wrap a JSON handler: its return value is the body; errors become problems. */
++function run(fn, status = 200) {
++ return async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (out === undefined || res.headersSent) return;
++ res.status(typeof status === 'function' ? status(out) : status).json(out);
++ } catch (err) {
++ if (res.headersSent) return;
++ const e = asApiError(err);
++ if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
++ console.error('[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 };
ddiff --git a/server/http/public.js b/server/http/public.js
nnew file mode 100644
iindex 0000000..1d7a51b
---- /dev/null
++++ b/server/http/public.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..2c4d92f
---- /dev/null
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..80dbd24
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/render/editor.js b/server/render/editor.js
nnew file mode 100644
iindex 0000000..fa7144a
---- /dev/null
++++ b/server/render/editor.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..bff88e5
---- /dev/null
++++ b/server/render/layout.js
@@@ -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 };
ddiff --git a/server/render/pages.js b/server/render/pages.js
nnew file mode 100644
iindex 0000000..f5eebd5
---- /dev/null
++++ b/server/render/pages.js
@@@ -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 };
ddiff --git a/server/seed.js b/server/seed.js
nnew file mode 100644
iindex 0000000..c4edb46
---- /dev/null
++++ b/server/seed.js
@@@ -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 };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..d20bec1
---- /dev/null
++++ b/server/worker.js
@@@ -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 };
ddiff --git a/test/api-ai.test.js b/test/api-ai.test.js
nnew file mode 100644
iindex 0000000..0cd56e1
---- /dev/null
++++ b/test/api-ai.test.js
@@@ -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();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..38da2c3
---- /dev/null
++++ b/test/contracts.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..df0e12d
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..cdbc9dd
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -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 };
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
nnew file mode 100644
iindex 0000000..81b8541
---- /dev/null
++++ b/test/lifecycle.test.js
@@@ -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();
++})();
ddiff --git a/test/nojs-editor.test.js b/test/nojs-editor.test.js
nnew file mode 100644
iindex 0000000..28efcf7
---- /dev/null
++++ b/test/nojs-editor.test.js
@@@ -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();
++})();
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
nnew file mode 100644
iindex 0000000..2fda2c8
---- /dev/null
++++ b/test/privacy.test.js
@@@ -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();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..578cbff
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
++ * databases and in-process mocks of OpenVibe.Network, Community, 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);
++})();
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
nnew file mode 100644
iindex 0000000..27bcd69
---- /dev/null
++++ b/test/schedule.test.js
@@@ -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();
++})();
ddiff --git a/test/seed-ops.test.js b/test/seed-ops.test.js
nnew file mode 100644
iindex 0000000..3975bbc
---- /dev/null
++++ b/test/seed-ops.test.js
@@@ -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();
++})();
ddiff --git a/test/slug-media.test.js b/test/slug-media.test.js
nnew file mode 100644
iindex 0000000..818dbef
---- /dev/null
++++ b/test/slug-media.test.js
@@@ -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();
++})();