openvibers/openvibe.news Commits

OpenVibe.News service (Wave 17, News half): Sources items ingested by Events webhook and cursor pull, dedupe (canonical URL, content hash, near-duplicate headlines), deterministic explainable clusters with audited reversible merges and splits, editor-written stories whose every paragraph cites source items, timelines, editor-labelled perspectives, corrections and retractions, upstream changes flagged with pending revisions, licensed-summary rule, AI drafts held for a person's review, SSR pages, feeds, sitemaps, Search index events

OP OpenVibers committed 23 Sept 2026 · 87a3e0a4f5 · 63 files, +8316 −0


Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/docs/capabilities-proposal/news.cluster.manage.json b/docs/capabilities-proposal/news.cluster.manage.json
nnew file mode 100644
iindex 0000000..604e526
---- /dev/null
++++ b/docs/capabilities-proposal/news.cluster.manage.json
@@@ -0,0 +1,23 @@
++{
++ "id": "news.cluster.manage",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Merge clusters, split items into a new cluster, and reverse a merge or split. Every change is audited with exactly which items moved.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [
++ "news.cluster.updated"
++ ],
++ "implementedBy": [
++ "POST /api/v1/clusters/:id/merge",
++ "POST /api/v1/clusters/:id/split",
++ "POST /api/v1/clusters/audit/:auditId/reverse"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.cluster.read.json b/docs/capabilities-proposal/news.cluster.read.json
nnew file mode 100644
iindex 0000000..227d670
---- /dev/null
++++ b/docs/capabilities-proposal/news.cluster.read.json
@@@ -0,0 +1,22 @@
++{
++ "id": "news.cluster.read",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read clusters (members, the stored explanation of every membership, merge/split history), source items with their dedupe outcome, and ingestion runs and source health.",
++ "permissions": [
++ "news:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/clusters",
++ "GET /api/v1/clusters/:id",
++ "GET /api/v1/source-items/:id",
++ "GET /api/v1/ingest"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.perspective.update.json b/docs/capabilities-proposal/news.perspective.update.json
nnew file mode 100644
iindex 0000000..622edc0
---- /dev/null
++++ b/docs/capabilities-proposal/news.perspective.update.json
@@@ -0,0 +1,21 @@
++{
++ "id": "news.perspective.update",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Create or remove editor-labelled perspective groupings on a story and assign its sources to them. Labels are written by editors, never generated.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/perspectives",
++ "DELETE /api/v1/stories/:id/perspectives/:pid",
++ "PUT /api/v1/stories/:id/sources/:item/perspective"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.source.attach.json b/docs/capabilities-proposal/news.source.attach.json
nnew file mode 100644
iindex 0000000..bea0cdb
---- /dev/null
++++ b/docs/capabilities-proposal/news.source.attach.json
@@@ -0,0 +1,20 @@
++{
++ "id": "news.source.attach",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Attach a source item to a story's source table (numbered, never renumbered) or detach it.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/sources",
++ "DELETE /api/v1/stories/:id/sources/:item"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.story.create.json b/docs/capabilities-proposal/news.story.create.json
nnew file mode 100644
iindex 0000000..7e5e283
---- /dev/null
++++ b/docs/capabilities-proposal/news.story.create.json
@@@ -0,0 +1,21 @@
++{
++ "id": "news.story.create",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Open a story from a cluster (the cluster's live, non-duplicate source items become its first sources). Editors only: the service acts for an editor named in X-OV-Subject.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [
++ "news.story.created"
++ ],
++ "implementedBy": [
++ "POST /api/v1/stories"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.story.publish.json b/docs/capabilities-proposal/news.story.publish.json
nnew file mode 100644
iindex 0000000..caf9c91
---- /dev/null
++++ b/docs/capabilities-proposal/news.story.publish.json
@@@ -0,0 +1,26 @@
++{
++ "id": "news.story.publish",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Publish a revision (refused while it cites no source, rests on a source removed upstream or detached, or is AI-generated/AI-assisted without a person's approval), or unpublish a story. Publishing resolves the upstream flags the revision answers and publishes pending correction/update notes.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [
++ "news.story.published",
++ "news.story.updated",
++ "news.story.unpublished",
++ "news.index_document.upserted",
++ "news.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/publish",
++ "POST /api/v1/stories/:id/unpublish"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.story.read.json b/docs/capabilities-proposal/news.story.read.json
nnew file mode 100644
iindex 0000000..37fa8c0
---- /dev/null
++++ b/docs/capabilities-proposal/news.story.read.json
@@@ -0,0 +1,22 @@
++{
++ "id": "news.story.read",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read any story, including drafts, revisions, diffs, publishability problems and editorial flags. Published stories need no capability.",
++ "permissions": [
++ "news:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/stories/:id",
++ "GET /api/v1/stories/:id/revisions",
++ "GET /api/v1/stories/:id/revisions/:n",
++ "GET /api/v1/stories/:id/diff"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.story.retract.json b/docs/capabilities-proposal/news.story.retract.json
nnew file mode 100644
iindex 0000000..b46cb46
---- /dev/null
++++ b/docs/capabilities-proposal/news.story.retract.json
@@@ -0,0 +1,22 @@
++{
++ "id": "news.story.retract",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Retract a published story with a public note. The story stays at its URL with the retraction notice, becomes noindex and leaves sitemaps and Search. Final.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [
++ "news.story.retracted",
++ "news.index_document.deleted"
++ ],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/retract"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.story.revise.json b/docs/capabilities-proposal/news.story.revise.json
nnew file mode 100644
iindex 0000000..a6d8a1f
---- /dev/null
++++ b/docs/capabilities-proposal/news.story.revise.json
@@@ -0,0 +1,21 @@
++{
++ "id": "news.story.revise",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Save a new immutable revision (headline and paragraphs that each cite attached sources, with the source table, timeline and perspectives snapshotted), add correction/update notes, or request an OpenVibe.AI draft. With X-OV-Origin: ai the revision is AI-generated and cannot be published until a person approves it.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/revisions",
++ "POST /api/v1/stories/:id/ai-drafts",
++ "POST /api/v1/stories/:id/flags"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.timeline.update.json b/docs/capabilities-proposal/news.timeline.update.json
nnew file mode 100644
iindex 0000000..25138b3
---- /dev/null
++++ b/docs/capabilities-proposal/news.timeline.update.json
@@@ -0,0 +1,20 @@
++{
++ "id": "news.timeline.update",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Add or remove a story's timeline entries; each entry has a date the source states and rests on one attached source.",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/stories/:id/timeline",
++ "DELETE /api/v1/stories/:id/timeline/:eid"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/news.topic.manage.json b/docs/capabilities-proposal/news.topic.manage.json
nnew file mode 100644
iindex 0000000..176a05b
---- /dev/null
++++ b/docs/capabilities-proposal/news.topic.manage.json
@@@ -0,0 +1,19 @@
++{
++ "id": "news.topic.manage",
++ "version": "1.0",
++ "owner": "news",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Add a canonical topic (slug and name).",
++ "permissions": [
++ "news:write"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "news-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/topics"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..2302f79
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,52 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.19.0 <1.0.0"
++ },
++ "id": "news",
++ "name": "OpenVibe.News",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.News",
++ "domains": [
++ "openvibe.news"
++ ],
++ "publicOrigin": "https://openvibe.news",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "news.story.create",
++ "news.story.read",
++ "news.story.revise",
++ "news.story.publish",
++ "news.story.retract",
++ "news.cluster.read",
++ "news.cluster.manage",
++ "news.source.attach",
++ "news.timeline.update",
++ "news.perspective.update",
++ "news.topic.manage"
++ ],
++ "eventsProduced": [
++ "news.source.ingested",
++ "news.source.failed",
++ "news.cluster.updated",
++ "news.story.created",
++ "news.story.flagged",
++ "news.story.published",
++ "news.story.updated",
++ "news.story.unpublished",
++ "news.story.retracted",
++ "news.index_document.upserted",
++ "news.index_document.deleted"
++ ],
++ "eventsConsumed": [
++ "sources.item.created",
++ "sources.item.updated",
++ "sources.item.removed",
++ "sources.fetch.failed"
++ ],
++ "namespacesOwned": [
++ "news.*"
++ ],
++ "notes": "Port 4820. Owns news_topics, news_source_items, news_story_clusters, news_stories, news_story_revisions, news_story_sources, news_perspectives, news_timeline_entries, news_editorial_flags in its own SQLite (openvibe-publishing packages, prefixes news_story and news). Consumes OpenVibe.Sources (sources.item.read, optionally sources.source.read) through an Events subscription (sources.item.*, sources.fetch.failed; events.subscription.manage) and a cursor pull; OpenVibe.Network (SSO, identity.subject.resolve); optionally OpenVibe.AI (ai.run.create, namespace news) for news.summarize_story and news.compare_perspectives drafts; publishes through OpenVibe.Events (events.event.publish); OpenVibe.Search reads news.index_document.* from Events."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..2d54a76
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1562 @@
++{
++ "name": "openvibe-news",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-news",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "devDependencies": {
++ "jsonwebtoken": "^9.0.2"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.19.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "integrity": "sha512-5DjPvXIfc68wZhFXgKlCzDarbbFD5dUV4yYQIKEK1qD+VIuR+4twbON1brdyM9rYZyRyYClPigAqReocmRGytg==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-publishing": {
++ "version": "0.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "integrity": "sha512-ZIvDLEIzOk+7A20FTywuiJsvUNVEfkpsa7qbm1PXxezDzIBFcEMJFXxGsm7Ran6rXQ1+Uio+LoBzp3YCC4kHiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-publishing/node_modules/openvibe-shared": {
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..d7847f9
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,36 @@
++{
++ "name": "openvibe-news",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.News — a source-backed publication product: source items from OpenVibe.Sources, dedupe, explainable clusters, editor-written stories whose every claim cites its sources, timelines, perspectives and correction/retraction revisions, 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",
++ "subscribe": "node scripts/subscribe.js"
++ },
++ "keywords": ["openvibe", "news", "publishing", "sources", "feeds"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.News",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.19.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "devDependencies": {
++ "jsonwebtoken": "^9.0.2"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/public/css/news.css b/public/css/news.css
nnew file mode 100644
iindex 0000000..dd101dd
---- /dev/null
++++ b/public/css/news.css
@@@ -0,0 +1,62 @@
++/* OpenVibe.News — 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: 820px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.page { max-width: 820px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
++body.editor .page { max-width: 1000px; }
++h1 { font-size: 2.1rem; line-height: 1.2; margin: 1.2rem 0 .6rem; }
++h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
++h3 { font-size: 1.1rem; margin: 1.2rem 0 .4rem; }
++.lede { color: var(--text-secondary, #96a7c2); font-size: 1.1rem; }
++.meta, .byline, .empty { color: var(--text-muted, #7386a3); font-size: .9rem; }
++.small { font-size: .85rem; }
++.badge { display: inline-block; font-size: .75rem; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary); vertical-align: middle; }
++.badge-retracted { border-color: var(--danger, #ef4444); color: var(--danger, #ef4444); }
++.story-list, .topic-list { list-style: none; padding: 0; }
++.topic-list { display: flex; flex-wrap: wrap; gap: 6px 14px; }
++.topic-list.wide { display: block; }
++.story-item { padding: 14px 0; border-bottom: 1px solid var(--border, #1f2d47); }
++.story-item.retracted .story-item-title a { text-decoration: line-through; }
++.story-item-title { margin: 0 0 4px; font-size: 1.3rem; }
++.story-item-title a { color: var(--text-primary); text-decoration: none; }
++.story-item-title a:hover { text-decoration: underline; }
++.summary { margin: 6px 0; }
++.story-body p.unsupported { opacity: .7; }
++sup.cites { font-size: .7rem; margin-left: 2px; }
++sup.cites a { text-decoration: none; }
++.retraction { border: 2px solid var(--danger, #ef4444); border-radius: 10px; padding: 8px 16px; margin: 12px 0; }
++.retraction h2 { margin-top: .4rem; color: var(--danger, #ef4444); }
++.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
++table { width: 100%; border-collapse: collapse; font-size: .95rem; }
++th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }
++table.sources tr.removed td, tr.detached td { color: var(--text-muted, #7386a3); }
++table.sources tr:target { background: var(--bg-card, #131c2e); }
++.source-summary { margin: 4px 0 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
++.framing { font-size: .9rem; }
++.timeline 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; }
++.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); }
++.card.warn { border-color: var(--warning, #f59e0b); }
++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; }
++form.inline { display: inline; }
++form.inline select { width: auto; }
++form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
++.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; } table { font-size: .85rem; } }
ddiff --git a/scripts/seed.js b/scripts/seed.js
nnew file mode 100644
iindex 0000000..5b17b79
---- /dev/null
++++ b/scripts/seed.js
@@@ -0,0 +1,19 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Seed OpenVibe.News: topics only (seeds/topics.json). Idempotent — missing topics are added,
++ * existing ones are never renamed, re-described or re-activated. There are no seed stories,
++ * source items or clusters: News publishes nothing until an editor does.
++ *
++ * npm run seed (uses NEWS_DB_PATH from .env or /etc/openvibe/news.env)
++ */
++require('dotenv').config();
++const { load } = require('../server/config');
++const { openStore } = require('../server/db');
++const { createTopics } = require('../server/domain/topics');
++
++const config = load();
++const store = openStore(config.dbPath);
++const r = createTopics({ store }).seed();
++console.log(`topics: ${r.created} added, ${r.existing} already present (${config.dbPath})`);
++store.close();
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..345b337
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -0,0 +1,53 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Create News' OpenVibe.Events subscriptions for Sources items and fetch failures.
++ *
++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4820/internal/events]
++ *
++ * Reads the environment (.env or /etc/openvibe/news.env):
++ * EVENTS_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the news
++ * principal needs events.subscription.manage for audience openvibe.events)
++ * NEWS_EVENTS_SECRET the delivery signing secret; the first value is handed to Events, so
++ * generate it before running this (e.g. `openssl rand -hex 32`). Nothing
++ * secret is printed.
++ * Patterns: sources.item.* and sources.fetch.failed. An existing identical subscription is
++ * reported, not duplicated.
++ */
++require('dotenv').config();
++const { serviceAuth } = require('openvibe-contracts');
++const { load } = require('../server/config');
++
++const args = process.argv.slice(2);
++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
++const PATTERNS = ['sources.item.*', 'sources.fetch.failed'];
++
++(async () => {
++ const config = load();
++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
++ const secret = config.events.webhookSecrets[0];
++ if (!config.events.url) throw new Error('EVENTS_URL is not set');
++ if (!secret || secret.length < 32) throw new Error('NEWS_EVENTS_SECRET must be set (32+ characters) before subscribing');
++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.events',
++ scope: 'events.subscription.manage',
++ });
++ for (const pattern of PATTERNS) {
++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); continue; }
++ if (!res.ok) throw new Error(`Events answered ${res.status} for ${pattern}: ${body.code || ''} ${body.detail || ''}`.trim());
++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
++ }
++})().catch((err) => {
++ console.error(`subscribe failed: ${err.message}`);
++ process.exit(1);
++});
ddiff --git a/seeds/topics.json b/seeds/topics.json
nnew file mode 100644
iindex 0000000..202a70c
---- /dev/null
++++ b/seeds/topics.json
@@@ -0,0 +1,17 @@
++{
++ "_about": "The only seed OpenVibe.News has: canonical topic identities (slug, name, description). No stories, no source items and no clusters are seeded — News publishes nothing until an editor does. Seeding never renames or re-describes an existing topic.",
++ "topics": [
++ { "slug": "space", "name": "Space", "description": "Spaceflight, astronomy and planetary science." },
++ { "slug": "science", "name": "Science", "description": "Research and scientific findings." },
++ { "slug": "technology", "name": "Technology", "description": "Computing, the internet and the companies that build them." },
++ { "slug": "open-source", "name": "Open source", "description": "Open-source software, projects and communities." },
++ { "slug": "games", "name": "Games", "description": "Video games, the people who make them and the industry around them." },
++ { "slug": "streaming", "name": "Streaming", "description": "Live streaming, creators and platforms." },
++ { "slug": "internet-policy", "name": "Internet policy", "description": "Law, regulation and governance of the internet." },
++ { "slug": "security", "name": "Security", "description": "Information security, vulnerabilities and incidents." },
++ { "slug": "climate", "name": "Climate", "description": "Climate, weather and the environment." },
++ { "slug": "health", "name": "Health", "description": "Public health and medicine." },
++ { "slug": "business", "name": "Business", "description": "Companies, markets and the economy." },
++ { "slug": "world", "name": "World", "description": "International news." }
++ ]
++}
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..acf505a
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,151 @@
++'use strict';
++
++/**
++ * OpenVibe.News — 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 desk (forms, http/editor.js) API (http/api.js)
++ * Discovery (robots, llms, sitemaps) /auth/* (Network SSO) /internal/events (Events webhook)
++ * /api/health, /api/ready, /release.json, /metrics
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createAuthClient, createAuthRoutes } = require('./auth/sso');
++const { createViewerResolver } = require('./auth/viewer');
++const { createPeople } = require('./clients/network');
++const { createSourcesClient } = require('./clients/sources');
++const { createAi } = require('./clients/ai');
++const { createNewsOutbox } = require('./events/outbox');
++const { createPublication } = require('./domain/publication');
++const { createClusters } = require('./domain/clusters');
++const { createIngest } = require('./domain/ingest');
++const { createStories } = require('./domain/stories');
++const { createTopics } = require('./domain/topics');
++const { createReading } = require('./domain/reading');
++const { createPublicRoutes } = require('./http/public');
++const { createEditorRoutes } = require('./http/editor');
++const { createApi } = require('./http/api');
++const { createDiscoveryRoutes } = require('./http/discovery');
++const { createWebhook } = require('./http/webhook');
++const { createNewsReadiness } = require('./observability');
++const { createWorker } = require('./worker');
++const { assetVersion } = require('./render/layout');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log */
++function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
++
++ const outbox = createNewsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ const publication = createPublication({ store, config, outbox });
++ const clusters = createClusters({ store, config, outbox });
++ const sources = createSourcesClient({ config, fetchImpl });
++ const ai = createAi({ config, fetchImpl });
++ const ingest = createIngest({ store, config, clusters, outbox, sources, log });
++ const stories = createStories({ store, config, publication, clusters, outbox, ai, log });
++ ingest.setStories(stories);
++ const topics = createTopics({ store });
++ // Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
++ if (opts.seedTopics !== false) topics.seed();
++ const people = createPeople({ store, config, fetchImpl });
++ const reading = createReading({ store, config, stories, publication, people, topics });
++ const auth = opts.auth || createAuthClient(config);
++ const viewers = createViewerResolver({ auth, config, people });
++ const worker = createWorker({ config, ingest, outbox, log });
++
++ const ctx = { config, store, outbox, publication, clusters, sources, ai, ingest, stories, topics, people, reading, auth, viewers, worker };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'news', root: path.join(__dirname, '..') });
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'news', release: release.release });
++ app.locals.metrics = metrics.registry;
++ app.locals.ctx = ctx;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https:'],
++ connectSrc: ["'self'", 'https://openvibe.network'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'self'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-news', version: VERSION }));
++ app.get('/release.json', release.handler);
++ const readiness = createNewsReadiness({ store, auth, outbox, ingest, config, release: release.release });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Events webhook (raw body; before any other body parser) ─
++ app.use(createWebhook({ config, store, ingest, sources, log }));
++
++ app.use(cookieParser());
++
++ // ── 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: 'news', service: 'news', host: 'openvibe.news', name: 'OpenVibe.News' })); }
++
++ // ── 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 desk, public pages ────────────────
++ app.use(createDiscoveryRoutes(ctx));
++ const publicRoutes = createPublicRoutes(ctx);
++ ctx.publicRoutes = publicRoutes;
++ app.use(['/edit', '/clusters'], rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
++ app.use(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('[News]', err && err.stack ? err.stack : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', 'private, no-store');
++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) 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..22da37a
---- /dev/null
++++ b/server/auth/capabilities.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * Capability checks for service tokens (audience openvibe.news), including the ids News introduces
++ * before the contracts library knows them.
++ *
++ * openvibe-contracts' capabilities.check() answers capability.unknown for an id that is not in its
++ * manifests yet. News' 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 as editors
++ * (domain/access.js). A service token is judged by its capability AND, except for AI draft
++ * delivery, by whether the person it acts for (X-OV-Subject) is an editor.
++ */
++const { capabilities } = require('openvibe-contracts');
++
++const CAPABILITIES = Object.freeze({
++ STORY_CREATE: 'news.story.create',
++ STORY_READ: 'news.story.read',
++ STORY_REVISE: 'news.story.revise',
++ STORY_PUBLISH: 'news.story.publish',
++ STORY_RETRACT: 'news.story.retract',
++ CLUSTER_READ: 'news.cluster.read',
++ CLUSTER_MANAGE: 'news.cluster.manage',
++ SOURCE_ATTACH: 'news.source.attach',
++ TIMELINE_UPDATE: 'news.timeline.update',
++ PERSPECTIVE_UPDATE: 'news.perspective.update',
++ TOPIC_MANAGE: 'news.topic.manage',
++});
++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..5b43fa5
---- /dev/null
++++ b/server/auth/forms.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++/**
++ * Form tokens for the no-JavaScript editor forms (CSRF). The session cookie is
++ * SameSite=Lax already; the token is the second lock: an HMAC of the signed-in subject under
++ * NEWS_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(`news-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..ce430f2
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,420 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.news — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
++//
++// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
++// News' client id: registered in the Network as OAuth client `news`, redirect
++// https://openvibe.news/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.news):
++// 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(`[News] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
++ return client.publicKey;
++ }
++ } catch (err) {
++ console.warn(`[News 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('[News 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('[News 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('[News 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..b5c8b9e
---- /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.news.
++ * X-OV-Subject names the editor it acts for; X-OV-Origin: ai marks OpenVibe.AI output
++ * (news.summarize_story, news.compare_perspectives), which is never attributed to a person:
++ * it is an AI-generated draft revision that needs a person's review before publication.
++ *
++ * 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.news';
++
++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 as editors (or not) 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/ai.js b/server/clients/ai.js
nnew file mode 100644
iindex 0000000..10ab1f3
---- /dev/null
++++ b/server/clients/ai.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * OpenVibe.AI seam (optional): run news.summarize_story or news.compare_perspectives over a story's
++ * source items and turn the result into a DRAFT revision with AI authorship. Nothing here publishes:
++ * the story's publish step refuses an AI revision until a person records an approving review.
++ *
++ * POST {OV_AI_INTERNAL_URL}/api/v1/runs?wait=… { workflow, input: { topic, sources } }
++ * (service token for audience openvibe.ai, capability ai.run.create, namespace news)
++ *
++ * Only what News itself stores goes to the model: headline, URL, outlet, authors, dates and the
++ * licensed short summary. Article bodies are never stored, so they can never be sent.
++ * Claims the model does not cite are dropped (and listed as gaps), never kept uncited; a failed or
++ * unfinished run produces no text at all.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const WORKFLOWS = ['news.summarize_story', 'news.compare_perspectives'];
++
++class AiError extends Error {
++ constructor(code, message, status = 502) { super(message); this.code = code; this.status = status; }
++}
++
++function createAi({ config, fetchImpl = globalThis.fetch }) {
++ const enabled = Boolean(config.ai.internalUrl && config.oauth.clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.ai', scope: 'ai.run.create', fetchImpl,
++ }) : null;
++
++ /** sources: [{ id (nsi_…), sources_item_id, headline, url, outlet, authors, published_at, retrieved_at, summary }] in citation order. */
++ async function run(workflow, { topic, sources }) {
++ if (!enabled) throw new AiError('ai.not_configured', 'OpenVibe.AI is not configured (OV_AI_INTERNAL_URL)', 503);
++ if (!WORKFLOWS.includes(workflow)) throw new AiError('ai.unknown_workflow', `workflow must be one of ${WORKFLOWS.join(', ')}`, 422);
++ const input = {
++ ...(topic ? { topic: String(topic).slice(0, 300) } : {}),
++ sources: sources.slice(0, 50).map((s) => ({
++ source_type: 'news.article', source_id: s.sources_item_id, title: s.headline, ...(s.url ? { url: s.url } : {}),
++ ...(s.authors && s.authors.length ? { author: s.authors.join(', ').slice(0, 200) } : {}),
++ ...(s.published_at ? { published_at: s.published_at } : {}), ...(s.retrieved_at ? { retrieved_at: s.retrieved_at } : {}),
++ ...(s.summary ? { snippet: s.summary } : {}),
++ provenance: { outlet: s.outlet, news_item: s.id },
++ })),
++ };
++ let res;
++ try {
++ res = await fetchImpl(`${config.ai.internalUrl}/api/v1/runs?wait=${config.ai.waitMs}`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ workflow, input }),
++ signal: AbortSignal.timeout(config.ai.waitMs + 5000),
++ });
++ } catch (err) {
++ throw new AiError('ai.unavailable', `OpenVibe.AI is unreachable: ${err.message}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok && res.status !== 202) throw new AiError(`ai.http_${res.status}`, `OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}`);
++ const r = body && body.run;
++ if (!r || r.status !== 'succeeded' || !r.output || typeof r.output !== 'object') {
++ throw new AiError('ai.no_result', `The ${workflow} run did not finish with a result (${r ? r.status : 'no run'}); no draft was made`);
++ }
++ return {
++ runId: String(r.id), workflow: { id: workflow, runId: String(r.id), version: r.workflow && r.workflow.version != null ? r.workflow.version : undefined, model: r.provenance && r.provenance.model ? r.provenance.model : undefined },
++ stub: Boolean(r.synthetic), output: r.output,
++ };
++ }
++
++ return { enabled, WORKFLOWS, run };
++}
++
++/**
++ * Turn a workflow output into paragraphs that each cite source item ids. citations are indexes
++ * into the sources array sent. Uncited or out-of-range claims are dropped and reported as gaps.
++ * → { headline, paragraphs: [{ text, sources }], gaps: [] }
++ */
++function draftFromOutput(workflow, output, sourceIds) {
++ const gaps = Array.isArray(output.gaps) ? output.gaps.map(String).slice(0, 20) : [];
++ const cite = (list) => [...new Set((Array.isArray(list) ? list : []).filter((n) => Number.isInteger(n) && n >= 0 && n < sourceIds.length).map((n) => sourceIds[n]))];
++ const paragraphs = [];
++ const add = (textValue, citations, what) => {
++ const t = String(textValue || '').replace(/\s+/g, ' ').trim();
++ if (!t) return;
++ const s = cite(citations);
++ if (!s.length) { gaps.push(`dropped an uncited ${what}: "${t.slice(0, 120)}"`); return; }
++ paragraphs.push({ text: t.slice(0, 2000), sources: s });
++ };
++ if (workflow === 'news.summarize_story') {
++ add(output.summary, output.citations, 'summary');
++ for (const k of Array.isArray(output.key_points) ? output.key_points : []) add(k && k.text, k && k.citations, 'key point');
++ for (const t of Array.isArray(output.timeline) ? output.timeline : []) if (t && t.what) add(`${t.when ? `${t.when}: ` : ''}${t.what}`, t.citations, 'timeline entry');
++ } else {
++ for (const p of Array.isArray(output.perspectives) ? output.perspectives : []) if (p) add(`${p.label ? `${p.label}: ` : ''}${p.summary || ''}`, p.citations, 'perspective');
++ for (const a of Array.isArray(output.agreements) ? output.agreements : []) add(a && a.text, a && a.citations, 'agreement');
++ for (const d of Array.isArray(output.disagreements) ? output.disagreements : []) add(d && d.text, d && d.citations, 'disagreement');
++ }
++ const headline = typeof output.headline === 'string' && output.headline.trim() ? output.headline.trim().slice(0, 200)
++ : typeof output.question === 'string' && output.question.trim() ? output.question.trim().slice(0, 200) : null;
++ return { headline, paragraphs, gaps };
++}
++
++module.exports = { createAi, draftFromOutput, AiError, WORKFLOWS };
ddiff --git a/server/clients/network.js b/server/clients/network.js
nnew file mode 100644
iindex 0000000..d6377be
---- /dev/null
++++ b/server/clients/network.js
@@@ -0,0 +1,88 @@
++'use strict';
++
++/**
++ * Who a subject is, for display (editor bylines, flag authors).
++ *
++ * News 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('[News] 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 News 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/clients/sources.js b/server/clients/sources.js
nnew file mode 100644
iindex 0000000..c2a6444
---- /dev/null
++++ b/server/clients/sources.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * OpenVibe.Sources client (host-local API, service tokens for audience openvibe.sources).
++ *
++ * sources.item.read GET /api/v1/items?category=news&after=&limit=&include_removed=1 (change order)
++ * GET /api/v1/items/:id
++ * sources.source.read GET /api/v1/sources/:key (optional: outlet names and source health)
++ *
++ * Failures are thrown as errors with a stable `code` (sources.unavailable, sources.http_<status>,
++ * sources.bad_response); the caller records them. Nothing here invents an item.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++class SourcesError extends Error {
++ constructor(code, message, status = null) { super(message); this.code = code; this.status = status; }
++}
++
++function createSourcesClient({ config, fetchImpl = globalThis.fetch }) {
++ const enabled = Boolean(config.oauth.clientSecret && config.sources.internalUrl);
++ const token = (scope) => (enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.sources', scope, fetchImpl,
++ }) : null);
++ const itemTokens = token('sources.item.read');
++ const sourceTokens = token('sources.source.read');
++
++ async function call(tokens, path) {
++ if (!tokens) throw new SourcesError('sources.not_configured', 'OV_OAUTH_CLIENT_SECRET or OV_SOURCES_INTERNAL_URL is not set');
++ let auth;
++ try { auth = await tokens.authHeaders(); } catch (err) {
++ throw new SourcesError('sources.token_unavailable', `No service token for OpenVibe.Sources: ${err.message}`);
++ }
++ let res;
++ try {
++ res = await fetchImpl(`${config.sources.internalUrl}${path}`, {
++ headers: { Accept: 'application/json', ...auth },
++ signal: AbortSignal.timeout(config.sources.timeoutMs),
++ });
++ } catch (err) {
++ throw new SourcesError('sources.unavailable', `OpenVibe.Sources is unreachable: ${err.message}`);
++ }
++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
++ const body = await res.json().catch(() => null);
++ if (!res.ok) throw new SourcesError(`sources.http_${res.status}`, `OpenVibe.Sources answered ${res.status}${body && (body.code || body.detail) ? `: ${body.code || ''} ${body.detail || ''}`.trimEnd() : ''}`, res.status);
++ if (!body || typeof body !== 'object') throw new SourcesError('sources.bad_response', 'OpenVibe.Sources sent no JSON');
++ return body;
++ }
++
++ return {
++ enabled,
++ SourcesError,
++
++ /** One page of news items in change order. → { items, next_after, more, sources } */
++ async listItems({ after = 0, limit = 100 } = {}) {
++ const qs = new URLSearchParams({ category: config.sources.category, after: String(after), limit: String(limit), include_removed: '1' });
++ const body = await call(itemTokens, `/api/v1/items?${qs}`);
++ if (!Array.isArray(body.items) || !Number.isInteger(body.next_after)) throw new SourcesError('sources.bad_response', 'items page without items/next_after');
++ return body;
++ },
++
++ /** One item (with its source's health). → { item, source } */
++ async getItem(id) {
++ const body = await call(itemTokens, `/api/v1/items/${encodeURIComponent(id)}`);
++ if (!body.item || typeof body.item.id !== 'string') throw new SourcesError('sources.bad_response', 'item response without item');
++ return body;
++ },
++
++ /** A registry record (name, homepage, health). Needs sources.source.read; callers treat failure as "unknown". */
++ async getSource(key) {
++ const body = await call(sourceTokens, `/api/v1/sources/${encodeURIComponent(key)}`);
++ return body.source || body;
++ },
++ };
++}
++
++module.exports = { createSourcesClient, SourcesError };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..0796582
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++/**
++ * OpenVibe.News configuration. Every value comes from the environment (production:
++ * /etc/openvibe/news.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, 4820);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.news' : `http://localhost:${port}`));
++
++ return {
++ service: 'news',
++ 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.NEWS_DB_PATH || './data/news.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 || 'news',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile theme',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // Signs the per-session form token (CSRF). Unset: a random per-process key.
++ formSecret: env.NEWS_FORM_SECRET || '',
++
++ // Editors: Network subjects (usr_…) who may create, revise, publish and retract stories and
++ // manage clusters. Network admins are editors too (staff).
++ editors: list(env.NEWS_EDITORS),
++
++ // OpenVibe.Sources: the news items News ingests (category=news). Reads need sources.item.read
++ // (and, optionally, sources.source.read for outlet names and source health).
++ sources: {
++ internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || 'http://127.0.0.1:4720'),
++ category: 'news',
++ // The cursor pull (backstop for missed webhook deliveries). 0 turns it off.
++ pullIntervalMs: int(env.NEWS_PULL_INTERVAL_MS, 5 * 60 * 1000),
++ pageSize: Math.min(Math.max(int(env.NEWS_PULL_PAGE_SIZE, 100), 1), 500),
++ maxPagesPerRun: Math.max(int(env.NEWS_PULL_MAX_PAGES, 20), 1),
++ timeoutMs: int(env.NEWS_SOURCES_TIMEOUT_MS, 8000),
++ },
++
++ // Licensing: the most of a source's own summary News stores and shows, and only when the
++ // source's terms or licence note explicitly allows short summaries. Article bodies are never stored.
++ licensing: {
++ summaryMaxChars: Math.min(Math.max(int(env.NEWS_SUMMARY_MAX_CHARS, 280), 0), 500),
++ },
++
++ // Clustering: the time window a new item may join an existing cluster in.
++ clustering: {
++ windowMs: int(env.NEWS_CLUSTER_WINDOW_HOURS, 72) * 3600 * 1000,
++ },
++
++ // OpenVibe.AI (optional): the news.summarize_story / news.compare_perspectives seams. Unset:
++ // stories are written by editors from the source items; AI output only ever lands as a draft.
++ ai: {
++ internalUrl: trim(env.OV_AI_INTERNAL_URL || ''),
++ waitMs: int(env.NEWS_AI_WAIT_MS, 30000),
++ },
++
++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set;
++ // the webhook (POST /internal/events) only when NEWS_EVENTS_SECRET is set.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ webhookSecrets: list(env.NEWS_EVENTS_SECRET),
++ },
++
++ worker: {
++ enabled: env.NEWS_WORKER !== 'off',
++ },
++
++ // 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'),
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..3413b89
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,266 @@
++'use strict';
++
++/**
++ * News' own SQLite database: created on boot, idempotently. Nothing here is shared with another
++ * service; the publishing packages create their tables inside this database with News' prefixes.
++ *
++ * The nine charter tables (roadmap §15.13):
++ *
++ * news_topics News-owned canonical topic identities (slug, name); the only seed
++ * news_source_items News-owned normalised Sources items: headline, URL, outlet, authors,
++ * published_at and a licensed short summary — never a body;
++ * dedupe outcome, cluster, upstream state
++ * news_story_clusters News-owned deterministic clusters with their key terms and window
++ * news_stories News-owned publication state: slug, state, published revision, topic
++ * news_story_revisions package openvibe-publishing/revisions, prefix news_story (immutable)
++ * news_story_sources News-owned which source items a story rests on (the source table)
++ * news_perspectives News-owned editor-assigned groupings of a story's sources
++ * news_timeline_entries News-owned dated entries, each resting on one source item
++ * news_editorial_flags News-owned corrections, updates, retractions and upstream changes
++ *
++ * Also here, as companions (not authority for anything outside News):
++ * news_story_citations, news_story_reviews, news_story_drafts, news_story_revision_purges,
++ * news_index_revisions (publishing packages); news_cluster_audit (every merge, split and reversal);
++ * news_ingest_runs (what each webhook delivery, pull and upstream failure did); news_state (the
++ * Sources cursor); news_source_status (a display cache of Sources' registry and health);
++ * event_outbox and idempotency_receipts (openvibe-sdk); subject_projections (Network names).
++ */
++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 { createReviewLog } = require('openvibe-publishing/authorship');
++const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS news_topics (
++ id TEXT PRIMARY KEY, -- top_<ULID>
++ slug TEXT NOT NULL UNIQUE,
++ name TEXT NOT NULL,
++ description TEXT,
++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS news_story_clusters (
++ id TEXT PRIMARY KEY, -- clu_<ULID>
++ label TEXT NOT NULL, -- derived from the key terms, never invented
++ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','merged','dissolved')),
++ merged_into TEXT REFERENCES news_story_clusters(id),
++ split_from TEXT REFERENCES news_story_clusters(id),
++ terms TEXT NOT NULL DEFAULT '{}', -- { term: count } over its members
++ entities TEXT NOT NULL DEFAULT '{}', -- { entity: count } over its members
++ window_start INTEGER, -- earliest member time (published_at, else first seen)
++ window_end INTEGER, -- latest member time
++ created_by TEXT NOT NULL, -- 'svc:news' (clustering) or usr_… (a split)
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_story_clusters_open ON news_story_clusters (status, window_end);
++
++CREATE TABLE IF NOT EXISTS news_source_items (
++ id TEXT PRIMARY KEY, -- nsi_<ULID>
++ sources_item_id TEXT NOT NULL UNIQUE, -- itm_… in OpenVibe.Sources (a typed reference)
++ sources_revision INTEGER NOT NULL, -- the Sources revision these fields came from
++ source_key TEXT NOT NULL,
++ canonical_url TEXT,
++ url_key TEXT, -- normalised URL for dedupe
++ headline TEXT NOT NULL,
++ outlet TEXT NOT NULL,
++ authors TEXT NOT NULL DEFAULT '[]',
++ published_at TEXT, -- as the source states it; NULL when it does not
++ summary TEXT, -- a short summary ONLY when the terms allow it
++ summary_basis TEXT, -- why a summary is (not) stored
++ license_note TEXT,
++ terms_note TEXT,
++ content_hash TEXT, -- Sources' hash of the parsed fields
++ title_key TEXT NOT NULL, -- normalised headline for near-duplicate checks
++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','duplicate','removed')),
++ duplicate_of TEXT REFERENCES news_source_items(id),
++ dedupe TEXT, -- { rule, detail } when a duplicate
++ cluster_id TEXT REFERENCES news_story_clusters(id),
++ cluster_reason TEXT, -- { rule, shared_entities, shared_terms, score } (explanation)
++ retrieved_at TEXT,
++ first_seen_at INTEGER NOT NULL,
++ upstream_updated_at INTEGER, -- the last time Sources revised it after first ingest
++ removed_at INTEGER,
++ removed_reason TEXT,
++ updated_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_source_items_url ON news_source_items (url_key);
++CREATE INDEX IF NOT EXISTS news_source_items_hash ON news_source_items (content_hash);
++CREATE INDEX IF NOT EXISTS news_source_items_cluster ON news_source_items (cluster_id);
++CREATE INDEX IF NOT EXISTS news_source_items_seen ON news_source_items (first_seen_at);
++
++CREATE TABLE IF NOT EXISTS news_stories (
++ id TEXT PRIMARY KEY, -- sty_<ULID>
++ slug TEXT NOT NULL UNIQUE,
++ working_headline TEXT NOT NULL, -- the editors' latest headline (readers see the revision's)
++ cluster_id TEXT REFERENCES news_story_clusters(id),
++ topic_id TEXT REFERENCES news_topics(id),
++ state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','published','unpublished','retracted')),
++ published_revision INTEGER, -- which revision readers see (NULL: never published)
++ first_published_at INTEGER,
++ published_at INTEGER, -- the latest publication of published_revision
++ retracted_at INTEGER,
++ noindex INTEGER NOT NULL DEFAULT 0, -- an editor asked search engines not to index it
++ created_by TEXT NOT NULL, -- usr_… (the editor who opened the story)
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_stories_listing ON news_stories (state, published_at);
++CREATE INDEX IF NOT EXISTS news_stories_topic ON news_stories (topic_id, state, published_at);
++CREATE INDEX IF NOT EXISTS news_stories_cluster ON news_stories (cluster_id);
++
++CREATE TABLE IF NOT EXISTS news_perspectives (
++ id TEXT PRIMARY KEY, -- per_<ULID>
++ story_id TEXT NOT NULL REFERENCES news_stories(id),
++ label TEXT NOT NULL, -- written by an editor; never generated
++ description TEXT,
++ position INTEGER NOT NULL DEFAULT 0,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ removed_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS news_perspectives_story ON news_perspectives (story_id, position);
++
++CREATE TABLE IF NOT EXISTS news_story_sources (
++ story_id TEXT NOT NULL REFERENCES news_stories(id),
++ source_item_id TEXT NOT NULL REFERENCES news_source_items(id),
++ position INTEGER NOT NULL, -- the [n] readers and editors cite
++ perspective_id TEXT REFERENCES news_perspectives(id),
++ added_by TEXT NOT NULL,
++ added_at INTEGER NOT NULL,
++ detached_at INTEGER,
++ PRIMARY KEY (story_id, source_item_id)
++);
++CREATE INDEX IF NOT EXISTS news_story_sources_item ON news_story_sources (source_item_id);
++
++CREATE TABLE IF NOT EXISTS news_timeline_entries (
++ id TEXT PRIMARY KEY, -- tle_<ULID>
++ story_id TEXT NOT NULL REFERENCES news_stories(id),
++ occurred_on TEXT NOT NULL, -- YYYY-MM-DD or an ISO instant, as the source states it
++ text TEXT NOT NULL,
++ source_item_id TEXT NOT NULL REFERENCES news_source_items(id),
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ removed_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS news_timeline_story ON news_timeline_entries (story_id, occurred_on);
++
++CREATE TABLE IF NOT EXISTS news_editorial_flags (
++ id TEXT PRIMARY KEY, -- flg_<ULID>
++ story_id TEXT NOT NULL REFERENCES news_stories(id),
++ kind TEXT NOT NULL CHECK (kind IN ('correction','update','retraction','source_updated','source_removed')),
++ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','published','resolved')),
++ note TEXT, -- public text for correction/update/retraction
++ source_item_id TEXT REFERENCES news_source_items(id),
++ pending_revision INTEGER, -- the revision the system prepared for an upstream change
++ revision INTEGER, -- the published revision it belongs to
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ resolved_by TEXT,
++ resolved_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS news_flags_story ON news_editorial_flags (story_id, status, created_at);
++
++-- Every merge, split and reversal, with exactly which items moved (so each can be undone).
++CREATE TABLE IF NOT EXISTS news_cluster_audit (
++ id TEXT PRIMARY KEY, -- cla_<ULID>
++ action TEXT NOT NULL CHECK (action IN ('merge','split','reverse_merge','reverse_split')),
++ cluster_id TEXT NOT NULL, -- merge: the surviving cluster; split: the source cluster
++ other_id TEXT NOT NULL, -- merge: the absorbed cluster; split: the new cluster
++ item_ids TEXT NOT NULL, -- JSON array of news_source_items ids that moved
++ reason TEXT,
++ actor TEXT NOT NULL,
++ reverses TEXT REFERENCES news_cluster_audit(id),
++ reversed_by TEXT REFERENCES news_cluster_audit(id),
++ created_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_cluster_audit_cluster ON news_cluster_audit (cluster_id, created_at);
++
++-- What ingestion did: webhook deliveries, cursor pulls and upstream fetch failures.
++CREATE TABLE IF NOT EXISTS news_ingest_runs (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ origin TEXT NOT NULL CHECK (origin IN ('webhook','pull','sources')),
++ state TEXT NOT NULL, -- ok | failed | ignored | upstream state (http_error, timeout, …)
++ source_key TEXT,
++ sources_item_id TEXT,
++ error_code TEXT,
++ detail TEXT,
++ counts TEXT, -- { created, updated, removed, duplicates, unchanged }
++ cursor_before INTEGER,
++ cursor_after INTEGER,
++ at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_ingest_runs_at ON news_ingest_runs (at);
++
++CREATE TABLE IF NOT EXISTS news_state (
++ key TEXT PRIMARY KEY,
++ value TEXT NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++
++-- Display cache of OpenVibe.Sources' registry (outlet names) and health; never authority.
++CREATE TABLE IF NOT EXISTS news_source_status (
++ source_key TEXT PRIMARY KEY,
++ name TEXT,
++ homepage_url TEXT,
++ status TEXT,
++ stale INTEGER,
++ last_success_at TEXT,
++ refreshed_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);
++`;
++
++/**
++ * 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() } = {}) {
++ 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: 'news_story', now });
++ const store = {
++ db,
++ now,
++ revisions,
++ citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),
++ reviews: createReviewLog(db, { prefix: 'news_story', now }),
++ sequencer: createIndexSequencer(db, { prefix: 'news', now }),
++ tx: (fn) => db.transaction(fn)(),
++ getState(key, def = null) {
++ const r = db.prepare('SELECT value FROM news_state WHERE key = ?').get(key);
++ return r ? JSON.parse(r.value) : def;
++ },
++ setState(key, value) {
++ db.prepare(`INSERT INTO news_state (key, value, updated_at) VALUES (?, ?, ?)
++ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, JSON.stringify(value), now());
++ },
++ close: () => db.close(),
++ };
++ return store;
++}
++
++const CHARTER_TABLES = ['news_topics', 'news_source_items', 'news_story_clusters', 'news_stories', 'news_story_revisions',
++ 'news_story_sources', 'news_perspectives', 'news_timeline_entries', 'news_editorial_flags'];
++
++module.exports = { openStore, CHARTER_TABLES };
ddiff --git a/server/domain/access.js b/server/domain/access.js
nnew file mode 100644
iindex 0000000..a7f45c2
---- /dev/null
++++ b/server/domain/access.js
@@@ -0,0 +1,34 @@
++'use strict';
++
++/**
++ * Who may do what. News is an editorial product: readers read published stories; editors do
++ * everything else.
++ *
++ * editor a signed-in Network user whose subject is in NEWS_EDITORS, or a Network admin (staff);
++ * or a service token acting for such a person (X-OV-Subject)
++ * ai a service token with X-OV-Origin: ai may deliver a DRAFT revision (news.story.revise)
++ * and nothing else; its revisions are AI-generated and need a person's review
++ *
++ * Service tokens are additionally judged by their capability at the route (auth/viewer.js guard).
++ */
++const { ApiError } = require('../http/errors');
++
++function isStaff(viewer) { return Boolean(viewer && viewer.kind === 'user' && viewer.staff); }
++
++function isEditor(config, viewer) {
++ if (!viewer || !viewer.subject) return false;
++ if (viewer.kind === 'user') return viewer.staff || config.editors.includes(viewer.subject);
++ if (viewer.kind === 'service' && viewer.origin !== 'ai') return config.editors.includes(viewer.subject);
++ return false;
++}
++
++function isAiDelivery(viewer) { return Boolean(viewer && viewer.kind === 'service' && viewer.origin === 'ai'); }
++
++/** Refuse unless the viewer is an editor (403) — anonymous callers get 401. */
++function requireEditor(config, viewer) {
++ if (!viewer || viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'Sign in with an editor account');
++ if (!isEditor(config, viewer)) throw new ApiError(403, 'news.editor_required', 'Only News editors can do this (NEWS_EDITORS, or a Network admin)');
++ return viewer.subject;
++}
++
++module.exports = { isStaff, isEditor, isAiDelivery, requireEditor };
ddiff --git a/server/domain/clusters.js b/server/domain/clusters.js
nnew file mode 100644
iindex 0000000..34089eb
---- /dev/null
++++ b/server/domain/clusters.js
@@@ -0,0 +1,270 @@
++'use strict';
++
++/**
++ * Clusters: deterministic, explainable grouping of source items that report the same story.
++ *
++ * assign(item) an active item joins the open cluster it shares the most with inside the time
++ * window (NEWS_CLUSTER_WINDOW_HOURS, default 72 h):
++ * shared named entities count 2, shared terms count 1;
++ * it joins when (≥ 1 shared entity AND ≥ 2 shared terms) OR ≥ 3 shared terms;
++ * ties go to the oldest cluster. Otherwise it starts a new cluster.
++ * The decision is stored on the item (cluster_reason) with the shared entities,
++ * shared terms and score, so every membership can be explained and recomputed.
++ * A duplicate joins its original's cluster (rule "duplicate_of").
++ * merge(a, b) an editor merges b into a; split(a, items) moves items into a new cluster.
++ * Both are audited (news_cluster_audit, with exactly which items moved) and
++ * reversible (reverse(auditId)); nothing is deleted.
++ *
++ * Every change emits news.cluster.updated (internal) in the same transaction.
++ */
++const { ids } = require('openvibe-contracts');
++const text = require('./text');
++const { ApiError } = require('../http/errors');
++
++const newClusterId = (now) => `clu_${ids.ulid(now)}`;
++const newAuditId = (now) => `cla_${ids.ulid(now)}`;
++const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
++
++/** Terms and entities of one item (headline + the licensed summary we store). */
++function signature(item) {
++ const t = `${item.headline || ''}. ${item.summary || ''}`;
++ return { terms: text.terms(t), entities: text.entities(item.headline || '').concat(text.entities(item.summary || '')).filter((v, i, a) => a.indexOf(v) === i).sort() };
++}
++
++/** The time an item is placed at: when the source says it was published, else when News first saw it. */
++function itemTime(item) {
++ const p = item.published_at ? Date.parse(item.published_at) : NaN;
++ return Number.isFinite(p) ? p : item.first_seen_at;
++}
++
++/** Match an item's signature against a cluster's. → { sharedEntities, sharedTerms, score, joins } */
++function compare(sig, cluster) {
++ const ce = parse(cluster.entities, {});
++ const ct = parse(cluster.terms, {});
++ const sharedEntities = sig.entities.filter((e) => ce[e]);
++ const sharedTerms = sig.terms.filter((t) => ct[t]);
++ const score = 2 * sharedEntities.length + sharedTerms.length;
++ const joins = (sharedEntities.length >= 1 && sharedTerms.length >= 2) || sharedTerms.length >= 3;
++ return { sharedEntities, sharedTerms, score, joins };
++}
++
++function labelOf(entities, terms) {
++ const top = (obj) => Object.entries(obj).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).map(([k]) => k);
++ const e = top(entities).slice(0, 3);
++ const words = e.length ? e : top(terms).slice(0, 4);
++ return words.length ? words.join(' · ') : 'unlabelled';
++}
++
++function createClusters({ store, config, outbox }) {
++ const { db } = store;
++ const q = {
++ get: db.prepare('SELECT * FROM news_story_clusters WHERE id = ?'),
++ open: db.prepare(`SELECT * FROM news_story_clusters WHERE status = 'open' AND window_end >= ? AND window_start <= ? ORDER BY created_at, rowid`),
++ insert: db.prepare(`INSERT INTO news_story_clusters (id, label, status, split_from, terms, entities, window_start, window_end, created_by, created_at, updated_at)
++ VALUES (@id, @label, 'open', @split_from, @terms, @entities, @window_start, @window_end, @created_by, @now, @now)`),
++ members: db.prepare("SELECT * FROM news_source_items WHERE cluster_id = ? ORDER BY first_seen_at, rowid"),
++ setItem: db.prepare('UPDATE news_source_items SET cluster_id = ?, cluster_reason = ?, updated_at = ? WHERE id = ?'),
++ audit: db.prepare('SELECT * FROM news_cluster_audit WHERE id = ?'),
++ auditFor: db.prepare('SELECT * FROM news_cluster_audit WHERE cluster_id = ? OR other_id = ? ORDER BY created_at, rowid'),
++ insertAudit: db.prepare(`INSERT INTO news_cluster_audit (id, action, cluster_id, other_id, item_ids, reason, actor, reverses, created_at)
++ VALUES (@id, @action, @cluster_id, @other_id, @item_ids, @reason, @actor, @reverses, @now)`),
++ };
++
++ function emit(cluster, action, extra = {}) {
++ outbox.emit({
++ event_type: 'news.cluster.updated', actor: { type: 'service', id: 'news' }, visibility: 'internal', priority: 'low',
++ subject: { type: 'cluster', id: cluster.id },
++ payload: { cluster_id: cluster.id, action, status: cluster.status, label: cluster.label, item_count: q.members.all(cluster.id).length, ...extra },
++ });
++ }
++
++ /** Rebuild a cluster's terms, entities, window and label from its members (removed items do not count). */
++ function recompute(clusterId) {
++ const terms = {};
++ const entities = {};
++ let start = null;
++ let end = null;
++ for (const it of q.members.all(clusterId)) {
++ if (it.status === 'removed') continue;
++ const sig = signature(it);
++ for (const t of sig.terms) terms[t] = (terms[t] || 0) + 1;
++ for (const e of sig.entities) entities[e] = (entities[e] || 0) + 1;
++ const at = itemTime(it);
++ start = start == null ? at : Math.min(start, at);
++ end = end == null ? at : Math.max(end, at);
++ }
++ db.prepare('UPDATE news_story_clusters SET terms = ?, entities = ?, window_start = COALESCE(?, window_start), window_end = COALESCE(?, window_end), label = ?, updated_at = ? WHERE id = ?')
++ .run(JSON.stringify(terms), JSON.stringify(entities), start, end, labelOf(entities, terms), store.now(), clusterId);
++ return q.get.get(clusterId);
++ }
++
++ /**
++ * Place one item (inside the ingest transaction). Duplicates follow their original. Returns
++ * { cluster, created, reason }.
++ */
++ function assign(item) {
++ if (item.status === 'duplicate' && item.duplicate_of) {
++ const original = db.prepare('SELECT * FROM news_source_items WHERE id = ?').get(item.duplicate_of);
++ if (original && original.cluster_id) {
++ const cluster = resolve(original.cluster_id);
++ const reason = { rule: 'duplicate_of', item: original.id };
++ q.setItem.run(cluster.id, JSON.stringify(reason), store.now(), item.id);
++ emit(cluster, 'item_added', { item_id: item.id, rule: 'duplicate_of' });
++ return { cluster, created: false, reason };
++ }
++ }
++ const sig = signature(item);
++ const at = itemTime(item);
++ const w = config.clustering.windowMs;
++ let best = null;
++ for (const c of q.open.all(at - w, at + w)) {
++ const m = compare(sig, c);
++ if (!m.joins) continue;
++ if (!best || m.score > best.m.score) best = { c, m }; // ORDER BY created_at, rowid: the oldest wins a tie
++ }
++ if (best) {
++ const reason = { rule: 'shared_terms', cluster: best.c.id, shared_entities: best.m.sharedEntities, shared_terms: best.m.sharedTerms, score: best.m.score, window_hours: w / 3600000 };
++ q.setItem.run(best.c.id, JSON.stringify(reason), store.now(), item.id);
++ const cluster = recompute(best.c.id);
++ emit(cluster, 'item_added', { item_id: item.id, rule: 'shared_terms', score: best.m.score });
++ return { cluster, created: false, reason };
++ }
++ const now = store.now();
++ const id = newClusterId(now);
++ q.insert.run({ id, label: 'new', split_from: null, terms: '{}', entities: '{}', window_start: at, window_end: at, created_by: 'svc:news', now });
++ const reason = { rule: 'new_cluster', cluster: id, terms: sig.terms.slice(0, 20), entities: sig.entities.slice(0, 10) };
++ q.setItem.run(id, JSON.stringify(reason), now, item.id);
++ const cluster = recompute(id);
++ emit(cluster, 'created', { item_id: item.id });
++ return { cluster, created: true, reason };
++ }
++
++ /** Follow merges to the cluster that holds the items now. */
++ function resolve(id) {
++ let c = q.get.get(id);
++ for (let i = 0; c && c.status === 'merged' && c.merged_into && i < 50; i++) c = q.get.get(c.merged_into);
++ return c;
++ }
++
++ function mustOpen(id) {
++ const c = q.get.get(String(id || ''));
++ if (!c) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
++ if (c.status !== 'open') throw new ApiError(409, 'cluster.not_open', `Cluster ${c.id} is ${c.status}`);
++ return c;
++ }
++
++ function actorId(viewer) { return viewer && viewer.subject ? viewer.subject : (viewer && viewer.service) || 'svc:news'; }
++
++ function recordAudit(row) {
++ const now = store.now();
++ const id = newAuditId(now);
++ q.insertAudit.run({ id, reason: null, reverses: null, ...row, item_ids: JSON.stringify(row.item_ids), now });
++ return q.audit.get(id);
++ }
++
++ function shapeAudit(a) {
++ return a ? { id: a.id, action: a.action, cluster_id: a.cluster_id, other_id: a.other_id, item_ids: parse(a.item_ids, []), reason: a.reason, actor: a.actor, reverses: a.reverses, reversed_by: a.reversed_by, at: new Date(a.created_at).toISOString() } : null;
++ }
++
++ const api = {
++ signature, compare, itemTime, labelOf, assign, resolve, recompute,
++
++ get: (id) => q.get.get(String(id || '')) || null,
++ members: (id) => q.members.all(id),
++ audit: (id) => q.auditFor.all(id, id).map(shapeAudit),
++ auditEntry: (id) => shapeAudit(q.audit.get(String(id || ''))),
++
++ /** Open clusters, newest activity first (the editor dashboard). */
++ recent({ limit = 50 } = {}) {
++ return db.prepare("SELECT * FROM news_story_clusters WHERE status = 'open' ORDER BY window_end DESC, id DESC LIMIT ?").all(limit);
++ },
++
++ /** Merge cluster `otherId` into `intoId`. */
++ merge(viewer, intoId, otherId, { reason = null } = {}) {
++ return store.tx(() => {
++ const into = mustOpen(intoId);
++ const other = mustOpen(otherId);
++ if (into.id === other.id) throw new ApiError(422, 'cluster.same', 'A cluster cannot be merged into itself');
++ const moved = q.members.all(other.id).map((i) => i.id);
++ const now = store.now();
++ for (const id of moved) q.setItem.run(into.id, JSON.stringify({ rule: 'merged', from: other.id }), now, id);
++ db.prepare("UPDATE news_story_clusters SET status = 'merged', merged_into = ?, updated_at = ? WHERE id = ?").run(into.id, now, other.id);
++ const audit = recordAudit({ action: 'merge', cluster_id: into.id, other_id: other.id, item_ids: moved, reason: reason ? String(reason).slice(0, 500) : null, actor: actorId(viewer) });
++ const c = recompute(into.id);
++ emit(c, 'merged', { absorbed: other.id, audit_id: audit.id, moved: moved.length });
++ return { cluster: c, audit: shapeAudit(audit) };
++ });
++ },
++
++ /** Move some items of a cluster into a new cluster. At least one item must stay. */
++ split(viewer, clusterId, itemIds, { reason = null } = {}) {
++ return store.tx(() => {
++ const from = mustOpen(clusterId);
++ const members = new Set(q.members.all(from.id).map((i) => i.id));
++ const wanted = [...new Set((itemIds || []).map(String))];
++ if (!wanted.length) throw new ApiError(422, 'cluster.split_empty', 'Choose the items to split off');
++ for (const id of wanted) if (!members.has(id)) throw new ApiError(422, 'cluster.split_foreign', `${id} is not in cluster ${from.id}`);
++ if (wanted.length >= members.size) throw new ApiError(422, 'cluster.split_all', 'At least one item must stay in the cluster');
++ const now = store.now();
++ const id = newClusterId(now);
++ q.insert.run({ id, label: 'new', split_from: from.id, terms: '{}', entities: '{}', window_start: null, window_end: null, created_by: actorId(viewer), now });
++ for (const it of wanted) q.setItem.run(id, JSON.stringify({ rule: 'split', from: from.id }), now, it);
++ const audit = recordAudit({ action: 'split', cluster_id: from.id, other_id: id, item_ids: wanted, reason: reason ? String(reason).slice(0, 500) : null, actor: actorId(viewer) });
++ const a = recompute(from.id);
++ const b = recompute(id);
++ emit(a, 'split', { new_cluster: id, audit_id: audit.id, moved: wanted.length });
++ emit(b, 'created', { split_from: from.id, audit_id: audit.id });
++ return { cluster: a, created: b, audit: shapeAudit(audit) };
++ });
++ },
++
++ /**
++ * Undo a merge or a split: the items that moved (and are still where they were moved to) go
++ * back; the absorbed cluster reopens / the split-off cluster is dissolved. Audited as well.
++ */
++ reverse(viewer, auditId, { reason = null } = {}) {
++ return store.tx(() => {
++ const a = q.audit.get(String(auditId || ''));
++ if (!a) throw new ApiError(404, 'cluster.audit_not_found', 'No such merge or split');
++ if (a.reversed_by) throw new ApiError(409, 'cluster.already_reversed', `Already reversed by ${a.reversed_by}`);
++ if (a.action !== 'merge' && a.action !== 'split') throw new ApiError(422, 'cluster.not_reversible', 'Only merges and splits can be reversed');
++ const now = store.now();
++ const items = parse(a.item_ids, []);
++ let moved = [];
++ let result;
++ if (a.action === 'merge') {
++ const other = q.get.get(a.other_id);
++ if (!other || other.status !== 'merged' || other.merged_into !== a.cluster_id) throw new ApiError(409, 'cluster.changed', 'The absorbed cluster has changed since the merge');
++ moved = items.filter((id) => (db.prepare('SELECT cluster_id FROM news_source_items WHERE id = ?').get(id) || {}).cluster_id === a.cluster_id);
++ for (const id of moved) q.setItem.run(other.id, JSON.stringify({ rule: 'merge_reversed', audit: a.id }), now, id);
++ db.prepare("UPDATE news_story_clusters SET status = 'open', merged_into = NULL, updated_at = ? WHERE id = ?").run(now, other.id);
++ const rev = recordAudit({ action: 'reverse_merge', cluster_id: a.cluster_id, other_id: other.id, item_ids: moved, reason: reason ? String(reason).slice(0, 500) : null, actor: actorId(viewer), reverses: a.id });
++ db.prepare('UPDATE news_cluster_audit SET reversed_by = ? WHERE id = ?').run(rev.id, a.id);
++ const c1 = recompute(a.cluster_id);
++ const c2 = recompute(other.id);
++ emit(c1, 'merge_reversed', { audit_id: rev.id, restored: other.id });
++ emit(c2, 'reopened', { audit_id: rev.id });
++ result = { audit: shapeAudit(q.audit.get(rev.id)), clusters: [c1, c2] };
++ } else {
++ const split = q.get.get(a.other_id);
++ const from = q.get.get(a.cluster_id);
++ if (!split || split.status !== 'open' || !from || from.status !== 'open') throw new ApiError(409, 'cluster.changed', 'One of the clusters is no longer open');
++ moved = items.filter((id) => (db.prepare('SELECT cluster_id FROM news_source_items WHERE id = ?').get(id) || {}).cluster_id === split.id);
++ for (const id of moved) q.setItem.run(from.id, JSON.stringify({ rule: 'split_reversed', audit: a.id }), now, id);
++ const left = q.members.all(split.id).length;
++ if (!left) db.prepare("UPDATE news_story_clusters SET status = 'dissolved', updated_at = ? WHERE id = ?").run(now, split.id);
++ const rev = recordAudit({ action: 'reverse_split', cluster_id: from.id, other_id: split.id, item_ids: moved, reason: reason ? String(reason).slice(0, 500) : null, actor: actorId(viewer), reverses: a.id });
++ db.prepare('UPDATE news_cluster_audit SET reversed_by = ? WHERE id = ?').run(rev.id, a.id);
++ const c1 = recompute(from.id);
++ const c2 = left ? recompute(split.id) : q.get.get(split.id);
++ emit(c1, 'split_reversed', { audit_id: rev.id, dissolved: left ? null : split.id });
++ result = { audit: shapeAudit(q.audit.get(rev.id)), clusters: [c1, c2] };
++ }
++ return result;
++ });
++ },
++ };
++ return api;
++}
++
++module.exports = { createClusters, signature, compare, itemTime, labelOf };
ddiff --git a/server/domain/ingest.js b/server/domain/ingest.js
nnew file mode 100644
iindex 0000000..7d96c91
---- /dev/null
++++ b/server/domain/ingest.js
@@@ -0,0 +1,287 @@
++'use strict';
++
++/**
++ * Ingestion: OpenVibe.Sources items (category news) → news_source_items → dedupe → clusters.
++ *
++ * Two ways in, one effect:
++ * - the Events webhook (sources.item.created|updated|removed, sources.fetch.failed), each event
++ * applied once through the inbox (http/webhook.js);
++ * - the cursor pull (worker), the backstop for missed deliveries: GET /api/v1/items in change
++ * order from the stored cursor.
++ * apply() is idempotent on (Sources item id, Sources revision): a replay, or the same item arriving
++ * by both paths, changes nothing and emits nothing.
++ *
++ * What is stored (licensing): headline, canonical URL, outlet, authors, published_at, the licence
++ * and terms notes, and a short summary ONLY when those notes explicitly allow short summaries
++ * (text.licensedSummary). Every other field of the Sources item — whatever an adapter mapped into
++ * `fields` — is ignored, so an article body can never be stored, shown, fed or indexed.
++ *
++ * Failure is a recorded state (news_ingest_runs + news.source.failed), never a replacement: a
++ * failed fetch or an unreadable item creates no source item, no cluster and no story text.
++ *
++ * Upstream changes to an item a story cites (a revision, a removal) go to stories.onUpstreamChange,
++ * which flags the story and prepares a pending revision; published text is never changed silently.
++ */
++const { ids } = require('openvibe-contracts');
++const text = require('./text');
++
++const NEAR_TITLE = { anyOutlet: 0.9, sameOutlet: 0.75, windowMs: 48 * 3600 * 1000 };
++const newItemId = (now) => `nsi_${ids.ulid(now)}`;
++
++function createIngest({ store, config, clusters, outbox, sources, log = console }) {
++ const { db } = store;
++ let stories = null; // set by app.js (stories depend on ingest's items too)
++ const q = {
++ bySourcesId: db.prepare('SELECT * FROM news_source_items WHERE sources_item_id = ?'),
++ byId: db.prepare('SELECT * FROM news_source_items WHERE id = ?'),
++ byUrl: db.prepare("SELECT * FROM news_source_items WHERE url_key = ? AND status <> 'removed' ORDER BY first_seen_at, rowid LIMIT 1"),
++ byHash: db.prepare("SELECT * FROM news_source_items WHERE content_hash = ? AND status <> 'removed' ORDER BY first_seen_at, rowid LIMIT 1"),
++ recent: db.prepare("SELECT * FROM news_source_items WHERE status <> 'removed' AND first_seen_at >= ? ORDER BY first_seen_at, rowid"),
++ status: db.prepare('SELECT * FROM news_source_status WHERE source_key = ?'),
++ putStatus: db.prepare(`INSERT INTO news_source_status (source_key, name, homepage_url, status, stale, last_success_at, refreshed_at)
++ VALUES (@source_key, @name, @homepage_url, @status, @stale, @last_success_at, @now)
++ ON CONFLICT (source_key) DO UPDATE SET name = COALESCE(excluded.name, name), homepage_url = COALESCE(excluded.homepage_url, homepage_url),
++ status = COALESCE(excluded.status, status), stale = COALESCE(excluded.stale, stale),
++ last_success_at = COALESCE(excluded.last_success_at, last_success_at), refreshed_at = excluded.refreshed_at`),
++ run: db.prepare(`INSERT INTO news_ingest_runs (origin, state, source_key, sources_item_id, error_code, detail, counts, cursor_before, cursor_after, at)
++ VALUES (@origin, @state, @source_key, @sources_item_id, @error_code, @detail, @counts, @cursor_before, @cursor_after, @at)`),
++ lastPull: db.prepare("SELECT * FROM news_ingest_runs WHERE origin = 'pull' ORDER BY id DESC LIMIT 1"),
++ };
++
++ function recordRun(r) {
++ q.run.run({ source_key: null, sources_item_id: null, error_code: null, detail: null, counts: null, cursor_before: null, cursor_after: null, ...r, counts: r.counts ? JSON.stringify(r.counts) : null, at: store.now() });
++ }
++
++ function failedEvent({ origin, sourceKey = null, sourcesItemId = null, state, errorCode = null, detail = null, httpStatus = null, extra = {} }) {
++ outbox.emit({
++ event_type: 'news.source.failed', actor: { type: 'service', id: 'news' }, visibility: 'internal', priority: 'important',
++ subject: sourceKey ? { type: 'source', id: sourceKey } : { type: 'ingest', id: origin },
++ payload: { origin, source_key: sourceKey, sources_item_id: sourcesItemId, state, error_code: errorCode, http_status: httpStatus, detail: detail ? String(detail).slice(0, 500) : null, ...extra },
++ });
++ }
++
++ function outletFor(item) {
++ const st = q.status.get(item.source_key);
++ return (st && st.name) || text.hostOf(item.canonical_url) || item.source_key;
++ }
++
++ /** The fields News keeps from a Sources item. Nothing else of the item is read. */
++ function normalise(item) {
++ const prov = item.provenance || {};
++ const headline = String(item.title || '').replace(/\s+/g, ' ').trim().slice(0, 500);
++ const lic = text.licensedSummary(item.summary, { termsNote: prov.terms_note, licenseNote: prov.license_note, maxChars: config.licensing.summaryMaxChars });
++ const authors = Array.isArray(item.authors) ? item.authors.map((a) => String(a || '').replace(/\s+/g, ' ').trim().slice(0, 200)).filter(Boolean).slice(0, 10) : [];
++ const published = item.published_at && Number.isFinite(Date.parse(item.published_at)) ? new Date(Date.parse(item.published_at)).toISOString() : null;
++ return {
++ headline,
++ canonical_url: item.canonical_url || null,
++ url_key: text.urlKey(item.canonical_url),
++ outlet: outletFor(item),
++ authors: JSON.stringify(authors),
++ published_at: published,
++ summary: lic.summary,
++ summary_basis: lic.basis,
++ license_note: prov.license_note ? String(prov.license_note).slice(0, 2000) : null,
++ terms_note: prov.terms_note ? String(prov.terms_note).slice(0, 2000) : null,
++ content_hash: prov.content_hash || null,
++ title_key: text.titleKey(headline),
++ retrieved_at: prov.retrieved_at || null,
++ };
++ }
++
++ /** Is this new item a duplicate of one we have? → null | { of, rule, detail } */
++ function dedupe(n, sourceKey, at) {
++ if (n.url_key) {
++ const hit = q.byUrl.get(n.url_key);
++ if (hit) return { of: hit.duplicate_of || hit.id, rule: 'canonical_url', detail: n.url_key };
++ }
++ if (n.content_hash) {
++ const hit = q.byHash.get(n.content_hash);
++ if (hit) return { of: hit.duplicate_of || hit.id, rule: 'content_hash', detail: n.content_hash };
++ }
++ const mine = text.shingles(n.headline);
++ const host = text.hostOf(n.canonical_url);
++ let best = null;
++ for (const c of q.recent.all(Math.min(at, store.now()) - NEAR_TITLE.windowMs)) {
++ const t = clusters.itemTime(c);
++ if (Math.abs(t - at) > NEAR_TITLE.windowMs) continue;
++ const score = text.jaccard(mine, text.shingles(c.headline));
++ const sameOutlet = c.source_key === sourceKey || (host && text.hostOf(c.canonical_url) === host);
++ const threshold = sameOutlet ? NEAR_TITLE.sameOutlet : NEAR_TITLE.anyOutlet;
++ if (score >= threshold && (!best || score > best.score)) best = { c, score, sameOutlet };
++ }
++ if (best) return { of: best.c.duplicate_of || best.c.id, rule: 'near_title', detail: `jaccard ${best.score.toFixed(2)} of headline 2-shingles${best.sameOutlet ? ' (same outlet)' : ''}` };
++ return null;
++ }
++
++ function ingestedEvent(row, action, extra = {}) {
++ outbox.emit({
++ event_type: 'news.source.ingested', actor: { type: 'service', id: 'news' }, visibility: 'internal', priority: 'low',
++ subject: { type: 'source_item', id: row.id },
++ payload: {
++ item_id: row.id, action, status: row.status,
++ sources_item: { service: 'sources', type: 'item', id: row.sources_item_id, revision: row.sources_revision },
++ source_key: row.source_key, canonical_url: row.canonical_url, duplicate_of: row.duplicate_of,
++ dedupe_rule: row.dedupe ? JSON.parse(row.dedupe).rule : null, cluster_id: row.cluster_id, ...extra,
++ },
++ });
++ }
++
++ /**
++ * Apply one Sources item (inside a transaction the caller may already hold). Returns
++ * { outcome: created|updated|removed|unchanged|ignored|rejected, item?, reason? }.
++ */
++ function apply(item, { origin = 'pull' } = {}) {
++ return db.transaction(() => {
++ if (!item || typeof item.id !== 'string') return { outcome: 'rejected', reason: 'item without id' };
++ if (item.category && item.category !== config.sources.category) return { outcome: 'ignored', reason: `category ${item.category}` };
++ const revision = Number.isInteger(item.revision) ? item.revision : 1;
++ const cur = q.bySourcesId.get(item.id);
++ if (item.removed) return applyRemoval({ sourcesItemId: item.id, reason: item.removed.reason || 'removed upstream', revision, origin });
++ if (cur && cur.sources_revision >= revision) return { outcome: 'unchanged', item: cur };
++ const n = normalise(item);
++ if (!n.headline) {
++ recordRun({ origin, state: 'rejected', source_key: item.source_key || null, sources_item_id: item.id, error_code: 'item.no_headline', detail: 'a Sources item without a title cannot be shown' });
++ failedEvent({ origin, sourceKey: item.source_key || null, sourcesItemId: item.id, state: 'rejected', errorCode: 'item.no_headline' });
++ return { outcome: 'rejected', reason: 'no headline' };
++ }
++ const now = store.now();
++ if (cur) {
++ if (cur.status === 'removed') return { outcome: 'unchanged', item: cur }; // removal is sticky here too
++ db.prepare(`UPDATE news_source_items SET sources_revision = @rev, canonical_url = @canonical_url, url_key = @url_key, headline = @headline,
++ outlet = @outlet, authors = @authors, published_at = @published_at, summary = @summary, summary_basis = @summary_basis,
++ license_note = @license_note, terms_note = @terms_note, content_hash = @content_hash, title_key = @title_key,
++ retrieved_at = @retrieved_at, upstream_updated_at = @now, updated_at = @now WHERE id = @id`)
++ .run({ ...n, rev: revision, now, id: cur.id });
++ const row = q.byId.get(cur.id);
++ if (row.cluster_id) clusters.recompute(clusters.resolve(row.cluster_id).id);
++ ingestedEvent(row, 'updated', { previous_revision: cur.sources_revision });
++ const affected = stories ? stories.onUpstreamChange(row, 'source_updated', { previous: cur }) : [];
++ return { outcome: 'updated', item: row, stories: affected };
++ }
++ const at = (() => { const p = n.published_at ? Date.parse(n.published_at) : NaN; return Number.isFinite(p) ? p : now; })();
++ const dup = dedupe(n, item.source_key, at);
++ const id = newItemId(now);
++ db.prepare(`INSERT INTO news_source_items (id, sources_item_id, sources_revision, source_key, canonical_url, url_key, headline, outlet, authors,
++ published_at, summary, summary_basis, license_note, terms_note, content_hash, title_key, status, duplicate_of, dedupe,
++ retrieved_at, first_seen_at, updated_at)
++ VALUES (@id, @sources_item_id, @rev, @source_key, @canonical_url, @url_key, @headline, @outlet, @authors,
++ @published_at, @summary, @summary_basis, @license_note, @terms_note, @content_hash, @title_key, @status, @duplicate_of, @dedupe,
++ @retrieved_at, @now, @now)`)
++ .run({ ...n, id, sources_item_id: item.id, rev: revision, source_key: String(item.source_key || 'unknown'),
++ status: dup ? 'duplicate' : 'active', duplicate_of: dup ? dup.of : null, dedupe: dup ? JSON.stringify({ rule: dup.rule, detail: dup.detail }) : null, now });
++ clusters.assign(q.byId.get(id));
++ const row = q.byId.get(id);
++ ingestedEvent(row, 'created');
++ return { outcome: 'created', item: row };
++ })();
++ }
++
++ /** A Sources removal (takedown, licence, error): sticky; the stored summary is dropped. */
++ function applyRemoval({ sourcesItemId, reason, revision = null, origin = 'webhook' }) {
++ return db.transaction(() => {
++ const cur = q.bySourcesId.get(sourcesItemId);
++ if (!cur) return { outcome: 'ignored', reason: 'never ingested' };
++ if (cur.status === 'removed') return { outcome: 'unchanged', item: cur };
++ const now = store.now();
++ db.prepare(`UPDATE news_source_items SET status = 'removed', summary = NULL, summary_basis = 'removed_upstream', removed_at = ?, removed_reason = ?,
++ sources_revision = MAX(sources_revision, COALESCE(?, sources_revision)), updated_at = ? WHERE id = ?`)
++ .run(now, String(reason || 'removed upstream').slice(0, 500), revision, now, cur.id);
++ const row = q.byId.get(cur.id);
++ if (row.cluster_id) clusters.recompute(clusters.resolve(row.cluster_id).id);
++ ingestedEvent(row, 'removed', { reason: row.removed_reason, origin });
++ const affected = stories ? stories.onUpstreamChange(row, 'source_removed', { previous: cur }) : [];
++ return { outcome: 'removed', item: row, stories: affected };
++ })();
++ }
++
++ /** sources.fetch.failed for a news source: recorded and relayed; nothing else changes. */
++ function upstreamFailure(payload) {
++ return db.transaction(() => {
++ if (payload.category && payload.category !== config.sources.category) return { outcome: 'ignored' };
++ recordRun({ origin: 'sources', state: String(payload.state || 'failed').slice(0, 40), source_key: payload.source_key || null, error_code: payload.error_code || null,
++ detail: `run ${payload.run_id || '?'}${payload.http_status ? ` HTTP ${payload.http_status}` : ''}${payload.consecutive_failures ? `, ${payload.consecutive_failures} consecutive failures` : ''}` });
++ q.putStatus.run({ source_key: payload.source_key || 'unknown', name: null, homepage_url: null, status: 'failing', stale: null, last_success_at: null, now: store.now() });
++ failedEvent({ origin: 'sources', sourceKey: payload.source_key || null, state: payload.state || 'failed', errorCode: payload.error_code || null, httpStatus: payload.http_status || null,
++ extra: { run_id: payload.run_id || null, consecutive_failures: payload.consecutive_failures || null } });
++ return { outcome: 'recorded' };
++ })();
++ }
++
++ /** Remember what a Sources page says about its sources' health (display only). */
++ function rememberSources(map) {
++ for (const [key, h] of Object.entries(map || {})) {
++ q.putStatus.run({ source_key: key, name: null, homepage_url: null, status: h.status || null, stale: h.stale == null ? null : (h.stale ? 1 : 0), last_success_at: h.last_success_at || null, now: store.now() });
++ }
++ }
++
++ /** Fetch registry names we do not know yet (needs sources.source.read; failure = keep the host name). */
++ async function learnOutlets(keys) {
++ for (const key of [...new Set(keys)]) {
++ const st = q.status.get(key);
++ if (st && st.name) continue;
++ try {
++ const s = await sources.getSource(key);
++ q.putStatus.run({ source_key: key, name: s && s.name ? String(s.name).slice(0, 200) : null, homepage_url: s && s.homepage_url ? String(s.homepage_url) : null,
++ status: s && s.health ? s.health.status || null : null, stale: s && s.health && s.health.stale != null ? (s.health.stale ? 1 : 0) : null,
++ last_success_at: s && s.health ? s.health.last_success_at || null : null, now: store.now() });
++ } catch { /* optional grant; the outlet stays the URL's host */ }
++ }
++ }
++
++ /**
++ * The cursor pull. Reads pages from the stored cursor until Sources says there is no more (or
++ * the page budget is spent), applying each item. The cursor moves only past applied pages.
++ */
++ let pulling = null;
++ async function pull() {
++ if (pulling) return pulling;
++ pulling = (async () => {
++ const before = store.getState('sources_cursor', 0);
++ let after = before;
++ const counts = { created: 0, updated: 0, removed: 0, unchanged: 0, ignored: 0, rejected: 0, duplicates: 0 };
++ try {
++ for (let page = 0; page < config.sources.maxPagesPerRun; page++) {
++ const body = await sources.listItems({ after, limit: config.sources.pageSize });
++ rememberSources(body.sources);
++ await learnOutlets(body.items.map((i) => i.source_key).filter(Boolean));
++ store.tx(() => {
++ for (const item of body.items) {
++ const r = apply(item, { origin: 'pull' });
++ counts[r.outcome] = (counts[r.outcome] || 0) + 1;
++ if (r.outcome === 'created' && r.item.status === 'duplicate') counts.duplicates++;
++ }
++ store.setState('sources_cursor', body.next_after);
++ });
++ after = body.next_after;
++ if (!body.more) break;
++ }
++ store.tx(() => recordRun({ origin: 'pull', state: 'ok', counts, cursor_before: before, cursor_after: after }));
++ return { ok: true, counts, cursor: after };
++ } catch (err) {
++ const prev = q.lastPull.get();
++ store.tx(() => {
++ recordRun({ origin: 'pull', state: 'failed', error_code: err.code || 'pull.error', detail: err.message, counts, cursor_before: before, cursor_after: after });
++ // One event per outage, not one per retry.
++ if (!prev || prev.state !== 'failed') failedEvent({ origin: 'pull', state: 'failed', errorCode: err.code || 'pull.error', detail: err.message, httpStatus: err.status || null });
++ });
++ log.warn(`[News] Sources pull failed (${err.code || 'error'}): ${err.message}`);
++ return { ok: false, error: err.code || 'pull.error', detail: err.message, counts, cursor: after };
++ } finally { pulling = null; }
++ })();
++ return pulling;
++ }
++
++ return {
++ NEAR_TITLE,
++ setStories(s) { stories = s; },
++ normalise, dedupe, apply, applyRemoval, upstreamFailure, pull, recordRun, failedEvent, learnOutlets, rememberSources,
++ get: (id) => q.byId.get(String(id || '')) || null,
++ bySourcesId: (id) => q.bySourcesId.get(String(id || '')) || null,
++ runs: ({ limit = 30 } = {}) => db.prepare('SELECT * FROM news_ingest_runs ORDER BY id DESC LIMIT ?').all(limit),
++ sourceStatus: () => db.prepare('SELECT * FROM news_source_status ORDER BY source_key').all(),
++ lastPull: () => q.lastPull.get() || null,
++ };
++}
++
++module.exports = { createIngest, NEAR_TITLE };
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
nnew file mode 100644
iindex 0000000..5b8d9ca
---- /dev/null
++++ b/server/domain/publication.js
@@@ -0,0 +1,215 @@
++'use strict';
++
++/**
++ * Publication mechanics shared by the routes, the worker and ingestion:
++ * - canonical paths and URLs
++ * - the indexability gate (openvibe-publishing/seo) with News' 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
++ *
++ * News' gate policy: at least one source (citations are the Sources items the published revision
++ * cites and that are still live upstream), at least 40 words; a paragraph whose every source was
++ * removed upstream counts as an unsupported claim (noindex until an editor revises); a retraction
++ * is noindex; AI-generated text is hidden until a person reviews it.
++ *
++ * Search receives ONLY published (not retracted), listable stories. Everything else is a tombstone,
++ * so a retraction or an unpublish removes the old copy; a story that was never indexed gets no tombstone.
++ */
++const seo = require('openvibe-publishing/seo');
++const hooks = require('openvibe-publishing/index-hooks');
++const authorship = require('openvibe-publishing/authorship');
++
++const POLICY = Object.freeze({ minWords: 40, requireSources: true, minSources: 1 });
++const OWNER = 'news';
++
++/**
++ * AI-assisted text (hybrid: a person edited an AI draft) is held like AI-generated text: hidden
++ * from indexes and not publishable until a person records an approving review of that revision.
++ */
++function gateAuthorship(rec, review) {
++ if (rec.mode === 'hybrid') return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) }, ...(rec.stubProvider ? { stubProvider: true } : {}) };
++ return authorship.gateFacts(rec, review);
++}
++function canPublish(rec, review) {
++ if (rec.mode === 'hybrid' && !authorship.isReviewed(rec, review)) return { ok: false, reason: 'ai_assisted_unreviewed' };
++ return authorship.canPublish(rec, review);
++}
++function disclosure(rec, review) {
++ const d = rec ? authorship.disclosure(rec, review) : null;
++ if (!d || rec.mode !== 'hybrid') return d;
++ const reviewed = authorship.isReviewed(rec, review);
++ return { ...d, long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}` };
++}
++
++function createPublication({ store, config, outbox }) {
++ const { db } = store;
++ const storyById = db.prepare('SELECT * FROM news_stories WHERE id = ?');
++ const itemById = db.prepare('SELECT * FROM news_source_items WHERE id = ?');
++ const topicById = db.prepare('SELECT * FROM news_topics WHERE id = ?');
++
++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
++ const storyPath = (story) => `/stories/${story.slug}`;
++ const storyUrl = (story) => abs(storyPath(story));
++ const topicPath = (topic) => `/topics/${topic.slug}`;
++ const feedId = (story) => `tag:openvibe.news,2026:story/${story.id}`; // stable across slug changes
++
++ const authorshipOf = (rev) => (rev && rev.meta && rev.meta.authorship) || null;
++ const reviewOf = (story, rev) => (rev ? store.reviews.latest(story.id, rev.number) : null);
++ const paragraphsOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.paragraphs) ? rev.fields.paragraphs : []);
++ const snapshotOf = (rev) => (rev && rev.fields && Array.isArray(rev.fields.sources) ? rev.fields.sources : []);
++
++ /** Live upstream state of every source a revision names: n → news_source_items row (or null). */
++ function liveSources(rev) {
++ const out = new Map();
++ for (const s of snapshotOf(rev)) out.set(s.n, itemById.get(s.id) || null);
++ return out;
++ }
++
++ /** What the revision rests on, against the live source records. */
++ function support(rev) {
++ const live = liveSources(rev);
++ const alive = (n) => { const it = live.get(n); return Boolean(it && it.status !== 'removed'); };
++ const cited = new Set();
++ let unsupported = 0;
++ for (const p of paragraphsOf(rev)) {
++ const ns = Array.isArray(p.sources) ? p.sources : [];
++ for (const n of ns) if (alive(n)) cited.add(n);
++ if (!ns.some(alive)) unsupported++;
++ }
++ return { citedLive: cited.size, unsupported, live };
++ }
++
++ const plain = (rev) => (rev ? `${rev.fields.headline || ''}\n${paragraphsOf(rev).map((p) => p.text).join('\n')}` : '');
++
++ function gateState(story, override) {
++ const s = override || story.state;
++ if (s === 'published' || s === 'retracted') return 'published';
++ if (s === 'unpublished') return 'unpublished';
++ return 'draft';
++ }
++
++ /** The gate's decision for a story at one revision (the published one by default). */
++ function decide(story, rev, { state } = {}) {
++ const sup = support(rev);
++ const rec = authorshipOf(rev);
++ const facts = {
++ state: gateState(story, state),
++ visibility: 'public',
++ canonicalUrl: storyUrl(story),
++ text: plain(rev),
++ citationCount: sup.citedLive,
++ unsupportedClaims: sup.unsupported,
++ retracted: (state || story.state) === 'retracted',
++ noindex: Boolean(story.noindex),
++ };
++ if (rec) Object.assign(facts, gateAuthorship(rec, reviewOf(story, rev)));
++ return seo.evaluate(facts, { policy: POLICY, now: store.now() });
++ }
++
++ function topicOf(story) { return story.topic_id ? topicById.get(story.topic_id) : null; }
++
++ /**
++ * The index document as it would describe this story (for the product events), or a tombstone
++ * when it is not published (or retracted).
++ */
++ function documentFor(story, { forSearch }) {
++ const rev = story.published_revision ? store.revisions.get(story.id, story.published_revision) : null;
++ const identity = { owner: OWNER, type: 'story', id: story.id, revision: 0 };
++ if (!rev || story.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(story, rev) : null };
++ const decision = decide(story, rev);
++ if (forSearch && !decision.listable) return { doc: hooks.tombstone(identity), decision };
++ const topic = topicOf(story);
++ const outlets = [...new Set(snapshotOf(rev).map((s) => s.outlet).filter(Boolean))].slice(0, 50);
++ const citations = store.citations.forRevision(story.id, rev.number).filter((c) => !c.anchor || !c.anchor.startsWith('t'));
++ const seen = new Set();
++ const uniqueCites = citations.filter((c) => { const k = c.sourceItemId || c.url; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 49);
++ const doc = hooks.buildIndexDocument({
++ ...identity,
++ state: 'published',
++ visibility: 'public',
++ canonicalUrl: storyUrl(story),
++ title: rev.fields.headline,
++ summary: paragraphsOf(rev)[0] ? paragraphsOf(rev)[0].text : null,
++ body: paragraphsOf(rev).map((p) => p.text).join('\n\n'),
++ facets: { ...(topic ? { topic: topic.slug } : {}), outlets, sources: snapshotOf(rev).length },
++ authorship: authorshipOf(rev),
++ citations: uniqueCites,
++ decision,
++ publishedAt: story.first_published_at,
++ updatedAt: rev.createdAt,
++ language: 'en',
++ });
++ return { doc, decision };
++ }
++
++ /** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
++ function syncIndex(story, { traceparent } = {}) {
++ const { doc } = documentFor(story, { forSearch: true });
++ const prev = store.sequencer.current(OWNER, 'story', story.id);
++ if (doc.deleted && prev == null) return null;
++ const stamped = store.sequencer.stamp(doc);
++ if (prev != null && stamped.revision === prev) return null;
++ return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
++ }
++
++ function snapshot(story) {
++ return story ? { state: story.state, visibility: 'public', revision: story.published_revision, url: storyUrl(story) } : null;
++ }
++
++ /**
++ * Emit the product event for a transition (news.story.published|updated|unpublished|retracted)
++ * and re-sync Search. Inside the caller's transaction, after the row changed.
++ */
++ function afterChange(before, storyId, { actor, traceparent, extra = {} } = {}) {
++ const story = storyById.get(storyId);
++ const after = snapshot(story);
++ let event = null;
++ if (after.state === 'retracted' && (!before || before.state !== 'retracted')) {
++ const d = decide(story, store.revisions.get(story.id, story.published_revision));
++ event = outbox.emit({
++ event_type: 'news.story.retracted', version: 1, source: OWNER, actor: actorRef(actor),
++ timestamp: new Date(store.now()).toISOString(),
++ visibility: d.listable ? 'public' : 'internal',
++ subject: { type: 'story', id: story.id, revision: story.published_revision || 0 },
++ payload: { canonical_url: storyUrl(story), publication_state: 'retracted', indexability: hooks.searchIndexability(d), ...extra },
++ }, { traceparent });
++ } else {
++ // A retracted story is still published (with its notice) for the product events.
++ const norm = (s) => (s && s.state === 'retracted' ? { ...s, state: 'published' } : s);
++ let action = hooks.actionFor(norm(before), norm(after));
++ if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';
++ if (action) {
++ const { doc, decision } = documentFor(story, { forSearch: false });
++ const topic = topicOf(story);
++ event = outbox.emit(hooks.publicationEvent({
++ product: OWNER, type: 'story', action, id: story.id, revision: story.published_revision || 0,
++ actor: actorRef(actor), document: doc, decision, now: store.now(),
++ extra: { ...(topic ? { topic: topic.slug } : {}), ...extra },
++ }), { traceparent });
++ }
++ }
++ syncIndex(story, { traceparent });
++ return { story, event };
++ }
++
++ return {
++ POLICY, OWNER, abs, storyPath, storyUrl, topicPath, feedId,
++ authorshipOf, reviewOf, paragraphsOf, snapshotOf, liveSources, support, decide, topicOf,
++ documentFor, syncIndex, snapshot, afterChange, plain,
++ };
++}
++
++/** Event actor from a viewer (or svc:news for the system). */
++function actorRef(actor) {
++ if (!actor) return { type: 'service', id: 'news' };
++ 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: 'news' };
++}
++
++module.exports = { createPublication, actorRef, POLICY, canPublish, disclosure, gateAuthorship };
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
nnew file mode 100644
iindex 0000000..24f06dd
---- /dev/null
++++ b/server/domain/reading.js
@@@ -0,0 +1,150 @@
++'use strict';
++
++/**
++ * What readers get: one model per story, built from the published revision's snapshot and the live
++ * state of its sources, used by the HTML page, the .json twin, feeds and JSON-LD alike (no
++ * divergence between what people and machines see).
++ *
++ * Public masking: a source that has since been REMOVED upstream shows only its number, outlet and
++ * the fact of the removal — no headline, summary or link — so material withdrawn for licensing or
++ * legal reasons never stays in public text. Open upstream flags are shown as a notice; the text of
++ * the published revision itself never changes.
++ */
++const seo = require('openvibe-publishing/seo');
++const { disclosure } = require('./publication');
++
++const iso = (v) => (v == null ? null : new Date(v).toISOString());
++
++function createReading({ store, config, stories, publication, people, topics }) {
++ /** The public model of a story at a revision (the published one unless given). */
++ async function storyModel(story, { rev = null, editorView = false } = {}) {
++ const r = rev || (story.published_revision ? store.revisions.get(story.id, story.published_revision) : null);
++ if (!r) return null;
++ const live = publication.liveSources(r);
++ const snap = publication.snapshotOf(r);
++ const upstream = stories.openUpstreamFlags(story);
++ const sources = snap.map((s) => {
++ const it = live.get(s.n);
++ const removed = !it || it.status === 'removed';
++ const changed = Boolean(it && !removed && it.sources_revision > s.sources_revision);
++ const base = { n: s.n, outlet: s.outlet, status: removed ? 'removed' : 'active', upstream_changed: changed, perspective: s.perspective || null,
++ sources_item: { service: 'sources', type: 'item', id: s.sources_item_id, revision: s.sources_revision } };
++ if (removed && !editorView) return { ...base, removed_at: it && it.removed_at ? iso(it.removed_at) : null };
++ return {
++ ...base, headline: s.headline, url: s.url || null, authors: s.authors || [], published_at: s.published_at || null,
++ retrieved_at: s.retrieved_at || null,
++ // The licensed short summary, as snapshotted; never shown once the source is removed.
++ summary: removed ? null : (s.summary || null),
++ ...(editorView && removed ? { removed_reason: it ? it.removed_reason : 'missing' } : {}),
++ };
++ });
++ const byN = new Map(sources.map((s) => [s.n, s]));
++ const paragraphs = publication.paragraphsOf(r).map((p) => ({ text: p.text, sources: p.sources, supported: p.sources.some((n) => byN.get(n) && byN.get(n).status !== 'removed') }));
++ const outletGroups = [];
++ for (const s of sources) {
++ let g = outletGroups.find((x) => x.outlet === s.outlet);
++ if (!g) { g = { outlet: s.outlet, sources: [] }; outletGroups.push(g); }
++ g.sources.push(s.n);
++ }
++ const rec = publication.authorshipOf(r);
++ const review = publication.reviewOf(story, r);
++ const authorSubjects = rec && Array.isArray(rec.authors) ? rec.authors : [];
++ const who = authorSubjects.length ? await people.many(authorSubjects) : new Map();
++ const authors = authorSubjects.map((s) => who.get(s)).filter((p) => p && p.known).map((p) => ({ name: p.name, username: p.username }));
++ const topic = publication.topicOf(story);
++ const flags = stories.publicFlags(story);
++ const retraction = flags.find((f) => f.kind === 'retraction') || null;
++ return {
++ story, rev: r, topic,
++ url: publication.storyUrl(story), path: publication.storyPath(story),
++ headline: r.fields.headline,
++ headline_sources: paragraphs[0] ? paragraphs[0].sources : [],
++ paragraphs, sources, outletGroups,
++ timeline: (r.fields.timeline || []).map((t) => ({ occurred_on: t.occurred_on, text: t.text, source: t.source, supported: Boolean(byN.get(t.source) && byN.get(t.source).status !== 'removed') })),
++ perspectives: (r.fields.perspectives || []).map((p) => ({ label: p.label, description: p.description || null, sources: p.sources })),
++ corrections: flags.filter((f) => f.kind !== 'retraction').map((f) => ({ kind: f.kind, note: f.note, revision: f.revision, at: iso(f.resolved_at || f.created_at) })),
++ retraction: retraction ? { note: retraction.note, at: iso(retraction.resolved_at || retraction.created_at), revision: retraction.revision } : null,
++ upstream: upstream.map((f) => {
++ const s = snap.find((x) => x.id === f.source_item_id);
++ return { kind: f.kind, n: s ? s.n : null, outlet: s ? s.outlet : null, at: iso(f.created_at) };
++ }),
++ authorship: rec, review, disclosure: rec ? disclosure(rec, review) : null, authors,
++ decision: publication.decide(story, r),
++ first_published_at: iso(story.first_published_at), updated_at: r.createdAt, published_at: iso(story.published_at),
++ };
++ }
++
++ /** The .json twin of a story page: the same content, provenance and indexability. */
++ function storyJson(m) {
++ return {
++ id: m.story.id, url: m.url, state: m.story.state, revision: m.rev.number,
++ headline: m.headline, headline_sources: m.headline_sources,
++ topic: m.topic ? { slug: m.topic.slug, name: m.topic.name, url: publication.abs(publication.topicPath(m.topic)) } : null,
++ first_published_at: m.first_published_at, updated_at: m.updated_at,
++ authorship: m.authorship ? { mode: m.authorship.mode, workflow: m.authorship.workflow ? m.authorship.workflow.id : null, reviewed_by_person: Boolean(m.review && m.review.decision === 'approved'), disclosure: m.disclosure ? m.disclosure.long : null } : null,
++ authors: m.authors,
++ claims: m.paragraphs.map((p) => ({ text: p.text, sources: p.sources, supported: p.supported })),
++ sources: m.sources,
++ timeline: m.timeline,
++ perspectives: m.perspectives,
++ corrections: m.corrections,
++ retraction: m.retraction,
++ pending_upstream_changes: m.upstream,
++ indexability: { indexable: m.decision.indexable, robots: m.decision.robots, reasons: m.decision.reasons },
++ };
++ }
++
++ /** schema.org NewsArticle from real fields only (openvibe-publishing/seo omits what is missing). */
++ function storyJsonLd(m) {
++ return seo.structuredData.article({
++ type: 'NewsArticle', headline: m.headline, url: m.url,
++ description: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 300) : undefined,
++ datePublished: m.first_published_at || undefined, dateModified: m.updated_at || undefined,
++ authors: m.authors.map((a) => ({ name: a.name })),
++ publisher: { name: 'OpenVibe.News', url: config.baseUrl },
++ section: m.topic ? m.topic.name : undefined, inLanguage: 'en',
++ citations: m.sources.filter((s) => s.status !== 'removed' && s.url).map((s) => ({ url: s.url, title: s.headline })),
++ });
++ }
++
++ /** Feed items for published (and retracted, labelled) stories: our own text only, never a source's. */
++ async function feedItems({ topicId = null, limit = 30 } = {}) {
++ const { stories: rows } = stories.listPublished({ topicId, limit });
++ const out = [];
++ for (const story of rows) {
++ const m = await storyModel(story);
++ if (!m) continue;
++ out.push({
++ id: publication.feedId(story), url: m.url,
++ title: m.retraction ? `Retracted: ${m.headline}` : m.headline,
++ summary: m.retraction ? `This story was retracted: ${m.retraction.note}` : (m.paragraphs[0] ? m.paragraphs[0].text : null),
++ published: story.first_published_at, updated: m.updated_at,
++ authors: m.authors.map((a) => ({ name: a.name })), tags: m.topic ? [m.topic.name] : [],
++ decision: m.decision,
++ });
++ }
++ return out;
++ }
++
++ /** Listing entries for the home and topic pages. */
++ async function listItems(rows) {
++ const out = [];
++ for (const story of rows) {
++ const r = store.revisions.get(story.id, story.published_revision);
++ if (!r) continue;
++ const paragraphs = publication.paragraphsOf(r);
++ const topic = publication.topicOf(story);
++ out.push({
++ story, headline: r.fields.headline, url: publication.storyPath(story), lede: paragraphs[0] ? paragraphs[0].text : '',
++ sourceCount: publication.snapshotOf(r).length, outlets: [...new Set(publication.snapshotOf(r).map((s) => s.outlet))].slice(0, 4),
++ topic: topic ? { name: topic.name, url: publication.topicPath(topic) } : null, retracted: story.state === 'retracted',
++ published_at: story.first_published_at, updated_at: r.createdAt,
++ });
++ }
++ return out;
++ }
++
++ return { storyModel, storyJson, storyJsonLd, feedItems, listItems };
++}
++
++module.exports = { createReading };
ddiff --git a/server/domain/stories.js b/server/domain/stories.js
nnew file mode 100644
iindex 0000000..e5e824e
---- /dev/null
++++ b/server/domain/stories.js
@@@ -0,0 +1,655 @@
++'use strict';
++
++/**
++ * Stories: the editorial layer. An editor opens a story from a cluster, attaches source items
++ * (the source table), writes paragraphs that each cite one or more of them, adds dated timeline
++ * entries (each resting on a source) and groups sources into perspectives with labels the editor
++ * writes. Every save is an immutable revision (openvibe-publishing/revisions, prefix news_story)
++ * that snapshots the text, the source table, the timeline and the perspectives, so what readers
++ * see is exactly one revision and never changes behind their back.
++ *
++ * Rules enforced here (tests: test/stories.test.js, test/upstream.test.js):
++ * - every paragraph cites ≥ 1 attached source; a revision with an uncited paragraph is refused
++ * - a story with zero sources cannot be published; neither can a revision that cites a source
++ * removed upstream or detached since, nor an AI revision no person has approved
++ * - when a cited source changes or is removed upstream, the story gets an editorial flag and a
++ * pending revision (system-prepared, same text, refreshed source table); the published
++ * revision stays as it is, with a visible notice, until an editor publishes a new one
++ * - a retraction keeps the story at its URL with a retraction notice; it is noindex and leaves
++ * sitemaps and Search
++ * - corrections and updates are flags published with the revision they belong to: the story's
++ * public correction history
++ */
++const { ids } = require('openvibe-contracts');
++const { slugify } = require('openvibe-publishing/taxonomy');
++const authorship = require('openvibe-publishing/authorship');
++const { ApiError } = require('../http/errors');
++const { actorRef, canPublish } = require('./publication');
++const access = require('./access');
++
++const MAX_PARAGRAPHS = 60;
++const MAX_PARAGRAPH = 3000;
++const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,99}$/;
++const MARKER = /\[(\d{1,3}(?:\s*,\s*\d{1,3})*)\]/g;
++const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2}))?$/;
++const AI_WORKFLOWS = ['news.summarize_story', 'news.compare_perspectives'];
++
++const newStoryId = (now) => `sty_${ids.ulid(now)}`;
++const newFlagId = (now) => `flg_${ids.ulid(now)}`;
++const newPerspectiveId = (now) => `per_${ids.ulid(now)}`;
++const newTimelineId = (now) => `tle_${ids.ulid(now)}`;
++const parseJson = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
++const clean = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
++
++/** "Text [1, 3]" paragraphs ↔ { text, sources: [1, 3] }. */
++function parseBody(body) {
++ const blocks = String(body == null ? '' : body).replace(/\r\n/g, '\n').split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean);
++ return blocks.map((b) => {
++ const sources = [];
++ for (const m of b.matchAll(MARKER)) for (const n of m[1].split(',')) sources.push(parseInt(n, 10));
++ return { text: b.replace(MARKER, '').replace(/\s+/g, ' ').replace(/\s+([.,;:!?])/g, '$1').trim(), sources: [...new Set(sources)] };
++ });
++}
++function formatBody(paragraphs) {
++ return (paragraphs || []).map((p) => `${p.text}${p.sources && p.sources.length ? ` [${p.sources.join(', ')}]` : ''}`).join('\n\n');
++}
++
++function createStories({ store, config, publication, clusters, outbox, ai = null, log = console }) {
++ const { db } = store;
++ const q = {
++ byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
++ bySlug: db.prepare('SELECT * FROM news_stories WHERE slug = ?'),
++ item: db.prepare('SELECT * FROM news_source_items WHERE id = ?'),
++ itemBySources: db.prepare('SELECT * FROM news_source_items WHERE sources_item_id = ?'),
++ topicBySlug: db.prepare("SELECT * FROM news_topics WHERE slug = ? AND status = 'active'"),
++ topicById: db.prepare('SELECT * FROM news_topics WHERE id = ?'),
++ links: db.prepare('SELECT * FROM news_story_sources WHERE story_id = ? ORDER BY position'),
++ link: db.prepare('SELECT * FROM news_story_sources WHERE story_id = ? AND source_item_id = ?'),
++ maxPos: db.prepare('SELECT COALESCE(MAX(position), 0) AS n FROM news_story_sources WHERE story_id = ?'),
++ storiesCiting: db.prepare('SELECT DISTINCT story_id FROM news_story_sources WHERE source_item_id = ? AND detached_at IS NULL'),
++ perspectives: db.prepare('SELECT * FROM news_perspectives WHERE story_id = ? AND removed_at IS NULL ORDER BY position, created_at, rowid'),
++ perspective: db.prepare('SELECT * FROM news_perspectives WHERE id = ? AND story_id = ?'),
++ timeline: db.prepare('SELECT * FROM news_timeline_entries WHERE story_id = ? AND removed_at IS NULL ORDER BY occurred_on, created_at, rowid'),
++ flags: db.prepare('SELECT * FROM news_editorial_flags WHERE story_id = ? ORDER BY created_at, rowid'),
++ openFlag: db.prepare("SELECT * FROM news_editorial_flags WHERE story_id = ? AND source_item_id = ? AND kind = ? AND status = 'open' ORDER BY created_at DESC LIMIT 1"),
++ insertFlag: db.prepare(`INSERT INTO news_editorial_flags (id, story_id, kind, status, note, source_item_id, pending_revision, revision, created_by, created_at, resolved_by, resolved_at)
++ VALUES (@id, @story_id, @kind, @status, @note, @source_item_id, @pending_revision, @revision, @created_by, @now, @resolved_by, @resolved_at)`),
++ };
++
++ const touch = (id) => db.prepare('UPDATE news_stories SET updated_at = ? WHERE id = ?').run(store.now(), id);
++
++ function mustItem(ref) {
++ const r = String(ref || '').trim();
++ const it = r.startsWith('itm_') ? q.itemBySources.get(r) : q.item.get(r);
++ if (!it) throw new ApiError(404, 'source_item.not_found', `No source item ${r}`);
++ return it;
++ }
++
++ function topicFrom(v) {
++ if (v == null || v === '') return null;
++ const s = String(v);
++ const t = s.startsWith('top_') ? q.topicById.get(s) : q.topicBySlug.get(s);
++ if (!t) throw new ApiError(422, 'topic.not_found', `No active topic ${s}`);
++ return t;
++ }
++
++ function slugFor(wanted, storyId = null) {
++ let base;
++ try { base = slugify(wanted).slice(0, 90); } catch { throw new ApiError(422, 'story.invalid_slug', 'That headline has no letters or digits to make a URL from'); }
++ if (!SLUG_RE.test(base)) throw new ApiError(422, 'story.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, 85)}-${i}`;
++ const hit = q.bySlug.get(s);
++ if (!hit || hit.id === storyId) return s;
++ }
++ throw new ApiError(409, 'story.slug_taken', 'No free slug for that headline');
++ }
++
++ function actingEditor(viewer) { return access.requireEditor(config, viewer); }
++
++ // ── The working source table, timeline and perspectives ─
++
++ /** Attached sources with their items, in citation order: [{ n, item, perspective_id, detached_at }]. */
++ function sourcesOf(story, { includeDetached = false } = {}) {
++ return q.links.all(story.id).filter((l) => includeDetached || !l.detached_at).map((l) => ({ n: l.position, item: q.item.get(l.source_item_id), perspective_id: l.perspective_id, detached_at: l.detached_at, added_by: l.added_by }));
++ }
++
++ function sourceEntry(n, item, perspectiveId) {
++ return {
++ n, id: item.id, sources_item_id: item.sources_item_id, sources_revision: item.sources_revision,
++ headline: item.headline, url: item.canonical_url, outlet: item.outlet, authors: parseJson(item.authors, []),
++ published_at: item.published_at, retrieved_at: item.retrieved_at, summary: item.summary, status: item.status,
++ ...(item.status === 'removed' ? { removed_reason: item.removed_reason } : {}),
++ perspective: perspectiveId || null,
++ };
++ }
++
++ /** What the next revision snapshots. */
++ function buildSnapshot(story) {
++ const srcs = sourcesOf(story);
++ const byItem = new Map(srcs.map((s) => [s.item.id, s.n]));
++ return {
++ sources: srcs.map((s) => sourceEntry(s.n, s.item, s.perspective_id)),
++ timeline: q.timeline.all(story.id).filter((t) => byItem.has(t.source_item_id)).map((t) => ({ id: t.id, occurred_on: t.occurred_on, text: t.text, source: byItem.get(t.source_item_id) })),
++ perspectives: q.perspectives.all(story.id).map((p) => ({ id: p.id, label: p.label, description: p.description, sources: srcs.filter((s) => s.perspective_id === p.id).map((s) => s.n) })),
++ };
++ }
++
++ function attachCitations(story, rev) {
++ const byN = new Map((rev.fields.sources || []).map((s) => [s.n, s]));
++ const list = [];
++ (rev.fields.paragraphs || []).forEach((p, i) => {
++ for (const n of p.sources || []) {
++ const s = byN.get(n);
++ if (s) list.push({ anchor: `p${i + 1}`, sourceItemId: s.sources_item_id, url: s.url || null, title: s.headline, retrievedAt: s.retrieved_at || null, attachedBy: 'svc:news' });
++ }
++ });
++ for (const t of rev.fields.timeline || []) {
++ const s = byN.get(t.source);
++ if (s) list.push({ anchor: `t:${t.id}`, sourceItemId: s.sources_item_id, url: s.url || null, title: s.headline, retrievedAt: s.retrieved_at || null, attachedBy: 'svc:news' });
++ }
++ if (list.length) store.citations.attachMany(story.id, rev.number, list.slice(0, 500));
++ }
++
++ function authorshipFor(viewer, input, previous) {
++ if (access.isAiDelivery(viewer)) {
++ const a = input.authorship || {};
++ const wf = a.workflow || {};
++ if (!AI_WORKFLOWS.includes(wf.id)) throw new ApiError(422, 'story.ai_workflow_required', `AI drafts name their workflow: ${AI_WORKFLOWS.join(' or ')}`);
++ return authorship.record({ mode: 'ai', workflow: { id: wf.id, runId: String(wf.runId || wf.run_id || ''), version: wf.version, model: wf.model }, stubProvider: Boolean(a.stubProvider || a.stub_provider), source: { label: 'the story’s source items' } });
++ }
++ if (previous && (previous.mode === 'ai' || previous.mode === 'hybrid') && previous.workflow) {
++ return authorship.record({ mode: 'hybrid', authors: [viewer.subject], workflow: previous.workflow });
++ }
++ return authorship.record({ mode: 'human', authors: [viewer.subject] });
++ }
++
++ function authorLabel(viewer) {
++ if (viewer.kind === 'service') return viewer.origin === 'ai' ? `${viewer.service} (ai)` : `${viewer.service} for ${viewer.subject}`;
++ return viewer.subject;
++ }
++
++ /** Normalise and check paragraphs against the attached (not detached) source numbers. */
++ function checkParagraphs(paragraphs, attached) {
++ if (!Array.isArray(paragraphs) || !paragraphs.length) throw new ApiError(422, 'story.no_text', 'A revision needs at least one paragraph');
++ if (paragraphs.length > MAX_PARAGRAPHS) throw new ApiError(422, 'story.too_long', `At most ${MAX_PARAGRAPHS} paragraphs`);
++ return paragraphs.map((p, i) => {
++ const text = clean(p && p.text, MAX_PARAGRAPH + 1);
++ if (!text) throw new ApiError(422, 'story.empty_paragraph', `Paragraph ${i + 1} is empty`);
++ if (text.length > MAX_PARAGRAPH) throw new ApiError(422, 'story.paragraph_too_long', `Paragraph ${i + 1} is longer than ${MAX_PARAGRAPH} characters`);
++ const sources = [...new Set((Array.isArray(p.sources) ? p.sources : []).map((n) => parseInt(n, 10)).filter(Number.isInteger))].sort((a, b) => a - b);
++ if (!sources.length) throw new ApiError(422, 'story.claim_unsourced', `Paragraph ${i + 1} cites no source: end it with the source numbers it rests on, e.g. [1] or [1, 3]`);
++ for (const n of sources) if (!attached.has(n)) throw new ApiError(422, 'story.unknown_source', `Paragraph ${i + 1} cites [${n}], which is not in this story's source table`);
++ return { text, sources };
++ });
++ }
++
++ /** Paragraphs from the API (by item id or number) or from the form body (markers). */
++ function paragraphsFrom(story, input) {
++ if (Array.isArray(input.paragraphs)) {
++ const numberOf = new Map(sourcesOf(story).map((s) => [s.item.id, s.n]).concat(sourcesOf(story).map((s) => [s.item.sources_item_id, s.n])));
++ return input.paragraphs.map((p) => ({ text: p && p.text, sources: (Array.isArray(p && p.sources) ? p.sources : []).map((x) => (typeof x === 'number' ? x : numberOf.get(String(x)) || -1)) }));
++ }
++ if (input.body !== undefined) return parseBody(input.body);
++ return null;
++ }
++
++ /** Everything that stands between a revision and publication. → [{ code, detail }] */
++ function problems(story, rev) {
++ const out = [];
++ if (!rev) return [{ code: 'story.no_text', detail: 'Nothing has been written yet' }];
++ const snap = rev.fields.sources || [];
++ if (!snap.length) out.push({ code: 'story.unsourced', detail: 'A story with zero sources cannot be published: attach the source items it rests on' });
++ const paragraphs = rev.fields.paragraphs || [];
++ if (!paragraphs.length) out.push({ code: 'story.no_text', detail: 'The revision has no paragraphs' });
++ const live = publication.liveSources(rev);
++ const links = new Map(q.links.all(story.id).map((l) => [l.source_item_id, l]));
++ const cited = new Set();
++ paragraphs.forEach((p, i) => {
++ if (!p.sources || !p.sources.length) out.push({ code: 'story.claim_unsourced', detail: `Paragraph ${i + 1} cites no source` });
++ for (const n of p.sources || []) cited.add(n);
++ });
++ for (const t of rev.fields.timeline || []) cited.add(t.source);
++ for (const n of [...cited].sort((a, b) => a - b)) {
++ const entry = snap.find((s) => s.n === n);
++ const it = live.get(n);
++ if (!entry || !it) { out.push({ code: 'story.unknown_source', detail: `[${n}] is not in the revision's source table` }); continue; }
++ if (it.status === 'removed') out.push({ code: 'story.source_removed', detail: `[${n}] (${it.outlet}) was removed upstream: ${it.removed_reason || 'no reason given'} — revise the text so nothing rests on it` });
++ const link = links.get(it.id);
++ if (!link || link.detached_at) out.push({ code: 'story.source_detached', detail: `[${n}] was detached from the story` });
++ }
++ const rec = publication.authorshipOf(rev) || { mode: 'human' };
++ const ok = canPublish(rec, publication.reviewOf(story, rev));
++ if (!ok.ok) out.push({ code: 'story.review_required', detail: `Revision ${rev.number} is ${rec.mode === 'hybrid' ? 'AI-assisted' : 'AI-generated'} (${ok.reason}): a person must review and approve it first` });
++ if (!clean(rev.fields.headline, 300)) out.push({ code: 'story.no_headline', detail: 'The revision has no headline' });
++ return out;
++ }
++
++ function flagRow(story, { kind, status = 'open', note = null, sourceItemId = null, pendingRevision = null, revision = null, createdBy, resolvedBy = null, resolvedAt = null }) {
++ const now = store.now();
++ const id = newFlagId(now);
++ q.insertFlag.run({ id, story_id: story.id, kind, status, note, source_item_id: sourceItemId, pending_revision: pendingRevision, revision, created_by: createdBy, now, resolved_by: resolvedBy, resolved_at: resolvedAt });
++ return db.prepare('SELECT * FROM news_editorial_flags WHERE id = ?').get(id);
++ }
++
++ function flagEvent(story, flag, traceparent) {
++ outbox.emit({
++ event_type: 'news.story.flagged', actor: flag.created_by === 'svc:news' ? { type: 'service', id: 'news' } : actorRef(flag.created_by),
++ visibility: 'internal', priority: flag.kind === 'source_removed' ? 'important' : 'normal',
++ subject: { type: 'story', id: story.id, revision: flag.pending_revision || story.published_revision || 0 },
++ payload: { flag_id: flag.id, kind: flag.kind, status: flag.status, source_item_id: flag.source_item_id, pending_revision: flag.pending_revision, published_revision: story.published_revision },
++ }, { traceparent });
++ }
++
++ const api = {
++ parseBody, formatBody, AI_WORKFLOWS,
++
++ get: (id) => q.byId.get(String(id || '')) || null,
++ bySlug: (slug) => q.bySlug.get(String(slug || '')) || null,
++ mustGet(id) {
++ const s = api.get(id);
++ if (!s) throw new ApiError(404, 'story.not_found', 'No such story');
++ return s;
++ },
++ sources: sourcesOf,
++ buildSnapshot,
++ perspectives: (story) => q.perspectives.all(story.id),
++ timeline: (story) => q.timeline.all(story.id),
++ flags: (story) => q.flags.all(story.id),
++ publicFlags: (story) => q.flags.all(story.id).filter((f) => f.status === 'published' && ['correction', 'update', 'retraction'].includes(f.kind)),
++ openUpstreamFlags: (story) => q.flags.all(story.id).filter((f) => f.status === 'open' && (f.kind === 'source_updated' || f.kind === 'source_removed')),
++ head: (story) => store.revisions.head(story.id),
++ revision: (story, n) => store.revisions.get(story.id, n),
++ revisions: (story, opts) => store.revisions.list(story.id, opts),
++ diff: (story, from, to, mode) => store.revisions.diff(story.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
++ problems,
++
++ /** Published and retracted stories (retracted ones stay listed with their notice). */
++ listPublished({ topicId = null, limit = 20, offset = 0 } = {}) {
++ const where = ["state IN ('published','retracted')"];
++ const args = [];
++ if (topicId) { where.push('topic_id = ?'); args.push(topicId); }
++ const w = where.join(' AND ');
++ const total = db.prepare(`SELECT COUNT(*) AS n FROM news_stories WHERE ${w}`).get(...args).n;
++ const rows = db.prepare(`SELECT * FROM news_stories WHERE ${w} ORDER BY published_at DESC, id DESC LIMIT ? OFFSET ?`).all(...args, limit, offset);
++ return { total, stories: rows };
++ },
++
++ /** Every story, for editors. */
++ listAll({ limit = 200 } = {}) {
++ return db.prepare('SELECT * FROM news_stories ORDER BY updated_at DESC, id DESC LIMIT ?').all(limit);
++ },
++ byCluster: (clusterId) => db.prepare('SELECT * FROM news_stories WHERE cluster_id = ? ORDER BY created_at').all(clusterId),
++
++ /**
++ * An editor opens a story from a cluster: the cluster's live, non-duplicate items are
++ * attached as the first sources. Optional first text (headline + body) becomes revision 1.
++ */
++ create(viewer, input = {}, { traceparent } = {}) {
++ const subject = actingEditor(viewer);
++ const headline = clean(input.headline, 201);
++ if (!headline) throw new ApiError(422, 'story.no_headline', 'A story needs a working headline');
++ if (headline.length > 200) throw new ApiError(422, 'story.no_headline', 'A headline is at most 200 characters');
++ let cluster = null;
++ if (input.cluster || input.clusterId || input.cluster_id) {
++ cluster = clusters.get(input.cluster || input.clusterId || input.cluster_id);
++ if (!cluster) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
++ cluster = clusters.resolve(cluster.id);
++ }
++ const topic = topicFrom(input.topic);
++ return store.tx(() => {
++ const now = store.now();
++ const id = newStoryId(now);
++ const slug = slugFor(input.slug || headline);
++ db.prepare(`INSERT INTO news_stories (id, slug, working_headline, cluster_id, topic_id, state, noindex, created_by, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, 'draft', 0, ?, ?, ?)`).run(id, slug, headline, cluster ? cluster.id : null, topic ? topic.id : null, subject, now, now);
++ const story = q.byId.get(id);
++ if (cluster) {
++ const items = clusters.members(cluster.id).filter((it) => it.status === 'active')
++ .sort((a, b) => String(a.published_at || '').localeCompare(String(b.published_at || '')) || a.first_seen_at - b.first_seen_at || (a.id < b.id ? -1 : 1));
++ items.forEach((it, i) => db.prepare('INSERT INTO news_story_sources (story_id, source_item_id, position, added_by, added_at) VALUES (?, ?, ?, ?, ?)').run(id, it.id, i + 1, subject, now));
++ }
++ outbox.emit({
++ event_type: 'news.story.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
++ subject: { type: 'story', id }, payload: { cluster_id: cluster ? cluster.id : null, topic: topic ? topic.slug : null, sources: sourcesOf(story).length },
++ }, { traceparent });
++ let revision = null;
++ if (input.body !== undefined || Array.isArray(input.paragraphs)) {
++ revision = api.revise(viewer, story, { headline, body: input.body, paragraphs: input.paragraphs, expectedRevision: 0, message: 'First draft' }).revision;
++ }
++ return { story: q.byId.get(id), revision };
++ });
++ },
++
++ attach(viewer, story, { item }) {
++ const subject = actingEditor(viewer);
++ const it = mustItem(item);
++ if (it.status === 'removed') throw new ApiError(409, 'source_item.removed', `${it.id} was removed upstream (${it.removed_reason || 'no reason given'}) and cannot be cited`);
++ return store.tx(() => {
++ const link = q.link.get(story.id, it.id);
++ if (link && !link.detached_at) return { n: link.position, item: it, created: false };
++ if (link) db.prepare('UPDATE news_story_sources SET detached_at = NULL WHERE story_id = ? AND source_item_id = ?').run(story.id, it.id);
++ else db.prepare('INSERT INTO news_story_sources (story_id, source_item_id, position, added_by, added_at) VALUES (?, ?, ?, ?, ?)').run(story.id, it.id, q.maxPos.get(story.id).n + 1, subject, store.now());
++ touch(story.id);
++ return { n: q.link.get(story.id, it.id).position, item: it, created: true };
++ });
++ },
++
++ /** Detach a source (its number is never reused). The next revision must not cite it. */
++ detach(viewer, story, item) {
++ actingEditor(viewer);
++ const it = mustItem(item);
++ const link = q.link.get(story.id, it.id);
++ if (!link || link.detached_at) throw new ApiError(404, 'story.source_not_attached', `${it.id} is not attached to this story`);
++ db.prepare('UPDATE news_story_sources SET detached_at = ? WHERE story_id = ? AND source_item_id = ?').run(store.now(), story.id, it.id);
++ db.prepare('UPDATE news_timeline_entries SET removed_at = ? WHERE story_id = ? AND source_item_id = ? AND removed_at IS NULL').run(store.now(), story.id, it.id);
++ touch(story.id);
++ return { detached: true, n: link.position };
++ },
++
++ addPerspective(viewer, story, { label, description = null }) {
++ const subject = actingEditor(viewer);
++ const l = clean(label, 81);
++ if (!l) throw new ApiError(422, 'perspective.no_label', 'A perspective needs a label an editor writes (for example the outlet group or the position it states)');
++ if (l.length > 80) throw new ApiError(422, 'perspective.label_too_long', 'A label is at most 80 characters');
++ const now = store.now();
++ const id = newPerspectiveId(now);
++ const pos = q.perspectives.all(story.id).length;
++ db.prepare('INSERT INTO news_perspectives (id, story_id, label, description, position, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(id, story.id, l, description ? clean(description, 500) : null, pos, subject, now, now);
++ touch(story.id);
++ return q.perspective.get(id, story.id);
++ },
++
++ removePerspective(viewer, story, perspectiveId) {
++ actingEditor(viewer);
++ const p = q.perspective.get(String(perspectiveId || ''), story.id);
++ if (!p || p.removed_at) throw new ApiError(404, 'perspective.not_found', 'No such perspective');
++ store.tx(() => {
++ db.prepare('UPDATE news_perspectives SET removed_at = ?, updated_at = ? WHERE id = ?').run(store.now(), store.now(), p.id);
++ db.prepare('UPDATE news_story_sources SET perspective_id = NULL WHERE story_id = ? AND perspective_id = ?').run(story.id, p.id);
++ });
++ touch(story.id);
++ return { removed: true };
++ },
++
++ /** Put an attached source in a perspective (or none). */
++ assignPerspective(viewer, story, item, perspectiveId) {
++ actingEditor(viewer);
++ const it = mustItem(item);
++ const link = q.link.get(story.id, it.id);
++ if (!link || link.detached_at) throw new ApiError(404, 'story.source_not_attached', `${it.id} is not attached to this story`);
++ let pid = null;
++ if (perspectiveId) {
++ const p = q.perspective.get(String(perspectiveId), story.id);
++ if (!p || p.removed_at) throw new ApiError(404, 'perspective.not_found', 'No such perspective');
++ pid = p.id;
++ }
++ db.prepare('UPDATE news_story_sources SET perspective_id = ? WHERE story_id = ? AND source_item_id = ?').run(pid, story.id, it.id);
++ touch(story.id);
++ return { n: link.position, perspective_id: pid };
++ },
++
++ /** A dated entry resting on one attached source. The date is what the source states. */
++ addTimeline(viewer, story, { occurredOn, text: entryText, source }) {
++ const subject = actingEditor(viewer);
++ const on = String(occurredOn || '').trim();
++ if (!DATE_RE.test(on) || !Number.isFinite(Date.parse(on))) throw new ApiError(422, 'timeline.invalid_date', 'occurred_on must be a date the source states (YYYY-MM-DD or an ISO 8601 time)');
++ const t = clean(entryText, 501);
++ if (!t) throw new ApiError(422, 'timeline.no_text', 'A timeline entry needs text');
++ if (t.length > 500) throw new ApiError(422, 'timeline.too_long', 'A timeline entry is at most 500 characters');
++ let it;
++ if (/^\d+$/.test(String(source))) {
++ const s = sourcesOf(story).find((x) => x.n === parseInt(source, 10));
++ if (!s) throw new ApiError(422, 'timeline.unknown_source', `[${source}] is not in this story's source table`);
++ it = s.item;
++ } else {
++ it = mustItem(source);
++ const link = q.link.get(story.id, it.id);
++ if (!link || link.detached_at) throw new ApiError(422, 'timeline.unknown_source', 'The source must be attached to the story');
++ }
++ if (it.status === 'removed') throw new ApiError(409, 'source_item.removed', 'That source was removed upstream');
++ const now = store.now();
++ const id = newTimelineId(now);
++ db.prepare('INSERT INTO news_timeline_entries (id, story_id, occurred_on, text, source_item_id, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, story.id, on, t, it.id, subject, now);
++ touch(story.id);
++ return db.prepare('SELECT * FROM news_timeline_entries WHERE id = ?').get(id);
++ },
++
++ removeTimeline(viewer, story, entryId) {
++ actingEditor(viewer);
++ const r = db.prepare('UPDATE news_timeline_entries SET removed_at = ? WHERE id = ? AND story_id = ? AND removed_at IS NULL').run(store.now(), String(entryId || ''), story.id);
++ if (!r.changes) throw new ApiError(404, 'timeline.not_found', 'No such timeline entry');
++ touch(story.id);
++ return { removed: true };
++ },
++
++ /**
++ * A new revision: headline + paragraphs (each citing ≥ 1 attached source) + the snapshot of
++ * the source table, timeline and perspectives. expectedRevision is required once a revision
++ * exists (412 revision.conflict on a stale base). Editors write human (or, after an AI draft,
++ * hybrid) revisions; an AI delivery writes an AI revision that needs a person's review.
++ */
++ revise(viewer, story, input = {}, { traceparent } = {}) {
++ const ai = access.isAiDelivery(viewer);
++ if (!ai) actingEditor(viewer);
++ if (story.state === 'retracted') throw new ApiError(409, 'story.retracted', 'A retracted story is not revised; open a new story');
++ return store.tx(() => {
++ const head = store.revisions.head(story.id);
++ const expected = input.expectedRevision ?? input.expected_revision;
++ if (head && (expected == null || expected === '')) throw new ApiError(428, 'revision.expected_required', 'Send expectedRevision (the revision you edited) so concurrent edits are not lost');
++ const headline = input.headline !== undefined && input.headline !== null ? clean(input.headline, 201) : (head ? head.fields.headline : story.working_headline);
++ if (!headline) throw new ApiError(422, 'story.no_headline', 'A revision needs a headline');
++ if (headline.length > 200) throw new ApiError(422, 'story.no_headline', 'A headline is at most 200 characters');
++ const attached = new Set(sourcesOf(story).map((s) => s.n));
++ const given = paragraphsFrom(story, input);
++ const paragraphs = checkParagraphs(given || (head ? head.fields.paragraphs : []), attached);
++ const snap = buildSnapshot(story);
++ const prevRec = head ? publication.authorshipOf(head) : null;
++ const rec = authorshipFor(viewer, input, prevRec);
++ const meta = { authorship: rec };
++ if (ai && Array.isArray(input.gaps) && input.gaps.length) meta.gaps = input.gaps.map((g) => clean(g, 300)).slice(0, 20);
++ const out = store.revisions.create({
++ entityId: story.id, expectedRevision: head ? parseInt(expected, 10) : 0,
++ content: formatBody(paragraphs), fields: { headline, paragraphs, ...snap }, meta,
++ author: authorLabel(viewer), message: input.message ? clean(input.message, 200) : null,
++ });
++ if (out.created) attachCitations(story, out.revision);
++ const sets = { working_headline: headline };
++ if (!ai && input.topic !== undefined) { const t = topicFrom(input.topic); sets.topic_id = t ? t.id : null; }
++ if (!ai && input.noindex !== undefined) sets.noindex = input.noindex === true || input.noindex === '1' || input.noindex === 1 ? 1 : 0;
++ const keys = Object.keys(sets).filter((k) => k !== 'working_headline');
++ db.prepare(`UPDATE news_stories SET ${Object.keys(sets).map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`).run({ ...sets, now: store.now(), id: story.id });
++ if (keys.length && story.state === 'published') publication.afterChange(publication.snapshot(story), story.id, { actor: viewer, traceparent });
++ return { revision: out.revision, created: out.created, story: q.byId.get(story.id) };
++ });
++ },
++
++ /**
++ * Publish revision N (default: the head). Refused while anything in problems() stands.
++ * Open correction/update flags are published with it; upstream flags the revision answers
++ * are resolved. Republishing after a source was removed needs a correction or update note.
++ */
++ publish(viewer, story, { revision, correction } = {}, { traceparent } = {}) {
++ actingEditor(viewer);
++ if (story.state === 'retracted') throw new ApiError(409, 'story.retracted', 'A retracted story stays retracted; open a new story');
++ return store.tx(() => {
++ const cur = q.byId.get(story.id);
++ const rev = revision == null || revision === '' ? store.revisions.head(story.id) : store.revisions.get(story.id, parseInt(revision, 10));
++ if (!rev && revision != null && revision !== '') throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
++ const found = problems(cur, rev);
++ if (found.length) {
++ const status = found.some((p) => p.code === 'story.review_required' || p.code === 'story.source_removed' || p.code === 'story.source_detached') ? 409 : 422;
++ throw new ApiError(status, found[0].code, found.map((p) => p.detail).join(' · '), { problems: found });
++ }
++ if (cur.state === 'published' && cur.published_revision === rev.number && !correction) return { changed: false, story: cur };
++ const citedIds = new Set();
++ for (const p of rev.fields.paragraphs) for (const n of p.sources) { const s = rev.fields.sources.find((x) => x.n === n); if (s) citedIds.add(s.id); }
++ const upstream = q.flags.all(story.id).filter((f) => f.status === 'open' && (f.kind === 'source_updated' || f.kind === 'source_removed'));
++ const answers = (f) => {
++ const it = q.item.get(f.source_item_id);
++ const entry = rev.fields.sources.find((s) => s.id === f.source_item_id);
++ if (f.kind === 'source_removed') return !citedIds.has(f.source_item_id) && !(rev.fields.timeline || []).some((t) => entry && t.source === entry.n);
++ return !entry || !it || entry.sources_revision >= it.sources_revision;
++ };
++ const resolving = upstream.filter(answers);
++ const note = correction && correction.note ? clean(correction.note, 2001) : '';
++ const kind = correction && correction.kind ? String(correction.kind) : (note ? 'correction' : null);
++ if (kind && !['correction', 'update'].includes(kind)) throw new ApiError(422, 'flag.invalid_kind', 'kind is correction or update');
++ if (note.length > 2000) throw new ApiError(422, 'flag.note_too_long', 'A note is at most 2000 characters');
++ if (cur.first_published_at && resolving.some((f) => f.kind === 'source_removed') && !note && !q.flags.all(story.id).some((f) => f.status === 'open' && (f.kind === 'correction' || f.kind === 'update') && f.note)) {
++ throw new ApiError(422, 'story.correction_note_required', 'A source this story cited was removed upstream: say what changed in a correction or update note readers will see');
++ }
++ const now = store.now();
++ const before = publication.snapshot(cur);
++ db.prepare(`UPDATE news_stories SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
++ published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, story.id);
++ const subject = viewer.subject;
++ for (const f of q.flags.all(story.id).filter((x) => x.status === 'open' && (x.kind === 'correction' || x.kind === 'update'))) {
++ db.prepare("UPDATE news_editorial_flags SET status = 'published', revision = ?, resolved_by = ?, resolved_at = ? WHERE id = ?").run(rev.number, subject, now, f.id);
++ }
++ if (note) flagRow(cur, { kind, status: 'published', note, revision: rev.number, createdBy: subject, resolvedBy: subject, resolvedAt: now });
++ for (const f of resolving) db.prepare("UPDATE news_editorial_flags SET status = 'resolved', revision = ?, resolved_by = ?, resolved_at = ? WHERE id = ?").run(rev.number, subject, now, f.id);
++ const out = publication.afterChange(before, story.id, { actor: viewer, traceparent });
++ return { changed: true, story: out.story, resolved: resolving.map((f) => f.id) };
++ });
++ },
++
++ unpublish(viewer, story, { traceparent } = {}) {
++ actingEditor(viewer);
++ return store.tx(() => {
++ const cur = q.byId.get(story.id);
++ if (cur.state !== 'published' && cur.state !== 'retracted') return { changed: false, story: cur };
++ const before = publication.snapshot(cur);
++ db.prepare("UPDATE news_stories SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), story.id);
++ return { changed: true, story: publication.afterChange(before, story.id, { actor: viewer, traceparent }).story };
++ });
++ },
++
++ /** Retract: the story stays at its URL with the retraction notice; noindex; out of Search. */
++ retract(viewer, story, { note } = {}, { traceparent } = {}) {
++ const subject = actingEditor(viewer);
++ const n = clean(note, 2001);
++ if (n.length < 10) throw new ApiError(422, 'story.retraction_note_required', 'A retraction says why, in at least a sentence readers will see');
++ if (n.length > 2000) throw new ApiError(422, 'flag.note_too_long', 'A note is at most 2000 characters');
++ return store.tx(() => {
++ const cur = q.byId.get(story.id);
++ if (cur.state === 'retracted') return { changed: false, story: cur };
++ if (cur.state !== 'published') throw new ApiError(409, 'story.not_published', 'Only a published story can be retracted');
++ const now = store.now();
++ const before = publication.snapshot(cur);
++ db.prepare("UPDATE news_stories SET state = 'retracted', retracted_at = ?, updated_at = ? WHERE id = ?").run(now, now, story.id);
++ const flag = flagRow(cur, { kind: 'retraction', status: 'published', note: n, revision: cur.published_revision, createdBy: subject, resolvedBy: subject, resolvedAt: now });
++ const out = publication.afterChange(before, story.id, { actor: viewer, traceparent, extra: { note: n, flag_id: flag.id } });
++ return { changed: true, story: out.story, flag };
++ });
++ },
++
++ /** A correction or update note, published with the next publication. */
++ addFlag(viewer, story, { kind, note } = {}) {
++ const subject = actingEditor(viewer);
++ if (!['correction', 'update'].includes(kind)) throw new ApiError(422, 'flag.invalid_kind', 'kind is correction or update (retractions use the retract action)');
++ const n = clean(note, 2001);
++ if (!n) throw new ApiError(422, 'flag.no_note', 'A correction or update note is public text: write it');
++ if (n.length > 2000) throw new ApiError(422, 'flag.note_too_long', 'A note is at most 2000 characters');
++ return flagRow(story, { kind, note: n, createdBy: subject });
++ },
++
++ /** A person's review of one revision (what lets an AI draft be published and indexed). */
++ review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {
++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
++ actingEditor(viewer);
++ const n = parseInt(revision, 10);
++ if (!store.revisions.get(story.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
++ return store.tx(() => {
++ const row = store.reviews.record({ entityId: story.id, revision: n, reviewer: viewer.subject, decision, note });
++ if (story.state === 'published') publication.syncIndex(q.byId.get(story.id), { traceparent });
++ return row;
++ });
++ },
++
++ /**
++ * A cited source changed (source_updated) or was removed (source_removed) upstream. For every
++ * story it is attached to: an open flag, and — when the story has text — a pending revision
++ * with the same paragraphs and the refreshed source entry. Nothing is published here.
++ * Runs inside the ingest transaction.
++ */
++ onUpstreamChange(item, kind) {
++ const affected = [];
++ for (const { story_id: sid } of q.storiesCiting.all(item.id)) {
++ const story = q.byId.get(sid);
++ if (!story) continue;
++ const open = q.openFlag.get(story.id, item.id, kind);
++ if (open && kind === 'source_removed') continue;
++ const head = store.revisions.head(story.id);
++ let pending = null;
++ if (head) {
++ const sources = (head.fields.sources || []).map((s) => (s.id === item.id ? sourceEntry(s.n, item, s.perspective) : s));
++ const fields = { ...head.fields, sources };
++ const out = store.revisions.create({
++ entityId: story.id, expectedRevision: head.number, content: head.content, fields,
++ meta: { authorship: head.meta.authorship, system: { reason: kind, source_item_id: item.id, sources_item_id: item.sources_item_id, sources_revision: item.sources_revision, based_on: head.number } },
++ author: 'svc:news',
++ message: kind === 'source_removed' ? `A cited source (${item.outlet}) was removed upstream: an editor must revise` : `A cited source (${item.outlet}) changed upstream: an editor must check the text`,
++ });
++ if (out.created) {
++ pending = out.revision.number;
++ store.citations.carryForward({ entityId: story.id, fromRevision: head.number, toRevision: pending, attachedBy: 'svc:news' });
++ }
++ }
++ let flag;
++ if (open) {
++ if (pending) db.prepare('UPDATE news_editorial_flags SET pending_revision = ? WHERE id = ?').run(pending, open.id);
++ flag = db.prepare('SELECT * FROM news_editorial_flags WHERE id = ?').get(open.id);
++ } else {
++ flag = flagRow(story, { kind, sourceItemId: item.id, pendingRevision: pending, revision: story.published_revision, createdBy: 'svc:news', note: kind === 'source_removed' ? (item.removed_reason || null) : null });
++ }
++ touch(story.id);
++ flagEvent(story, flag);
++ if (story.state === 'published') publication.syncIndex(q.byId.get(story.id));
++ affected.push({ story: story.id, flag: flag.id, pending_revision: pending });
++ log.warn && log.warn(`[News] ${kind} for ${item.id}: story ${story.id} flagged${pending ? `, pending revision ${pending}` : ''}`);
++ }
++ return affected;
++ },
++
++ /**
++ * Ask OpenVibe.AI for a draft (news.summarize_story / news.compare_perspectives). The result
++ * is an AI revision that cannot be published until a person approves it; a failure makes no text.
++ */
++ async aiDraft(viewer, story, workflow, { traceparent } = {}) {
++ actingEditor(viewer);
++ if (!ai || !ai.enabled) throw new ApiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured on this server; write the story from the source items');
++ const srcs = sourcesOf(story).filter((s) => s.item.status !== 'removed');
++ if (!srcs.length) throw new ApiError(422, 'story.unsourced', 'Attach source items first');
++ if (workflow === 'news.compare_perspectives' && srcs.length < 2) throw new ApiError(422, 'story.too_few_sources', 'Comparing perspectives needs at least two sources');
++ const topic = publication.topicOf(story);
++ const head = store.revisions.head(story.id);
++ let result;
++ try {
++ result = await ai.run(workflow, {
++ topic: head ? head.fields.headline : (topic ? topic.name : null),
++ sources: srcs.map((s) => ({ id: s.item.id, sources_item_id: s.item.sources_item_id, headline: s.item.headline, url: s.item.canonical_url, outlet: s.item.outlet,
++ authors: parseJson(s.item.authors, []), published_at: s.item.published_at, retrieved_at: s.item.retrieved_at, summary: s.item.summary })),
++ });
++ } catch (err) {
++ throw new ApiError(err.status || 502, err.code || 'ai.failed', err.message);
++ }
++ const { draftFromOutput } = require('../clients/ai');
++ const draft = draftFromOutput(workflow, result.output, srcs.map((s) => s.n));
++ if (!draft.paragraphs.length) throw new ApiError(422, 'ai.nothing_cited', `The ${workflow} run returned no cited text; no draft was made${draft.gaps.length ? ` (${draft.gaps.slice(0, 3).join('; ')})` : ''}`);
++ const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: null };
++ const fresh = q.byId.get(story.id);
++ const cur = store.revisions.head(story.id);
++ return api.revise(aiViewer, fresh, {
++ headline: draft.headline || (cur ? cur.fields.headline : fresh.working_headline),
++ paragraphs: draft.paragraphs, expectedRevision: cur ? cur.number : 0, gaps: draft.gaps,
++ authorship: { workflow: result.workflow, stubProvider: result.stub },
++ message: `AI draft (${workflow}, run ${result.runId}) requested by ${viewer.subject}`,
++ }, { traceparent });
++ },
++ };
++ return api;
++}
++
++module.exports = { createStories, parseBody, formatBody, AI_WORKFLOWS };
ddiff --git a/server/domain/text.js b/server/domain/text.js
nnew file mode 100644
iindex 0000000..4777a91
---- /dev/null
++++ b/server/domain/text.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++/**
++ * Deterministic text helpers for ingestion: URL keys and title keys for dedupe, word shingles for
++ * near-duplicate headlines, the terms and named entities clustering compares, and the licensing
++ * rule for source summaries. Pure functions: the same input always gives the same output, so every
++ * dedupe and cluster decision can be recomputed and explained.
++ */
++
++const STOPWORDS = new Set(('a about above after again against all am an and any are as at be because been before being below between both but by '
++ + 'can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its '
++ + 'itself just me more most my no nor not now of off on once only or other our ours out over own same she should so some such than that the '
++ + 'their theirs them then there these they this those through to too under until up very was we were what when where which while who whom '
++ + 'why will with would you your yours says said say new news report reports update updates live latest via amid over after more first year '
++ + 'years today yesterday week one two three four five six seven eight nine ten'
++).split(/\s+/));
++
++// Capitalised words that start sentences or headlines far more often than they name something.
++const NOT_ENTITIES = new Set(['the', 'a', 'an', 'in', 'on', 'at', 'as', 'how', 'why', 'what', 'when', 'where', 'who', 'new', 'breaking', 'update', 'live', 'watch', 'exclusive', 'opinion', 'analysis', 'report']);
++
++/** Lowercase, strip accents and punctuation, collapse spaces. */
++function fold(s) {
++ return String(s == null ? '' : s).normalize('NFKD').replace(/[̀-ͯ]/g, '')
++ .toLowerCase().replace(/[’']/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
++}
++
++/** A headline normalised for comparison. */
++function titleKey(title) { return fold(title); }
++
++const TRACKING = /^(utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid|ref|ref_src|cmpid|ocid|igshid|smid)$/i;
++
++/**
++ * The URL key dedupe compares: https, lowercase host without "www.", no fragment, no tracking
++ * parameters, remaining parameters sorted, no trailing slash. null for anything that is not http(s).
++ */
++function urlKey(url) {
++ if (!url) return null;
++ let u;
++ try { u = new URL(String(url)); } catch { return null; }
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
++ const host = u.hostname.toLowerCase().replace(/^www\./, '');
++ const params = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k)).sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
++ const q = params.length ? `?${new URLSearchParams(params).toString()}` : '';
++ let p = u.pathname.replace(/\/{2,}/g, '/');
++ if (p.length > 1) p = p.replace(/\/+$/, '');
++ return `https://${host}${u.port && u.port !== '443' && u.port !== '80' ? `:${u.port}` : ''}${p}${q}`;
++}
++
++/** The outlet shown when the registry has no name: the URL's host without "www.". */
++function hostOf(url) {
++ try { return new URL(String(url)).hostname.toLowerCase().replace(/^www\./, ''); } catch { return null; }
++}
++
++/** Very light stemming: plural and possessive endings, so "rockets" and "rocket" meet. */
++function stem(w) {
++ if (w.length > 4 && w.endsWith('ies')) return `${w.slice(0, -3)}y`;
++ if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') && !w.endsWith('us') && !w.endsWith('is')) return w.slice(0, -1);
++ return w;
++}
++
++/** Content terms of a text: folded words of 3+ letters (or any number), minus stopwords, stemmed, unique, sorted. */
++function terms(text) {
++ const out = new Set();
++ for (const w of fold(text).split(' ')) {
++ if (!w || STOPWORDS.has(w)) continue;
++ if (w.length < 3 && !/^\d+$/.test(w)) continue;
++ out.add(stem(w));
++ }
++ return [...out].sort();
++}
++
++/**
++ * Named entities, deterministically: runs of Capitalised words (and ALL-CAPS acronyms of 2+
++ * letters) in the original text, folded. A single capitalised word at the very start of the text
++ * counts only if it is an acronym or appears capitalised again later. Sorted, unique.
++ */
++function entities(text) {
++ const src = String(text || '');
++ const tokens = src.split(/\s+/).filter(Boolean);
++ const out = new Set();
++ let run = [];
++ const flush = () => {
++ if (run.length) {
++ const words = run.map((r) => r.word);
++ const startsText = run[0].index === 0;
++ const acronym = words.some((w) => /^[A-Z0-9]{2,}$/.test(w));
++ if (!(startsText && words.length === 1 && !acronym && !laterCapitalised(words[0], run[0].index))) {
++ const f = fold(words.join(' '));
++ if (f && !NOT_ENTITIES.has(f) && !STOPWORDS.has(f)) out.add(f);
++ }
++ }
++ run = [];
++ };
++ const laterCapitalised = (w, idx) => tokens.slice(idx + 1).some((t) => t.replace(/[^\p{L}\p{N}]/gu, '') === w);
++ tokens.forEach((raw, index) => {
++ const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
++ const cap = /^\p{Lu}/u.test(word) && !NOT_ENTITIES.has(word.toLowerCase());
++ if (word && cap) run.push({ word, index }); else flush();
++ if (/[.!?:;,]$/.test(raw)) flush();
++ });
++ flush();
++ return [...out].sort();
++}
++
++/** Word 2-shingles of a folded headline (single-word headlines give the word itself). */
++function shingles(title) {
++ const w = titleKey(title).split(' ').filter(Boolean);
++ if (w.length < 2) return new Set(w);
++ const s = new Set();
++ for (let i = 0; i < w.length - 1; i++) s.add(`${w[i]} ${w[i + 1]}`);
++ return s;
++}
++
++function jaccard(a, b) {
++ if (!a.size && !b.size) return 0;
++ let inter = 0;
++ for (const x of a) if (b.has(x)) inter++;
++ return inter / (a.size + b.size - inter);
++}
++
++// ── Licensing ─────────────────────────────────────────────────
++
++const ALLOWS_SUMMARY = /\b(short|brief)\s+summar(y|ies)\b|\bsummar(y|ies)\s+(are|is)\s+(allowed|permitted)\b/i;
++const FORBIDS_SUMMARY = /\b(no|not|never|without)\s+(short\s+|brief\s+)?summar(y|ies)\b|\bsummar(y|ies)\s+(are|is)\s+not\b|\bheadlines?\s+(and\s+links\s+)?only\b|\btitles?\s+and\s+links\s+only\b/i;
++
++/**
++ * What of a source's summary News may keep: a short summary, only when the item's terms or licence
++ * note explicitly allows short summaries (and nothing forbids them), cut to maxChars at a word
++ * boundary. Article bodies are never kept, whatever the terms say. → { summary, basis }
++ */
++function licensedSummary(summary, { termsNote, licenseNote, maxChars = 280 } = {}) {
++ const notes = `${termsNote || ''}\n${licenseNote || ''}`;
++ const text = String(summary || '').replace(/\s+/g, ' ').trim();
++ if (!text) return { summary: null, basis: 'none_provided' };
++ if (!maxChars) return { summary: null, basis: 'summaries_disabled' };
++ if (FORBIDS_SUMMARY.test(notes)) return { summary: null, basis: 'terms_forbid_summaries' };
++ if (!ALLOWS_SUMMARY.test(notes)) return { summary: null, basis: 'terms_do_not_allow_summaries' };
++ if (text.length <= maxChars) return { summary: text, basis: 'terms_allow_short_summaries' };
++ const cut = text.slice(0, maxChars - 1);
++ const at = cut.lastIndexOf(' ');
++ return { summary: `${(at > maxChars * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.-]+$/, '')}…`, basis: 'terms_allow_short_summaries' };
++}
++
++module.exports = { fold, titleKey, urlKey, hostOf, terms, entities, shingles, jaccard, licensedSummary, stem, STOPWORDS };
ddiff --git a/server/domain/topics.js b/server/domain/topics.js
nnew file mode 100644
iindex 0000000..f4de183
---- /dev/null
++++ b/server/domain/topics.js
@@@ -0,0 +1,58 @@
++'use strict';
++
++/**
++ * Topics: canonical topic identities (slug → name). The seed (seeds/topics.json) is the only data
++ * News seeds; it inserts missing topics and never renames, re-describes or archives an existing one.
++ * Editors may add topics (news.topic.manage).
++ */
++const fs = require('fs');
++const path = require('path');
++const { ids } = require('openvibe-contracts');
++const { slugify } = require('openvibe-publishing/taxonomy');
++const { ApiError } = require('../http/errors');
++
++const SEED_FILE = path.join(__dirname, '..', '..', 'seeds', 'topics.json');
++
++function createTopics({ store }) {
++ const { db } = store;
++ const q = {
++ bySlug: db.prepare('SELECT * FROM news_topics WHERE slug = ?'),
++ byId: db.prepare('SELECT * FROM news_topics WHERE id = ?'),
++ active: db.prepare("SELECT * FROM news_topics WHERE status = 'active' ORDER BY name"),
++ insert: db.prepare('INSERT INTO news_topics (id, slug, name, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, \'active\', ?, ?)'),
++ };
++
++ function create({ slug, name, description = null }) {
++ const n = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 80);
++ if (!n) throw new ApiError(422, 'topic.no_name', 'A topic needs a name');
++ let s;
++ try { s = slugify(slug || n).slice(0, 60); } catch { throw new ApiError(422, 'topic.invalid_slug', 'That name has no letters or digits to make a URL from'); }
++ if (q.bySlug.get(s)) return { topic: q.bySlug.get(s), created: false };
++ const now = store.now();
++ const id = `top_${ids.ulid(now)}`;
++ q.insert.run(id, s, n, description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) : null, now, now);
++ return { topic: q.byId.get(id), created: true };
++ }
++
++ return {
++ create,
++ list: () => q.active.all(),
++ bySlug: (slug) => q.bySlug.get(String(slug || '')) || null,
++ byId: (id) => q.byId.get(String(id || '')) || null,
++ /** Insert the seeded topics that are missing. → { created, existing } */
++ seed(file = SEED_FILE) {
++ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
++ let created = 0;
++ let existing = 0;
++ store.tx(() => {
++ for (const t of data.topics || []) {
++ const r = create(t);
++ if (r.created) created++; else existing++;
++ }
++ });
++ return { created, existing };
++ },
++ };
++}
++
++module.exports = { createTopics, SEED_FILE };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..de22648
---- /dev/null
++++ b/server/events/outbox.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * News → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
++ *
++ * news.source.ingested a Sources item was ingested (created, revised or removed
++ * upstream), with its dedupe and cluster outcome
++ * news.source.failed ingestion failed: Sources reported a failed fetch of a news
++ * source, or News could not read an item (no story text is
++ * ever made in its place)
++ * news.cluster.updated a cluster was created, grew, merged, split or a merge/split
++ * was reversed
++ * news.story.created|flagged internal editorial events
++ * news.story.published|updated|unpublished|retracted
++ * publication changes (canonical URL, state, indexability; no
++ * body; public only for public, listable stories)
++ * news.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks)
++ *
++ * 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 News' 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 createNewsOutbox({ 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: 'news' });
++ 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('[News] 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 = { createNewsOutbox };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..435ef89
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,266 @@
++'use strict';
++
++/**
++ * /api/v1 — JSON for services (Network client-credentials tokens, audience openvibe.news, one
++ * capability per route, acting for the editor in X-OV-Subject) and for browsers or apps with a
++ * Network user JWT (judged as editors). Errors are problem+json.
++ *
++ * GET /topics public
++ * POST /topics news.topic.manage { name, slug?, description? }
++ * GET /stories?topic=&limit=&offset= public (published and retracted stories)
++ * GET /stories/:id public for published; editors / news.story.read: everything
++ * POST /stories news.story.create { cluster, headline, topic?, body | paragraphs? }
++ * GET /stories/:id/revisions[/:n], /diff news.story.read
++ * POST /stories/:id/revisions news.story.revise { headline?, body | paragraphs, expected_revision, topic?, noindex? }
++ * (X-OV-Origin: ai → an AI draft that needs a person's review)
++ * POST /stories/:id/ai-drafts news.story.revise { workflow: news.summarize_story | news.compare_perspectives }
++ * POST /stories/:id/publish news.story.publish { revision?, correction?: { kind, note } }
++ * POST /stories/:id/unpublish news.story.publish
++ * POST /stories/:id/retract news.story.retract { note }
++ * POST /stories/:id/reviews people only { revision, decision, note? }
++ * POST /stories/:id/flags news.story.revise { kind: correction|update, note }
++ * POST /stories/:id/sources news.source.attach { item: nsi_… | itm_… }
++ * DELETE /stories/:id/sources/:item news.source.attach
++ * POST /stories/:id/perspectives news.perspective.update { label, description? }
++ * DELETE /stories/:id/perspectives/:pid news.perspective.update
++ * PUT /stories/:id/sources/:item/perspective news.perspective.update { perspective: per_… | null }
++ * POST /stories/:id/timeline news.timeline.update { occurred_on, text, source }
++ * DELETE /stories/:id/timeline/:eid news.timeline.update
++ * GET /clusters, /clusters/:id news.cluster.read
++ * POST /clusters/:id/merge news.cluster.manage { other, reason? }
++ * POST /clusters/:id/split news.cluster.manage { items: [...], reason? }
++ * POST /clusters/audit/:auditId/reverse news.cluster.manage { reason? }
++ * GET /source-items/:id news.cluster.read
++ * GET /ingest news.cluster.read recent ingestion runs, source health, cursor
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
++const { guard } = require('../auth/viewer');
++const access = require('../domain/access');
++
++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();
++ };
++}
++
++const iso = (v) => (v == null ? null : new Date(v).toISOString());
++const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
++
++function itemDto(it) {
++ if (!it) return null;
++ return {
++ id: it.id, sources_item: { service: 'sources', type: 'item', id: it.sources_item_id, revision: it.sources_revision },
++ source_key: it.source_key, headline: it.headline, url: it.canonical_url, outlet: it.outlet, authors: parse(it.authors, []),
++ published_at: it.published_at, summary: it.summary, summary_basis: it.summary_basis, license_note: it.license_note, terms_note: it.terms_note,
++ status: it.status, duplicate_of: it.duplicate_of, dedupe: parse(it.dedupe, null), cluster_id: it.cluster_id, cluster_reason: parse(it.cluster_reason, null),
++ retrieved_at: it.retrieved_at, first_seen_at: iso(it.first_seen_at), upstream_updated_at: iso(it.upstream_updated_at),
++ removed: it.removed_at ? { at: iso(it.removed_at), reason: it.removed_reason } : null,
++ };
++}
++
++function createApi(ctx) {
++ const { config, stories, clusters, publication, reading, topics, ingest, viewers } = 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 });
++ /** Reads for editors: a service passed its capability guard; a browser must be an editor. */
++ const editorRead = (req) => { if (req.viewer.kind !== 'service') access.requireEditor(config, req.viewer); };
++
++ function clusterDto(c, { full = false } = {}) {
++ const out = {
++ id: c.id, label: c.label, status: c.status, merged_into: c.merged_into, split_from: c.split_from,
++ window: { start: iso(c.window_start), end: iso(c.window_end) }, created_by: c.created_by,
++ key_terms: Object.entries(parse(c.terms, {})).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).slice(0, 15).map(([t, n]) => ({ term: t, items: n })),
++ key_entities: Object.entries(parse(c.entities, {})).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).slice(0, 10).map(([t, n]) => ({ entity: t, items: n })),
++ item_count: clusters.members(c.id).length,
++ };
++ if (full) Object.assign(out, { items: clusters.members(c.id).map(itemDto), audit: clusters.audit(c.id), stories: stories.byCluster(c.id).map((s) => ({ id: s.id, slug: s.slug, state: s.state })) });
++ return out;
++ }
++
++ function storyDto(story) {
++ const head = stories.head(story);
++ const topic = publication.topicOf(story);
++ return {
++ id: story.id, slug: story.slug, url: publication.storyUrl(story), state: story.state, cluster_id: story.cluster_id,
++ topic: topic ? topic.slug : null, working_headline: story.working_headline, noindex: Boolean(story.noindex),
++ published_revision: story.published_revision, first_published_at: iso(story.first_published_at), published_at: iso(story.published_at), retracted_at: iso(story.retracted_at),
++ head: head ? { revision: head.number, headline: head.fields.headline, paragraphs: head.fields.paragraphs, authorship: head.meta.authorship || null, system: head.meta.system || null, gaps: head.meta.gaps || null, author: head.author, created_at: head.createdAt } : null,
++ publishable: head ? stories.problems(story, head) : [{ code: 'story.no_text', detail: 'Nothing has been written yet' }],
++ indexability: story.published_revision ? (() => { const d = publication.decide(story, stories.revision(story, story.published_revision)); return { indexable: d.indexable, robots: d.robots, reasons: d.reasons }; })() : null,
++ sources: stories.sources(story, { includeDetached: true }).map((s) => ({ n: s.n, perspective_id: s.perspective_id, detached_at: iso(s.detached_at), item: itemDto(s.item) })),
++ timeline: stories.timeline(story).map((t) => ({ id: t.id, occurred_on: t.occurred_on, text: t.text, source_item_id: t.source_item_id })),
++ perspectives: stories.perspectives(story).map((p) => ({ id: p.id, label: p.label, description: p.description })),
++ flags: stories.flags(story).map((f) => ({ id: f.id, kind: f.kind, status: f.status, note: f.note, source_item_id: f.source_item_id, pending_revision: f.pending_revision, revision: f.revision, created_by: f.created_by, created_at: iso(f.created_at), resolved_at: iso(f.resolved_at) })),
++ };
++ }
++
++ const mustStory = (req) => stories.mustGet(req.params.id);
++
++ // ── Topics ──────────────────────────────────────────────
++
++ router.get('/topics', run(() => ({ topics: topics.list().map((t) => ({ id: t.id, slug: t.slug, name: t.name, description: t.description, url: publication.abs(publication.topicPath(t)) })) })));
++
++ router.post('/topics', guard('news.topic.manage'), jsonBody, run((req) => {
++ access.requireEditor(config, req.viewer);
++ const b = req.body || {};
++ const r = topics.create({ name: b.name, slug: b.slug, description: b.description });
++ return { topic: r.topic, created: r.created };
++ }, (out) => (out.created ? 201 : 200)));
++
++ // ── Stories ─────────────────────────────────────────────
++
++ router.get('/stories', run(async (req) => {
++ const topic = req.query.topic ? topics.bySlug(req.query.topic) : null;
++ if (req.query.topic && !topic) throw new ApiError(404, 'topic.not_found', 'No such topic');
++ 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);
++ const { total, stories: rows } = stories.listPublished({ topicId: topic ? topic.id : null, limit, offset });
++ const items = await reading.listItems(rows);
++ return { total, stories: items.map((i) => ({ id: i.story.id, url: publication.abs(i.url), headline: i.headline, state: i.story.state, topic: i.topic ? i.topic.name : null, sources: i.sourceCount, first_published_at: iso(i.published_at), updated_at: i.updated_at })) };
++ }));
++
++ router.get('/stories/:id', run(async (req) => {
++ const story = mustStory(req);
++ const privileged = req.viewer.kind === 'service'
++ ? contracts.capabilities.grants(req.viewer.claims && req.viewer.claims.cap, 'news.story.read')
++ : access.isEditor(config, req.viewer);
++ if (privileged) {
++ const m = story.published_revision ? await reading.storyModel(story) : null;
++ return { story: storyDto(story), published: m ? reading.storyJson(m) : null };
++ }
++ if (!story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) throw new ApiError(404, 'story.not_found', 'No such story');
++ return { published: reading.storyJson(await reading.storyModel(story)) };
++ }));
++
++ router.post('/stories', guard('news.story.create'), jsonBody, run((req) => {
++ const b = req.body || {};
++ const out = stories.create(req.viewer, { cluster: b.cluster || b.cluster_id, headline: b.headline, topic: b.topic, slug: b.slug, body: b.body, paragraphs: b.paragraphs }, tp(req));
++ return { story: storyDto(out.story), revision: out.revision ? out.revision.number : null };
++ }, 201));
++
++ router.get('/stories/:id/revisions', guard('news.story.read'), run((req) => {
++ editorRead(req);
++ const story = mustStory(req);
++ return { revisions: stories.revisions(story, { limit: req.query.limit, before: req.query.before }).map((r) => ({ revision: r.number, kind: r.kind, author: r.author, message: r.message, created_at: r.createdAt, headline: r.fields.headline, authorship: r.meta.authorship || null, system: r.meta.system || null })) };
++ }));
++ router.get('/stories/:id/revisions/:n', guard('news.story.read'), run((req) => {
++ editorRead(req);
++ const story = mustStory(req);
++ const rev = stories.revision(story, parseInt(req.params.n, 10));
++ if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
++ return { revision: rev, citations: ctx.store.citations.forRevision(story.id, rev.number), problems: stories.problems(story, rev) };
++ }));
++ router.get('/stories/:id/diff', guard('news.story.read'), run((req) => {
++ editorRead(req);
++ return { diff: stories.diff(mustStory(req), parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
++ }));
++
++ const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => fn(req, mustStory(req), req.body || {}), status));
++
++ write('post', '/stories/:id/revisions', guard('news.story.revise'), (req, story, b) => {
++ const out = stories.revise(req.viewer, story, {
++ headline: b.headline, body: b.body, paragraphs: b.paragraphs, expectedRevision: b.expected_revision ?? b.expectedRevision,
++ topic: b.topic, noindex: b.noindex, message: b.message, authorship: b.authorship, gaps: b.gaps,
++ }, tp(req));
++ return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
++ }, 201);
++ write('post', '/stories/:id/ai-drafts', guard('news.story.revise'), async (req, story, b) => {
++ const out = await stories.aiDraft(req.viewer, story, String(b.workflow || 'news.summarize_story'), tp(req));
++ return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
++ }, 201);
++ write('post', '/stories/:id/publish', guard('news.story.publish'), (req, story, b) => {
++ const out = stories.publish(req.viewer, story, { revision: b.revision, correction: b.correction }, tp(req));
++ return { changed: out.changed, resolved_flags: out.resolved || [], story: storyDto(out.story) };
++ });
++ write('post', '/stories/:id/unpublish', guard('news.story.publish'), (req, story) => {
++ const out = stories.unpublish(req.viewer, story, tp(req));
++ return { changed: out.changed, story: storyDto(out.story) };
++ });
++ write('post', '/stories/:id/retract', guard('news.story.retract'), (req, story, b) => {
++ const out = stories.retract(req.viewer, story, { note: b.note }, tp(req));
++ return { changed: out.changed, story: storyDto(out.story) };
++ });
++ router.post('/stories/:id/reviews', jsonBody, run((req) => {
++ const story = mustStory(req);
++ const b = req.body || {};
++ return { review: stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
++ }, 201));
++ write('post', '/stories/:id/flags', guard('news.story.revise'), (req, story, b) => ({ flag: stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }) }), 201);
++ write('post', '/stories/:id/sources', guard('news.source.attach'), (req, story, b) => {
++ const out = stories.attach(req.viewer, story, { item: b.item || b.item_id });
++ return { n: out.n, created: out.created, item: itemDto(out.item) };
++ }, 201);
++ write('delete', '/stories/:id/sources/:item', guard('news.source.attach'), (req, story) => stories.detach(req.viewer, story, req.params.item));
++ write('post', '/stories/:id/perspectives', guard('news.perspective.update'), (req, story, b) => ({ perspective: stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }) }), 201);
++ write('delete', '/stories/:id/perspectives/:pid', guard('news.perspective.update'), (req, story) => stories.removePerspective(req.viewer, story, req.params.pid));
++ write('put', '/stories/:id/sources/:item/perspective', guard('news.perspective.update'), (req, story, b) => stories.assignPerspective(req.viewer, story, req.params.item, b.perspective || null));
++ write('post', '/stories/:id/timeline', guard('news.timeline.update'), (req, story, b) => ({ entry: stories.addTimeline(req.viewer, story, { occurredOn: b.occurred_on ?? b.occurredOn, text: b.text, source: b.source }) }), 201);
++ write('delete', '/stories/:id/timeline/:eid', guard('news.timeline.update'), (req, story) => stories.removeTimeline(req.viewer, story, req.params.eid));
++
++ // ── Clusters, items, ingestion ──────────────────────────
++
++ router.get('/clusters', guard('news.cluster.read'), run((req) => {
++ editorRead(req);
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
++ return { clusters: clusters.recent({ limit }).map((c) => clusterDto(c)) };
++ }));
++ router.get('/clusters/:id', guard('news.cluster.read'), run((req) => {
++ editorRead(req);
++ const c = clusters.get(req.params.id);
++ if (!c) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
++ return { cluster: clusterDto(c, { full: true }) };
++ }));
++ router.post('/clusters/audit/:auditId/reverse', guard('news.cluster.manage'), jsonBody, run((req) => {
++ access.requireEditor(config, req.viewer);
++ const out = clusters.reverse(req.viewer, req.params.auditId, { reason: (req.body || {}).reason });
++ return { audit: out.audit, clusters: out.clusters.map((c) => clusterDto(c)) };
++ }));
++ router.post('/clusters/:id/merge', guard('news.cluster.manage'), jsonBody, run((req) => {
++ access.requireEditor(config, req.viewer);
++ const b = req.body || {};
++ const out = clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
++ return { cluster: clusterDto(out.cluster, { full: true }), audit: out.audit };
++ }));
++ router.post('/clusters/:id/split', guard('news.cluster.manage'), jsonBody, run((req) => {
++ access.requireEditor(config, req.viewer);
++ const b = req.body || {};
++ const out = clusters.split(req.viewer, req.params.id, b.items, { reason: b.reason });
++ return { cluster: clusterDto(out.cluster), created: clusterDto(out.created, { full: true }), audit: out.audit };
++ }, 201));
++ router.get('/source-items/:id', guard('news.cluster.read'), run((req) => {
++ editorRead(req);
++ const it = ingest.get(req.params.id) || ingest.bySourcesId(req.params.id);
++ if (!it) throw new ApiError(404, 'source_item.not_found', 'No such source item');
++ return { item: itemDto(it) };
++ }));
++ router.get('/ingest', guard('news.cluster.read'), run((req) => {
++ editorRead(req);
++ return {
++ cursor: ctx.store.getState('sources_cursor', 0),
++ runs: ingest.runs({ limit: 50 }).map((r) => ({ ...r, counts: parse(r.counts, null), at: iso(r.at) })),
++ sources: ingest.sourceStatus().map((s) => ({ ...s, stale: s.stale == null ? null : Boolean(s.stale), refreshed_at: iso(s.refreshed_at) })),
++ };
++ }));
++
++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
++ return router;
++}
++
++module.exports = { createApi, itemDto };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..564f37e
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * Crawl and machine-readability artifacts (roadmap §32.4/§32.5):
++ *
++ * GET /robots.txt sitemap location + explicit automated-consumer policy
++ * GET /llms.txt orientation for language models
++ * GET /sitemap.xml sitemap index over the two sections below
++ * GET /sitemaps/stories.xml published, INDEXABLE stories only (the gate decides: retracted,
++ * unsupported, AI-unreviewed and noindex stories never appear);
++ * lastmod = the published revision's real time
++ * GET /sitemaps/topics.xml topic pages that have indexable stories
++ *
++ * Built from the database on every request, never from the viewer.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const sharedSeo = require('openvibe-shared/seo');
++
++function createDiscoveryRoutes({ config, store, publication, topics }) {
++ const router = express.Router();
++ const { db } = store;
++ const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
++
++ function storyEntries() {
++ return db.prepare("SELECT * FROM news_stories WHERE state IN ('published','retracted') AND published_revision IS NOT NULL ORDER BY published_at DESC LIMIT 50000").all()
++ .map((story) => {
++ const rev = store.revisions.get(story.id, story.published_revision);
++ return { story, rev, decision: publication.decide(story, 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.news automated-consumer policy: search engines and AI crawlers are welcome to read',
++ '# published stories, topic pages, feeds and sitemaps. Every story page has a JSON twin',
++ '# (<story URL>.json) with its claims, sources and provenance. The editor desk, clusters,',
++ '# sign-in and the API are not for crawling. Pages decide their own indexability (meta robots /',
++ '# X-Robots-Tag): retracted stories stay readable but are noindex. A Disallow is not a noindex.',
++ sharedSeo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/edit', '/clusters/', '/auth/', '/api/', '/internal/'] }),
++ ].join('\n');
++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
++ });
++
++ router.get('/llms.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
++ name: 'OpenVibe.News',
++ summary: 'A source-backed publication: stories written by OpenVibe.News editors from source items collected by OpenVibe.Sources. Every paragraph cites the sources it rests on.',
++ details: 'OpenVibe.News is not an automatic headline generator. Each story page lists its sources (headline, outlet, author and date as the source published them, with links), a timeline, editor-assigned perspective groupings and its full correction history. Full article text from sources is never republished; at most a short summary where the source’s terms allow it. AI-assisted drafts are never published or indexed before a person reviews them. Retracted stories stay at their URL with a retraction notice and are noindex. Append .json to any story URL for the same content as data, including every claim’s source numbers and the Sources item ids.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'Latest stories', url: abs('/') },
++ { title: 'Topics', url: abs('/topics') },
++ { 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: 'Per topic', url: abs('/topics'), note: 'each topic has /topics/<slug>/feed.xml, atom.xml and feed.json' },
++ ] },
++ { title: 'Data', links: [{ title: 'Story JSON', url: abs('/'), note: 'append .json to any story URL (/stories/<slug>.json)' }] },
++ ],
++ }));
++ });
++
++ router.get('/sitemap.xml', (_req, res) => {
++ const entries = storyEntries().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/stories.xml'), ...(newest ? { lastmod: newest } : {}) },
++ { loc: abs('/sitemaps/topics.xml'), ...(newest ? { lastmod: newest } : {}) },
++ ]));
++ });
++
++ router.get('/sitemaps/stories.xml', (_req, res) => {
++ xml(res, seo.sitemap(storyEntries().map((e) => ({ loc: publication.storyUrl(e.story), lastmod: e.rev.createdAt, decision: e.decision }))).files[0]);
++ });
++
++ router.get('/sitemaps/topics.xml', (_req, res) => {
++ const byTopic = new Map();
++ for (const e of storyEntries()) {
++ if (!e.decision.indexable || !e.story.topic_id) continue;
++ const t = e.rev.createdAt;
++ if (!byTopic.has(e.story.topic_id) || byTopic.get(e.story.topic_id) < t) byTopic.set(e.story.topic_id, t);
++ }
++ const entries = [];
++ for (const [id, lastmod] of byTopic) {
++ const topic = topics.byId(id);
++ if (!topic || topic.status !== 'active') continue;
++ const path = publication.topicPath(topic);
++ const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() });
++ entries.push({ loc: abs(path), lastmod, decision });
++ }
++ 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..4eec4cf
---- /dev/null
++++ b/server/http/editor.js
@@@ -0,0 +1,206 @@
++'use strict';
++
++/**
++ * The editor desk as plain HTML forms (no JavaScript needed). Editors only (NEWS_EDITORS or Network
++ * admins, via Network SSO); every POST carries the form token. Every response is private, no-store
++ * and noindex.
++ *
++ * GET /edit dashboard: flags, stories, clusters, ingestion
++ * POST /edit/pull run the Sources cursor pull now
++ * GET /clusters/:id a cluster: items, why each is there, history
++ * POST /clusters/:id/{merge,split,stories} merge another in, split items off, open a story
++ * POST /clusters/audit/:aid/reverse undo a merge or split
++ * GET /edit/stories/:id the story editor
++ * POST /edit/stories/:id/{revise,publish,unpublish,retract,review,flag,attach,detach,assign,
++ * perspective,perspective/remove,timeline,timeline/remove,ai}
++ * GET /edit/stories/:id/preview?revision=N GET /edit/stories/:id/diff?from&to
++ * GET|POST /edit/topics
++ */
++const express = require('express');
++const { renderPage } = require('../render/layout');
++const editor = require('../render/editor');
++const { csrfToken, checkCsrf } = require('../auth/forms');
++const { asApiError } = require('./errors');
++const access = require('../domain/access');
++
++/** Checkbox + hidden fallback arrive as ['1','0']; a repeated text field keeps its last value. */
++const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
++
++function createEditorRoutes(ctx) {
++ const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai } = ctx;
++ const router = express.Router();
++ const form = express.urlencoded({ extended: false, limit: '300kb' });
++ router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (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('/edit')}`, label: 'Sign in' });
++ }
++ if (!access.isEditor(config, req.viewer)) return publicRoutes.messagePage(req, res, 403, 'Editors only', 'The editor desk is for OpenVibe.News editors.', { href: '/', label: 'OpenVibe.News' });
++ next();
++ });
++ router.post(['/edit/*', '/clusters/*'], 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 back = (req) => {
++ try { const u = new URL(req.get('referer') || '', config.baseUrl); return u.origin === new URL(config.baseUrl).origin && /^\/(edit|clusters)/.test(u.pathname) ? u.pathname : '/edit'; } catch { return '/edit'; }
++ };
++ 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 story again, re-apply your change and save.`
++ : e.message;
++ publicRoutes.messagePage(req, res, e.status, 'That did not work', text, { href: back(req), 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', editor: true }));
++ }
++ const csrf = (req) => csrfToken(config, req.viewer);
++ const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 300) } : null);
++ const done = (res, path, text) => res.redirect(303, `${path}?saved=${encodeURIComponent(text)}`);
++ const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
++ const input = (body) => { const b = {}; for (const [k, v] of Object.entries(body || {})) b[k] = one(v); return b; };
++
++ // ── Dashboard ───────────────────────────────────────────
++
++ router.get('/edit', wrap(async (req, res) => {
++ const flags = store.db.prepare(`SELECT f.*, s.working_headline FROM news_editorial_flags f JOIN news_stories s ON s.id = f.story_id
++ WHERE f.status = 'open' ORDER BY f.created_at DESC LIMIT 100`).all();
++ page(req, res, 'Editor desk', editor.dashboard({
++ clusters: clusters.recent({ limit: 40 }).map((c) => ({ ...c, count: clusters.members(c.id).length })),
++ stories: stories.listAll({ limit: 100 }), flags, runs: ingest.runs({ limit: 20 }), sources: ingest.sourceStatus(),
++ cursor: store.getState('sources_cursor', 0), csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled),
++ pullOn: Boolean(config.worker.enabled && config.sources.pullIntervalMs),
++ }));
++ }));
++
++ router.post('/edit/pull', wrap(async (req, res) => {
++ const r = await ingest.pull();
++ done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
++ }));
++
++ router.get('/edit/topics', wrap(async (req, res) => page(req, res, 'Topics', editor.topicsPage({ topics: topics.list(), csrf: csrf(req), message: flashOf(req) }))));
++ router.post('/edit/topics', wrap(async (req, res) => {
++ const b = input(req.body);
++ const r = topics.create({ name: b.name, description: b.description });
++ done(res, '/edit/topics', r.created ? `Added ${r.topic.name}` : `${r.topic.name} already exists`);
++ }));
++
++ // ── Clusters ────────────────────────────────────────────
++
++ function mustCluster(req, res) {
++ const c = clusters.get(req.params.id);
++ if (!c) { publicRoutes.notFound(req, res); return null; }
++ return c;
++ }
++
++ router.get('/clusters/:id', wrap(async (req, res) => {
++ const c = mustCluster(req, res);
++ if (!c) return;
++ page(req, res, `Cluster: ${c.label}`, editor.clusterPage({
++ cluster: c, items: clusters.members(c.id), audit: clusters.audit(c.id),
++ others: clusters.recent({ limit: 100 }).filter((o) => o.id !== c.id), storiesOf: stories.byCluster(c.id),
++ topics: topics.list(), csrf: csrf(req), message: flashOf(req),
++ }));
++ }));
++ router.post('/clusters/:id/merge', wrap(async (req, res) => {
++ const b = input(req.body);
++ clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
++ done(res, `/clusters/${req.params.id}`, `Merged ${b.other} into this cluster`);
++ }));
++ router.post('/clusters/:id/split', wrap(async (req, res) => {
++ const items = [].concat((req.body && req.body.items) || []);
++ const out = clusters.split(req.viewer, req.params.id, items, { reason: one(req.body.reason) });
++ done(res, `/clusters/${out.created.id}`, `Split ${items.length} items off ${req.params.id}`);
++ }));
++ router.post('/clusters/audit/:aid/reverse', wrap(async (req, res) => {
++ const out = clusters.reverse(req.viewer, req.params.aid, { reason: one(req.body.reason) });
++ done(res, `/clusters/${out.clusters[0].id}`, `Reversed (${out.audit.action.replace('_', ' ')})`);
++ }));
++ router.post('/clusters/:id/stories', wrap(async (req, res) => {
++ const b = input(req.body);
++ const out = stories.create(req.viewer, { cluster: req.params.id, headline: b.headline, topic: b.topic || null }, tp(req));
++ done(res, `/edit/stories/${out.story.id}`, 'Story opened. Write the text; every paragraph cites its sources.');
++ }));
++
++ // ── Stories ─────────────────────────────────────────────
++
++ function mustStory(req, res) {
++ const s = stories.get(req.params.id);
++ if (!s) { publicRoutes.notFound(req, res); return null; }
++ return s;
++ }
++
++ router.get('/edit/stories/:id', wrap(async (req, res) => {
++ const story = mustStory(req, res);
++ if (!story) return;
++ const head = stories.head(story);
++ page(req, res, story.working_headline, editor.storyEditor({
++ story, head, problems: head ? stories.problems(story, head) : [],
++ sources: stories.sources(story, { includeDetached: true }), timeline: stories.timeline(story), perspectives: stories.perspectives(story),
++ flags: stories.flags(story), revisions: stories.revisions(story, { limit: 100 }), topics: topics.list(), topic: publication.topicOf(story),
++ csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled), publicUrl: publication.storyPath(story),
++ decision: story.published_revision ? publication.decide(story, stories.revision(story, story.published_revision)) : null,
++ }));
++ }));
++
++ router.get('/edit/stories/:id/preview', wrap(async (req, res) => {
++ const story = mustStory(req, res);
++ if (!story) return;
++ const n = parseInt(req.query.revision, 10);
++ const rev = Number.isInteger(n) ? stories.revision(story, n) : stories.head(story);
++ if (!rev) return publicRoutes.notFound(req, res);
++ await publicRoutes.renderStory(req, res, { story, rev, preview: true });
++ return undefined;
++ }));
++
++ router.get('/edit/stories/:id/diff', wrap(async (req, res) => {
++ const story = mustStory(req, res);
++ if (!story) return;
++ page(req, res, 'Diff', editor.diffPage({ story, diff: stories.diff(story, parseInt(req.query.from, 10), parseInt(req.query.to, 10), 'word') }));
++ }));
++
++ const act = (path, fn) => router.post(`/edit/stories/:id/${path}`, wrap(async (req, res) => {
++ const story = mustStory(req, res);
++ if (!story) return;
++ const text = await fn(req, story, input(req.body));
++ done(res, `/edit/stories/${story.id}`, text);
++ }));
++
++ act('revise', (req, story, b) => {
++ const out = stories.revise(req.viewer, story, { headline: b.headline, body: b.body, expectedRevision: b.expectedRevision, topic: b.topic, noindex: b.noindex, message: b.message }, tp(req));
++ return out.created ? `Saved revision ${out.revision.number}` : 'Nothing changed';
++ });
++ act('publish', (req, story, b) => {
++ const correction = b.correctionKind || b.correctionNote ? { kind: b.correctionKind || 'correction', note: b.correctionNote } : undefined;
++ const out = stories.publish(req.viewer, story, { revision: b.revision, correction }, tp(req));
++ return out.changed ? `Published revision ${out.story.published_revision}` : 'Already published';
++ });
++ act('unpublish', (req, story) => (stories.unpublish(req.viewer, story, tp(req)).changed ? 'Unpublished' : 'Not published'));
++ act('retract', (req, story, b) => (stories.retract(req.viewer, story, { note: b.note }, tp(req)).changed ? 'Retracted' : 'Already retracted'));
++ act('review', (req, story, b) => { stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)); return `Review recorded for revision ${b.revision}`; });
++ act('flag', (req, story, b) => { stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }); return 'Note added; it is published with the next publication'; });
++ act('attach', (req, story, b) => `Attached as [${stories.attach(req.viewer, story, { item: b.item }).n}]`);
++ act('detach', (req, story, b) => `Detached [${stories.detach(req.viewer, story, b.item).n}]`);
++ act('assign', (req, story, b) => { stories.assignPerspective(req.viewer, story, b.item, b.perspective || null); return 'Perspective set'; });
++ act('perspective', (req, story, b) => { stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }); return 'Perspective added'; });
++ act('perspective/remove', (req, story, b) => { stories.removePerspective(req.viewer, story, b.perspective); return 'Perspective removed'; });
++ act('timeline', (req, story, b) => { stories.addTimeline(req.viewer, story, { occurredOn: b.occurredOn, text: b.text, source: b.source }); return 'Timeline entry added'; });
++ act('timeline/remove', (req, story, b) => { stories.removeTimeline(req.viewer, story, b.entry); return 'Timeline entry removed'; });
++ act('ai', async (req, story, b) => {
++ const out = await stories.aiDraft(req.viewer, story, b.workflow, tp(req));
++ return `AI draft saved as revision ${out.revision.number}: review it before publishing`;
++ });
++
++ return router;
++}
++
++module.exports = { createEditorRoutes };
ddiff --git a/server/http/errors.js b/server/http/errors.js
nnew file mode 100644
iindex 0000000..d46b8d3
---- /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('[News 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..e48cd8d
---- /dev/null
++++ b/server/http/public.js
@@@ -0,0 +1,169 @@
++'use strict';
++
++/**
++ * Public, server-rendered routes — useful without JavaScript:
++ *
++ * GET / published stories, newest first (retracted ones stay, labelled)
++ * GET /topics, /topics/:slug topics and each topic's stories
++ * GET /stories/:slug a story (…/:slug.json: the same story as data); ?group=outlet|perspective
++ * lets the reader choose how the source table is framed
++ * GET /feed.xml, /atom.xml, /feed.json, /topics/:slug/feed.{xml,json}, /topics/:slug/atom.xml
++ *
++ * Caching: a published story or a list rendered for an ANONYMOUS visitor is public for 60 s;
++ * signed-in views, drafts, previews and refusals are `private, no-store`. Feeds are never
++ * built for a viewer. Robots come from the gate: a retracted story is noindex with its reason.
++ */
++const express = require('express');
++const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
++const { renderPage } = require('../render/layout');
++const pages = require('../render/pages');
++const access = require('../domain/access');
++
++const PER_PAGE = 20;
++
++function createPublicRoutes(ctx) {
++ const { config, store, stories, publication, reading, topics, viewers } = ctx;
++ const router = express.Router();
++ router.use(viewers.middleware({ services: false }));
++
++ 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, editor: access.isEditor(config, req.viewer) }));
++ }
++
++ /** A decision for a page that is not a story (lists, 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: 'OpenVibe.News' });
++
++ const pageNumber = (req) => { const n = parseInt(req.query.page, 10); return Number.isInteger(n) && n > 0 ? n : 1; };
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
++ const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
++ const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
++
++ async function listing(req, res, { topic = null }) {
++ const page = pageNumber(req);
++ const path = topic ? publication.topicPath(topic) : '/';
++ const { total, stories: rows } = stories.listPublished({ topicId: topic ? topic.id : null, 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 = feedsFor(path);
++ const crumbs = topic ? [{ name: 'OpenVibe.News', url: '/' }, { name: 'Topics', url: '/topics' }, { name: topic.name }] : null;
++ send(req, res, 200, {
++ title: topic ? topic.name : null,
++ description: topic ? (topic.description || `Stories on ${topic.name}.`) : 'Source-backed stories: every paragraph cites the reports it rests on.',
++ decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
++ canonical, feeds,
++ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
++ jsonLd: topic
++ ? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]
++ : [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],
++ body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs }) : pages.home({ items, pager, topics: topics.list(), feeds }),
++ }, { cacheable: true });
++ }
++
++ router.get('/', wrap((req, res) => listing(req, res, {})));
++
++ router.get('/topics', wrap(async (req, res) => {
++ send(req, res, 200, { title: 'Topics', description: 'Topics on OpenVibe.News.', decision: pageDecision('/topics'), canonical: publication.abs('/topics'), body: pages.topicsIndex({ topics: topics.list() }) }, { cacheable: true });
++ }));
++
++ router.get('/topics/:slug', wrap(async (req, res) => {
++ const topic = topics.bySlug(req.params.slug);
++ if (!topic || topic.status !== 'active') return notFound(req, res);
++ return listing(req, res, { topic });
++ }));
++
++ // ── Feeds (published stories only, never viewer-dependent) ─
++
++ async function feed(req, res, kind, topic) {
++ const items = await reading.feedItems({ topicId: topic ? topic.id : null, limit: 30 });
++ const base = topic ? publication.topicPath(topic) : '/';
++ const link = publication.abs(base);
++ const title = topic ? `${topic.name} · OpenVibe.News` : 'OpenVibe.News';
++ const description = topic ? (topic.description || `Stories on ${topic.name}`) : 'Source-backed stories from OpenVibe.News';
++ const file = FEEDS.find((f) => f.type === kind).file;
++ const feedUrl = publication.abs(`${base === '/' ? '' : base}/${file}`);
++ 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, link, description, feedUrl, language: 'en' }, items));
++ if (kind === 'json') return res.type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title, link, feedUrl, description, language: 'en' }, items)));
++ const listed = items.filter((i) => i.decision.listable);
++ // An empty Atom feed still needs <updated>: the time the topic (or News' topic list) was set up, a real time.
++ const updated = listed.length ? null : new Date(topic ? topic.updated_at : (store.db.prepare('SELECT MIN(created_at) AS t FROM news_topics').get().t || store.now())).toISOString();
++ return res.type('application/atom+xml').send(seo.atomFeed({ title, link, feedUrl, id: `tag:openvibe.news,2026:${topic ? `topic/${topic.id}` : 'all'}`, subtitle: description, ...(updated ? { updated } : {}) }, items));
++ }
++ for (const f of FEEDS) {
++ router.get(`/${f.file}`, wrap((req, res) => feed(req, res, f.type, null)));
++ router.get(`/topics/:slug/${f.file}`, wrap((req, res) => {
++ const topic = topics.bySlug(req.params.slug);
++ if (!topic || topic.status !== 'active') return notFound(req, res);
++ return feed(req, res, f.type, topic);
++ }));
++ }
++
++ // ── Stories ─────────────────────────────────────────────
++
++ /** Render one story (also the editor's preview of a chosen revision). */
++ async function renderStory(req, res, { story, rev = null, preview = false }) {
++ const isEditor = access.isEditor(config, req.viewer);
++ const m = await reading.storyModel(story, { rev, editorView: preview && isEditor });
++ const group = ['outlet', 'perspective'].includes(req.query.group) ? req.query.group : 'number';
++ const decision = preview ? publication.decide(story, m.rev, { state: 'draft' }) : m.decision;
++ const crumbs = [{ name: 'OpenVibe.News', url: '/' }, ...(m.topic ? [{ name: m.topic.name, url: publication.topicPath(m.topic) }] : []), { name: m.headline }];
++ const cacheable = !preview && (story.state === 'published' || story.state === 'retracted');
++ send(req, res, 200, {
++ title: m.retraction ? `Retracted: ${m.headline}` : m.headline,
++ description: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 200) : undefined,
++ decision, canonical: m.url, type: 'article',
++ author: m.authors.length ? m.authors.map((a) => a.name).join(', ') : undefined,
++ published: m.first_published_at, modified: m.updated_at,
++ feeds: feedsFor('/'),
++ jsonLd: preview ? [] : [reading.storyJsonLd(m), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : m.url })))],
++ body: pages.storyPage(m, {
++ group, breadcrumbs: crumbs, jsonUrl: `${m.path}.json`,
++ editUrl: isEditor ? `/edit/stories/${story.id}` : null,
++ decisionNote: preview ? `preview of revision ${m.rev.number} · ${decision.robots}` : null,
++ }),
++ }, { cacheable });
++ }
++
++ router.get('/stories/:slug', wrap(async (req, res) => {
++ const asJson = req.params.slug.endsWith('.json');
++ const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
++ const story = stories.bySlug(slug);
++ if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) {
++ if (story && story.state === 'unpublished') return messagePage(req, res, 410, 'Gone', 'This story was unpublished.', { href: '/', label: 'OpenVibe.News' });
++ return notFound(req, res);
++ }
++ if (asJson) {
++ const m = await reading.storyModel(story);
++ cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous', robots: m.decision.robots });
++ return res.json(reading.storyJson(m));
++ }
++ return renderStory(req, res, { story });
++ }));
++
++ return { router, renderStory, notFound, messagePage, pageDecision, send };
++}
++
++module.exports = { createPublicRoutes, PER_PAGE };
ddiff --git a/server/http/webhook.js b/server/http/webhook.js
nnew file mode 100644
iindex 0000000..0656727
---- /dev/null
++++ b/server/http/webhook.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Events consumer: POST /internal/events, the endpoint of News' OpenVibe.Events subscriptions
++ * (topic patterns `sources.item.*` and `sources.fetch.failed`, see scripts/subscribe.js).
++ *
++ * sources.item.created|updated the item is read from Sources (sources.item.read) and applied
++ * sources.item.removed applied from the payload (the removal is sticky)
++ * sources.fetch.failed recorded; relayed as news.source.failed; no text is made
++ *
++ * Deliveries are verified with X-OpenVibe-Signature against NEWS_EVENTS_SECRET (comma-separated
++ * during rotation). Exactly once: the inbox receipt (consumer, event_id) and the change commit in
++ * one SQLite transaction; a redelivery is answered 204 and changes nothing. When Sources cannot be
++ * read, the delivery is answered 503 so Events retries it, and the failure is recorded (and relayed
++ * as news.source.failed on the first attempt). The cursor pull catches anything that never arrives.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
++
++const CONSUMER = 'news-sources';
++const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createWebhook({ config, store, ingest, sources, log = console }) {
++ const router = express.Router();
++ const inbox = createInbox(store.db, { now: store.now });
++ inbox.ensureSchema();
++
++ router.post('/internal/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
++ const ctx = req.ov;
++ res.set('Cache-Control', 'no-store');
++ const secrets = config.events.webhookSecrets;
++ if (!secrets.length) return http.sendProblem(res, 503, 'news.webhook_disabled', { detail: 'NEWS_EVENTS_SECRET is not set', ctx });
++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ const sig = req.get('x-openvibe-signature');
++ if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });
++ let body;
++ try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
++ const event = body && body.event;
++ if (!event || typeof event.event_id !== 'string' || !EVT_RE.test(event.event_id)) {
++ return http.sendProblem(res, 400, 'news.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx });
++ }
++ const attempt = Number(req.get('x-openvibe-delivery-attempt')) || 1;
++ const type = String(event.event_type || '');
++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
++ const fromSources = event.source === 'sources';
++
++ try {
++ if (!fromSources || !/^sources\.(item\.(created|updated|removed)|fetch\.failed)$/.test(type)) {
++ inbox.once(CONSUMER, event.event_id, () => null);
++ return res.status(204).end();
++ }
++ if (type === 'sources.item.removed') {
++ inbox.once(CONSUMER, event.event_id, () => {
++ const r = ingest.applyRemoval({ sourcesItemId: String(p.item_id || ''), reason: p.reason, revision: Number.isInteger(p.revision) ? p.revision : null, origin: 'webhook' });
++ ingest.recordRun({ origin: 'webhook', state: r.outcome, source_key: p.source_key || null, sources_item_id: p.item_id || null });
++ return r;
++ });
++ return res.status(204).end();
++ }
++ if (type === 'sources.fetch.failed') {
++ inbox.once(CONSUMER, event.event_id, () => ingest.upstreamFailure(p));
++ return res.status(204).end();
++ }
++ // created | updated
++ if (p.category && p.category !== config.sources.category) {
++ inbox.once(CONSUMER, event.event_id, () => null);
++ return res.status(204).end();
++ }
++ let fetched;
++ try {
++ if (p.source_key) await ingest.learnOutlets([String(p.source_key)]);
++ fetched = await sources.getItem(String(p.item_id || ''));
++ } catch (err) {
++ store.tx(() => {
++ ingest.recordRun({ origin: 'webhook', state: 'failed', source_key: p.source_key || null, sources_item_id: p.item_id || null, error_code: err.code || 'sources.error', detail: `${type} ${event.event_id} attempt ${attempt}: ${err.message}` });
++ if (attempt === 1) ingest.failedEvent({ origin: 'webhook', sourceKey: p.source_key || null, sourcesItemId: p.item_id || null, state: 'failed', errorCode: err.code || 'sources.error', detail: err.message, httpStatus: err.status || null });
++ });
++ if (err.status === 404) {
++ // Sources no longer has it: nothing to apply, and retrying cannot help.
++ inbox.once(CONSUMER, event.event_id, () => null);
++ return res.status(204).end();
++ }
++ return http.sendProblem(res, 503, 'news.sources_unavailable', { detail: 'OpenVibe.Sources could not be read; retry later', ctx });
++ }
++ if (fetched.source) ingest.rememberSources({ [fetched.item.source_key]: fetched.source });
++ inbox.once(CONSUMER, event.event_id, () => {
++ const r = ingest.apply(fetched.item, { origin: 'webhook' });
++ ingest.recordRun({ origin: 'webhook', state: r.outcome, source_key: fetched.item.source_key || null, sources_item_id: fetched.item.id, detail: r.reason || null });
++ return r;
++ });
++ return res.status(204).end();
++ } catch (err) {
++ log.error('[News] webhook failed:', err && err.stack ? err.stack : err);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx });
++ }
++ });
++
++ return router;
++}
++
++module.exports = { createWebhook, CONSUMER };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..d737bb6
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/**
++ * OpenVibe.News — process entry. `node server/index.js`
++ * Listens on PORT (4820) behind nginx (deploy/). Starts the outbox relay (when EVENTS_URL and the
++ * client secret are set) and the worker (the OpenVibe.Sources cursor pull).
++ */
++const { createApp } = require('./app');
++
++const { app, ctx } = createApp();
++const { config } = ctx;
++
++const server = app.listen(config.port, config.host, () => {
++ console.log(`[News] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
++ console.log(`[News] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; webhook ${config.events.webhookSecrets.length ? 'on' : 'off (NEWS_EVENTS_SECRET unset)'}; Sources pull ${config.worker.enabled && config.sources.pullIntervalMs ? `every ${config.sources.pullIntervalMs} ms` : 'off'}; AI ${ctx.ai.enabled ? 'on' : 'off'}`);
++});
++server.keepAliveTimeout = 65_000;
++ctx.outbox.start();
++ctx.worker.start();
++
++function shutdown(signal) {
++ console.log(`[News] ${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..463db69
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
++ *
++ * db required a real query on News' SQLite (the nine charter tables answer)
++ * network_jwks optional the Network signing key has loaded; without it pages and feeds still
++ * serve, but nobody can sign in and service tokens are refused (503)
++ * events_relay optional the outbox relay is configured and has no rejected rows; when it is
++ * off, events wait in event_outbox (Search and subscribers lag)
++ * events_webhook optional NEWS_EVENTS_SECRET is set, so Events can deliver sources.item.*
++ * sources_pull optional the last cursor pull from OpenVibe.Sources succeeded (or pulls are
++ * off); a failing pull means new source items are not arriving
++ *
++ * 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 createNewsReadiness({ store, auth, outbox, ingest, config, release = null }) {
++ const { db } = store;
++ return createReadiness({
++ service: 'news',
++ 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: 'events_webhook', required: false,
++ check: () => (config.events.webhookSecrets.length ? true : 'NEWS_EVENTS_SECRET unset: Events cannot deliver sources.item.* (the cursor pull still runs)'),
++ },
++ {
++ name: 'sources_pull', required: false,
++ check: () => {
++ const last = ingest.lastPull();
++ const off = !config.sources.pullIntervalMs || !config.worker.enabled;
++ if (!last) return off ? { ok: true, detail: { pull: 'off' } } : 'no pull from OpenVibe.Sources has run yet';
++ if (last.state !== 'ok') return `last pull failed (${last.error_code}): ${last.detail}`;
++ return { ok: true, detail: { cursor: last.cursor_after, at: new Date(last.at).toISOString() } };
++ },
++ },
++ ],
++ });
++}
++
++module.exports = { createNewsReadiness };
ddiff --git a/server/render/editor.js b/server/render/editor.js
nnew file mode 100644
iindex 0000000..f22b39e
---- /dev/null
++++ b/server/render/editor.js
@@@ -0,0 +1,166 @@
++'use strict';
++
++/**
++ * The editor desk as 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; every page is private,
++ * no-store and noindex.
++ */
++const ssr = require('openvibe-publishing/ssr');
++const { time } = require('./pages');
++
++const { html: h, raw } = ssr;
++const csrfField = (csrf) => h`<input type="hidden" name="_csrf" value="${csrf}">`;
++const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } };
++const STATE_LABEL = { draft: 'Draft', published: 'Published', unpublished: 'Unpublished', retracted: 'Retracted' };
++
++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 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>`;
++}
++
++function topicSelect(topics, current, name = 'topic') {
++ return select(name, [['', '— no topic —'], ...topics.map((t) => [t.slug, t.name])], current || '');
++}
++
++function why(reason) {
++ const r = parse(reason, null);
++ if (!r) return '';
++ if (r.rule === 'shared_terms') return `shares ${r.shared_entities.length ? `entities ${r.shared_entities.join(', ')}; ` : ''}terms ${r.shared_terms.join(', ')} (score ${r.score}, ${r.window_hours} h window)`;
++ if (r.rule === 'new_cluster') return `started this cluster (terms ${r.terms.slice(0, 8).join(', ')})`;
++ if (r.rule === 'duplicate_of') return `duplicate of ${r.item}`;
++ return r.rule + (r.from ? ` from ${r.from}` : '');
++}
++
++function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn }) {
++ return String(h`<h1>Editor desk</h1>
++${flash(message)}
++<p class="meta">Stories are written by editors from source items. Nothing is published until an editor publishes it${aiEnabled ? '; AI drafts wait for a person’s review' : ''}.</p>
++<section><h2>Open flags</h2>${flags.length ? h`<ul>${flags.map((f) => h`<li><a href="/edit/stories/${f.story_id}">${f.working_headline}</a> — ${f.kind.replace('_', ' ')}${f.pending_revision ? h` (pending revision ${f.pending_revision})` : ''} · ${time(f.created_at)}</li>`)}</ul>` : h`<p class="empty">None.</p>`}</section>
++<section><h2>Stories</h2>${stories.length ? h`<table><thead><tr><th>Headline</th><th>State</th><th>Revision</th><th>Updated</th></tr></thead><tbody>${stories.map((s) => h`<tr><td><a href="/edit/stories/${s.id}">${s.working_headline}</a></td><td>${STATE_LABEL[s.state]}</td><td>${s.published_revision || '—'}</td><td>${time(s.updated_at)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No stories yet. Open one from a cluster below.</p>`}</section>
++<section><h2>Recent clusters</h2>${clusters.length ? h`<table><thead><tr><th>Cluster</th><th>Items</th><th>Latest</th></tr></thead><tbody>${clusters.map((c) => h`<tr><td><a href="/clusters/${c.id}">${c.label}</a></td><td>${c.count}</td><td>${time(c.window_end)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No source items have arrived yet.</p>`}</section>
++<section><h2>Ingestion</h2>
++<p class="meta">Cursor ${cursor} · cursor pull ${pullOn ? 'on' : 'off'}.</p>
++${sources.length ? h`<table><thead><tr><th>Source</th><th>Name</th><th>Status</th><th>Last success</th></tr></thead><tbody>${sources.map((s) => h`<tr><td>${s.source_key}</td><td>${s.name || '—'}</td><td>${s.status || 'unknown'}${s.stale ? ' (stale)' : ''}</td><td>${s.last_success_at || '—'}</td></tr>`)}</tbody></table>` : ''}
++${runs.length ? h`<table><thead><tr><th>When</th><th>Origin</th><th>State</th><th>Detail</th></tr></thead><tbody>${runs.map((r) => h`<tr><td>${time(r.at)}</td><td>${r.origin}</td><td>${r.state}</td><td>${r.source_key || ''} ${r.sources_item_id || ''} ${r.error_code || ''} ${r.detail || ''}${r.counts ? ` ${r.counts}` : ''}</td></tr>`)}</tbody></table>` : h`<p class="empty">Nothing ingested yet.</p>`}
++<form method="post" action="/edit/pull">${csrfField(csrf)}<button type="submit">Pull from OpenVibe.Sources now</button></form>
++</section>
++<p><a href="/edit/topics">Topics</a></p>`);
++}
++
++function clusterPage({ cluster, items, audit, others, storiesOf, topics, csrf, message }) {
++ const open = cluster.status === 'open';
++ return String(h`<p><a href="/edit">← Editor desk</a></p>
++<h1>Cluster: ${cluster.label}</h1>
++${flash(message)}
++<p class="meta">${cluster.id} · ${cluster.status}${cluster.merged_into ? h` into <a href="/clusters/${cluster.merged_into}">${cluster.merged_into}</a>` : ''}${cluster.split_from ? h` · split from <a href="/clusters/${cluster.split_from}">${cluster.split_from}</a>` : ''} · items grouped by shared named entities and terms within a time window; every membership says why.</p>
++<form method="post" action="/clusters/${cluster.id}/split">${csrfField(csrf)}
++<table><thead><tr><th>Split</th><th>Headline</th><th>Outlet</th><th>Published</th><th>Status</th><th>Why here</th></tr></thead><tbody>
++${items.map((it) => h`<tr><td>${open ? h`<input type="checkbox" name="items" value="${it.id}" aria-label="split ${it.headline}">` : ''}</td><td>${it.canonical_url ? h`<a href="${it.canonical_url}" rel="noopener nofollow">${it.headline}</a>` : it.headline}<br><span class="meta">${it.id} · Sources ${it.sources_item_id} r${it.sources_revision}</span></td><td>${it.outlet}</td><td>${it.published_at ? it.published_at.slice(0, 10) : 'not stated'}</td><td>${it.status}${it.dedupe ? h`<br><span class="meta">${parse(it.dedupe, {}).rule}: ${parse(it.dedupe, {}).detail}</span>` : ''}${it.removed_reason ? h`<br><span class="meta">${it.removed_reason}</span>` : ''}</td><td class="meta">${why(it.cluster_reason)}</td></tr>`)}
++</tbody></table>
++${open ? h`<label for="split-reason">Reason for the split</label><input id="split-reason" name="reason" maxlength="500"><button type="submit">Split the ticked items into a new cluster</button>` : ''}
++</form>
++${open ? h`<section class="card"><h2>Merge another cluster into this one</h2><form method="post" action="/clusters/${cluster.id}/merge">${csrfField(csrf)}
++<label for="other">Cluster</label>${select('other', others.map((o) => [o.id, `${o.label} (${o.id})`]), '')}
++<label for="merge-reason">Reason</label><input id="merge-reason" name="reason" maxlength="500">
++<button type="submit">Merge</button></form></section>` : ''}
++<section><h2>History</h2>${audit.length ? h`<ul>${audit.map((a) => h`<li>${a.action.replace('_', ' ')} · ${a.cluster_id} ⇄ ${a.other_id} · ${a.item_ids.length} items · ${a.actor}${a.reason ? h` — ${a.reason}` : ''}${a.reversed_by ? h` · reversed by ${a.reversed_by}` : ''}${!a.reversed_by && (a.action === 'merge' || a.action === 'split') ? h` <form class="inline" method="post" action="/clusters/audit/${a.id}/reverse">${csrfField(csrf)}<button type="submit">Reverse</button></form>` : ''}</li>`)}</ul>` : h`<p class="empty">No merges or splits.</p>`}</section>
++<section><h2>Stories from this cluster</h2>${storiesOf.length ? h`<ul>${storiesOf.map((s) => h`<li><a href="/edit/stories/${s.id}">${s.working_headline}</a> (${s.state})</li>`)}</ul>` : h`<p class="empty">None yet.</p>`}
++${open ? h`<form method="post" action="/clusters/${cluster.id}/stories" class="card">${csrfField(csrf)}
++<h3>Open a story</h3><p class="meta">The cluster's live, non-duplicate items become the first sources [1], [2], … You write the headline and text.</p>
++<label for="headline">Working headline</label><input id="headline" name="headline" maxlength="200" required>
++<label for="topic">Topic</label>${topicSelect(topics, '')}
++<button type="submit">Open the story</button></form>` : ''}</section>`);
++}
++
++function storyEditor({ story, head, problems, sources, timeline, perspectives, flags, revisions, topics, topic, csrf, message, aiEnabled, publicUrl, decision }) {
++ const body = head ? head.fields.paragraphs.map((p) => `${p.text} [${p.sources.join(', ')}]`).join('\n\n') : '';
++ const rec = head && head.meta.authorship;
++ const needsReview = rec && (rec.mode === 'ai' || rec.mode === 'hybrid');
++ const openFlags = flags.filter((f) => f.status === 'open');
++ const live = sources.filter((s) => !s.detached_at);
++ return String(h`<p><a href="/edit">← Editor desk</a>${story.cluster_id ? h` · <a href="/clusters/${story.cluster_id}">Cluster</a>` : ''}${story.published_revision ? h` · <a href="${publicUrl}">Public page</a>` : ''}</p>
++<h1>${story.working_headline}</h1>
++${flash(message)}
++<p class="meta">${story.id} · ${STATE_LABEL[story.state]}${story.published_revision ? ` · readers see revision ${story.published_revision}` : ''}${head ? ` · latest revision ${head.number}` : ''}</p>
++${decision ? (decision.indexable ? h`<p class="meta">Search engines: indexable.</p>` : h`<p class="meta">Search engines: <strong>${decision.robots}</strong> — ${decision.reasons.map((r) => `${r.code}${r.detail ? ` (${r.detail})` : ''}`).join('; ')}.</p>`) : ''}
++${openFlags.length ? h`<section class="card warn"><h2>Open flags</h2><ul>${openFlags.map((f) => h`<li>${f.kind.replace('_', ' ')}${f.source_item_id ? ` · ${f.source_item_id}` : ''}${f.pending_revision ? h` · pending revision <a href="/edit/stories/${story.id}/preview?revision=${f.pending_revision}">${f.pending_revision}</a>` : ''}${f.note ? h` — ${f.note}` : ''}</li>`)}</ul></section>` : ''}
++${head && problems.length ? h`<section class="card warn"><h2>Not publishable yet</h2><ul>${problems.map((p) => h`<li><code>${p.code}</code> ${p.detail}</li>`)}</ul></section>` : ''}
++${head && head.meta.system ? h`<p class="notice">Revision ${head.number} was prepared by the system because a source ${head.meta.system.reason === 'source_removed' ? 'was removed' : 'changed'} upstream. Check the text, then save and publish.</p>` : ''}
++${head && head.meta.gaps && head.meta.gaps.length ? h`<section class="card"><h2>What the AI draft could not support</h2><ul>${head.meta.gaps.map((g) => h`<li>${g}</li>`)}</ul></section>` : ''}
++
++<section class="card"><h2>Text</h2>
++<form method="post" action="/edit/stories/${story.id}/revise">${csrfField(csrf)}
++${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : h`<input type="hidden" name="expectedRevision" value="0">`}
++<label for="headline">Headline</label><input id="headline" name="headline" maxlength="200" required value="${head ? head.fields.headline : story.working_headline}">
++<label for="body">Paragraphs <span class="meta">(blank line between paragraphs; end each with the numbers of the sources it rests on, e.g. <code>[1]</code> or <code>[1, 3]</code>)</span></label>
++<textarea id="body" name="body" rows="18">${body}</textarea>
++<label for="topic">Topic</label>${topicSelect(topics, topic ? topic.slug : '')}
++<label><input type="checkbox" name="noindex" value="1"${raw(story.noindex ? ' checked' : '')}> Ask search engines not to index it</label><input type="hidden" name="noindex" value="0">
++<label for="message">Revision note <span class="meta">(optional)</span></label><input id="message" name="message" maxlength="200">
++<button type="submit">Save revision</button></form>
++${aiEnabled ? h`<form method="post" action="/edit/stories/${story.id}/ai" class="inline">${csrfField(csrf)}<input type="hidden" name="workflow" value="news.summarize_story"><button type="submit">Ask OpenVibe.AI for a draft summary</button></form>
++<form method="post" action="/edit/stories/${story.id}/ai" class="inline">${csrfField(csrf)}<input type="hidden" name="workflow" value="news.compare_perspectives"><button type="submit">Ask OpenVibe.AI to compare perspectives</button></form>
++<p class="meta">AI output arrives as a draft revision marked AI-generated; it cannot be published until a person approves it below.</p>` : ''}
++</section>
++
++<section class="card"><h2>Sources</h2>
++<table><thead><tr><th>#</th><th>Headline</th><th>Outlet</th><th>Status</th><th>Perspective</th><th></th></tr></thead><tbody>
++${sources.map((s) => h`<tr${s.detached_at ? raw(' class="detached"') : ''}><td>${s.n}</td><td>${s.item.headline}<br><span class="meta">${s.item.id} · ${s.item.published_at ? s.item.published_at.slice(0, 10) : 'date not stated'}${s.item.summary ? ' · licensed summary kept' : ` · no summary (${s.item.summary_basis || 'none'})`}</span></td><td>${s.item.outlet}</td><td>${s.detached_at ? 'detached' : s.item.status}${s.item.removed_reason ? h`<br><span class="meta">${s.item.removed_reason}</span>` : ''}</td>
++<td>${s.detached_at ? '' : h`<form method="post" action="/edit/stories/${story.id}/assign" class="inline">${csrfField(csrf)}<input type="hidden" name="item" value="${s.item.id}">${select('perspective', [['', '— none —'], ...perspectives.map((p) => [p.id, p.label])], s.perspective_id || '')}<button type="submit">Set</button></form>`}</td>
++<td>${s.detached_at ? '' : h`<form method="post" action="/edit/stories/${story.id}/detach" class="inline">${csrfField(csrf)}<input type="hidden" name="item" value="${s.item.id}"><button type="submit">Detach</button></form>`}</td></tr>`)}
++</tbody></table>
++<form method="post" action="/edit/stories/${story.id}/attach">${csrfField(csrf)}<label for="item">Attach a source item (nsi_… or the Sources itm_… id)</label><input id="item" name="item" required><button type="submit">Attach</button></form>
++</section>
++
++<section class="card"><h2>Perspectives</h2><p class="meta">Labels are yours: describe the grouping (an outlet group, a stated position). Never invent a stance a source does not state.</p>
++${perspectives.length ? h`<ul>${perspectives.map((p) => h`<li><strong>${p.label}</strong>${p.description ? h` — ${p.description}` : ''} <form method="post" action="/edit/stories/${story.id}/perspective/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="perspective" value="${p.id}"><button type="submit">Remove</button></form></li>`)}</ul>` : ''}
++<form method="post" action="/edit/stories/${story.id}/perspective">${csrfField(csrf)}<label for="label">Label</label><input id="label" name="label" maxlength="80" required><label for="description">Description</label><input id="description" name="description" maxlength="500"><button type="submit">Add perspective</button></form>
++</section>
++
++<section class="card"><h2>Timeline</h2>
++${timeline.length ? h`<ul>${timeline.map((t) => h`<li>${t.occurred_on} — ${t.text} [${(live.find((s) => s.item.id === t.source_item_id) || {}).n || '?'}] <form method="post" action="/edit/stories/${story.id}/timeline/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="entry" value="${t.id}"><button type="submit">Remove</button></form></li>`)}</ul>` : ''}
++<form method="post" action="/edit/stories/${story.id}/timeline">${csrfField(csrf)}
++<label for="occurredOn">Date the source states (YYYY-MM-DD)</label><input id="occurredOn" name="occurredOn" pattern="\\d{4}-\\d{2}-\\d{2}.*" required>
++<label for="entry-text">What happened</label><input id="entry-text" name="text" maxlength="500" required>
++<label for="entry-source">Source number</label>${select('source', live.map((s) => [String(s.n), `[${s.n}] ${s.item.outlet}`]), '')}
++<button type="submit">Add to the timeline</button></form>
++<p class="meta">Timeline, perspective and source changes reach readers with the next saved and published revision.</p>
++</section>
++
++<section class="card"><h2>Publish</h2>
++${needsReview ? h`<form method="post" action="/edit/stories/${story.id}/review">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">
++<p>Revision ${head.number} is ${rec.mode === 'ai' ? 'AI-generated' : 'AI-assisted'} (${rec.workflow ? rec.workflow.id : 'workflow'}). Read it against the sources before approving.</p>
++${select('decision', [['approved', 'Approve: I checked every claim against its sources'], ['rejected', 'Reject']], 'approved')}<label for="review-note">Note</label><input id="review-note" name="note" maxlength="500"><button type="submit">Record review</button></form>` : ''}
++${story.state !== 'retracted' && head ? h`<form method="post" action="/edit/stories/${story.id}/publish">${csrfField(csrf)}
++<label for="pub-rev">Revision</label>${select('revision', revisions.map((r) => [String(r.number), `${r.number}${r.number === story.published_revision ? ' (published)' : ''} · ${r.createdAt.slice(0, 16).replace('T', ' ')}${r.meta.system ? ' · system' : ''}${r.meta.authorship && r.meta.authorship.mode !== 'human' ? ` · ${r.meta.authorship.mode}` : ''}`]), String(head.number))}
++<label for="correction-kind">With a public note</label>${select('correctionKind', [['', '— none —'], ['correction', 'Correction'], ['update', 'Update']], '')}
++<textarea id="correction-note" name="correctionNote" rows="2" maxlength="2000" aria-label="Correction or update note"></textarea>
++<button type="submit">Publish</button> <a href="/edit/stories/${story.id}/preview?revision=${head.number}">Preview</a></form>` : ''}
++${story.state === 'published' ? h`<form method="post" action="/edit/stories/${story.id}/retract">${csrfField(csrf)}<label for="retract-note">Retraction notice (public)</label><textarea id="retract-note" name="note" rows="2" maxlength="2000" required></textarea><button type="submit">Retract</button></form>` : ''}
++${story.state === 'published' || story.state === 'retracted' ? h`<form method="post" action="/edit/stories/${story.id}/unpublish">${csrfField(csrf)}<button type="submit">Unpublish (take the page down)</button></form>` : ''}
++</section>
++
++<section class="card"><h2>Flags and history</h2>
++<form method="post" action="/edit/stories/${story.id}/flag">${csrfField(csrf)}<label for="flag-kind">Add a note for the next publication</label>${select('kind', [['correction', 'Correction'], ['update', 'Update']], 'correction')}<textarea name="note" rows="2" maxlength="2000" required aria-label="Note"></textarea><button type="submit">Add</button></form>
++${flags.length ? h`<ul>${flags.map((f) => h`<li>${f.kind.replace('_', ' ')} · ${f.status}${f.revision ? ` · revision ${f.revision}` : ''} · ${time(f.created_at)}${f.note ? h` — ${f.note}` : ''}</li>`)}</ul>` : ''}
++<h3>Revisions</h3><ol reversed>${revisions.map((r) => h`<li>${r.number} · ${r.createdAt.slice(0, 16).replace('T', ' ')} · ${r.author || ''}${r.message ? h` — ${r.message}` : ''} · <a href="/edit/stories/${story.id}/preview?revision=${r.number}">preview</a>${r.number > 1 ? h` · <a href="/edit/stories/${story.id}/diff?from=${r.number - 1}&amp;to=${r.number}">diff</a>` : ''}</li>`)}</ol>
++</section>`);
++}
++
++function diffPage({ story, diff }) {
++ return String(h`<p><a href="/edit/stories/${story.id}">← Back to the story</a></p><h1>Revision ${diff.from} → ${diff.to}</h1>
++${diff.fields.length ? h`<p class="meta">Changed fields: ${diff.fields.map((f) => f.field).join(', ')}</p>` : ''}
++<div class="diff">${raw(ssr.diffHtml(diff.content))}</div>`);
++}
++
++function topicsPage({ topics, csrf, message }) {
++ return String(h`<p><a href="/edit">← Editor desk</a></p><h1>Topics</h1>${flash(message)}
++<ul>${topics.map((t) => h`<li>${t.name} <span class="meta">/topics/${t.slug}</span></li>`)}</ul>
++<form method="post" action="/edit/topics">${csrfField(csrf)}<label for="name">Name</label><input id="name" name="name" maxlength="80" required><label for="description">Description</label><input id="description" name="description" maxlength="300"><button type="submit">Add topic</button></form>`);
++}
++
++module.exports = { dashboard, clusterPage, storyEditor, diffPage, topicsPage, flash };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..4fbe465
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,104 @@
++'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
++ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a
++ * <noscript> navigation bar and the server-rendered shared footer (openvibe-shared)
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const seo = require('openvibe-publishing/seo');
++const { escapeHtml: esc } = require('openvibe-publishing/ssr');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.News';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++/**
++ * o: title, description, decision (required), canonical, type ('website'|'article'), image,
++ * jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,
++ * 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 || 'Source-backed news: every claim cites the reports it rests on.',
++ 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: 'news',
++ apiBase: NETWORK_URL,
++ links: [
++ { label: 'News', href: '/' },
++ { label: 'Topics', href: '/topics' },
++ ],
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${loginNext}`,
++ };
++ const account = signedIn
++ ? `${o.editor ? '<a href="/edit">Editor desk</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: 'news' })}
++<link rel="stylesheet" href="${asset('css/news.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: 'News', href: '/' }, { label: 'Topics', href: '/topics' }] })}
++<div class="account-bar" role="navigation" aria-label="Account">${account}</div>
++<main id="main" class="page">
++${o.body || ''}
++</main>
++${chrome.footer({ service: 'news', variant: 'full' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++module.exports = { renderPage, asset, assetVersion, SITE_NAME, NETWORK_URL };
ddiff --git a/server/render/pages.js b/server/render/pages.js
nnew file mode 100644
iindex 0000000..238757c
---- /dev/null
++++ b/server/render/pages.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++/**
++ * Public pages (HTML bodies; layout.js wraps them). Everything a reader needs is in the HTML: the
++ * text with its source numbers, the source table (factual fields from the sources, kept apart from
++ * the editors' text), the timeline, perspectives, the correction history and any retraction or
++ * upstream-change notice. Values are escaped by ssr.html unless wrapped in raw().
++ */
++const ssr = require('openvibe-publishing/ssr');
++
++const { html: h, raw } = ssr;
++
++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 }));
++const cites = (ns) => h`<sup class="cites">${ns.map((n, i) => h`${i ? ',' : ''}<a href="#source-${n}" aria-label="source ${n}">${n}</a>`)}</sup>`;
++
++function storyListItem(it) {
++ return h`<li class="story-item${it.retracted ? ' retracted' : ''}">
++<h2 class="story-item-title"><a href="${it.url}">${it.headline}</a>${it.retracted ? h` <span class="badge badge-retracted">Retracted</span>` : ''}</h2>
++<p class="meta">${time(it.published_at)}${it.topic ? h` · <a href="${it.topic.url}">${it.topic.name}</a>` : ''} · ${it.sourceCount} ${it.sourceCount === 1 ? 'source' : 'sources'}${it.outlets.length ? h` (${it.outlets.join(', ')})` : ''}</p>
++${it.lede ? h`<p class="summary">${it.lede}</p>` : ''}
++</li>`;
++}
++
++function storyList(items, empty) {
++ if (!items.length) return h`<p class="empty">${empty}</p>`;
++ return h`<ol class="story-list">${items.map(storyListItem)}</ol>`;
++}
++
++function feedLinksHtml(feeds) {
++ if (!feeds.length) return '';
++ return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;
++}
++
++function home({ items, pager, topics, feeds }) {
++ return String(h`<header class="site-header">
++<h1>OpenVibe.News</h1>
++<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>
++${raw(feedLinksHtml(feeds))}
++</header>
++<section aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>
++${raw(ssr.paginationHtml(pager))}
++${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);
++}
++
++function topicsIndex({ topics }) {
++ return String(h`<header class="site-header"><h1>Topics</h1></header>
++<ul class="topic-list wide">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a>${t.description ? h` <span class="meta">— ${t.description}</span>` : ''}</li>`)}</ul>`);
++}
++
++function topicPage({ topic, items, pager, feeds, breadcrumbs }) {
++ return String(h`${raw(ssr.breadcrumbsHtml(breadcrumbs))}
++<header class="site-header"><h1>${topic.name}</h1>${topic.description ? h`<p class="lede">${topic.description}</p>` : ''}${raw(feedLinksHtml(feeds))}</header>
++<section aria-label="Stories">${storyList(items, `No stories on ${topic.name} have been published yet.`)}</section>
++${raw(ssr.paginationHtml(pager))}`);
++}
++
++function sourceRow(s) {
++ if (s.status === 'removed' && !s.headline) {
++ return h`<tr id="source-${s.n}" class="removed"><td>${s.n}</td><td colspan="4"><strong>${s.outlet}</strong> — this source was removed by the source registry${s.removed_at ? h` on ${time(Date.parse(s.removed_at))}` : ''}. Its headline, summary and link are no longer shown.</td></tr>`;
++ }
++ return h`<tr id="source-${s.n}"${s.upstream_changed ? raw(' class="changed"') : ''}>
++<td>${s.n}</td>
++<td>${s.url ? h`<a href="${s.url}" rel="noopener nofollow">${s.headline}</a>` : s.headline}${s.summary ? h`<p class="source-summary">${s.summary}</p>` : ''}${s.upstream_changed ? h`<p class="notice small">The source changed after this revision; editors are checking it.</p>` : ''}${s.status === 'removed' ? h`<p class="notice small">Removed upstream${s.removed_reason ? h`: ${s.removed_reason}` : ''}.</p>` : ''}</td>
++<td>${s.outlet}</td>
++<td>${s.authors && s.authors.length ? s.authors.join(', ') : raw('<span class="meta">not stated</span>')}</td>
++<td>${s.published_at ? time(Date.parse(s.published_at)) : raw('<span class="meta">not stated</span>')}</td>
++</tr>`;
++}
++
++function sourceTable(m, group) {
++ const table = (rows) => h`<table class="sources"><thead><tr><th scope="col">#</th><th scope="col">Headline (as published by the source)</th><th scope="col">Outlet</th><th scope="col">Author</th><th scope="col">Published</th></tr></thead><tbody>${rows.map(sourceRow)}</tbody></table>`;
++ const byN = new Map(m.sources.map((s) => [s.n, s]));
++ const tabs = h`<p class="framing">Group sources by: ${['number', 'outlet', 'perspective'].map((g) => (g === group ? h`<strong>${g}</strong> ` : h`<a href="?group=${g}#sources" rel="nofollow">${g}</a> `))}</p>`;
++ if (group === 'outlet') {
++ return h`${tabs}${m.outletGroups.map((g) => h`<h3>${g.outlet}</h3>${table(g.sources.map((n) => byN.get(n)))}`)}`;
++ }
++ if (group === 'perspective') {
++ if (!m.perspectives.length) return h`${tabs}<p class="empty">Editors have not grouped these sources into perspectives.</p>${table(m.sources)}`;
++ const grouped = new Set(m.perspectives.flatMap((p) => p.sources));
++ const rest = m.sources.filter((s) => !grouped.has(s.n));
++ return h`${tabs}<p class="meta">Perspective labels are written by OpenVibe.News editors; they describe how the sources are grouped, not a verdict.</p>
++${m.perspectives.map((p) => h`<h3>${p.label}</h3>${p.description ? h`<p>${p.description}</p>` : ''}${p.sources.length ? table(p.sources.map((n) => byN.get(n))) : h`<p class="empty">No sources in this group.</p>`}`)}
++${rest.length ? h`<h3>Not grouped</h3>${table(rest)}` : ''}`;
++ }
++ return h`${tabs}${table(m.sources)}`;
++}
++
++/** One story. */
++function storyPage(m, { group = 'number', breadcrumbs, jsonUrl, editUrl = null, decisionNote = null }) {
++ const notices = [];
++ if (m.retraction) notices.push(h`<div class="retraction" role="alert"><h2>Retracted</h2><p>${m.retraction.note}</p><p class="meta">Retracted ${time(Date.parse(m.retraction.at))}. The story is kept here for the record and is not offered to search engines.</p></div>`);
++ for (const u of m.upstream) {
++ notices.push(h`<p class="notice" role="status">${u.kind === 'source_removed'
++ ? h`Source ${u.n ? `[${u.n}]` : ''} (${u.outlet || 'a source'}) was removed by the source registry after publication. Editors are reviewing this story.`
++ : h`Source ${u.n ? `[${u.n}]` : ''} (${u.outlet || 'a source'}) changed after publication. Editors are checking whether the text needs a correction.`}</p>`);
++ }
++ return String(h`${raw(ssr.breadcrumbsHtml(breadcrumbs))}
++<article class="story" data-story-id="${m.story.id}" data-revision="${m.rev.number}">
++<header>
++${raw(notices.join(''))}
++<h1>${m.headline}</h1>
++<p class="byline">${m.authors.length ? h`By ${m.authors.map((a) => a.name).join(', ')} · ` : ''}OpenVibe.News${m.first_published_at ? h` · ${time(Date.parse(m.first_published_at))}` : ''}${m.rev.number > 1 && m.first_published_at && Date.parse(m.updated_at) > Date.parse(m.first_published_at) + 60000 ? h` · updated ${time(Date.parse(m.updated_at))}` : ''}${m.topic ? h` · <a href="/topics/${m.topic.slug}">${m.topic.name}</a>` : ''}${m.story.state !== 'published' && m.story.state !== 'retracted' ? h` <span class="badge">${m.story.state}</span>` : ''}</p>
++${m.disclosure ? h`<p class="disclosure" role="note"><strong>${m.disclosure.short}.</strong> ${m.disclosure.long}</p>` : ''}
++</header>
++<div class="story-body">${m.paragraphs.map((p) => h`<p${p.supported ? '' : raw(' class="unsupported"')}>${p.text} ${cites(p.sources)}${p.supported ? '' : h` <span class="badge">source removed</span>`}</p>`)}</div>
++<p class="meta">Numbers in brackets point to the sources below. Headlines, outlets, authors and dates in the table are as each source published them; the text above is written by OpenVibe.News.</p>
++${m.timeline.length ? h`<section class="timeline" aria-labelledby="timeline-h"><h2 id="timeline-h">Timeline</h2><ol>${m.timeline.map((t) => h`<li><time datetime="${t.occurred_on}">${t.occurred_on.slice(0, 10)}</time> — ${t.text} ${cites([t.source])}</li>`)}</ol></section>` : ''}
++<section id="sources" class="source-table" aria-labelledby="sources-h"><h2 id="sources-h">Sources</h2>${sourceTable(m, group)}</section>
++${m.perspectives.length && group !== 'perspective' ? h`<section class="perspectives" aria-labelledby="persp-h"><h2 id="persp-h">Perspectives</h2><p class="meta">Groupings by OpenVibe.News editors.</p><ul>${m.perspectives.map((p) => h`<li><strong>${p.label}</strong>${p.description ? h` — ${p.description}` : ''} ${p.sources.length ? cites(p.sources) : ''}</li>`)}</ul></section>` : ''}
++<section class="corrections" aria-labelledby="corr-h"><h2 id="corr-h">Corrections and updates</h2>
++${m.corrections.length || m.retraction ? h`<ol>${m.corrections.map((c) => h`<li><strong>${c.kind === 'correction' ? 'Correction' : 'Update'}</strong> (${time(Date.parse(c.at))}, revision ${c.revision}): ${c.note}</li>`)}${m.retraction ? h`<li><strong>Retraction</strong> (${time(Date.parse(m.retraction.at))}): ${m.retraction.note}</li>` : ''}</ol>` : h`<p class="empty">None.</p>`}
++</section>
++<footer class="story-footer"><p class="meta">Revision ${m.rev.number}${decisionNote ? h` · ${decisionNote}` : ''} · <a href="${jsonUrl}">JSON</a>${editUrl ? h` · <a href="${editUrl}">Edit</a>` : ''}</p></footer>
++</article>`);
++}
++
++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 = { home, topicsIndex, topicPage, storyPage, message, storyList, time, dateLabel, cites };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..2e3fa45
---- /dev/null
++++ b/server/worker.js
@@@ -0,0 +1,37 @@
++'use strict';
++/**
++ * Background work in the News process (one timer):
++ *
++ * Sources cursor pull every NEWS_PULL_INTERVAL_MS (default 5 min): read news items from
++ * OpenVibe.Sources in change order from the stored cursor and apply them
++ * (idempotent), the backstop for webhook deliveries that never arrived.
++ * A failed pull is recorded and relayed once per outage; it creates nothing.
++ */
++function createWorker({ config, ingest, outbox, log = console }) {
++ let timer = null;
++
++ async function pullTick() {
++ if (!ingest) return null;
++ try {
++ const r = await ingest.pull();
++ outbox.kick();
++ return r;
++ } catch (err) {
++ log.error('[News] pull tick failed:', err.message);
++ return null;
++ }
++ }
++
++ return {
++ pullTick,
++ start() {
++ if (!config.worker.enabled || !config.sources.pullIntervalMs) return;
++ timer = setInterval(pullTick, config.sources.pullIntervalMs);
++ timer.unref();
++ setTimeout(pullTick, 2000).unref();
++ },
++ stop() { clearInterval(timer); },
++ };
++}
++
++module.exports = { createWorker };
ddiff --git a/test/ai.test.js b/test/ai.test.js
nnew file mode 100644
iindex 0000000..8fcf8dc
---- /dev/null
++++ b/test/ai.test.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * AI never owns publication truth: OpenVibe.AI output (news.summarize_story,
++ * news.compare_perspectives) arrives only as a DRAFT revision with AI authorship; it cannot be
++ * published or indexed until a person approves that revision; an editor's edit of it is
++ * AI-assisted and needs a review too; uncited model claims are dropped, never kept; a failed run
++ * makes no text; with no AI configured, editors write from the sources.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot({ ai: true });
++ const reports = launchReports(t.sources, { a: { fields: { body: 'SECRET-BODY-FOR-AI-5c1d' } } });
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).cluster_id;
++ const story = (await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper launch', topic: 'space' } })).json().story;
++ const aiToken = t.network.serviceToken('ai', ['news.story.revise']);
++ const aiHeaders = { 'x-ov-origin': 'ai' };
++ const wf = { id: 'news.summarize_story', runId: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8', version: 1 };
++ const paragraphs = [
++ { text: 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, beginning a cruise of several years to Jupiter and its moon Europa.', sources: [reports.a.id, reports.b.id] },
++ { text: 'According to NASA the spacecraft should arrive at Jupiter in 2030, after which it will make repeated close passes of Europa to study its ice shell and the ocean beneath.', sources: [reports.b.id] },
++ ];
++
++ await check('an AI delivery needs its workflow and cited paragraphs; it can only write drafts', async () => {
++ let r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: aiHeaders, json: { paragraphs, expected_revision: 0 } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'story.ai_workflow_required');
++ r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: aiHeaders, json: { paragraphs: [{ text: 'Uncited model claim.', sources: [] }], expected_revision: 0, authorship: { workflow: wf } } });
++ assert.strictEqual(r.json().code, 'story.claim_unsourced');
++ r = await t.get(`/api/v1/stories/${story.id}/publish`, { as: t.network.serviceToken('ai', ['news.story.publish']), headers: aiHeaders, json: {} });
++ assert.strictEqual(r.status, 403, 'AI cannot publish');
++ });
++
++ let rev;
++ await check('an AI draft is an AI-generated revision that cannot be published before a person reviews it', async () => {
++ const r = await t.get(`/api/v1/stories/${story.id}/revisions`, { as: aiToken, headers: aiHeaders, json: { headline: 'Europa Clipper is on its way to Jupiter', paragraphs, expected_revision: 0, authorship: { workflow: wf } } });
++ assert.strictEqual(r.status, 201, r.text);
++ rev = r.json().revision;
++ const head = r.json().story.head;
++ assert.strictEqual(head.authorship.mode, 'ai');
++ assert.strictEqual(head.authorship.workflow.id, 'news.summarize_story');
++ assert.deepStrictEqual(head.authorship.authors, [], 'never attributed to a person');
++ assert.ok(r.json().story.publishable.some((p) => p.code === 'story.review_required'));
++ const pub = await t.api(`/stories/${story.id}/publish`, { json: { revision: rev } });
++ assert.strictEqual(pub.status, 409);
++ assert.strictEqual(pub.json().code, 'story.review_required');
++ const svcReview = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: aiToken, json: { revision: rev, decision: 'approved' } });
++ assert.strictEqual(svcReview.status, 403, 'a service cannot review');
++ const reader = await t.get(`/api/v1/stories/${story.id}/reviews`, { as: t.network.addUser('reader'), json: { revision: rev, decision: 'approved' } });
++ assert.strictEqual(reader.status, 403, 'only an editor reviews');
++ });
++
++ await check('after a person approves, it publishes with an AI disclosure; Search sees ai_generated', async () => {
++ let r = await t.api(`/stories/${story.id}/reviews`, { json: { revision: rev, decision: 'approved', note: 'Checked every claim against [1] and [2].' } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: rev } });
++ assert.strictEqual(r.status, 200, r.text);
++ const page = await t.get(`/stories/${story.slug}`);
++ assert.match(page.text, /<strong>AI-generated\.<\/strong> AI-generated from the story’s source items by workflow news.summarize_story v1, reviewed by a person\./);
++ assert.match(page.text, /<meta name="robots" content="index, follow">/);
++ const doc = t.events('news.index_document.upserted').pop().payload;
++ assert.strictEqual(doc.authorship, 'ai_generated');
++ assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === wf.runId));
++ const json = (await t.get(`/stories/${story.slug}.json`)).json();
++ assert.strictEqual(json.authorship.reviewed_by_person, true);
++ });
++
++ await check('an editor’s edit of AI text is AI-assisted: it needs its own review before publication', async () => {
++ let r = await t.api(`/stories/${story.id}/revisions`, { json: { body: `${paragraphs[0].text} [1, 2]\n\n${paragraphs[1].text} Arrival is planned for April 2030. [2]`, expected_revision: rev } });
++ assert.strictEqual(r.status, 201, r.text);
++ const n = r.json().revision;
++ assert.strictEqual(r.json().story.head.authorship.mode, 'hybrid');
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: n } });
++ assert.strictEqual(r.status, 409);
++ assert.match(r.json().detail, /AI-assisted/);
++ await t.api(`/stories/${story.id}/reviews`, { json: { revision: n, decision: 'approved' } });
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: n } });
++ assert.strictEqual(r.status, 200, r.text);
++ const page = await t.get(`/stories/${story.slug}`);
++ assert.match(page.text, /Written with AI assistance\..*Reviewed by a person\./);
++ });
++
++ await check('the AI seam: a run becomes a draft; uncited claims are dropped as gaps; only stored fields are sent', async () => {
++ const s2 = (await t.api('/stories', { json: { cluster: clusterId, headline: 'Second draft by AI' } })).json().story;
++ t.ai.setNext({ status: 201, run: { id: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7D9', status: 'succeeded', workflow: { key: 'news.summarize_story', version: 2 }, synthetic: false, provenance: { model: 'test-model' },
++ output: { headline: 'Europa Clipper launches', summary: 'NASA launched Europa Clipper toward Jupiter.', key_points: [{ text: 'The probe will reach Jupiter in 2030.', citations: [1] }, { text: 'An uncited claim the model made up.', citations: [] }, { text: 'Out of range', citations: [9] }], timeline: [], citations: [0, 1], gaps: ['launch cost'] } } });
++ const r = await t.api(`/stories/${s2.id}/ai-drafts`, { json: { workflow: 'news.summarize_story' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const head = r.json().story.head;
++ assert.strictEqual(head.authorship.mode, 'ai');
++ assert.strictEqual(head.authorship.workflow.runId, 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7D9');
++ assert.deepStrictEqual(head.paragraphs.map((p) => p.sources), [[1, 2], [2]]);
++ assert.ok(!head.paragraphs.some((p) => /made up|Out of range/.test(p.text)));
++ assert.ok(head.gaps.some((g) => /uncited key point/.test(g)));
++ assert.ok(head.gaps.includes('launch cost'));
++ const sent = JSON.stringify(t.ai.requests.pop());
++ assert.ok(!sent.includes('SECRET-BODY-FOR-AI'), 'no body is ever sent to the model');
++ assert.match(sent, /"source_type":"news.article"/);
++ assert.strictEqual(t.ctx.stories.get(s2.id).state, 'draft');
++ });
++
++ await check('a stub-provider run is held (stub_provider) and a failed run makes no text', async () => {
++ const s3 = (await t.api('/stories', { json: { cluster: clusterId, headline: 'Third draft' } })).json().story;
++ t.ai.setNext({ status: 201, run: { id: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7DA', status: 'failed', output: null } });
++ let r = await t.api(`/stories/${s3.id}/ai-drafts`, { json: { workflow: 'news.compare_perspectives' } });
++ assert.strictEqual(r.status, 502);
++ assert.strictEqual(t.ctx.store.revisions.head(s3.id), null);
++ t.ai.setNext({ status: 201, run: { id: 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7DB', status: 'succeeded', synthetic: true, workflow: { key: 'news.compare_perspectives', version: 1 },
++ output: { question: 'How did outlets frame the launch?', perspectives: [{ label: 'Launch', summary: 'Outlets described the launch.', citations: [0, 1] }], agreements: [], disagreements: [], citations: [], gaps: [] } } });
++ r = await t.api(`/stories/${s3.id}/ai-drafts`, { json: { workflow: 'news.compare_perspectives' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const head = t.ctx.store.revisions.head(s3.id);
++ assert.strictEqual(head.meta.authorship.stubProvider, true);
++ const d = t.ctx.publication.decide(t.ctx.stories.get(s3.id), head, { state: 'published' });
++ assert.ok(d.codes.includes('stub_provider') && d.codes.includes('ai_generated_unreviewed'));
++ assert.strictEqual(t.db().prepare('SELECT COUNT(*) AS n FROM news_perspectives WHERE story_id = ?').get(s3.id).n, 0, 'AI never creates perspective labels');
++ });
++
++ await check('the published product events validate as events.event-envelope@1', async () => {
++ for (const e of t.events(/^news\./)) {
++ const v = contracts.validate('events.event-envelope@1', { ...e, event_id: e.event_id || 'evt_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8' });
++ assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
++ }
++ });
++
++ await t.close();
++
++ const t2 = await boot();
++ await check('with no AI configured, the AI seam is off and says so', async () => {
++ const r2 = launchReports(t2.sources);
++ await t2.pull();
++ const cid = t2.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(r2.a.id).cluster_id;
++ const s = (await t2.api('/stories', { json: { cluster: cid, headline: 'Written by hand' } })).json().story;
++ const r = await t2.api(`/stories/${s.id}/ai-drafts`, { json: { workflow: 'news.summarize_story' } });
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.json().code, 'ai.not_configured');
++ const edit = await t2.get(`/edit/stories/${s.id}`, { as: t2.editor });
++ assert.doesNotMatch(edit.text, /Ask OpenVibe.AI/);
++ });
++ await t2.close();
++ done();
++})();
ddiff --git a/test/clusters.test.js b/test/clusters.test.js
nnew file mode 100644
iindex 0000000..789b2dd
---- /dev/null
++++ b/test/clusters.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Clusters are deterministic and explainable, and every merge and split is audited and reversible:
++ * reversing puts exactly the moved items back, reopens (or dissolves) the other cluster, and is
++ * audited too. The same input in the same order gives the same clusters.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const reports = launchReports(t.sources);
++ const other = t.sources.addItem({ source_key: 'paper-b', title: 'Clipper mission team answers questions about Jupiter cruise', url: 'https://paper-b.example/qa', published_at: '2026-09-22T10:30:00Z' });
++ const unrelated = t.sources.addItem({ source_key: 'site-c', title: 'Local bakery wins regional sourdough prize', url: 'https://site-c.example/bakery', published_at: '2026-09-22T10:40:00Z' });
++ await t.pull();
++ const item = (itm) => t.db().prepare('SELECT * FROM news_source_items WHERE sources_item_id = ?').get(itm);
++ const main = item(reports.a.id).cluster_id;
++ const lone = item(unrelated.id).cluster_id;
++
++ await check('clustering is deterministic: rebuilding from the same items in the same order gives the same grouping', async () => {
++ const { createClusters } = require('../server/domain/clusters');
++ const cfg = t.ctx.config;
++ const { openStore } = require('../server/db');
++ const store = openStore(':memory:', { now: t.ctx.store.now });
++ const db = store.db;
++ const events = [];
++ const c2 = createClusters({ store, config: cfg, outbox: { emit: (e) => events.push(e) } });
++ const rows = t.db().prepare("SELECT * FROM news_source_items WHERE status <> 'duplicate' ORDER BY first_seen_at, rowid").all();
++ const groups = new Map();
++ for (const r of rows) {
++ db.prepare('INSERT INTO news_source_items (id, sources_item_id, sources_revision, source_key, headline, outlet, title_key, status, published_at, summary, first_seen_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
++ .run(r.id, r.sources_item_id, r.sources_revision, r.source_key, r.headline, r.outlet, r.title_key, r.status, r.published_at, r.summary, r.first_seen_at, r.updated_at);
++ const out = c2.assign(db.prepare('SELECT * FROM news_source_items WHERE id = ?').get(r.id));
++ groups.set(r.id, out.cluster.id);
++ }
++ const original = new Map(rows.map((r) => [r.id, r.cluster_id]));
++ const same = (m) => { const byC = new Map(); for (const [i, c] of m) { if (!byC.has(c)) byC.set(c, []); byC.get(c).push(i); } return [...byC.values()].map((v) => v.sort().join(',')).sort(); };
++ assert.deepStrictEqual(same(groups), same(original));
++ });
++
++ await check('the cluster page explains every membership (shared entities and terms, score, window)', async () => {
++ const r = await t.api(`/clusters/${main}`);
++ assert.strictEqual(r.status, 200, r.text);
++ const c = r.json().cluster;
++ assert.ok(c.item_count >= 3);
++ assert.ok(c.key_entities.some((e) => e.entity === 'europa clipper'));
++ for (const it of c.items) assert.ok(it.cluster_reason && it.cluster_reason.rule, JSON.stringify(it));
++ const page = await t.get(`/clusters/${main}`, { as: t.editor });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /shares entities/);
++ assert.match(page.headers.get('x-robots-tag'), /noindex/);
++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
++ const anon = await t.get(`/clusters/${main}`);
++ assert.strictEqual(anon.status, 303, 'clusters are for editors');
++ const reader = await t.get(`/clusters/${main}`, { as: t.network.addUser('reader') });
++ assert.strictEqual(reader.status, 403);
++ });
++
++ let mergeAudit;
++ await check('merge is audited: the absorbed cluster’s items move, it is marked merged', async () => {
++ const before = t.ctx.clusters.members(lone).map((i) => i.id);
++ const r = await t.api(`/clusters/${main}/merge`, { json: { other: lone, reason: 'testing a merge' } });
++ assert.strictEqual(r.status, 200, r.text);
++ mergeAudit = r.json().audit;
++ assert.strictEqual(mergeAudit.action, 'merge');
++ assert.deepStrictEqual(mergeAudit.item_ids.sort(), before.sort());
++ assert.strictEqual(item(unrelated.id).cluster_id, main);
++ assert.strictEqual(t.ctx.clusters.get(lone).status, 'merged');
++ assert.strictEqual(t.ctx.clusters.get(lone).merged_into, main);
++ const ev = t.events('news.cluster.updated').filter((e) => e.payload.action === 'merged');
++ assert.strictEqual(ev.length, 1);
++ });
++
++ await check('reversing the merge restores exactly the moved items and reopens the cluster', async () => {
++ const r = await t.api(`/clusters/audit/${mergeAudit.id}/reverse`, { json: { reason: 'wrong merge' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().audit.action, 'reverse_merge');
++ assert.strictEqual(item(unrelated.id).cluster_id, lone);
++ assert.strictEqual(item(reports.a.id).cluster_id, main);
++ assert.strictEqual(t.ctx.clusters.get(lone).status, 'open');
++ const again = await t.api(`/clusters/audit/${mergeAudit.id}/reverse`, { json: {} });
++ assert.strictEqual(again.status, 409);
++ assert.strictEqual(again.json().code, 'cluster.already_reversed');
++ });
++
++ let splitAudit;
++ let splitId;
++ await check('split is audited: chosen items move to a new cluster; at least one must stay', async () => {
++ const all = t.ctx.clusters.members(main).map((i) => i.id);
++ let r = await t.api(`/clusters/${main}/split`, { json: { items: all } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'cluster.split_all');
++ const moving = [item(other.id).id];
++ r = await t.api(`/clusters/${main}/split`, { json: { items: moving, reason: 'a Q&A is a different story' } });
++ assert.strictEqual(r.status, 201, r.text);
++ splitAudit = r.json().audit;
++ splitId = r.json().created.id;
++ assert.strictEqual(item(other.id).cluster_id, splitId);
++ assert.strictEqual(t.ctx.clusters.get(splitId).split_from, main);
++ });
++
++ await check('reversing the split puts the items back and dissolves the empty cluster (through the no-JS form)', async () => {
++ const csrf = t.csrf(t.editor);
++ const r = await t.get(`/clusters/audit/${splitAudit.id}/reverse`, { as: t.editor, form: { _csrf: csrf, reason: 'undo' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual(item(other.id).cluster_id, main);
++ assert.strictEqual(t.ctx.clusters.get(splitId).status, 'dissolved');
++ const audit = t.ctx.clusters.audit(main).map((a) => a.action);
++ assert.deepStrictEqual(audit, ['merge', 'reverse_merge', 'split', 'reverse_split']);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..962bd9a
---- /dev/null
++++ b/test/contracts.test.js
@@@ -0,0 +1,64 @@
++'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; every
++ * event type the code emits is declared.
++ */
++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 ROOT = path.join(__dirname, '..');
++const DIR = path.join(ROOT, '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(ROOT, 'docs', 'service-manifest-proposal.json'), 'utf8'));
++
++ await check('every capability proposal is a valid capabilities.capability@1 with 3+ segments, owned by news', 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, 'news');
++ assert.ok(c.id.split('.').length >= 3);
++ assert.strictEqual(`${c.id}.json`, files[caps.indexOf(c)]);
++ const released = contracts.capabilities.get(c.id);
++ assert.ok(!released || released.owner === 'news', `${c.id} collides with a released capability`);
++ }
++ });
++
++ await check('the charter capabilities are all present as 3-segment ids', async () => {
++ for (const id of ['news.story.create', 'news.story.revise', 'news.story.publish', 'news.cluster.read', 'news.source.attach', 'news.timeline.update']) assert.ok(PROPOSED.has(id), id);
++ });
++
++ await check('the proposals are exactly the capabilities the code enforces, and every route guard names one', async () => {
++ assert.deepStrictEqual(caps.map((c) => c.id).sort(), [...PROPOSED].sort());
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...PROPOSED].sort());
++ const api = fs.readFileSync(path.join(ROOT, 'server', 'http', 'api.js'), 'utf8');
++ for (const m of api.matchAll(/(?:guard\(|write\('[a-z]+', '[^']+', )'(news\.[a-z_.]+)'/g)) assert.ok(PROPOSED.has(m[1]), m[1]);
++ });
++
++ 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, 'news');
++ for (const c of caps) for (const e of c.events) assert.ok(manifest.eventsProduced.includes(e), `${c.id} names ${e}, missing from eventsProduced`);
++ for (const e of ['news.source.ingested', 'news.source.failed', 'news.cluster.updated', 'news.story.published', 'news.story.updated', 'news.story.retracted']) assert.ok(manifest.eventsProduced.includes(e), e);
++ const released = contracts.services && contracts.services.get ? contracts.services.get('sources') : null;
++ for (const e of manifest.eventsConsumed) assert.ok(!released || released.eventsProduced.includes(e), `${e} is not produced by sources`);
++ });
++
++ await check('every event type the code emits is declared', async () => {
++ const src = ['server/domain/stories.js', 'server/domain/publication.js', 'server/domain/ingest.js', 'server/domain/clusters.js']
++ .map((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')).join('\n');
++ const found = [...src.matchAll(/event_type: '([a-z_.]+)'/g)].map((m) => m[1]);
++ assert.ok(found.length >= 6);
++ for (const e of found) assert.ok(manifest.eventsProduced.includes(e), e);
++ });
++
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..76706af
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Boots News 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, form, json })
++ * t.editor // a Network user listed in NEWS_EDITORS
++ * t.sources.addItem({...}); await t.pull() // Sources items → News
++ * t.deliver(event) // a signed Events webhook delivery
++ * t.clock.advance(ms)
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { startNetwork, startSources, startAi, delivery } = require('./mocks');
++
++const SECRET = 'test-webhook-secret-0123456789abcdef0123';
++
++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 sources = await startSources({ network });
++ const ai = await startAi({ network });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-news-test-'));
++ const dbPath = path.join(dir, 'news.db');
++ const clock = opts.clock || makeClock();
++ const editor = network.addUser('ed', { display_name: 'Edie Editor' });
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.news', TRUST_PROXY: '1',
++ NEWS_DB_PATH: dbPath,
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'news', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
++ OV_SOURCES_INTERNAL_URL: sources.url,
++ NEWS_EDITORS: editor.subject, NEWS_WORKER: 'off', NEWS_FORM_SECRET: 'test-form-secret',
++ NEWS_EVENTS_SECRET: SECRET,
++ ...(opts.ai ? { OV_AI_INTERNAL_URL: ai.url } : {}),
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
++
++ let server = null;
++ let built = null;
++ async function start() {
++ const config = configLib.load(env);
++ built = createApp({ config, now: clock.now, log: quiet });
++ await built.ctx.auth.ensureKey();
++ server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ t.base = `http://127.0.0.1:${server.address().port}`;
++ t.app = built.app;
++ t.ctx = built.ctx;
++ }
++ async function stop() {
++ if (server) await new Promise((r) => server.close(r));
++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
++ server = null; built = null;
++ }
++
++ /** as: a network user ({ subject, … }) → ov_token cookie; a string → Bearer token. */
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as && typeof o.as === 'object') headers.cookie = `ov_token=${network.userToken(o.as)}`;
++ if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ const res = await fetch(t.base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
++ const text = await res.text();
++ return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
++ }
++
++ /** 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)));
++ }
++
++ async function deliver(event, o = {}) {
++ const d = delivery(event, o.secret || SECRET, o);
++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', headers: d.headers, body: d.body });
++ return { status: res.status, text: await res.text(), envelope: d.envelope, body: d.body, headers: d.headers };
++ }
++
++ /** API call as the editor (browser JWT). */
++ const api = (p, o = {}) => get(`/api/v1${p}`, { as: editor, ...o });
++
++ const t = {
++ network, sources, ai, clock, dbPath, editor, get, api, events, deliver, SECRET,
++ pull: () => t.ctx.ingest.pull(),
++ db: () => t.ctx.store.db,
++ csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.NEWS_FORM_SECRET }, user),
++ async restart() { await stop(); await start(); },
++ async close() { await stop(); await network.close(); await sources.close(); await ai.close(); fs.rmSync(dir, { recursive: true, force: true }); },
++ };
++ await start();
++ return t;
++}
++
++/** A cluster-ready set: three reports of one event from three outlets, all in the news category. */
++function launchReports(sources, extra = {}) {
++ sources.addSource('wire-a', { name: 'Wire A' });
++ sources.addSource('paper-b', { name: 'Paper B' });
++ sources.addSource('site-c', { name: 'Site C' });
++ const a = sources.addItem({ source_key: 'wire-a', title: 'NASA launches Europa Clipper probe to Jupiter moon Europa', url: 'https://wire-a.example/space/europa-clipper-launch', summary: 'The Europa Clipper spacecraft lifted off on a Falcon Heavy rocket from Kennedy Space Center.', authors: ['Ana Reporter'], published_at: '2026-09-22T09:00:00Z', ...(extra.a || {}) });
++ const b = sources.addItem({ source_key: 'paper-b', title: 'Europa Clipper lifts off as NASA begins long trip to Jupiter', url: 'https://paper-b.example/2026/09/22/europa-clipper', summary: 'NASA said the Europa Clipper mission will reach Jupiter in 2030.', authors: ['Ben Writer'], published_at: '2026-09-22T09:30:00Z', ...(extra.b || {}) });
++ const c = sources.addItem({ source_key: 'site-c', title: 'Why NASA is sending Europa Clipper to Jupiter', url: 'https://site-c.example/europa-clipper-why', summary: null, authors: [], published_at: '2026-09-22T10:00:00Z', ...(extra.c || {}) });
++ return { a, b, c };
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done, makeClock, launchReports, SECRET };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..53a26f7
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * In-process stand-ins for News' 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)
++ * Sources /api/v1/items (change order, category filter, include_removed), /api/v1/items/:id,
++ * /api/v1/sources/:key — items the test adds, revises and removes
++ * AI /api/v1/runs — a canned run result the test sets
++ * userToken()/serviceToken() mint the tokens browsers and services present to News;
++ * delivery() signs an Events webhook delivery.
++ */
++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.news'], cap });
++ }
++ return { ...srv, publicPem, grants, directory, addUser, userToken, serviceToken, signService };
++}
++
++
++async function startSources({ network }) {
++ const items = []; // Sources item views
++ const sources = new Map(); // key → registry record
++ const calls = [];
++ let down = false;
++ let seq = 0;
++ let n = 0;
++ const srv = await listen((req, raw, json) => {
++ calls.push({ method: req.method, url: req.url });
++ if (down) return json(503, { code: 'down' });
++ const token = String(req.headers.authorization || '').slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.sources' });
++ const u = new URL(req.url, 'http://x');
++ const need = u.pathname.startsWith('/api/v1/sources') ? 'sources.source.read' : 'sources.item.read';
++ if (!v.ok || !(v.claims.cap || []).includes(need)) return json(403, { code: 'capability.denied' });
++ if (u.pathname === '/api/v1/items') {
++ const after = Number(u.searchParams.get('after') || 0);
++ const limit = Number(u.searchParams.get('limit') || 100);
++ const cat = u.searchParams.get('category');
++ const incl = u.searchParams.get('include_removed') === '1';
++ const rows = items.filter((i) => i.change_seq > after && (!cat || i.category === cat) && (incl || !i.removed)).sort((a, b) => a.change_seq - b.change_seq);
++ const page = rows.slice(0, limit);
++ const status = {};
++ for (const i of page) status[i.source_key] = { status: 'healthy', stale: false, last_success_at: '2026-09-22T11:00:00.000Z' };
++ return json(200, { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: rows.length > limit, sources: status });
++ }
++ let m = u.pathname.match(/^\/api\/v1\/items\/([A-Za-z0-9_]+)$/);
++ if (m) {
++ const it = items.find((i) => i.id === m[1]);
++ if (!it) return json(404, { code: 'sources.not_found' });
++ return json(200, { item: it, source: { key: it.source_key, status: 'healthy', stale: false, last_success_at: '2026-09-22T11:00:00.000Z' } });
++ }
++ m = u.pathname.match(/^\/api\/v1\/sources\/([a-z0-9-]+)$/);
++ if (m) {
++ const s = sources.get(m[1]);
++ if (!s) return json(404, { code: 'sources.not_found' });
++ return json(200, { source: s });
++ }
++ return json(404, { code: 'route.not_found' });
++ });
++ const ULID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++ const newId = () => { n++; let s = String(n); while (s.length < 26) s = `0${s}`; return `itm_${s.replace(/[^0-9]/g, '0')}`; };
++ function addSource(key, extra = {}) {
++ sources.set(key, { key, name: extra.name || key, homepage_url: extra.homepage_url || null, category: extra.category || 'news', health: { status: 'healthy', stale: false, last_success_at: '2026-09-22T11:00:00.000Z' } });
++ }
++ /** Add an item as Sources' ingest would. fields: title, url, summary, authors, published_at, source_key, terms_note, license_note, category, fields */
++ function addItem(f = {}) {
++ const it = {
++ id: newId(), source_key: f.source_key || 'wire-a', category: f.category || 'news', kind: f.kind || 'article', identity: f.url || `id-${n}`,
++ canonical_url: f.url === undefined ? `https://example.org/a/${n}` : f.url, title: f.title === undefined ? `Item ${n}` : f.title, summary: f.summary || null,
++ authors: f.authors || [], published_at: f.published_at || null, source_updated_at: null, fields: f.fields || {},
++ revision: 1,
++ provenance: {
++ retrieved_at: f.retrieved_at || '2026-09-22T11:00:00.000Z', first_seen_at: '2026-09-22T11:00:00.000Z',
++ content_hash: f.content_hash || require('crypto').createHash('sha256').update(`${n}:${f.title}:${f.url}`).digest('hex'),
++ raw_body_hash: 'x', parser_version: 'feed@1', fetch_run_id: 'run_1',
++ license_note: f.license_note === undefined ? null : f.license_note,
++ terms_note: f.terms_note === undefined ? 'Titles, links and short summaries only; link back.' : f.terms_note, entered_by: null,
++ },
++ removed: null, change_seq: ++seq,
++ };
++ items.push(it);
++ return it;
++ }
++ function updateItem(id, f = {}) {
++ const it = items.find((i) => i.id === id);
++ if (f.title !== undefined) it.title = f.title;
++ if (f.summary !== undefined) it.summary = f.summary;
++ if (f.published_at !== undefined) it.published_at = f.published_at;
++ it.provenance.content_hash = require('crypto').createHash('sha256').update(`${id}:${it.title}:${it.revision + 1}`).digest('hex');
++ it.revision++;
++ it.change_seq = ++seq;
++ return it;
++ }
++ function removeItem(id, reason) {
++ const it = items.find((i) => i.id === id);
++ it.removed = { at: '2026-09-22T12:30:00.000Z', reason };
++ it.revision++;
++ it.change_seq = ++seq;
++ return it;
++ }
++ void ULID;
++ return { ...srv, items, calls, addSource, addItem, updateItem, removeItem, setDown: (v) => { down = v; } };
++}
++
++async function startAi({ network }) {
++ let next = null; // { status, run } the next POST /api/v1/runs answers with
++ const requests = [];
++ const srv = await listen((req, raw, json) => {
++ const token = String(req.headers.authorization || '').slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.ai' });
++ if (!v.ok || !(v.claims.cap || []).includes('ai.run.create')) return json(403, { code: 'capability.denied' });
++ if (req.url.startsWith('/api/v1/runs') && req.method === 'POST') {
++ requests.push(JSON.parse(raw || '{}'));
++ if (!next) return json(503, { code: 'ai.unavailable' });
++ return json(next.status || 201, { run: next.run });
++ }
++ return json(404, { code: 'route.not_found' });
++ });
++ return { ...srv, requests, setNext: (v) => { next = v; } };
++}
++
++/** An Events webhook delivery for News: { body, headers } signed with the secret. */
++function delivery(event, secret, { seq = 1, attempt = 1 } = {}) {
++ const envelope = { event_id: `evt_${ids.ulid(Date.now())}`, version: 1, timestamp: new Date().toISOString(), visibility: 'internal', actor: { type: 'service', id: 'sources' }, source: 'sources', payload: {}, ...event };
++ const body = JSON.stringify({ event: envelope, seq });
++ const sig = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
++ return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
++}
++
++module.exports = { startNetwork, startSources, startAi, delivery, listen };
ddiff --git a/test/ingest.test.js b/test/ingest.test.js
nnew file mode 100644
iindex 0000000..2d802ee
---- /dev/null
++++ b/test/ingest.test.js
@@@ -0,0 +1,178 @@
++'use strict';
++/**
++ * Ingestion: the cursor pull and the Events webhook bring Sources items in exactly once; dedupe by
++ * canonical URL, content hash and near-duplicate headlines; failures are recorded states that never
++ * create a source item, a cluster or any story text.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const count = (table) => t.db().prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ const byItm = (id) => t.db().prepare('SELECT * FROM news_source_items WHERE sources_item_id = ?').get(id);
++
++ await check('only topics are seeded: no stories, source items or clusters exist on boot', async () => {
++ assert.ok(count('news_topics') >= 10);
++ for (const table of ['news_stories', 'news_source_items', 'news_story_clusters', 'news_story_revisions', 'news_editorial_flags']) assert.strictEqual(count(table), 0, table);
++ const home = await t.get('/');
++ assert.match(home.text, /No stories have been published yet/);
++ });
++
++ let reports;
++ await check('the cursor pull ingests news items in change order and clusters them with an explanation', async () => {
++ reports = launchReports(t.sources);
++ t.sources.addItem({ source_key: 'blog-x', category: 'blog', title: 'A blog item that News must ignore' });
++ const r = await t.pull();
++ assert.ok(r.ok, JSON.stringify(r));
++ assert.strictEqual(r.counts.created, 3);
++ assert.strictEqual(count('news_source_items'), 3);
++ const a = byItm(reports.a.id);
++ const b = byItm(reports.b.id);
++ const c = byItm(reports.c.id);
++ assert.strictEqual(a.outlet, 'Wire A', 'outlet from the Sources registry name');
++ assert.strictEqual(a.cluster_id, b.cluster_id);
++ assert.strictEqual(a.cluster_id, c.cluster_id);
++ const why = JSON.parse(b.cluster_reason);
++ assert.strictEqual(why.rule, 'shared_terms');
++ assert.ok(why.shared_entities.includes('europa clipper'), JSON.stringify(why));
++ assert.ok(why.score >= 3);
++ assert.strictEqual(t.ctx.store.getState('sources_cursor'), reports.c.change_seq);
++ assert.strictEqual(t.events('news.source.ingested').length, 3);
++ assert.ok(t.events('news.cluster.updated').length >= 3);
++ });
++
++ await check('a second pull (and a replayed page) changes nothing and emits nothing', async () => {
++ const before = t.events().length;
++ const r = await t.pull();
++ assert.strictEqual(r.counts.created || 0, 0);
++ t.ctx.store.setState('sources_cursor', 0);
++ const again = await t.pull();
++ assert.strictEqual(again.counts.unchanged, 3);
++ assert.strictEqual(t.events().length, before);
++ assert.strictEqual(count('news_source_items'), 3);
++ });
++
++ await check('dedupe: same canonical URL (tracking parameters and www. ignored) is a duplicate', async () => {
++ const dup = t.sources.addItem({ source_key: 'paper-b', title: 'Europa Clipper launch: our live coverage', url: 'https://www.wire-a.example/space/europa-clipper-launch/?utm_source=x', published_at: '2026-09-22T11:00:00Z' });
++ await t.pull();
++ const row = byItm(dup.id);
++ assert.strictEqual(row.status, 'duplicate');
++ assert.strictEqual(row.duplicate_of, byItm(reports.a.id).id);
++ assert.strictEqual(JSON.parse(row.dedupe).rule, 'canonical_url');
++ assert.strictEqual(row.cluster_id, byItm(reports.a.id).cluster_id, 'a duplicate follows its original into the cluster');
++ });
++
++ await check('dedupe: identical Sources content hash is a duplicate', async () => {
++ const dup = t.sources.addItem({ source_key: 'site-c', title: 'Syndicated copy', url: 'https://mirror.example/copy', content_hash: reports.b.provenance.content_hash });
++ await t.pull();
++ const row = byItm(dup.id);
++ assert.strictEqual(row.status, 'duplicate');
++ assert.strictEqual(JSON.parse(row.dedupe).rule, 'content_hash');
++ assert.strictEqual(row.duplicate_of, byItm(reports.b.id).id);
++ });
++
++ await check('dedupe: a near-identical headline (2-shingle Jaccard) within 48 h is a duplicate; a different story is not', async () => {
++ const near = t.sources.addItem({ source_key: 'wire-a', title: 'NASA launches Europa Clipper probe to Jupiter moon Europa, officials say', url: 'https://wire-a.example/space/europa-clipper-launch-updated', published_at: '2026-09-22T12:00:00Z' });
++ const other = t.sources.addItem({ source_key: 'paper-b', title: 'City council approves new bike lanes downtown', url: 'https://paper-b.example/bike-lanes', published_at: '2026-09-22T12:00:00Z' });
++ await t.pull();
++ const n = byItm(near.id);
++ assert.strictEqual(n.status, 'duplicate', JSON.stringify(n));
++ assert.strictEqual(JSON.parse(n.dedupe).rule, 'near_title');
++ assert.match(JSON.parse(n.dedupe).detail, /jaccard 0\.\d\d/);
++ const o = byItm(other.id);
++ assert.strictEqual(o.status, 'active');
++ assert.notStrictEqual(o.cluster_id, byItm(reports.a.id).cluster_id, 'an unrelated story starts its own cluster');
++ assert.strictEqual(JSON.parse(o.cluster_reason).rule, 'new_cluster');
++ });
++
++ await check('a failed pull is recorded, relayed once per outage, and creates nothing', async () => {
++ const items = count('news_source_items');
++ const clusters = count('news_story_clusters');
++ t.sources.addItem({ source_key: 'wire-a', title: 'Arrives while Sources is down' });
++ t.sources.setDown(true);
++ const r1 = await t.pull();
++ const r2 = await t.pull();
++ assert.strictEqual(r1.ok, false);
++ assert.strictEqual(r2.ok, false);
++ assert.strictEqual(count('news_source_items'), items);
++ assert.strictEqual(count('news_story_clusters'), clusters);
++ assert.strictEqual(count('news_stories'), 0);
++ const runs = t.db().prepare("SELECT * FROM news_ingest_runs WHERE origin = 'pull' AND state = 'failed'").all();
++ assert.strictEqual(runs.length, 2);
++ assert.strictEqual(t.events('news.source.failed').filter((e) => e.payload.origin === 'pull').length, 1);
++ const ready = await t.get('/api/ready');
++ assert.match(ready.text, /last pull failed/);
++ t.sources.setDown(false);
++ const r3 = await t.pull();
++ assert.ok(r3.ok);
++ assert.strictEqual(count('news_source_items'), items + 1);
++ });
++
++ await check('webhook: a signed sources.item.created is applied once; a redelivery changes nothing', async () => {
++ const it = t.sources.addItem({ source_key: 'site-c', title: 'Europa Clipper team describes first weeks of cruise to Jupiter', url: 'https://site-c.example/cruise', published_at: '2026-09-22T12:30:00Z' });
++ const ev = { event_type: 'sources.item.created', subject: { type: 'item', id: it.id, revision: 1 }, payload: { item_id: it.id, source_key: 'site-c', category: 'news', revision: 1 } };
++ const r = await t.deliver(ev);
++ assert.strictEqual(r.status, 204, r.text + JSON.stringify(t.db().prepare('SELECT * FROM news_ingest_runs ORDER BY id DESC LIMIT 1').get()));
++ assert.ok(byItm(it.id));
++ const n = t.events('news.source.ingested').length;
++ const again = await fetch(`${t.base}/internal/events`, { method: 'POST', headers: r.headers, body: r.body });
++ assert.strictEqual(again.status, 204);
++ assert.strictEqual(t.events('news.source.ingested').length, n);
++ const pulled = await t.pull();
++ assert.strictEqual(pulled.counts.created || 0, 0, 'the pull backstop sees it as already applied');
++ });
++
++ await check('webhook: bad signature 401, events from other producers ignored', async () => {
++ const r = await t.deliver({ event_type: 'sources.item.created', payload: {} }, { secret: 'wrong-secret-wrong-secret-wrong-secret' });
++ assert.strictEqual(r.status, 401);
++ const other = await t.deliver({ event_type: 'blog.post.published', source: 'blog', payload: {} });
++ assert.strictEqual(other.status, 204);
++ const forged = await t.deliver({ event_type: 'sources.item.removed', source: 'blog', payload: { item_id: reports.a.id, reason: 'forged' } });
++ assert.strictEqual(forged.status, 204);
++ assert.strictEqual(byItm(reports.a.id).status, 'active', 'only source "sources" may remove an item');
++ });
++
++ await check('webhook: when Sources cannot be read the delivery is 503 (retried), recorded, relayed once, and nothing is made', async () => {
++ const it = t.sources.addItem({ source_key: 'wire-a', title: 'Delivered while Sources is unreachable' });
++ t.sources.setDown(true);
++ const before = count('news_source_items');
++ const failedBefore = t.events('news.source.failed').length;
++ const ev = { event_type: 'sources.item.created', payload: { item_id: it.id, source_key: 'wire-a', category: 'news', revision: 1 } };
++ const r1 = await t.deliver(ev, { attempt: 1 });
++ const r2 = await t.deliver(ev, { attempt: 2 });
++ assert.strictEqual(r1.status, 503);
++ assert.strictEqual(r2.status, 503);
++ assert.strictEqual(count('news_source_items'), before);
++ assert.strictEqual(t.events('news.source.failed').length, failedBefore + 1);
++ assert.ok(t.db().prepare("SELECT COUNT(*) AS n FROM news_ingest_runs WHERE origin = 'webhook' AND state = 'failed'").get().n >= 2);
++ t.sources.setDown(false);
++ });
++
++ await check('sources.fetch.failed for a news source is recorded and relayed; no text is fabricated', async () => {
++ const stories = count('news_stories');
++ const items = count('news_source_items');
++ const r = await t.deliver({ event_type: 'sources.fetch.failed', subject: { type: 'source', id: 'wire-a' }, payload: { source_key: 'wire-a', category: 'news', run_id: 'run_9', state: 'timeout', error_code: 'fetch.timeout', http_status: null, consecutive_failures: 3 } });
++ assert.strictEqual(r.status, 204);
++ const run = t.db().prepare("SELECT * FROM news_ingest_runs WHERE origin = 'sources' ORDER BY id DESC").get();
++ assert.strictEqual(run.state, 'timeout');
++ assert.strictEqual(run.source_key, 'wire-a');
++ const ev = t.events('news.source.failed').pop();
++ assert.strictEqual(ev.payload.origin, 'sources');
++ assert.strictEqual(ev.payload.state, 'timeout');
++ assert.strictEqual(count('news_stories'), stories);
++ assert.strictEqual(count('news_source_items'), items);
++ const desk = await t.get('/edit', { as: t.editor });
++ assert.match(desk.text, /timeout/);
++ });
++
++ await check('an item without a title is rejected and recorded, never shown', async () => {
++ const it = t.sources.addItem({ source_key: 'wire-a', title: '' });
++ await t.pull();
++ assert.strictEqual(byItm(it.id), undefined);
++ assert.ok(t.db().prepare("SELECT COUNT(*) AS n FROM news_ingest_runs WHERE state = 'rejected' AND sources_item_id = ?").get(it.id).n === 1);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/licensing.test.js b/test/licensing.test.js
nnew file mode 100644
iindex 0000000..01df4c1
---- /dev/null
++++ b/test/licensing.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * Licensed material never leaks: whatever a Sources item carries beyond headline, URL, outlet,
++ * authors and date — an article body an adapter mapped into `fields`, a long summary — never
++ * reaches News' database, public pages, JSON, feeds, sitemaps, the API, events or the Search
++ * document. A short summary is kept only when the item's terms or licence note allows short
++ * summaries, and then only up to NEWS_SUMMARY_MAX_CHARS.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { licensedSummary } = require('../server/domain/text');
++
++const BODY_SENTINEL = 'SECRET-FULL-ARTICLE-BODY-7f3a';
++const TAIL_SENTINEL = 'SECRET-SUMMARY-TAIL-91c2';
++const FORBIDDEN_SENTINEL = 'SECRET-SUMMARY-FORBIDDEN-44de';
++const UNLICENSED_SENTINEL = 'SECRET-SUMMARY-NO-TERMS-0b8e';
++const longBody = `${BODY_SENTINEL} ${'The full article text goes on and on. '.repeat(200)}`;
++
++(async () => {
++ await check('the licensing rule: only an explicit allowance keeps a summary, capped at a word boundary', async () => {
++ assert.deepStrictEqual(licensedSummary('Short one.', { termsNote: 'Titles, links and short summaries only.' }), { summary: 'Short one.', basis: 'terms_allow_short_summaries' });
++ assert.strictEqual(licensedSummary('x', { termsNote: 'Headlines and links only.' }).summary, null);
++ assert.strictEqual(licensedSummary('x', { termsNote: 'No summaries may be republished.' }).basis, 'terms_forbid_summaries');
++ assert.strictEqual(licensedSummary('x', { termsNote: null, licenseNote: null }).basis, 'terms_do_not_allow_summaries');
++ const cut = licensedSummary(`${'word '.repeat(100)}END`, { termsNote: 'brief summaries are allowed', maxChars: 50 });
++ assert.ok(cut.summary.length <= 50 && cut.summary.endsWith('…'), cut.summary);
++ assert.strictEqual(licensedSummary('x', { termsNote: 'short summaries', maxChars: 0 }).summary, null);
++ });
++
++ const t = await boot();
++ t.sources.addSource('wire-a', { name: 'Wire A' });
++ t.sources.addSource('paper-b', { name: 'Paper B' });
++ t.sources.addSource('site-c', { name: 'Site C' });
++ const allowed = t.sources.addItem({
++ source_key: 'wire-a', title: 'Europa Clipper launch delayed by storms over Florida', url: 'https://wire-a.example/europa-delay',
++ summary: `Storms delayed the Europa Clipper launch. ${'More detail follows here in the long feed summary. '.repeat(10)}${TAIL_SENTINEL}`,
++ terms_note: 'Titles, links and short summaries only; link back.', fields: { body: longBody, content: longBody, article_text: longBody },
++ published_at: '2026-09-22T08:00:00Z', authors: ['Ana Reporter'],
++ });
++ const forbidden = t.sources.addItem({
++ source_key: 'paper-b', title: 'Europa Clipper launch slips after Florida storms, NASA says', url: 'https://paper-b.example/europa-slips',
++ summary: `${FORBIDDEN_SENTINEL} NASA said the launch would move.`, terms_note: 'Headlines and links only; no summaries.', fields: { body: longBody },
++ published_at: '2026-09-22T08:30:00Z',
++ });
++ const unlicensed = t.sources.addItem({
++ source_key: 'site-c', title: 'Florida storms push back NASA Europa Clipper launch', url: 'https://site-c.example/europa-storms',
++ summary: `${UNLICENSED_SENTINEL} The launch team stood down.`, terms_note: null, license_note: null, fields: { html: `<p>${longBody}</p>` },
++ published_at: '2026-09-22T09:00:00Z',
++ });
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(allowed.id).cluster_id;
++ let r = await t.api('/stories', { json: { cluster: clusterId, headline: 'Storms delay Europa Clipper launch', topic: 'space', body: [
++ 'Storms over Florida delayed the launch of NASA’s Europa Clipper mission, according to reports from three outlets that covered the countdown at Kennedy Space Center. [1, 2, 3]',
++ 'NASA said the launch would move to a later window once the weather clears, and the agency did not give a new date in the reports cited here. [2]',
++ ].join('\n\n') } });
++ assert.strictEqual(r.status, 201, r.text);
++ const story = r.json().story;
++ r = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++
++ await check('bodies are never stored: no column of News’ database holds the article text', async () => {
++ const rows = t.db().prepare('SELECT * FROM news_source_items').all();
++ assert.strictEqual(rows.length, 3);
++ const dump = JSON.stringify(rows) + JSON.stringify(t.db().prepare('SELECT fields, content FROM news_story_revisions').all());
++ for (const s of [BODY_SENTINEL, TAIL_SENTINEL, FORBIDDEN_SENTINEL, UNLICENSED_SENTINEL]) assert.ok(!dump.includes(s), `${s} was stored`);
++ const a = rows.find((x) => x.sources_item_id === allowed.id);
++ assert.ok(a.summary && a.summary.length <= 280, 'the allowed summary is kept, short');
++ assert.strictEqual(a.summary_basis, 'terms_allow_short_summaries');
++ assert.strictEqual(rows.find((x) => x.sources_item_id === forbidden.id).summary, null);
++ assert.strictEqual(rows.find((x) => x.sources_item_id === forbidden.id).summary_basis, 'terms_forbid_summaries');
++ assert.strictEqual(rows.find((x) => x.sources_item_id === unlicensed.id).summary_basis, 'terms_do_not_allow_summaries');
++ });
++
++ await check('no public surface, API response, event or Search document carries licensed text beyond the allowance', async () => {
++ const paths = ['/', '/topics', '/topics/space', `/stories/${story.slug}`, `/stories/${story.slug}.json`, `/stories/${story.slug}?group=outlet`, `/stories/${story.slug}?group=perspective`,
++ '/feed.xml', '/atom.xml', '/feed.json', '/topics/space/feed.xml', '/topics/space/atom.xml', '/topics/space/feed.json',
++ '/sitemap.xml', '/sitemaps/stories.xml', '/sitemaps/topics.xml', '/robots.txt', '/llms.txt', '/api/v1/stories', `/api/v1/stories/${story.id}`, '/api/v1/topics'];
++ let seen = '';
++ for (const p of paths) {
++ const res = await t.get(p);
++ assert.strictEqual(res.status, 200, `${p} → ${res.status}`);
++ seen += res.text;
++ }
++ seen += JSON.stringify(t.events());
++ for (const s of [BODY_SENTINEL, TAIL_SENTINEL, FORBIDDEN_SENTINEL, UNLICENSED_SENTINEL, 'The full article text goes on']) assert.ok(!seen.includes(s), `${s} leaked`);
++ const page = await t.get(`/stories/${story.slug}`);
++ assert.match(page.text, /Storms delayed the Europa Clipper launch\./, 'the licensed short summary is shown in the source table');
++ const feeds = (await t.get('/feed.xml')).text + (await t.get('/feed.json')).text;
++ assert.ok(!feeds.includes('Storms delayed the Europa Clipper launch.'), 'feeds carry our text only, never a source summary');
++ const doc = t.events('news.index_document.upserted').pop().payload;
++ assert.ok(!JSON.stringify(doc).includes('Storms delayed the Europa Clipper launch.'), 'the Search document carries our text only');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
nnew file mode 100644
iindex 0000000..dfec750
---- /dev/null
++++ b/test/nojs.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Useful without JavaScript: the whole editorial journey with plain HTML forms (Network SSO
++ * cookies and form tokens), and public pages whose content is complete in the HTML — no script is
++ * needed to read a story, its sources, timeline, perspectives or corrections.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++const BODY = [
++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, starting a cruise of several years toward Jupiter and its moon Europa. [1, 2]',
++ 'Reports differ on emphasis: one outlet focused on the rocket and the launch itself, another on when the probe will arrive at Jupiter and what it will study there. [1, 2, 3]',
++].join('\n\n');
++
++(async () => {
++ const t = await boot();
++ const reports = launchReports(t.sources);
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).cluster_id;
++ const ed = t.editor;
++ const csrf = t.csrf(ed);
++ let storyId;
++ const loc = (r) => r.headers.get('location') || '';
++
++ await check('the desk sends a signed-out browser to Network sign-in and refuses non-editors', async () => {
++ const r = await t.get('/edit');
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(loc(r), '/auth/login?next=%2Fedit');
++ const login = await t.get('/auth/login?next=/edit');
++ assert.strictEqual(login.status, 302);
++ assert.match(loc(login), /\/oauth\/authorize\?.*client_id=news/);
++ const reader = await t.get('/edit', { as: t.network.addUser('someone') });
++ assert.strictEqual(reader.status, 403);
++ assert.match(reader.text, /Editors only/);
++ });
++
++ await check('the desk lists clusters and ingestion status; a form without the token is refused', async () => {
++ const desk = await t.get('/edit', { as: ed });
++ assert.strictEqual(desk.status, 200);
++ assert.match(desk.text, new RegExp(`href="/clusters/${clusterId}"`));
++ assert.strictEqual(desk.headers.get('cache-control'), 'private, no-store');
++ assert.match(desk.headers.get('x-robots-tag'), /noindex/);
++ const r = await t.get(`/clusters/${clusterId}/stories`, { as: ed, form: { headline: 'No token' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(t.db().prepare('SELECT COUNT(*) AS n FROM news_stories').get().n, 0);
++ });
++
++ await check('open a story from the cluster page, then write paragraphs with [n] markers', async () => {
++ const page = await t.get(`/clusters/${clusterId}`, { as: ed });
++ assert.match(page.text, new RegExp(`<form method="post" action="/clusters/${clusterId}/stories"`));
++ let r = await t.get(`/clusters/${clusterId}/stories`, { as: ed, form: { _csrf: csrf, headline: 'Europa Clipper begins its trip to Jupiter', topic: 'space' } });
++ assert.strictEqual(r.status, 303, r.text);
++ storyId = (loc(r).match(/\/edit\/stories\/(sty_[0-9A-Z]+)/) || [])[1];
++ assert.ok(storyId, loc(r));
++ const edit = await t.get(`/edit/stories/${storyId}`, { as: ed });
++ assert.match(edit.text, /<textarea id="body" name="body"/);
++ assert.match(edit.text, /name="expectedRevision" value="0"/);
++ r = await t.get(`/edit/stories/${storyId}/revise`, { as: ed, form: { _csrf: csrf, expectedRevision: '0', headline: 'Europa Clipper begins its trip to Jupiter', body: 'An uncited sentence.', topic: 'space' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /cites no source/);
++ r = await t.get(`/edit/stories/${storyId}/revise`, { as: ed, form: { _csrf: csrf, expectedRevision: '0', headline: 'Europa Clipper begins its trip to Jupiter', body: BODY, topic: 'space', noindex: '0' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(decodeURIComponent(loc(r)), /Saved revision 1/);
++ });
++
++ await check('timeline, perspective and grouping with forms; a second revision snapshots them', async () => {
++ let r = await t.get(`/edit/stories/${storyId}/timeline`, { as: ed, form: { _csrf: csrf, occurredOn: '2026-09-22', text: 'Launch from Kennedy Space Center.', source: '1' } });
++ assert.strictEqual(r.status, 303, r.text);
++ r = await t.get(`/edit/stories/${storyId}/perspective`, { as: ed, form: { _csrf: csrf, label: 'Launch coverage', description: 'Reports about the launch itself.' } });
++ assert.strictEqual(r.status, 303);
++ const per = t.db().prepare('SELECT id FROM news_perspectives WHERE story_id = ?').get(storyId).id;
++ const item = t.db().prepare('SELECT id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).id;
++ r = await t.get(`/edit/stories/${storyId}/assign`, { as: ed, form: { _csrf: csrf, item, perspective: per } });
++ assert.strictEqual(r.status, 303);
++ r = await t.get(`/edit/stories/${storyId}/revise`, { as: ed, form: { _csrf: csrf, expectedRevision: '1', headline: 'Europa Clipper begins its trip to Jupiter', body: BODY, topic: 'space' } });
++ assert.match(decodeURIComponent(loc(r)), /Saved revision 2/);
++ const stale = await t.get(`/edit/stories/${storyId}/revise`, { as: ed, form: { _csrf: csrf, expectedRevision: '1', headline: 'x', body: BODY } });
++ assert.strictEqual(stale.status, 412);
++ assert.match(stale.text, /Someone saved revision 2 while you were editing/);
++ });
++
++ await check('preview, publish with the form, and read the public page with no JavaScript', async () => {
++ const prev = await t.get(`/edit/stories/${storyId}/preview?revision=2`, { as: ed });
++ assert.strictEqual(prev.status, 200);
++ assert.match(prev.text, /preview of revision 2/);
++ assert.strictEqual(prev.headers.get('cache-control'), 'private, no-store');
++ let r = await t.get(`/edit/stories/${storyId}/publish`, { as: ed, form: { _csrf: csrf, revision: '2', correctionKind: '', correctionNote: '' } });
++ assert.strictEqual(r.status, 303, r.text);
++ const page = await t.get('/stories/europa-clipper-begins-its-trip-to-jupiter');
++ assert.strictEqual(page.status, 200);
++ const withoutScripts = page.text.replace(/<script[\s\S]*?<\/script>/g, '');
++ for (const needle of ['<h1>Europa Clipper begins its trip to Jupiter</h1>', 'NASA launched the Europa Clipper spacecraft', '<h2 id="sources-h">Sources</h2>',
++ 'Launch from Kennedy Space Center.', '<h2 id="persp-h">Perspectives</h2>', 'Launch coverage', '<h2 id="corr-h">Corrections and updates</h2>', 'href="?group=outlet#sources"', 'Wire A', 'Paper B', 'Site C']) {
++ assert.ok(withoutScripts.includes(needle), `missing without JS: ${needle}`);
++ }
++ assert.match(page.text, /<noscript>/);
++ assert.match(page.text, /<link rel="alternate" type="application\/rss\+xml"/);
++ r = await t.get(`/edit/stories/${storyId}/flag`, { as: ed, form: { _csrf: csrf, kind: 'update', note: 'Added the timeline entry for the launch.' } });
++ assert.strictEqual(r.status, 303);
++ });
++
++ await check('the editor’s diff page and the revisions list work as plain links', async () => {
++ const diff = await t.get(`/edit/stories/${storyId}/diff?from=1&to=2`, { as: ed });
++ assert.strictEqual(diff.status, 200);
++ assert.match(diff.text, /Revision 1 → 2/);
++ assert.match(diff.text, /Changed fields: .*perspectives/);
++ const edit = await t.get(`/edit/stories/${storyId}`, { as: ed });
++ assert.match(edit.text, /readers see revision 2/);
++ assert.match(edit.text, /Search engines: indexable/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/ops.test.js b/test/ops.test.js
nnew file mode 100644
iindex 0000000..df3f12a
---- /dev/null
++++ b/test/ops.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * Operator and discovery surfaces: health, truthful readiness, release, metrics, robots.txt with an
++ * explicit automated-consumer policy, llms.txt, the sitemap index, legal pages, 404s, problem+json
++ * with request ids, CORS for the network's origins, and the topics-only seed (idempotent).
++ */
++const assert = require('assert');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++
++ await check('health, readiness (degraded honestly, never falsely ok), release manifest, metrics', 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.strictEqual(body.checks.events_webhook.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, 'news');
++ const m = await t.get('/metrics');
++ assert.ok([200, 404].includes(m.status));
++ });
++
++ await check('robots.txt: sitemap, automated-consumer policy, editor/API disallowed; llms.txt; sitemap index', async () => {
++ const robots = (await t.get('/robots.txt')).text;
++ assert.match(robots, /Sitemap: https:\/\/openvibe\.news\/sitemap\.xml/);
++ assert.match(robots, /automated-consumer policy/);
++ for (const p of ['/edit', '/clusters/', '/api/', '/internal/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
++ const llms = (await t.get('/llms.txt')).text;
++ assert.match(llms, /^# OpenVibe\.News/);
++ assert.match(llms, /not an automatic headline generator/);
++ assert.match(llms, /\.json/);
++ const idx = (await t.get('/sitemap.xml')).text;
++ assert.match(idx, /<sitemapindex/);
++ assert.match(idx, /sitemaps\/stories\.xml/);
++ assert.match(idx, /sitemaps\/topics\.xml/);
++ const empty = (await t.get('/sitemaps/stories.xml')).text;
++ assert.doesNotMatch(empty, /<url>/, 'no stories, no URLs');
++ const home = await t.get('/');
++ assert.match(home.text, /<meta name="robots" content="noindex, follow">/, 'an empty front page is not offered for indexing');
++ });
++
++ await check('legal pages, 404s and API problems', async () => {
++ assert.strictEqual((await t.get('/terms')).status, 200);
++ const nf = await t.get('/stories/nothing-here');
++ assert.strictEqual(nf.status, 404);
++ assert.strictEqual(nf.headers.get('cache-control'), 'private, no-store');
++ const p = await t.get('/api/v1/stories/sty_nope');
++ assert.strictEqual(p.status, 404);
++ assert.match(p.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(p.json().code, 'story.not_found');
++ assert.ok(p.json().request_id);
++ assert.strictEqual(p.headers.get('x-robots-tag'), 'noindex');
++ const bad = await t.get('/api/v1/stories', { as: 'not-a-token.x.y', json: {} });
++ assert.ok([401, 403].includes(bad.status));
++ });
++
++ await check('CORS: network origins may call the API; others get no CORS headers', async () => {
++ const ok = await t.get('/api/v1/topics', { headers: { origin: 'https://openvibe.network' } });
++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), 'https://openvibe.network');
++ const no = await t.get('/api/v1/topics', { headers: { origin: 'https://evil.example' } });
++ assert.strictEqual(no.headers.get('access-control-allow-origin'), null);
++ });
++
++ await check('the seed is topics only, idempotent, and never touches an existing topic', async () => {
++ const n = t.db().prepare('SELECT COUNT(*) AS n FROM news_topics').get().n;
++ t.db().prepare("UPDATE news_topics SET name = 'Space (renamed by an editor)' WHERE slug = 'space'").run();
++ await t.restart();
++ assert.strictEqual(t.db().prepare('SELECT COUNT(*) AS n FROM news_topics').get().n, n);
++ assert.strictEqual(t.db().prepare("SELECT name FROM news_topics WHERE slug = 'space'").get().name, 'Space (renamed by an editor)');
++ const out = execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'seed.js')], { env: { ...process.env, NEWS_DB_PATH: t.dbPath }, encoding: 'utf8' });
++ assert.match(out, /topics: 0 added, \d+ already present/);
++ for (const table of ['news_stories', 'news_source_items', 'news_story_clusters']) assert.strictEqual(t.db().prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n, 0, table);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/retraction.test.js b/test/retraction.test.js
nnew file mode 100644
iindex 0000000..d45452c
---- /dev/null
++++ b/test/retraction.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * A retraction keeps the story visible at its URL with a retraction notice, makes it noindex
++ * (meta robots, X-Robots-Tag and the gate's reason), removes it from sitemaps and Search (a
++ * tombstone), labels it in listings and feeds, and emits news.story.retracted. A retraction needs a
++ * note, only applies to a published story, and is final.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++const BODY = [
++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, starting a cruise of several years toward Jupiter and its moon Europa. [1, 2]',
++ 'NASA expects the probe to reach the Jupiter system in 2030 and to fly past Europa many times to study the ocean scientists think lies beneath the ice. [2, 3]',
++].join('\n\n');
++
++(async () => {
++ const t = await boot();
++ const reports = launchReports(t.sources);
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).cluster_id;
++ let r = await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper heads for Jupiter', topic: 'space', body: BODY } });
++ const story = r.json().story;
++ await t.api(`/stories/${story.id}/publish`, { json: {} });
++ const url = `/stories/${story.slug}`;
++
++ await check('retraction needs a published story and a note', async () => {
++ r = await t.api(`/stories/${story.id}/retract`, { json: { note: 'no' } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'story.retraction_note_required');
++ const draft = (await t.api('/stories', { json: { cluster: clusterId, headline: 'A draft' } })).json().story;
++ r = await t.api(`/stories/${draft.id}/retract`, { json: { note: 'Retracting a draft makes no sense at all.' } });
++ assert.strictEqual(r.status, 409);
++ const noCap = await t.get(`/api/v1/stories/${story.id}/retract`, { as: t.network.serviceToken('live', ['news.story.publish']), headers: { 'x-ov-subject': t.editor.subject }, json: { note: 'Service without the retract capability.' } });
++ assert.strictEqual(noCap.status, 403);
++ });
++
++ await check('the retracted story stays at its URL with the notice, noindex, and its reason', async () => {
++ r = await t.api(`/stories/${story.id}/retract`, { json: { note: 'The launch date reported in this story was wrong: our sources described a rehearsal, not the launch.' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().story.state, 'retracted');
++ const page = await t.get(url);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<div class="retraction" role="alert"><h2>Retracted<\/h2><p>The launch date reported in this story was wrong/);
++ assert.match(page.text, /<title>Retracted: Europa Clipper heads for Jupiter/);
++ assert.match(page.text, /<meta name="robots" content="noindex, follow">/);
++ assert.strictEqual(page.headers.get('x-robots-tag'), 'noindex, follow');
++ assert.match(page.text, /NASA launched the Europa Clipper spacecraft/, 'the text stays on the record');
++ assert.match(page.text, /<strong>Retraction<\/strong>/);
++ const json = (await t.get(`${url}.json`)).json();
++ assert.strictEqual(json.state, 'retracted');
++ assert.ok(json.retraction.note.startsWith('The launch date'));
++ assert.deepStrictEqual(json.indexability.reasons.map((x) => x.code), ['retracted']);
++ });
++
++ await check('out of sitemaps and Search (tombstone), labelled in listings and feeds, event emitted', async () => {
++ assert.doesNotMatch((await t.get('/sitemaps/stories.xml')).text, new RegExp(story.slug));
++ const del = t.events('news.index_document.deleted');
++ assert.strictEqual(del.length, 1);
++ assert.strictEqual(del[0].payload.id, story.id);
++ const ev = t.events('news.story.retracted');
++ assert.strictEqual(ev.length, 1);
++ assert.strictEqual(ev[0].payload.publication_state, 'retracted');
++ assert.ok(ev[0].payload.note.startsWith('The launch date'));
++ assert.ok(contracts.validate('events.event-envelope@1', { ...ev[0], event_id: 'evt_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8' }).valid);
++ const home = await t.get('/');
++ assert.match(home.text, /badge-retracted/);
++ const rss = await t.get('/feed.xml');
++ assert.match(rss.text, /<title>Retracted: Europa Clipper heads for Jupiter<\/title>/);
++ assert.match(rss.text, /This story was retracted: The launch date/);
++ });
++
++ await check('a retraction is final: no new revision or publication', async () => {
++ r = await t.api(`/stories/${story.id}/revisions`, { json: { body: BODY, expected_revision: 1 } });
++ assert.strictEqual(r.status, 409);
++ r = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json().code, 'story.retracted');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..bbde582
---- /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, Sources, AI and Events deliveries; 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/stories.test.js b/test/stories.test.js
nnew file mode 100644
iindex 0000000..9b1824d
---- /dev/null
++++ b/test/stories.test.js
@@@ -0,0 +1,205 @@
++'use strict';
++/**
++ * The editorial path: a story opened from a cluster, paragraphs that each cite sources, a story with
++ * zero sources that cannot be published, publication with every claim traceable to Sources records
++ * (page, JSON, citations table and the Search document), the timeline and perspectives, corrections
++ * published with a revision, and non-editors refused.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++const BODY = [
++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center on Tuesday morning, starting a long cruise toward Jupiter and its icy moon Europa. [1, 2]',
++ 'According to NASA, the probe is scheduled to reach the Jupiter system in 2030, where it will fly past Europa dozens of times to study the ocean scientists believe lies beneath its ice shell. [2]',
++ 'Coverage from other outlets focused on why the mission matters for the search for places where life could exist beyond Earth. [3]',
++].join('\n\n');
++
++(async () => {
++ const t = await boot();
++ const reports = launchReports(t.sources);
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).cluster_id;
++ const reader = t.network.addUser('reader');
++ let story;
++
++ await check('non-editors cannot open, write or publish stories (browser and service)', async () => {
++ const r = await t.get('/api/v1/stories', { as: reader, json: { cluster: clusterId, headline: 'Nope' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'news.editor_required');
++ const svc = t.network.serviceToken('live', ['news.story.create']);
++ const s = await t.get('/api/v1/stories', { as: svc, json: { cluster: clusterId, headline: 'Nope' } });
++ assert.strictEqual(s.status, 403, 'a service needs X-OV-Subject of an editor');
++ const nocap = await t.get('/api/v1/stories', { as: t.network.serviceToken('live', []), json: { cluster: clusterId, headline: 'Nope' }, headers: { 'x-ov-subject': t.editor.subject } });
++ assert.strictEqual(nocap.status, 403);
++ assert.strictEqual(nocap.json().code, 'capability.denied');
++ });
++
++ await check('an editor opens a story from a cluster: the live, non-duplicate items become sources [1]..[3]', async () => {
++ const r = await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper launches for Jupiter', topic: 'space' } });
++ assert.strictEqual(r.status, 201, r.text);
++ story = r.json().story;
++ assert.strictEqual(story.state, 'draft');
++ assert.deepStrictEqual(story.sources.map((s) => [s.n, s.item.sources_item.id]), [[1, reports.a.id], [2, reports.b.id], [3, reports.c.id]]);
++ assert.strictEqual(story.head, null);
++ assert.strictEqual(t.events('news.story.created').length, 1);
++ });
++
++ await check('a paragraph without a source, or citing a number not in the source table, is refused', async () => {
++ let r = await t.api(`/stories/${story.id}/revisions`, { json: { body: 'A claim with no source at all, which News must never publish.', expected_revision: 0 } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'story.claim_unsourced');
++ r = await t.api(`/stories/${story.id}/revisions`, { json: { body: 'A claim citing a source that is not attached. [9]', expected_revision: 0 } });
++ assert.strictEqual(r.json().code, 'story.unknown_source');
++ assert.strictEqual(t.db().prepare('SELECT COUNT(*) AS n FROM news_story_revisions').get().n, 0);
++ });
++
++ await check('a story with zero sources cannot be published', async () => {
++ const r = await t.api('/stories', { json: { headline: 'A story without any source' } });
++ const lonely = r.json().story;
++ assert.strictEqual(lonely.sources.length, 0);
++ const rev = await t.api(`/stories/${lonely.id}/revisions`, { json: { body: 'Text. [1]', expected_revision: 0 } });
++ assert.strictEqual(rev.status, 422, 'no [1] exists to cite');
++ const pub = await t.api(`/stories/${lonely.id}/publish`, { json: {} });
++ assert.strictEqual(pub.status, 422);
++ assert.strictEqual(pub.json().code, 'story.no_text');
++ assert.strictEqual(t.db().prepare("SELECT COUNT(*) AS n FROM news_stories WHERE state = 'published'").get().n, 0);
++ const direct = t.ctx.stories.problems(t.ctx.stories.get(lonely.id), { number: 1, fields: { headline: 'x', paragraphs: [{ text: 'x', sources: [1] }], sources: [] }, meta: {} });
++ assert.ok(direct.some((p) => p.code === 'story.unsourced'));
++ });
++
++ await check('revision 1 with cited paragraphs, a timeline entry and an editor-labelled perspective', async () => {
++ let r = await t.api(`/stories/${story.id}/timeline`, { json: { occurred_on: '2026-09-22', text: 'Europa Clipper lifts off from Kennedy Space Center.', source: 1 } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await t.api(`/stories/${story.id}/timeline`, { json: { occurred_on: 'next spring', text: 'An invented date', source: 1 } });
++ assert.strictEqual(r.json().code, 'timeline.invalid_date');
++ r = await t.api(`/stories/${story.id}/perspectives`, { json: { label: 'Mission announcements', description: 'Reports built on NASA’s own statements.' } });
++ const per = r.json().perspective;
++ r = await t.api(`/stories/${story.id}/sources/${story.sources[1].item.id}/perspective`, { method: 'PUT', json: { perspective: per.id } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await t.api(`/stories/${story.id}/revisions`, { json: { body: BODY, expected_revision: 0 } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().revision, 1);
++ const head = r.json().story.head;
++ assert.deepStrictEqual(head.paragraphs.map((p) => p.sources), [[1, 2], [2], [3]]);
++ assert.strictEqual(head.authorship.mode, 'human');
++ assert.deepStrictEqual(r.json().story.publishable, []);
++ const stale = await t.api(`/stories/${story.id}/revisions`, { json: { body: BODY.replace('Tuesday', 'Tuesday local time'), expected_revision: 0 } });
++ assert.strictEqual(stale.status, 412, 'a stale base is a conflict, nothing is lost');
++ });
++
++ await check('publish: page, JSON, citations and the Search document all trace every claim to Sources records', async () => {
++ const r = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().story.state, 'published');
++ const page = await t.get('/stories/europa-clipper-launches-for-jupiter');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<h1>Europa Clipper launches for Jupiter<\/h1>/);
++ assert.match(page.text, /<a href="#source-1" aria-label="source 1">1<\/a>/);
++ assert.match(page.text, /<tr id="source-3">/);
++ assert.match(page.text, /Wire A/);
++ assert.match(page.text, /Ana Reporter/);
++ assert.match(page.text, /<meta name="robots" content="index, follow">/);
++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=60');
++ assert.match(page.text, /"@type":"NewsArticle"/);
++ assert.match(page.text, /Mission announcements/);
++ assert.match(page.text, /2026-09-22<\/time> — Europa Clipper lifts off/);
++
++ const json = (await t.get('/stories/europa-clipper-launches-for-jupiter.json')).json();
++ assert.strictEqual(json.claims.length, 3);
++ const byN = new Map(json.sources.map((s) => [s.n, s]));
++ for (const c of json.claims) {
++ assert.ok(c.sources.length >= 1);
++ for (const n of c.sources) {
++ const s = byN.get(n);
++ assert.ok(s, `claim cites [${n}] which is in the source table`);
++ assert.strictEqual(s.sources_item.service, 'sources');
++ assert.ok(t.sources.items.find((i) => i.id === s.sources_item.id), `[${n}] resolves to a real Sources item`);
++ assert.ok(s.url && s.headline && s.outlet);
++ }
++ }
++ assert.deepStrictEqual(json.headline_sources, [1, 2]);
++ assert.strictEqual(json.indexability.indexable, true);
++
++ const cites = t.db().prepare('SELECT anchor, source_item_id FROM news_story_citations WHERE entity_id = ? AND revision = 1 ORDER BY id').all(story.id);
++ assert.deepStrictEqual(cites.filter((c) => c.anchor.startsWith('p')).map((c) => `${c.anchor}:${c.source_item_id}`), [`p1:${reports.a.id}`, `p1:${reports.b.id}`, `p2:${reports.b.id}`, `p3:${reports.c.id}`]);
++
++ const idx = t.events('news.index_document.upserted').pop();
++ assert.ok(idx, 'Search gets the document');
++ const doc = idx.payload;
++ assert.ok(contracts.validate('search.index-document@1', doc).valid, JSON.stringify(contracts.validate('search.index-document@1', doc).errors));
++ const prov = doc.provenance.filter((p) => p.service === 'sources').map((p) => p.id).sort();
++ assert.deepStrictEqual(prov, [reports.a.id, reports.b.id, reports.c.id].sort());
++ assert.strictEqual(doc.indexability.decision, 'index');
++ const pub = t.events('news.story.published');
++ assert.strictEqual(pub.length, 1);
++ assert.strictEqual(pub[0].visibility, 'public');
++ assert.strictEqual(pub[0].payload.canonical_url, 'https://openvibe.news/stories/europa-clipper-launches-for-jupiter');
++ assert.strictEqual(pub[0].payload.topic, 'space');
++ });
++
++ await check('home, topic page, feeds and sitemap list the published story; its text reaches feeds, source summaries do not', async () => {
++ const home = await t.get('/');
++ assert.match(home.text, /Europa Clipper launches for Jupiter/);
++ const topic = await t.get('/topics/space');
++ assert.match(topic.text, /Europa Clipper launches for Jupiter/);
++ const rss = await t.get('/feed.xml');
++ assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe.news,2026:story\/sty_/);
++ assert.doesNotMatch(rss.text, /spacecraft lifted off/, 'a source summary is never fed');
++ const tfeed = await t.get('/topics/space/feed.json');
++ assert.strictEqual(tfeed.json().items.length, 1);
++ const other = await t.get('/topics/games/feed.json');
++ assert.strictEqual(other.json().items.length, 0);
++ const sm = await t.get('/sitemaps/stories.xml');
++ assert.match(sm.text, /<loc>https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter<\/loc>/);
++ const tm = await t.get('/sitemaps/topics.xml');
++ assert.match(tm.text, /\/topics\/space/);
++ });
++
++ await check('a correction is published with the next revision and shows in the correction history', async () => {
++ let r = await t.api(`/stories/${story.id}/revisions`, { json: { body: BODY.replace('dozens of times', 'about fifty times'), expected_revision: 1 } });
++ assert.strictEqual(r.json().revision, 2);
++ let page = await t.get('/stories/europa-clipper-launches-for-jupiter');
++ assert.match(page.text, /dozens of times/, 'readers still see revision 1 until revision 2 is published');
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: 2, correction: { kind: 'correction', note: 'An earlier version said the probe would pass Europa “dozens of times”; NASA’s figure is about fifty.' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ page = await t.get('/stories/europa-clipper-launches-for-jupiter');
++ assert.match(page.text, /about fifty times/);
++ assert.match(page.text, /<strong>Correction<\/strong>/);
++ assert.match(page.text, /revision 2\): An earlier version said/);
++ assert.strictEqual(t.events('news.story.updated').length, 1);
++ const json = (await t.get('/stories/europa-clipper-launches-for-jupiter.json')).json();
++ assert.strictEqual(json.corrections.length, 1);
++ assert.strictEqual(json.revision, 2);
++ });
++
++ await check('reader framing: ?group=outlet and ?group=perspective re-frame the source table without JavaScript; canonical ignores it', async () => {
++ const byOutlet = await t.get('/stories/europa-clipper-launches-for-jupiter?group=outlet');
++ assert.match(byOutlet.text, /<h3>Paper B<\/h3>/);
++ assert.match(byOutlet.text, /<link rel="canonical" href="https:\/\/openvibe.news\/stories\/europa-clipper-launches-for-jupiter">/);
++ const byPer = await t.get('/stories/europa-clipper-launches-for-jupiter?group=perspective');
++ assert.match(byPer.text, /<h3>Mission announcements<\/h3>/);
++ assert.match(byPer.text, /<h3>Not grouped<\/h3>/);
++ assert.match(byPer.text, /written by OpenVibe.News editors/);
++ });
++
++ await check('drafts are not public: never-published stories 404, the API hides them from readers', async () => {
++ const r = await t.api('/stories', { json: { cluster: clusterId, headline: 'Second angle on the launch' } });
++ const s = r.json().story;
++ assert.strictEqual((await t.get(`/stories/${s.slug}`)).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/stories/${s.id}`)).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/stories/${s.id}`, { as: t.editor })).status, 200);
++ });
++
++ await check('unpublish takes the page down (410) and removes it from Search', async () => {
++ const r = await t.api(`/stories/${story.id}/unpublish`, { json: {} });
++ assert.strictEqual(r.json().story.state, 'unpublished');
++ assert.strictEqual((await t.get('/stories/europa-clipper-launches-for-jupiter')).status, 410);
++ assert.strictEqual(t.events('news.index_document.deleted').length, 1);
++ assert.strictEqual(t.events('news.story.unpublished').length, 1);
++ assert.doesNotMatch((await t.get('/sitemaps/stories.xml')).text, /europa-clipper-launches/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/upstream.test.js b/test/upstream.test.js
nnew file mode 100644
iindex 0000000..f4e3067
---- /dev/null
++++ b/test/upstream.test.js
@@@ -0,0 +1,125 @@
++'use strict';
++/**
++ * Upstream changes never change published text silently: a cited source revised or removed in
++ * OpenVibe.Sources flags the story and prepares a pending revision; the published revision stays,
++ * with a visible notice; a removed source's headline, summary and link leave the public page at
++ * once; a revision still resting on it cannot be published; the editor's new revision, published
++ * with a correction note, resolves the flags.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++const BODY = [
++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center, starting a cruise of several years toward Jupiter and its moon Europa. [1, 2]',
++ 'NASA expects the probe to reach the Jupiter system in 2030 and to fly past Europa many times to study the ocean scientists think lies beneath the ice. [2]',
++ 'One outlet reported that the launch window was chosen to use a gravity assist from Mars on the way to the outer planets, according to mission planners. [1]',
++].join('\n\n');
++
++(async () => {
++ const t = await boot();
++ const reports = launchReports(t.sources, { a: { summary: 'The Europa Clipper spacecraft lifted off on a Falcon Heavy rocket. ORIGINAL-SUMMARY-A.' } });
++ await t.pull();
++ const clusterId = t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).cluster_id;
++ let r = await t.api('/stories', { json: { cluster: clusterId, headline: 'Europa Clipper is on its way to Jupiter', topic: 'space', body: BODY } });
++ const story = r.json().story;
++ r = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ const url = '/stories/europa-clipper-is-on-its-way-to-jupiter';
++ const published = await t.get(url);
++
++ await check('an upstream revision of a cited source: flag + pending revision; the published text is unchanged, with a notice', async () => {
++ t.sources.updateItem(reports.b.id, { title: 'Europa Clipper lifts off; NASA now says arrival in 2031', summary: 'NASA revised its arrival estimate.' });
++ await t.pull();
++ const flags = t.db().prepare("SELECT * FROM news_editorial_flags WHERE story_id = ? AND kind = 'source_updated'").all(story.id);
++ assert.strictEqual(flags.length, 1);
++ assert.strictEqual(flags[0].status, 'open');
++ assert.strictEqual(flags[0].pending_revision, 2);
++ const rev2 = t.ctx.store.revisions.get(story.id, 2);
++ assert.strictEqual(rev2.author, 'svc:news');
++ assert.strictEqual(rev2.meta.system.reason, 'source_updated');
++ assert.strictEqual(rev2.content, t.ctx.store.revisions.get(story.id, 1).content, 'the pending revision keeps the text for an editor to check');
++ assert.strictEqual(rev2.fields.sources.find((s) => s.n === 2).headline, 'Europa Clipper lifts off; NASA now says arrival in 2031');
++ const s = t.ctx.stories.get(story.id);
++ assert.strictEqual(s.published_revision, 1, 'nothing was published by the system');
++ const page = await t.get(url);
++ assert.match(page.text, /changed after publication\. Editors are checking/);
++ assert.match(page.text, /Europa Clipper lifts off as NASA begins long trip to Jupiter/, 'the source table still shows what revision 1 cited');
++ const body = (h) => (h.match(/<div class="story-body">[\s\S]*?<\/div>/) || [null])[0];
++ assert.ok(body(published.text));
++ assert.strictEqual(body(page.text), body(published.text), 'the published paragraphs are byte-for-byte the same');
++ assert.strictEqual(t.events('news.story.flagged').length, 1);
++ assert.strictEqual(t.events('news.story.updated').length, 0);
++ });
++
++ await check('an upstream removal: flag + pending revision; the removed source’s headline, summary and link leave the page; noindex for unsupported claims', async () => {
++ t.sources.removeItem(reports.a.id, 'licence withdrawn by the publisher');
++ const d = await t.deliver({ event_type: 'sources.item.removed', subject: { type: 'item', id: reports.a.id, revision: 2 }, payload: { item_id: reports.a.id, source_key: 'wire-a', category: 'news', revision: 2, reason: 'licence withdrawn by the publisher' } });
++ assert.strictEqual(d.status, 204, d.text);
++ const item = t.db().prepare('SELECT * FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id);
++ assert.strictEqual(item.status, 'removed');
++ assert.strictEqual(item.summary, null, 'the licensed summary is dropped');
++ const flag = t.db().prepare("SELECT * FROM news_editorial_flags WHERE story_id = ? AND kind = 'source_removed'").get(story.id);
++ assert.strictEqual(flag.status, 'open');
++ assert.strictEqual(flag.pending_revision, 3);
++ const page = await t.get(url);
++ assert.doesNotMatch(page.text, /ORIGINAL-SUMMARY-A/);
++ assert.doesNotMatch(page.text, /wire-a\.example/);
++ assert.doesNotMatch(page.text, /NASA launches Europa Clipper probe to Jupiter moon Europa/);
++ assert.match(page.text, /this source was removed by the source registry/);
++ assert.match(page.text, /was removed by the source registry after publication\. Editors are reviewing/);
++ assert.match(page.text, /class="unsupported"/, 'the paragraph resting only on [1] is marked');
++ assert.match(page.text, /<meta name="robots" content="noindex, follow">/);
++ assert.match(page.headers.get('x-robots-tag'), /noindex/);
++ const json = (await t.get(`${url}.json`)).json();
++ assert.ok(json.indexability.reasons.some((x) => x.code === 'unsupported_claims'));
++ assert.strictEqual(json.sources.find((s) => s.n === 1).headline, undefined);
++ assert.strictEqual(json.claims[2].supported, false);
++ assert.doesNotMatch(JSON.stringify(json), /ORIGINAL-SUMMARY-A|wire-a\.example/);
++ const idx = t.events('news.index_document.upserted').pop();
++ assert.strictEqual(idx.payload.indexability.decision, 'noindex', 'Search learns the story is noindex');
++ assert.doesNotMatch((await t.get('/sitemaps/stories.xml')).text, /europa-clipper-is-on-its-way/);
++ const replay = await t.deliver({ event_type: 'sources.item.removed', payload: { item_id: reports.a.id, reason: 'again' } });
++ assert.strictEqual(replay.status, 204);
++ assert.strictEqual(t.db().prepare("SELECT COUNT(*) AS n FROM news_editorial_flags WHERE kind = 'source_removed'").get().n, 1, 'a removal flags once');
++ });
++
++ await check('the pending revision (still citing the removed source) cannot be published', async () => {
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: 3 } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json().code, 'story.source_removed');
++ assert.strictEqual(t.ctx.stories.get(story.id).published_revision, 1);
++ });
++
++ await check('the editor revises without the removed source; publishing needs a correction note, then resolves both flags', async () => {
++ const fixed = BODY.split('\n\n').slice(0, 2).map((p) => p.replace('[1, 2]', '[2]')).join('\n\n');
++ r = await t.api(`/stories/${story.id}/sources/${t.db().prepare('SELECT id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).id}`, { method: 'DELETE' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await t.api(`/stories/${story.id}/revisions`, { json: { body: fixed.replace('2030', '2031'), expected_revision: 3 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const n = r.json().revision;
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: n } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json().code, 'story.correction_note_required');
++ r = await t.api(`/stories/${story.id}/publish`, { json: { revision: n, correction: { kind: 'update', note: 'A source we cited withdrew its report, so the paragraph based only on it was removed; NASA now gives 2031 as the arrival year.' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().resolved_flags.length, 2);
++ const open = t.db().prepare("SELECT COUNT(*) AS n FROM news_editorial_flags WHERE story_id = ? AND status = 'open'").get(story.id).n;
++ assert.strictEqual(open, 0);
++ const page = await t.get(url);
++ assert.doesNotMatch(page.text, /Editors are (reviewing|checking)/);
++ assert.match(page.text, /<strong>Update<\/strong>/);
++ assert.match(page.text, /2031/);
++ assert.match(page.text, /<meta name="robots" content="index, follow">/);
++ assert.strictEqual(t.events('news.story.updated').length, 1);
++ });
++
++ await check('an unchanged upstream refetch (same revision) flags nothing', async () => {
++ const before = t.db().prepare('SELECT COUNT(*) AS n FROM news_editorial_flags').get().n;
++ t.ctx.store.setState('sources_cursor', 0);
++ await t.pull();
++ assert.strictEqual(t.db().prepare('SELECT COUNT(*) AS n FROM news_editorial_flags').get().n, before);
++ });
++
++ await t.close();
++ done();
++})();