openvibers/openvibe.news Commits
Community comment threads on stories, referenced not copied
Changes
@@@ -53,6 +53,12 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720# Unset = webhook off (503); the cursor pull still runs.#NEWS_EVENTS_SECRET=++# OpenVibe.Community — the comment thread of each public story (referenced by id, never copied).++# Needs the grants community.comment.write and community.comment.moderate (audience openvibe.community);++# without them a story page says comments are unavailable.++OV_COMMUNITY_URL=https://openvibe.community++OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200++# OpenVibe.AI (optional) — news.summarize_story / news.compare_perspectives drafts (ai.run.create).# Unset: no AI; editors write every story from the source items.#OV_AI_INTERNAL_URL=http://127.0.0.1:4700@@@ -45,7 +45,9 @@ Other tables in the same database:- Package companions: `news_story_citations` (one row per paragraph and timeline entry and citedSources item, append-only), `news_story_reviews` (a person's approval of AI output),-- `news_story_drafts`, `news_story_revision_purges`, `news_index_revisions` (Search sequencer).++ `news_story_drafts`, `news_story_revision_purges`, `news_index_revisions` (Search sequencer),++ `news_story_discussion_refs` (a story's OpenVibe.Community thread id; never a comment, a count or++ an author).- `news_cluster_audit`: every merge, split and reversal with exactly which items moved.- `news_ingest_runs`: what every webhook delivery, pull and upstream fetch failure did.- `news_state` (the Sources cursor), `news_source_status` (display cache of Sources' registry@@@ -57,7 +59,8 @@ Other tables in the same database:- **Source adapters and the source registry.** OpenVibe.Sources owns them: which sites are read,under which terms, how and when. News reads items with `sources.item.read` and never fetches asite itself.--- **Discussion.** OpenVibe.Community. (News does not attach comment threads yet.)++- **Discussion.** OpenVibe.Community owns the comment threads, their comments and their++ moderation. News keeps only each story's thread id and reads the thread on every render.- **AI generation.** OpenVibe.AI. News only receives drafts.- **Search.** OpenVibe.Search indexes what News sends through Events.- **Identity.** OpenVibe.Network: SSO, subjects and service principals.@@@ -110,6 +113,22 @@ Other tables in the same database:source's headline, summary and link leave the public page at once; a paragraph resting only onit is marked, and the gate makes the story noindex (`unsupported_claims`) until an editorpublishes a revision that no longer rests on it — with a correction or update note.++- **Comments** (OpenVibe.Community, referenced, never copied):++ - A story open for discussion gets the thread Community resolves for EntityRef++ `{ service: 'news', type: 'story', id }`, the first time its page is read. Open means published++ (indexable or not: a noindex story is still public) with no paragraph resting only on a source++ removed upstream. Drafts never get a thread: a Community thread is readable by anyone with its id.++ - Only the thread id is stored (`news_story_discussion_refs`). The page reads the thread from++ Community on every render, so Community's moderation and deletions are what readers see. When++ Community cannot be read the page says comments are unavailable; it never shows an empty or++ invented thread.++ - Signed-in members comment with a plain form (`POST /stories/:slug/comments`, form token), as++ themselves (`X-OV-Subject`, `community.comment.write`).++ - Retracting or unpublishing a story, or a removal upstream that leaves a paragraph without a++ source, hides its thread; publishing a revision that no longer rests on the removed source (or++ republishing) shows it again. The call (`community.comment.moderate`) runs after the change++ commits, is best effort, and never fails the change. A retracted story's page says comments are++ closed; a story waiting for a revision says they are paused.- **AI** (optional seams, never publication truth):- OpenVibe.AI may deliver a draft revision with `X-OV-Origin: ai` (`news.story.revise`), naming`news.summarize_story` or `news.compare_perspectives` and its run.@@@ -128,7 +147,8 @@ Other tables in the same database:|---|---|| `/` | published stories, newest first (retracted ones stay, labelled) and the topics || `/topics`, `/topics/:slug` | topics and each topic's stories |--| `/stories/:slug`, `/stories/:slug.json` | a story (text with source numbers, source table, timeline, perspectives, corrections), and the same story as data |++| `/stories/:slug`, `/stories/:slug.json` | a story (text with source numbers, source table, timeline, perspectives, corrections, the Community comment thread), and the same story as data |++| `POST /stories/:slug/comments` | comment on the story's Community thread as the signed-in member (form token) || `/feed.xml`, `/atom.xml`, `/feed.json`, `/topics/:slug/{feed.xml,atom.xml,feed.json}` | feeds (News' own text only) || `/sitemap.xml` → `/sitemaps/stories.xml`, `/sitemaps/topics.xml` | indexable stories and topics only || `/robots.txt`, `/llms.txt` | the automated-consumer policy and machine orientation |@@@ -195,6 +215,9 @@ are the proposals they were released from.`sources.item.*` and `sources.fetch.failed` subscriptions (`npm run subscribe`).- **OpenVibe.Network** (4000): SSO (OAuth client `news`, redirect `https://openvibe.news/auth/callback`),JWKS, `identity.subject.resolve` for editor names.++- **OpenVibe.Community** (4200): `community.comment.write` (resolve a story's thread, comment as++ the signed-in member) and `community.comment.moderate` (hide and show a story's thread).++ Without them a story page says comments are unavailable; nothing else depends on Community.- **OpenVibe.Search**: consumes `news.index_document.*` through its `*.index_document.*`subscription; `news` must be in `SEARCH_EVENT_OWNERS`.- **Optional:** OpenVibe.AI (4700) with `ai.run.create` for namespace `news.*` (a bare `news` only matches a workflow literally named `news`).@@@ -208,6 +231,9 @@ Each grant is `[client, capability, audience]`:- `[news, events.event.publish, openvibe.events]`- `[news, events.subscription.manage, openvibe.events]`- `[news, identity.subject.resolve, openvibe.network]`++- `[news, community.comment.write, openvibe.community]` (comment threads on stories)++- `[news, community.comment.moderate, openvibe.community]` (hide the thread of a retracted,++ unpublished or source-removed story)- `[news, ai.run.create, openvibe.ai]`, namespace `news.*` (optional: only with `OV_AI_INTERNAL_URL`)- For OpenVibe.AI to deliver drafts: `[ai, news.story.revise, openvibe.news]`, and`[ai, news.story.read, openvibe.news]` if it reads stories back.@@@ -220,6 +246,7 @@ Each grant is `[client, capability, audience]`:| A story with zero sources cannot be published; an uncited paragraph is refused. | `test/stories.test.js` || Failed ingestion never fabricates: failed pulls, unreadable deliveries, `sources.fetch.failed` and title-less items are recorded and create no item, cluster or story text. | `test/ingest.test.js` || A source correction or removal triggers a flag and a pending revision; published text is unchanged; a removed source leaves the page; publishing a revision that rests on it is refused; the editor's revision resolves the flags with a note. | `test/upstream.test.js` |++| Community discussion is referenced, not duplicated: one thread per published story, only its id stored, read on render; Community down says comments are unavailable; drafts get no thread; retraction, unpublishing and a removed source hide it after the commit; members comment by form as themselves. | `test/discussion.test.js` || Licensed material never leaks: bodies and summaries beyond the allowance never reach the database, pages, JSON, feeds, sitemaps, the API, events or Search. | `test/licensing.test.js` || Dedupe by canonical URL, content hash and near-duplicate headline; idempotent replays. | `test/ingest.test.js` || Clusters are deterministic and explained; merges and splits are audited and reversible. | `test/clusters.test.js` |@@@ -270,15 +297,19 @@ call the service live.- **XSS:** all HTML goes through `openvibe-publishing/ssr` auto-escaping; source links get`rel="noopener nofollow"`; helmet CSP.- **SSRF:** News makes no outbound request to a URL from content. It calls only its configured-- Network, Sources, Events and AI hosts.++ Network, Sources, Events, Community and AI hosts.- **Licensing and privacy:** see "Licensing" above; removed sources are masked everywhere public.The editor view shows removal reasons; the public view does not.- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or aneditor citing one; empty feeds use a real time; JSON-LD omits unknown fields.--- **Abuse:** rate limits on `/auth`, `/edit`, `/clusters` and `/api/v1`, in Express and in the-- nginx reference.--- **Known gaps:** no Community comment threads on stories yet; no per-story Media attachments; the-- Sources registry name is cached and not refreshed after the first successful read.++- **Abuse:** rate limits on `/auth`, `/edit`, `/clusters`, `/api/v1` and the comment form, in++ Express and in the nginx reference.++- **Comments:** Community owns the text and its moderation; News renders it through the same++ auto-escaping and stores only the thread id. The comment form needs a signed-in member and the++ form token, is rate-limited, and is refused for a story that is not open for discussion.++- **Known gaps:** no per-story Media attachments; the Sources registry name is cached and not++ refreshed after the first successful read; the Network does not hold News' two Community grants++ yet, so story pages say comments are unavailable until they are added.## Development@@@ -294,7 +325,8 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4820 (set OV_OAUTHCreate `/etc/openvibe/news.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`,`NEWS_EDITORS`, `NEWS_FORM_SECRET`, `NEWS_EVENTS_SECRET` (`openssl rand -hex 32`),`EVENTS_URL=http://127.0.0.1:4300`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`,-- `BASE_URL=https://openvibe.news`, and optionally `OV_AI_INTERNAL_URL`.++ `BASE_URL=https://openvibe.news`, `OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200`, and++ optionally `OV_AI_INTERNAL_URL`.2. **Network:** create (or give a secret to) the OAuth client `news` with redirect`https://openvibe.news/auth/callback`, and add the grants listed above.3. **Search:** make sure `news` is in `SEARCH_EVENT_OWNERS`.@@@ -87,6 +87,12 @@ server {proxy_pass http://127.0.0.1:4820;}++ # ── Comment form posts (relayed to the story's OpenVibe.Community thread) ──++ location ~ ^/stories/[a-z0-9-]+/comments$ {++ limit_req zone=ovnews_write burst=10 nodelay;++ proxy_pass http://127.0.0.1:4820;++ }++# ── The Events webhook: OpenVibe.Events delivers over loopback, never through the public vhost ──location /internal/ { return 404; }@@@ -48,5 +48,5 @@"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."++ "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); OpenVibe.Community (community.comment.write, community.comment.moderate) for story comment threads, referenced by id in news_story_discussion_refs; 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."}@@@ -39,6 +39,12 @@ 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; }++.comments { margin-top: 28px; border-top: 1px solid var(--border, #1f2d47); }++.comment-list, .comments .replies { list-style: none; padding-left: 0; }++.comments .replies { margin-left: 1.2rem; border-left: 2px solid var(--border, #1f2d47); padding-left: 10px; }++.comment { margin: 12px 0; }++.comment p { margin: 4px 0; }++.comment-form textarea { font-family: inherit; }.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; }@@@ -22,8 +22,10 @@ const { createViewerResolver } = require('./auth/viewer');const { createPeople } = require('./clients/network');const { createSourcesClient } = require('./clients/sources');const { createAi } = require('./clients/ai');++const { createCommunity } = require('./clients/community');const { createNewsOutbox } = require('./events/outbox');const { createPublication } = require('./domain/publication');++const { createDiscussion } = require('./domain/discussion');const { createClusters } = require('./domain/clusters');const { createIngest } = require('./domain/ingest');const { createStories } = require('./domain/stories');@@@ -53,8 +55,10 @@ function createApp(opts = {}) {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 });++ const community = createCommunity({ store, config, fetchImpl, log });++ const discussion = createDiscussion({ store, publication, community, log });++ const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });++ const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log });ingest.setStories(stories);const topics = createTopics({ store });// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).@@@ -65,7 +69,7 @@ function createApp(opts = {}) {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 ctx = { config, store, outbox, publication, clusters, sources, ai, community, discussion, ingest, stories, topics, people, reading, auth, viewers, worker };const app = express();app.disable('x-powered-by');nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * Comments are OpenVibe.Community threads, referenced — never copied. A story's thread is the one++ * Community resolves for EntityRef { service: 'news', type: 'story', id } (openvibe-publishing/++ * discussion stores only the thread id, in news_story_discussion_refs). Pages read the thread from++ * Community on render, so moderation and deletion there are always what readers see; a failure is++ * shown as "comments are unavailable", never as an empty or invented thread.++ *++ * Which stories may have an open thread is domain/discussion.js' rule; this client only talks to++ * Community.++ *++ * Calls with News' service token (audience openvibe.community):++ * community.comment.write resolve a thread; comment as the signed-in member (X-OV-Subject)++ * community.comment.moderate hide the thread of a story that was retracted, unpublished or rests++ * on a removed source; show it again when the story is open again++ */++const { serviceAuth, http } = require('openvibe-contracts');++const { createDiscussionClient } = require('openvibe-publishing/discussion');++++function createCommunity({ store, config, fetchImpl = globalThis.fetch, log = console }) {++ const base = config.community.internalUrl;++ const enabled = Boolean(config.oauth.clientSecret && base);++ const tokenClient = (scope) => serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.community', scope, fetchImpl,++ });++ const writeTokens = enabled ? tokenClient('community.comment.write') : null;++ const modTokens = enabled ? tokenClient('community.comment.moderate') : null;++ const discussion = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;++++ const refFor = (story, label) => ({ service: 'news', type: 'story', id: story.id, ...(label ? { label: String(label).slice(0, 200) } : {}) });++++ async function call(method, path, { tokens, subject, body, ctx, timeoutMs = 4000 } = {}) {++ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };++ if (tokens) Object.assign(headers, await tokens.authHeaders());++ if (subject) headers['X-OV-Subject'] = subject;++ if (body) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });++ const data = await res.json().catch(() => null);++ if (res.status === 401 && tokens && tokens.invalidate) tokens.invalidate();++ if (!res.ok) {++ const err = new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);++ err.status = res.status;++ err.code = data && data.code;++ throw err;++ }++ return data;++ }++++ return {++ enabled,++ publicUrl: config.community.publicUrl,++++ /** The stored thread id, or resolve it once through Community (the caller checks the story may have one). */++ async threadFor(story, label, ctx) {++ const known = store.discussion.get(story.id);++ if (known) return known.threadId;++ if (!discussion) return null;++ const out = await store.discussion.threadFor(story.id, refFor(story, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });++ return out.threadId;++ },++++ /** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */++ async readThread(threadId, { after, ctx } = {}) {++ const qs = after ? `?after=${encodeURIComponent(after)}` : '';++ return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });++ },++++ /** Comment as the signed-in member. */++ async comment(threadId, subject, { message, parentId } = {}, ctx) {++ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }++ return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {++ tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,++ });++ },++++ /**++ * Best effort: set a story's thread to hidden or public. Nothing to do when the story never++ * had a thread. Never throws; a failure is logged.++ */++ async setThreadVisibility(storyId, visibility, ctx) {++ const known = store.discussion.get(storyId);++ if (!known || !modTokens) return false;++ try {++ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });++ return true;++ } catch (err) {++ log.warn(`[News] could not set the comment thread of ${storyId} to ${visibility}: ${err.message}`);++ return false;++ }++ },++ };++}++++module.exports = { createCommunity };@@@ -71,6 +71,13 @@ function load(env = process.env) {windowMs: int(env.NEWS_CLUSTER_WINDOW_HOURS, 72) * 3600 * 1000,},++ // OpenVibe.Community: the comment thread of each public story (referenced by id, never++ // copied). Needs community.comment.write, and community.comment.moderate to hide a thread.++ community: {++ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),++ internalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),++ },++// 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: {@@@ -20,6 +20,7 @@** Also here, as companions (not authority for anything outside News):* news_story_citations, news_story_reviews, news_story_drafts, news_story_revision_purges,++ * news_story_discussion_refs (a story's Community thread id, never its comments),* 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);@@@ -32,6 +33,7 @@ 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 { createDiscussionRefs } = require('openvibe-publishing/discussion');const SCHEMA = `CREATE TABLE IF NOT EXISTS news_topics (@@@ -246,6 +248,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),reviews: createReviewLog(db, { prefix: 'news_story', now }),sequencer: createIndexSequencer(db, { prefix: 'news', now }),++ discussion: createDiscussionRefs(db, { prefix: 'news_story', now }),tx: (fn) => db.transaction(fn)(),getState(key, def = null) {const r = db.prepare('SELECT value FROM news_state WHERE key = ?').get(key);nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++++/**++ * Which stories have an open Community thread, and keeping the thread in step with the story.++ *++ * A story is open for discussion when it is published and no paragraph of its published revision++ * rests only on sources removed upstream. Indexability does not matter: a noindex story (a single++ * source, an editor's noindex) is public all the same.++ * - a draft never gets a thread (Community threads are readable by anyone with the id)++ * - retracted: the story stays on the record, its thread is hidden++ * - unpublished: the page is gone (410), its thread is hidden++ * - a paragraph resting only on a removed source: the thread is hidden until an editor publishes++ * a revision that no longer rests on it (comments could repeat the withdrawn material)++ *++ * Writes that can change this call watch() inside their transaction, before they change anything.++ * better-sqlite3 transactions are synchronous, so the setImmediate flush runs after the commit (or++ * the rollback): the state is compared again and, when it changed, the thread is set hidden or++ * public (best effort, community.comment.moderate). A rolled-back write compares equal and calls++ * nothing; a story that never had a thread calls nothing either (threads are created only when an++ * open story is read).++ */++function createDiscussion({ store, publication, community, log = console }) {++ const { db } = store;++ const storyById = db.prepare('SELECT * FROM news_stories WHERE id = ?');++ const citing = db.prepare('SELECT DISTINCT story_id FROM news_story_sources WHERE source_item_id = ?');++++ /** { open, reason: null | not_published | retracted | source_removed } */++ function status(story) {++ if (!story || !story.published_revision) return { open: false, reason: 'not_published' };++ if (story.state === 'retracted') return { open: false, reason: 'retracted' };++ if (story.state !== 'published') return { open: false, reason: 'not_published' };++ const rev = store.revisions.get(story.id, story.published_revision);++ if (!rev) return { open: false, reason: 'not_published' };++ if (publication.support(rev).unsupported > 0) return { open: false, reason: 'source_removed' };++ return { open: true, reason: null };++ }++++ const pending = new Map(); // story id → open before the write++ let scheduled = false;++ let flushing = Promise.resolve();++++ async function flush() {++ scheduled = false;++ const batch = [...pending];++ pending.clear();++ for (const [id, was] of batch) {++ const now = status(storyById.get(id)).open;++ if (now !== was) await community.setThreadVisibility(id, now ? 'public' : 'hidden');++ }++ }++++ /** Remember whether a story is open now; after the commit, follow any change. Inside the write's transaction. */++ function watch(storyId) {++ if (!community.enabled) return;++ if (!pending.has(storyId)) pending.set(storyId, status(storyById.get(storyId)).open);++ if (!scheduled) {++ scheduled = true;++ setImmediate(() => { flushing = flushing.then(flush).catch((err) => log.warn(`[News] comment thread sync failed: ${err.message}`)); });++ }++ }++++ return {++ status,++ watch,++ /** Every story that cites a source item (before the item changes upstream). */++ watchSource(itemId) { for (const r of citing.all(itemId)) watch(r.story_id); },++ /** Resolves once the scheduled visibility changes have been sent (tests, shutdown). */++ idle: () => new Promise((resolve) => setImmediate(() => flushing.then(resolve))),++ };++}++++module.exports = { createDiscussion };@@@ -21,6 +21,7 @@** 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.++ * A removal can also hide the story's Community thread (domain/discussion.js, after the commit).*/const { ids } = require('openvibe-contracts');const text = require('./text');@@@ -28,7 +29,7 @@ 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 }) {++function createIngest({ store, config, clusters, outbox, sources, discussion = null, log = console }) {const { db } = store;let stories = null; // set by app.js (stories depend on ingest's items too)const q = {@@@ -183,6 +184,8 @@ function createIngest({ store, config, clusters, outbox, sources, log = consoleconst cur = q.bySourcesId.get(sourcesItemId);if (!cur) return { outcome: 'ignored', reason: 'never ingested' };if (cur.status === 'removed') return { outcome: 'unchanged', item: cur };++ // A story resting on it may stop being open for comments (domain/discussion.js).++ if (discussion) discussion.watchSource(cur.id);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 = ?`)@@@ -54,7 +54,7 @@ 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 }) {++function createStories({ store, config, publication, clusters, outbox, ai = null, discussion = null, log = console }) {const { db } = store;const q = {byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),@@@ -475,6 +475,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullif (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);++ if (discussion) discussion.watch(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);@@@ -520,6 +521,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullreturn store.tx(() => {const cur = q.byId.get(story.id);if (cur.state !== 'published' && cur.state !== 'retracted') return { changed: false, story: cur };++ if (discussion) discussion.watch(story.id);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 };@@@ -536,6 +538,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullconst 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');++ if (discussion) discussion.watch(story.id);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);@@@ -7,23 +7,29 @@* 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++ * POST /stories/:slug/comments comment (the story's Community thread) as the signed-in member* GET /feed.xml, /atom.xml, /feed.json, /topics/:slug/feed.{xml,json}, /topics/:slug/atom.xml*++ * Comments are the story's OpenVibe.Community thread, read from Community on every render (never++ * copied here); a story that is not open for discussion (domain/discussion.js) shows no thread.++ ** 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 rateLimit = require('express-rate-limit');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 { csrfToken, checkCsrf } = require('../auth/forms');const PER_PAGE = 20;function createPublicRoutes(ctx) {-- const { config, store, stories, publication, reading, topics, viewers } = ctx;++ const { config, store, stories, publication, reading, topics, viewers, community, discussion } = ctx;const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -123,6 +129,23 @@ function createPublicRoutes(ctx) {// ── Stories ─────────────────────────────────────────────++ /** The story's Community thread as the page shows it: { state: ok|closed|off|unavailable, … }. */++ async function commentsFor(req, story, headline) {++ const st = discussion.status(story);++ if (!st.open) return { state: 'closed', reason: st.reason };++ if (!community.enabled) return { state: 'off' };++ try {++ const threadId = await community.threadFor(story, headline, req.ov);++ if (!threadId) return { state: 'off' };++ const data = await community.readThread(threadId, { after: req.query.comments_after, ctx: req.ov });++ if (!data || !data.thread) throw new Error('Community returned no thread');++ return { state: 'ok', thread: data.thread, comments: Array.isArray(data.comments) ? data.comments : [], nextCursor: data.next_cursor || null, communityUrl: community.publicUrl };++ } catch (err) {++ console.warn(`[News] comments for ${story.id} unavailable: ${err.message}`);++ return { state: 'unavailable' };++ }++ }++/** 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);@@@ -131,6 +154,8 @@ function createPublicRoutes(ctx) {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');++ const comments = preview ? null : await commentsFor(req, story, m.headline);++ const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);send(req, res, 200, {title: m.retraction ? `Retracted: ${m.headline}` : m.headline,description: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 200) : undefined,@@@ -143,6 +168,8 @@ function createPublicRoutes(ctx) {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,++ comments, signedIn, csrf: signedIn ? csrfToken(config, req.viewer) : '',++ loginUrl: `/auth/login?next=${encodeURIComponent(m.path)}`,}),}, { cacheable });}@@@ -163,6 +190,28 @@ function createPublicRoutes(ctx) {return renderStory(req, res, { story });}));++ router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {++ const story = stories.bySlug(req.params.slug);++ if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);++ const path = publication.storyPath(story);++ if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);++ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });++ if (!discussion.status(story).open) return messagePage(req, res, 409, 'Comments are closed', 'This story does not take comments.', { href: path, label: 'Back to the story' });++ if (!community.enabled) return messagePage(req, res, 503, 'Comments are unavailable', 'Comments are not connected on this server.', { href: path, label: 'Back to the story' });++ const message = String((req.body && req.body.message) || '').trim();++ if (!message) return res.redirect(303, `${path}#comments`);++ if (message.length > 5000) return messagePage(req, res, 422, 'Comment too long', 'A comment is at most 5000 characters.', { href: path, label: 'Back to the story' });++ try {++ const rev = store.revisions.get(story.id, story.published_revision);++ const threadId = await community.threadFor(story, rev && rev.fields.headline, req.ov);++ if (!threadId) throw new Error('no thread');++ await community.comment(threadId, req.viewer.subject, { message }, req.ov);++ } catch (err) {++ return messagePage(req, res, err.status === 429 ? 429 : 502, 'Comment not posted', `OpenVibe.Community did not accept the comment: ${err.message}`, { href: path, label: 'Back to the story' });++ }++ return res.redirect(303, `${path}#comments`);++ }));++return { router, renderStory, notFound, messagePage, pageDecision, send };}@@@ -4,7 +4,8 @@* 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().++ * upstream-change notice, then the story's OpenVibe.Community thread with a plain comment form.++ * Values are escaped by ssr.html unless wrapped in raw().*/const ssr = require('openvibe-publishing/ssr');@@@ -86,8 +87,40 @@ ${rest.length ? h`<h3>Not grouped</h3>${table(rest)}` : ''}`;return h`${tabs}${table(m.sources)}`;}++const CLOSED = {++ retracted: 'Comments are closed: this story was retracted.',++ source_removed: 'Comments are paused while editors revise this story: a source it rests on was removed.',++ not_published: 'Comments are available on published stories only.',++};++++/** The Community thread (read from Community on this render), or why there is none. */++function commentsSection(c, { path, csrf, signedIn, loginUrl }) {++ const wrap = (inner) => String(h`<section id="comments" class="comments" aria-labelledby="comments-h"><h2 id="comments-h">Comments</h2>${raw(String(inner))}</section>`);++ if (c.state === 'closed') return wrap(h`<p class="empty">${CLOSED[c.reason] || CLOSED.not_published}</p>`);++ if (c.state === 'off') return wrap(h`<p class="empty">Comments are not connected on this server.</p>`);++ if (c.state !== 'ok') return wrap(h`<p class="notice" role="status">Comments are unavailable: they could not be loaded from OpenVibe.Community right now. Reload later.</p>`);++ const one = (x) => h`<li class="comment" id="comment-${x.id}">++<p class="meta"><strong>${x.deleted ? '[deleted]' : (x.display_name || 'Anonymous')}</strong>${x.origin === 'ai' ? h` <span class="badge">AI</span>` : ''}${Number.isFinite(Date.parse(x.created_at)) ? h` · ${time(Date.parse(x.created_at))}` : ''}</p>++${x.deleted ? h`<p class="empty">This comment was deleted.</p>` : raw(`<p>${ssr.escapeHtml(String(x.message || '')).replace(/\n/g, '<br>')}</p>`)}++${x.replies && x.replies.length ? h`<ol class="replies">${x.replies.map(one)}</ol>` : ''}++</li>`;++ const list = c.comments.length ? h`<ol class="comment-list">${c.comments.map(one)}</ol>` : h`<p class="empty">No comments yet.</p>`;++ const more = c.nextCursor ? h`<p><a href="${path}?comments_after=${c.nextCursor}#comments" rel="nofollow">More comments</a></p>` : '';++ const form = c.thread && c.thread.visibility === 'locked'++ ? h`<p class="empty">This thread is locked.</p>`++ : signedIn++ ? h`<form method="post" action="${path}/comments" class="comment-form">++<input type="hidden" name="_csrf" value="${csrf}">++<label for="comment-message">Add a comment</label>++<textarea id="comment-message" name="message" rows="4" maxlength="5000" required></textarea>++<button type="submit">Post comment</button>++</form>`++ : h`<p><a href="${loginUrl}">Sign in with OpenVibe</a> to comment.</p>`;++ return wrap(h`<p class="meta">Comments are hosted by <a href="${c.communityUrl}">OpenVibe.Community</a>.</p>${list}${more}${form}`);++}++/** One story. */--function storyPage(m, { group = 'number', breadcrumbs, jsonUrl, editUrl = null, decisionNote = null }) {++function storyPage(m, { group = 'number', breadcrumbs, jsonUrl, editUrl = null, decisionNote = null, comments = null, signedIn = false, csrf = '', loginUrl = '/auth/login' }) {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) {@@@ -112,11 +145,12 @@ ${m.perspectives.length && group !== 'perspective' ? h`<section class="perspecti${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>`);++</article>++${raw(comments ? commentsSection(comments, { path: m.path, csrf, signedIn, loginUrl }) : '')}`);}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 };++module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites };nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * Community discussion is referenced, not duplicated: a published story's comments are the++ * OpenVibe.Community thread resolved once for EntityRef { service: 'news', type: 'story', id }.++ * News stores only the thread id (news_story_discussion_refs) and reads the thread from Community++ * on every render; a Community failure says comments are unavailable, never shows an empty or++ * invented thread. Drafts never get a thread; retracting, unpublishing or a removed source hides it++ * (community.comment.moderate, after the commit, best effort); members comment through a plain++ * form, as themselves (X-OV-Subject).++ */++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, 3]',++ '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);++ 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 open = async (headline, body = BODY) => {++ const r = await t.api('/stories', { json: { cluster: clusterId, headline, topic: 'space', body } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().story;++ };++ const publish = async (s, body = {}) => { const r = await t.api(`/stories/${s.id}/publish`, { json: body }); assert.strictEqual(r.status, 200, r.text); return r; };++ const calls = (re, method) => t.community.calls.filter((c) => re.test(c.url.split('?')[0]) && (!method || c.method === method));++ const resolves = () => calls(/\/threads\/resolve$/, 'POST');++ const hides = () => calls(/\/visibility$/, 'PUT');++ const settle = () => t.ctx.discussion.idle();++ const refOf = (id) => t.db().prepare('SELECT * FROM news_story_discussion_refs WHERE entity_id = ?').get(id);++ t.community.setProbe(() => t.db().inTransaction);++++ const story = await open('Europa Clipper is on its way to Jupiter');++ const url = `/stories/${story.slug}`;++ const member = t.network.addUser('reader', { display_name: 'Rita Reader' });++++ await check('a draft has no thread: nothing is resolved while a story is unpublished', async () => {++ assert.strictEqual((await t.get(url)).status, 404);++ const prev = await t.get(`/edit/stories/${story.id}/preview`, { as: t.editor });++ assert.strictEqual(prev.status, 200, prev.text);++ assert.doesNotMatch(prev.text, /id="comments"/, 'the editor preview shows no thread');++ assert.strictEqual(resolves().length, 0);++ assert.strictEqual(refOf(story.id), undefined);++ });++++ await check('a published story resolves its thread once, for the story’s EntityRef, and stores only the id', async () => {++ await publish(story);++ let page = await t.get(url);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<section id="comments" class="comments"/);++ assert.match(page.text, /No comments yet\./);++ assert.match(page.text, /Comments are hosted by <a href="https:\/\/openvibe\.community">OpenVibe\.Community<\/a>/);++ assert.match(page.text, /Sign in with OpenVibe<\/a> to comment/);++ assert.strictEqual(resolves().length, 1);++ const call = resolves()[0];++ assert.ok(call.cap.includes('community.comment.write'), 'resolved with the write capability');++ assert.deepStrictEqual(JSON.parse(call.body).ref, { service: 'news', type: 'story', id: story.id, label: 'Europa Clipper is on its way to Jupiter' });++ const ref = refOf(story.id);++ assert.strictEqual(ref.thread_id, '1');++ assert.deepStrictEqual(Object.keys(ref).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'the reference table has no room for comments');++ page = await t.get(url);++ await t.get(`${url}?group=outlet`);++ assert.strictEqual(resolves().length, 1, 'later renders reuse the stored id');++ assert.strictEqual(calls(/\/threads\/1$/, 'GET').length, 3, 'every render reads the thread from Community');++ });++++ await check('a member comments through the plain form, as themselves; the comment lives in Community only', async () => {++ const csrf = t.csrf(member);++ let r = await t.get(`${url}/comments`, { form: { _csrf: csrf, message: 'Anonymous?' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(url)}`);++ r = await t.get(`${url}/comments`, { as: member, form: { message: 'No token' } });++ assert.strictEqual(r.status, 403);++ const page = await t.get(url, { as: member });++ assert.match(page.text, new RegExp(`<form method="post" action="${url}/comments" class="comment-form">`));++ assert.match(page.text, new RegExp(`name="_csrf" value="${csrf}"`));++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');++ r = await t.get(`${url}/comments`, { as: member, form: { _csrf: csrf, message: 'Great summary; the 2030 arrival <b>date</b> matters.\nThanks.' } });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(r.headers.get('location'), `${url}#comments`);++ const posted = calls(/\/threads\/1\/comments$/, 'POST');++ assert.strictEqual(posted.length, 1);++ assert.strictEqual(posted[0].subject, member.subject, 'commented as the member (X-OV-Subject)');++ assert.ok(posted[0].cap.includes('community.comment.write'));++ const after = await t.get(url);++ assert.match(after.text, /<strong>Rita Reader<\/strong>/);++ assert.match(after.text, /Great summary; the 2030 arrival <b>date<\/b> matters\.<br>Thanks\./, 'escaped, read from Community');++ const dump = t.db().prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()++ .map(({ name }) => JSON.stringify(t.db().prepare(`SELECT * FROM "${name}"`).all())).join('\n');++ assert.doesNotMatch(dump, /Great summary|2030 arrival/, 'no comment text is copied into News');++ });++++ await check('Community down: the story still serves and says comments are unavailable — no empty or invented thread', async () => {++ t.community.setDown(true);++ const page = await t.get(url);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /NASA launched the Europa Clipper spacecraft/);++ assert.match(page.text, /Comments are unavailable/);++ assert.doesNotMatch(page.text, /No comments yet|Rita Reader|comment-form/);++ const r = await t.get(`${url}/comments`, { as: member, form: { _csrf: t.csrf(member), message: 'Lost?' } });++ assert.strictEqual(r.status, 502);++ assert.match(r.text, /Comment not posted/);++ t.community.setDown(false);++ const fresh = await open('A second Europa Clipper story');++ await publish(fresh);++ t.community.setDown(true);++ const p2 = await t.get(`/stories/${fresh.slug}`);++ assert.match(p2.text, /Comments are unavailable/);++ assert.strictEqual(refOf(fresh.id), undefined, 'a failed resolve stores no thread id');++ t.community.setDown(false);++ });++++ await check('a removed source a paragraph rests on hides the thread after the commit; the fixed revision shows it again', async () => {++ const before = hides().length;++ 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);++ await settle();++ const now = hides().slice(before);++ assert.strictEqual(now.length, 1, 'only the story that had a thread is hidden (the second one never resolved one)');++ assert.deepStrictEqual(JSON.parse(now[0].body), { visibility: 'hidden' });++ assert.strictEqual(now[0].url, '/api/v1/comments/threads/1/visibility');++ assert.ok(now[0].cap.includes('community.comment.moderate'), 'hidden with the moderate capability');++ assert.strictEqual(now[0].probe, false, 'called after the write committed, never inside the transaction');++ assert.strictEqual(t.community.threads.get(1).visibility, 'hidden');++ const reads = calls(/\/threads\/1$/, 'GET').length;++ const page = await t.get(url);++ assert.match(page.text, /Comments are paused while editors revise this story/);++ assert.doesNotMatch(page.text, /comment-form|Rita Reader/);++ assert.strictEqual(calls(/\/threads\/1$/, 'GET').length, reads, 'a closed story does not read its thread');++ const r = await t.get(`${url}/comments`, { as: member, form: { _csrf: t.csrf(member), message: 'Still here?' } });++ assert.strictEqual(r.status, 409);++++ const itemA = t.db().prepare('SELECT id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).id;++ assert.strictEqual((await t.api(`/stories/${story.id}/sources/${itemA}`, { method: 'DELETE' })).status, 200);++ const head = t.ctx.store.revisions.head(story.id).number;++ const fixed = BODY.split('\n\n').slice(0, 2).map((p) => p.replace('[1, 2]', '[2, 3]')).join('\n\n');++ let rr = await t.api(`/stories/${story.id}/revisions`, { json: { body: fixed, expected_revision: head } });++ assert.strictEqual(rr.status, 201, rr.text);++ await publish(story, { revision: rr.json().revision, correction: { kind: 'update', note: 'A source withdrew its report; the paragraph based only on it was removed.' } });++ await settle();++ assert.deepStrictEqual(JSON.parse(hides().pop().body), { visibility: 'public' });++ assert.strictEqual(t.community.threads.get(1).visibility, 'public');++ rr = await t.get(url);++ assert.match(rr.text, /Rita Reader/);++ });++++ await check('retraction hides the thread and the retracted page shows none; unpublish hides, republish shows', async () => {++ // Source [1] (wire-a) was removed above, so this story rests on the two that remain.++ const other = await open('Europa Clipper: the launch in numbers', BODY.split('\n\n').slice(0, 2).map((p) => p.replace(/\[[\d, ]+\]$/, '[1, 2]')).join('\n\n'));++ await publish(other);++ await t.get(`/stories/${other.slug}`);++ const id = refOf(other.id).thread_id;++ let r = await t.api(`/stories/${other.id}/unpublish`, { json: {} });++ assert.strictEqual(r.status, 200, r.text);++ await settle();++ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'hidden');++ await publish(other);++ await settle();++ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'public');++ r = await t.api(`/stories/${other.id}/retract`, { json: { note: 'Our sources described a rehearsal, not the launch.' } });++ assert.strictEqual(r.status, 200, r.text);++ await settle();++ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'hidden');++ const last = hides().pop();++ assert.strictEqual(last.probe, false);++ const page = await t.get(`/stories/${other.slug}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Comments are closed: this story was retracted\./);++ assert.doesNotMatch(page.text, /comment-form/);++ });++++ await check('a write refused inside its transaction (rolled back) calls nothing', async () => {++ const n = hides().length;++ const r = await t.api(`/stories/${story.id}/publish`, { json: { revision: 2 } });++ assert.strictEqual(r.status, 409, 'revision 2 is the pending revision that still cites the removed source');++ assert.strictEqual(r.json().code, 'story.source_removed');++ await settle();++ assert.strictEqual(hides().length, n);++ assert.strictEqual(t.community.threads.get(1).visibility, 'public');++ });++++ await t.close();++ done();++})();@@@ -6,6 +6,7 @@* 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.community.threads / .calls // the Community mock (comment threads)* t.deliver(event) // a signed Events webhook delivery* t.clock.advance(ms)*/@@@ -13,7 +14,7 @@ const fs = require('fs');const os = require('os');const path = require('path');const http = require('http');--const { startNetwork, startSources, startAi, delivery } = require('./mocks');++const { startNetwork, startSources, startAi, startCommunity, delivery } = require('./mocks');const SECRET = 'test-webhook-secret-0123456789abcdef0123';@@@ -26,6 +27,7 @@ async function boot(opts = {}) {const network = await startNetwork();const sources = await startSources({ network });const ai = await startAi({ network });++ const community = await startCommunity({ network });const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-news-test-'));const dbPath = path.join(dir, 'news.db');const clock = opts.clock || makeClock();@@@ -36,6 +38,7 @@ async function boot(opts = {}) {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,++ OV_COMMUNITY_URL: 'https://openvibe.community', OV_COMMUNITY_INTERNAL_URL: community.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 } : {}),@@@ -90,12 +93,12 @@ async function boot(opts = {}) {const api = (p, o = {}) => get(`/api/v1${p}`, { as: editor, ...o });const t = {-- network, sources, ai, clock, dbPath, editor, get, api, events, deliver, SECRET,++ network, sources, ai, community, 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 }); },++ async close() { await stop(); await network.close(); await sources.close(); await ai.close(); await community.close(); fs.rmSync(dir, { recursive: true, force: true }); },};await start();return t;@@@ -6,6 +6,7 @@* 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++ * Community /api/v1/comments/threads/resolve, GET thread, POST comment, PUT visibility* userToken()/serviceToken() mint the tokens browsers and services present to News;* delivery() signs an Events webhook delivery.*/@@@ -188,6 +189,59 @@ async function startAi({ network }) {return { ...srv, requests, setNext: (v) => { next = v; } };}++async function startCommunity({ network }) {++ const threads = new Map(); // id → { ref, visibility, comments: [] }++ const byRef = new Map();++ const calls = [];++ let down = false;++ let seq = 0;++ const box = { probe: null }; // called on every request (e.g. to see whether News is inside a transaction)++ const srv = await listen((req, raw, json) => {++ const token = String(req.headers.authorization || '').slice(7);++ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.community' }) : null;++ const cap = v && v.ok ? (v.claims.cap || []) : [];++ calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, cap, body: raw || null, probe: box.probe ? box.probe() : null });++ if (down) return json(503, { code: 'down' });++ if (req.url === '/api/v1/comments/threads/resolve' && req.method === 'POST') {++ if (!cap.includes('community.comment.write')) return json(403, { code: 'capability.denied' });++ const { ref } = JSON.parse(raw);++ const key = `${ref.service}:${ref.type}:${ref.id}`;++ if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });++ const id = ++seq;++ threads.set(id, { ref, visibility: 'public', comments: [] });++ byRef.set(key, id);++ return json(201, { thread: { id, visibility: 'public' }, created: true });++ }++ let m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)(\?.*)?$/);++ if (m && req.method === 'GET') {++ const t = threads.get(Number(m[1]));++ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });++ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility, comment_count: t.comments.length }, comments: t.comments, next_cursor: null });++ }++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/comments$/);++ if (m && req.method === 'POST') {++ if (!cap.includes('community.comment.write')) return json(403, { code: 'capability.denied' });++ const t = threads.get(Number(m[1]));++ if (!t || t.visibility !== 'public') return json(409, { code: 'thread.closed' });++ const body = JSON.parse(raw);++ const who = network.directory.get(req.headers['x-ov-subject']);++ const comment = { id: t.comments.length + 1, origin: 'user', display_name: who ? who.display_name : 'Someone', message: body.message, deleted: false, created_at: '2026-09-22T12:05:00.000Z' };++ t.comments.push(comment);++ return json(201, { comment });++ }++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);++ if (m && req.method === 'PUT') {++ if (!cap.includes('community.comment.moderate')) return json(403, { code: 'capability.denied' });++ const t = threads.get(Number(m[1]));++ if (!t) return json(404, { code: 'thread.not_found' });++ t.visibility = JSON.parse(raw).visibility;++ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility } });++ }++ return json(404, { code: 'route.not_found' });++ });++ return { ...srv, threads, calls, setDown: (v) => { down = v; }, setProbe: (fn) => { box.probe = fn; } };++}++/*** An Events webhook delivery for News: { body, headers } signed with the secret (v1 and v2, as* Events sends it). `v1Only` leaves the v2 headers off; `now` (ms) backdates the v2 timestamp.@@@ -201,4 +255,4 @@ function delivery(event, secret, { seq = 1, attempt = 1, v1Only = false, now = Dreturn { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, ...v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };}--module.exports = { startNetwork, startSources, startAi, delivery, listen };++module.exports = { startNetwork, startSources, startAi, startCommunity, delivery, listen };@@@ -1,8 +1,8 @@#!/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.++ * databases and in-process mocks of OpenVibe.Network, Sources, AI, Community 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
@@ -53,6 +53,12 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
@@ -53,6 +53,12 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
# Unset = webhook off (503); the cursor pull still runs.
# Unset = webhook off (503); the cursor pull still runs.
#NEWS_EVENTS_SECRET=
#NEWS_EVENTS_SECRET=
+# OpenVibe.Community — the comment thread of each public story (referenced by id, never copied).
+# Needs the grants community.comment.write and community.comment.moderate (audience openvibe.community);
+# without them a story page says comments are unavailable.
+OV_COMMUNITY_URL=https://openvibe.community
+OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
+
# OpenVibe.AI (optional) — news.summarize_story / news.compare_perspectives drafts (ai.run.create).
# OpenVibe.AI (optional) — news.summarize_story / news.compare_perspectives drafts (ai.run.create).
# Unset: no AI; editors write every story from the source items.
# Unset: no AI; editors write every story from the source items.
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
@@ -45,7 +45,9 @@ Other tables in the same database:
@@ -45,7 +45,9 @@ Other tables in the same database:
- Package companions: `news_story_citations` (one row per paragraph and timeline entry and cited
- Package companions: `news_story_citations` (one row per paragraph and timeline entry and cited
Sources item, append-only), `news_story_reviews` (a person's approval of AI output),
Sources item, append-only), `news_story_reviews` (a person's approval of AI output),
- `news_story_drafts`, `news_story_revision_purges`, `news_index_revisions` (Search sequencer).
+ `news_story_drafts`, `news_story_revision_purges`, `news_index_revisions` (Search sequencer),
+ `news_story_discussion_refs` (a story's OpenVibe.Community thread id; never a comment, a count or
+ an author).
- `news_cluster_audit`: every merge, split and reversal with exactly which items moved.
- `news_cluster_audit`: every merge, split and reversal with exactly which items moved.
- `news_ingest_runs`: what every webhook delivery, pull and upstream fetch failure did.
- `news_ingest_runs`: what every webhook delivery, pull and upstream fetch failure did.
- `news_state` (the Sources cursor), `news_source_status` (display cache of Sources' registry
- `news_state` (the Sources cursor), `news_source_status` (display cache of Sources' registry
@@ -57,7 +59,8 @@ Other tables in the same database:
@@ -57,7 +59,8 @@ Other tables in the same database:
- **Source adapters and the source registry.** OpenVibe.Sources owns them: which sites are read,
- **Source adapters and the source registry.** OpenVibe.Sources owns them: which sites are read,
under which terms, how and when. News reads items with `sources.item.read` and never fetches a
under which terms, how and when. News reads items with `sources.item.read` and never fetches a
site itself.
site itself.
-- **Discussion.** OpenVibe.Community. (News does not attach comment threads yet.)
+- **Discussion.** OpenVibe.Community owns the comment threads, their comments and their
+ moderation. News keeps only each story's thread id and reads the thread on every render.
- **AI generation.** OpenVibe.AI. News only receives drafts.
- **AI generation.** OpenVibe.AI. News only receives drafts.
- **Search.** OpenVibe.Search indexes what News sends through Events.
- **Search.** OpenVibe.Search indexes what News sends through Events.
- **Identity.** OpenVibe.Network: SSO, subjects and service principals.
- **Identity.** OpenVibe.Network: SSO, subjects and service principals.
@@ -110,6 +113,22 @@ Other tables in the same database:
@@ -110,6 +113,22 @@ Other tables in the same database:
source's headline, summary and link leave the public page at once; a paragraph resting only on
source's headline, summary and link leave the public page at once; a paragraph resting only on
it is marked, and the gate makes the story noindex (`unsupported_claims`) until an editor
it is marked, and the gate makes the story noindex (`unsupported_claims`) until an editor
publishes a revision that no longer rests on it — with a correction or update note.
publishes a revision that no longer rests on it — with a correction or update note.
+- **Comments** (OpenVibe.Community, referenced, never copied):
+ - A story open for discussion gets the thread Community resolves for EntityRef
+ `{ service: 'news', type: 'story', id }`, the first time its page is read. Open means published
+ (indexable or not: a noindex story is still public) with no paragraph resting only on a source
+ removed upstream. Drafts never get a thread: a Community thread is readable by anyone with its id.
+ - Only the thread id is stored (`news_story_discussion_refs`). The page reads the thread from
+ Community on every render, so Community's moderation and deletions are what readers see. When
+ Community cannot be read the page says comments are unavailable; it never shows an empty or
+ invented thread.
+ - Signed-in members comment with a plain form (`POST /stories/:slug/comments`, form token), as
+ themselves (`X-OV-Subject`, `community.comment.write`).
+ - Retracting or unpublishing a story, or a removal upstream that leaves a paragraph without a
+ source, hides its thread; publishing a revision that no longer rests on the removed source (or
+ republishing) shows it again. The call (`community.comment.moderate`) runs after the change
+ commits, is best effort, and never fails the change. A retracted story's page says comments are
+ closed; a story waiting for a revision says they are paused.
- **AI** (optional seams, never publication truth):
- **AI** (optional seams, never publication truth):
- OpenVibe.AI may deliver a draft revision with `X-OV-Origin: ai` (`news.story.revise`), naming
- OpenVibe.AI may deliver a draft revision with `X-OV-Origin: ai` (`news.story.revise`), naming
`news.summarize_story` or `news.compare_perspectives` and its run.
`news.summarize_story` or `news.compare_perspectives` and its run.
@@ -128,7 +147,8 @@ Other tables in the same database:
@@ -128,7 +147,8 @@ Other tables in the same database:
|---|---|
|---|---|
| `/` | published stories, newest first (retracted ones stay, labelled) and the topics |
| `/` | published stories, newest first (retracted ones stay, labelled) and the topics |
| `/topics`, `/topics/:slug` | topics and each topic's stories |
| `/topics`, `/topics/:slug` | topics and each topic's stories |
-| `/stories/:slug`, `/stories/:slug.json` | a story (text with source numbers, source table, timeline, perspectives, corrections), and the same story as data |
+| `/stories/:slug`, `/stories/:slug.json` | a story (text with source numbers, source table, timeline, perspectives, corrections, the Community comment thread), and the same story as data |
+| `POST /stories/:slug/comments` | comment on the story's Community thread as the signed-in member (form token) |
| `/feed.xml`, `/atom.xml`, `/feed.json`, `/topics/:slug/{feed.xml,atom.xml,feed.json}` | feeds (News' own text only) |
| `/feed.xml`, `/atom.xml`, `/feed.json`, `/topics/:slug/{feed.xml,atom.xml,feed.json}` | feeds (News' own text only) |
| `/sitemap.xml` → `/sitemaps/stories.xml`, `/sitemaps/topics.xml` | indexable stories and topics only |
| `/sitemap.xml` → `/sitemaps/stories.xml`, `/sitemaps/topics.xml` | indexable stories and topics only |
| `/robots.txt`, `/llms.txt` | the automated-consumer policy and machine orientation |
| `/robots.txt`, `/llms.txt` | the automated-consumer policy and machine orientation |
@@ -195,6 +215,9 @@ are the proposals they were released from.
@@ -195,6 +215,9 @@ are the proposals they were released from.
`sources.item.*` and `sources.fetch.failed` subscriptions (`npm run subscribe`).
`sources.item.*` and `sources.fetch.failed` subscriptions (`npm run subscribe`).
- **OpenVibe.Network** (4000): SSO (OAuth client `news`, redirect `https://openvibe.news/auth/callback`),
- **OpenVibe.Network** (4000): SSO (OAuth client `news`, redirect `https://openvibe.news/auth/callback`),
JWKS, `identity.subject.resolve` for editor names.
JWKS, `identity.subject.resolve` for editor names.
+- **OpenVibe.Community** (4200): `community.comment.write` (resolve a story's thread, comment as
+ the signed-in member) and `community.comment.moderate` (hide and show a story's thread).
+ Without them a story page says comments are unavailable; nothing else depends on Community.
- **OpenVibe.Search**: consumes `news.index_document.*` through its `*.index_document.*`
- **OpenVibe.Search**: consumes `news.index_document.*` through its `*.index_document.*`
subscription; `news` must be in `SEARCH_EVENT_OWNERS`.
subscription; `news` must be in `SEARCH_EVENT_OWNERS`.
- **Optional:** OpenVibe.AI (4700) with `ai.run.create` for namespace `news.*` (a bare `news` only matches a workflow literally named `news`).
- **Optional:** OpenVibe.AI (4700) with `ai.run.create` for namespace `news.*` (a bare `news` only matches a workflow literally named `news`).
@@ -208,6 +231,9 @@ Each grant is `[client, capability, audience]`:
@@ -208,6 +231,9 @@ Each grant is `[client, capability, audience]`:
- `[news, events.event.publish, openvibe.events]`
- `[news, events.event.publish, openvibe.events]`
- `[news, events.subscription.manage, openvibe.events]`
- `[news, events.subscription.manage, openvibe.events]`
- `[news, identity.subject.resolve, openvibe.network]`
- `[news, identity.subject.resolve, openvibe.network]`
+- `[news, community.comment.write, openvibe.community]` (comment threads on stories)
+- `[news, community.comment.moderate, openvibe.community]` (hide the thread of a retracted,
+ unpublished or source-removed story)
- `[news, ai.run.create, openvibe.ai]`, namespace `news.*` (optional: only with `OV_AI_INTERNAL_URL`)
- `[news, ai.run.create, openvibe.ai]`, namespace `news.*` (optional: only with `OV_AI_INTERNAL_URL`)
- For OpenVibe.AI to deliver drafts: `[ai, news.story.revise, openvibe.news]`, and
- For OpenVibe.AI to deliver drafts: `[ai, news.story.revise, openvibe.news]`, and
`[ai, news.story.read, openvibe.news]` if it reads stories back.
`[ai, news.story.read, openvibe.news]` if it reads stories back.
@@ -220,6 +246,7 @@ Each grant is `[client, capability, audience]`:
@@ -220,6 +246,7 @@ Each grant is `[client, capability, audience]`:
| A story with zero sources cannot be published; an uncited paragraph is refused. | `test/stories.test.js` |
| A story with zero sources cannot be published; an uncited paragraph is refused. | `test/stories.test.js` |
| Failed ingestion never fabricates: failed pulls, unreadable deliveries, `sources.fetch.failed` and title-less items are recorded and create no item, cluster or story text. | `test/ingest.test.js` |
| Failed ingestion never fabricates: failed pulls, unreadable deliveries, `sources.fetch.failed` and title-less items are recorded and create no item, cluster or story text. | `test/ingest.test.js` |
| A source correction or removal triggers a flag and a pending revision; published text is unchanged; a removed source leaves the page; publishing a revision that rests on it is refused; the editor's revision resolves the flags with a note. | `test/upstream.test.js` |
| A source correction or removal triggers a flag and a pending revision; published text is unchanged; a removed source leaves the page; publishing a revision that rests on it is refused; the editor's revision resolves the flags with a note. | `test/upstream.test.js` |
+| Community discussion is referenced, not duplicated: one thread per published story, only its id stored, read on render; Community down says comments are unavailable; drafts get no thread; retraction, unpublishing and a removed source hide it after the commit; members comment by form as themselves. | `test/discussion.test.js` |
| Licensed material never leaks: bodies and summaries beyond the allowance never reach the database, pages, JSON, feeds, sitemaps, the API, events or Search. | `test/licensing.test.js` |
| Licensed material never leaks: bodies and summaries beyond the allowance never reach the database, pages, JSON, feeds, sitemaps, the API, events or Search. | `test/licensing.test.js` |
| Dedupe by canonical URL, content hash and near-duplicate headline; idempotent replays. | `test/ingest.test.js` |
| Dedupe by canonical URL, content hash and near-duplicate headline; idempotent replays. | `test/ingest.test.js` |
| Clusters are deterministic and explained; merges and splits are audited and reversible. | `test/clusters.test.js` |
| Clusters are deterministic and explained; merges and splits are audited and reversible. | `test/clusters.test.js` |
@@ -270,15 +297,19 @@ call the service live.
@@ -270,15 +297,19 @@ call the service live.
- **XSS:** all HTML goes through `openvibe-publishing/ssr` auto-escaping; source links get
- **XSS:** all HTML goes through `openvibe-publishing/ssr` auto-escaping; source links get
`rel="noopener nofollow"`; helmet CSP.
`rel="noopener nofollow"`; helmet CSP.
- **SSRF:** News makes no outbound request to a URL from content. It calls only its configured
- **SSRF:** News makes no outbound request to a URL from content. It calls only its configured
- Network, Sources, Events and AI hosts.
+ Network, Sources, Events, Community and AI hosts.
- **Licensing and privacy:** see "Licensing" above; removed sources are masked everywhere public.
- **Licensing and privacy:** see "Licensing" above; removed sources are masked everywhere public.
The editor view shows removal reasons; the public view does not.
The editor view shows removal reasons; the public view does not.
- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or an
- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or an
editor citing one; empty feeds use a real time; JSON-LD omits unknown fields.
editor citing one; empty feeds use a real time; JSON-LD omits unknown fields.
-- **Abuse:** rate limits on `/auth`, `/edit`, `/clusters` and `/api/v1`, in Express and in the
- nginx reference.
-- **Known gaps:** no Community comment threads on stories yet; no per-story Media attachments; the
- Sources registry name is cached and not refreshed after the first successful read.
+- **Abuse:** rate limits on `/auth`, `/edit`, `/clusters`, `/api/v1` and the comment form, in
+ Express and in the nginx reference.
+- **Comments:** Community owns the text and its moderation; News renders it through the same
+ auto-escaping and stores only the thread id. The comment form needs a signed-in member and the
+ form token, is rate-limited, and is refused for a story that is not open for discussion.
+- **Known gaps:** no per-story Media attachments; the Sources registry name is cached and not
+ refreshed after the first successful read; the Network does not hold News' two Community grants
+ yet, so story pages say comments are unavailable until they are added.
## Development
## Development
@@ -294,7 +325,8 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4820 (set OV_OAUTH
@@ -294,7 +325,8 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4820 (set OV_OAUTH
Create `/etc/openvibe/news.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`,
Create `/etc/openvibe/news.env` (0600) from `.env.example` with `OV_OAUTH_CLIENT_SECRET`,
`NEWS_EDITORS`, `NEWS_FORM_SECRET`, `NEWS_EVENTS_SECRET` (`openssl rand -hex 32`),
`NEWS_EDITORS`, `NEWS_FORM_SECRET`, `NEWS_EVENTS_SECRET` (`openssl rand -hex 32`),
`EVENTS_URL=http://127.0.0.1:4300`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`,
`EVENTS_URL=http://127.0.0.1:4300`, `OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720`,
- `BASE_URL=https://openvibe.news`, and optionally `OV_AI_INTERNAL_URL`.
+ `BASE_URL=https://openvibe.news`, `OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200`, and
+ optionally `OV_AI_INTERNAL_URL`.
2. **Network:** create (or give a secret to) the OAuth client `news` with redirect
2. **Network:** create (or give a secret to) the OAuth client `news` with redirect
`https://openvibe.news/auth/callback`, and add the grants listed above.
`https://openvibe.news/auth/callback`, and add the grants listed above.
3. **Search:** make sure `news` is in `SEARCH_EVENT_OWNERS`.
3. **Search:** make sure `news` is in `SEARCH_EVENT_OWNERS`.
@@ -87,6 +87,12 @@ server {
@@ -87,6 +87,12 @@ server {
proxy_pass http://127.0.0.1:4820;
proxy_pass http://127.0.0.1:4820;
}
}
+ # ── Comment form posts (relayed to the story's OpenVibe.Community thread) ──
+ location ~ ^/stories/[a-z0-9-]+/comments$ {
+ limit_req zone=ovnews_write burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4820;
+ }
+
# ── The Events webhook: OpenVibe.Events delivers over loopback, never through the public vhost ──
# ── The Events webhook: OpenVibe.Events delivers over loopback, never through the public vhost ──
location /internal/ { return 404; }
location /internal/ { return 404; }
@@ -48,5 +48,5 @@
@@ -48,5 +48,5 @@
"namespacesOwned": [
"namespacesOwned": [
"news.*"
"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."
+ "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); OpenVibe.Community (community.comment.write, community.comment.moderate) for story comment threads, referenced by id in news_story_discussion_refs; 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."
}
}
@@ -39,6 +39,12 @@ table.sources tr:target { background: var(--bg-card, #131c2e); }
@@ -39,6 +39,12 @@ table.sources tr:target { background: var(--bg-card, #131c2e); }
.source-summary { margin: 4px 0 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
.source-summary { margin: 4px 0 0; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
.framing { font-size: .9rem; }
.framing { font-size: .9rem; }
.timeline ol { padding-left: 1.2rem; }
.timeline ol { padding-left: 1.2rem; }
+.comments { margin-top: 28px; border-top: 1px solid var(--border, #1f2d47); }
+.comment-list, .comments .replies { list-style: none; padding-left: 0; }
+.comments .replies { margin-left: 1.2rem; border-left: 2px solid var(--border, #1f2d47); padding-left: 10px; }
+.comment { margin: 12px 0; }
+.comment p { margin: 4px 0; }
+.comment-form textarea { font-family: inherit; }
.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
.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-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
@@ -22,8 +22,10 @@ const { createViewerResolver } = require('./auth/viewer');
@@ -22,8 +22,10 @@ const { createViewerResolver } = require('./auth/viewer');
const { createPeople } = require('./clients/network');
const { createPeople } = require('./clients/network');
const { createSourcesClient } = require('./clients/sources');
const { createSourcesClient } = require('./clients/sources');
const { createAi } = require('./clients/ai');
const { createAi } = require('./clients/ai');
+const { createCommunity } = require('./clients/community');
const { createNewsOutbox } = require('./events/outbox');
const { createNewsOutbox } = require('./events/outbox');
const { createPublication } = require('./domain/publication');
const { createPublication } = require('./domain/publication');
+const { createDiscussion } = require('./domain/discussion');
const { createClusters } = require('./domain/clusters');
const { createClusters } = require('./domain/clusters');
const { createIngest } = require('./domain/ingest');
const { createIngest } = require('./domain/ingest');
const { createStories } = require('./domain/stories');
const { createStories } = require('./domain/stories');
@@ -53,8 +55,10 @@ function createApp(opts = {}) {
@@ -53,8 +55,10 @@ function createApp(opts = {}) {
const clusters = createClusters({ store, config, outbox });
const clusters = createClusters({ store, config, outbox });
const sources = createSourcesClient({ config, fetchImpl });
const sources = createSourcesClient({ config, fetchImpl });
const ai = createAi({ 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 });
+ const community = createCommunity({ store, config, fetchImpl, log });
+ const discussion = createDiscussion({ store, publication, community, log });
+ const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });
+ const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log });
ingest.setStories(stories);
ingest.setStories(stories);
const topics = createTopics({ store });
const topics = createTopics({ store });
// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
@@ -65,7 +69,7 @@ function createApp(opts = {}) {
@@ -65,7 +69,7 @@ function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, people });
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, ingest, outbox, log });
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 ctx = { config, store, outbox, publication, clusters, sources, ai, community, discussion, ingest, stories, topics, people, reading, auth, viewers, worker };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * Comments are OpenVibe.Community threads, referenced — never copied. A story's thread is the one
+ * Community resolves for EntityRef { service: 'news', type: 'story', id } (openvibe-publishing/
+ * discussion stores only the thread id, in news_story_discussion_refs). Pages read the thread from
+ * Community on render, so moderation and deletion there are always what readers see; a failure is
+ * shown as "comments are unavailable", never as an empty or invented thread.
+ *
+ * Which stories may have an open thread is domain/discussion.js' rule; this client only talks to
+ * Community.
+ *
+ * Calls with News' service token (audience openvibe.community):
+ * community.comment.write resolve a thread; comment as the signed-in member (X-OV-Subject)
+ * community.comment.moderate hide the thread of a story that was retracted, unpublished or rests
+ * on a removed source; show it again when the story is open again
+ */
+const { serviceAuth, http } = require('openvibe-contracts');
+const { createDiscussionClient } = require('openvibe-publishing/discussion');
+
+function createCommunity({ store, config, fetchImpl = globalThis.fetch, log = console }) {
+ const base = config.community.internalUrl;
+ const enabled = Boolean(config.oauth.clientSecret && base);
+ const tokenClient = (scope) => serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.community', scope, fetchImpl,
+ });
+ const writeTokens = enabled ? tokenClient('community.comment.write') : null;
+ const modTokens = enabled ? tokenClient('community.comment.moderate') : null;
+ const discussion = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;
+
+ const refFor = (story, label) => ({ service: 'news', type: 'story', id: story.id, ...(label ? { label: String(label).slice(0, 200) } : {}) });
+
+ async function call(method, path, { tokens, subject, body, ctx, timeoutMs = 4000 } = {}) {
+ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };
+ if (tokens) Object.assign(headers, await tokens.authHeaders());
+ if (subject) headers['X-OV-Subject'] = subject;
+ if (body) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
+ const data = await res.json().catch(() => null);
+ if (res.status === 401 && tokens && tokens.invalidate) tokens.invalidate();
+ if (!res.ok) {
+ const err = new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);
+ err.status = res.status;
+ err.code = data && data.code;
+ throw err;
+ }
+ return data;
+ }
+
+ return {
+ enabled,
+ publicUrl: config.community.publicUrl,
+
+ /** The stored thread id, or resolve it once through Community (the caller checks the story may have one). */
+ async threadFor(story, label, ctx) {
+ const known = store.discussion.get(story.id);
+ if (known) return known.threadId;
+ if (!discussion) return null;
+ const out = await store.discussion.threadFor(story.id, refFor(story, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
+ return out.threadId;
+ },
+
+ /** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
+ async readThread(threadId, { after, ctx } = {}) {
+ const qs = after ? `?after=${encodeURIComponent(after)}` : '';
+ return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
+ },
+
+ /** Comment as the signed-in member. */
+ async comment(threadId, subject, { message, parentId } = {}, ctx) {
+ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
+ return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
+ tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
+ });
+ },
+
+ /**
+ * Best effort: set a story's thread to hidden or public. Nothing to do when the story never
+ * had a thread. Never throws; a failure is logged.
+ */
+ async setThreadVisibility(storyId, visibility, ctx) {
+ const known = store.discussion.get(storyId);
+ if (!known || !modTokens) return false;
+ try {
+ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
+ return true;
+ } catch (err) {
+ log.warn(`[News] could not set the comment thread of ${storyId} to ${visibility}: ${err.message}`);
+ return false;
+ }
+ },
+ };
+}
+
+module.exports = { createCommunity };
@@ -71,6 +71,13 @@ function load(env = process.env) {
@@ -71,6 +71,13 @@ function load(env = process.env) {
windowMs: int(env.NEWS_CLUSTER_WINDOW_HOURS, 72) * 3600 * 1000,
windowMs: int(env.NEWS_CLUSTER_WINDOW_HOURS, 72) * 3600 * 1000,
},
},
+ // OpenVibe.Community: the comment thread of each public story (referenced by id, never
+ // copied). Needs community.comment.write, and community.comment.moderate to hide a thread.
+ community: {
+ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
+ internalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200'),
+ },
+
// OpenVibe.AI (optional): the news.summarize_story / news.compare_perspectives seams. Unset:
// 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.
// stories are written by editors from the source items; AI output only ever lands as a draft.
ai: {
ai: {
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
*
*
* Also here, as companions (not authority for anything outside News):
* Also here, as companions (not authority for anything outside News):
* news_story_citations, news_story_reviews, news_story_drafts, news_story_revision_purges,
* news_story_citations, news_story_reviews, news_story_drafts, news_story_revision_purges,
+ * news_story_discussion_refs (a story's Community thread id, never its comments),
* news_index_revisions (publishing packages); news_cluster_audit (every merge, split and reversal);
* 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
* 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);
* Sources cursor); news_source_status (a display cache of Sources' registry and health);
@@ -32,6 +33,7 @@ const { createRevisionStore } = require('openvibe-publishing/revisions');
@@ -32,6 +33,7 @@ const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
+const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const SCHEMA = `
const SCHEMA = `
CREATE TABLE IF NOT EXISTS news_topics (
CREATE TABLE IF NOT EXISTS news_topics (
@@ -246,6 +248,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
@@ -246,6 +248,7 @@ function openStore(dbPath, { now = () => Date.now() } = {}) {
citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),
citations: createCitationStore(db, { prefix: 'news_story', now, revisions }),
reviews: createReviewLog(db, { prefix: 'news_story', now }),
reviews: createReviewLog(db, { prefix: 'news_story', now }),
sequencer: createIndexSequencer(db, { prefix: 'news', now }),
sequencer: createIndexSequencer(db, { prefix: 'news', now }),
+ discussion: createDiscussionRefs(db, { prefix: 'news_story', now }),
tx: (fn) => db.transaction(fn)(),
tx: (fn) => db.transaction(fn)(),
getState(key, def = null) {
getState(key, def = null) {
const r = db.prepare('SELECT value FROM news_state WHERE key = ?').get(key);
const r = db.prepare('SELECT value FROM news_state WHERE key = ?').get(key);
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+
+/**
+ * Which stories have an open Community thread, and keeping the thread in step with the story.
+ *
+ * A story is open for discussion when it is published and no paragraph of its published revision
+ * rests only on sources removed upstream. Indexability does not matter: a noindex story (a single
+ * source, an editor's noindex) is public all the same.
+ * - a draft never gets a thread (Community threads are readable by anyone with the id)
+ * - retracted: the story stays on the record, its thread is hidden
+ * - unpublished: the page is gone (410), its thread is hidden
+ * - a paragraph resting only on a removed source: the thread is hidden until an editor publishes
+ * a revision that no longer rests on it (comments could repeat the withdrawn material)
+ *
+ * Writes that can change this call watch() inside their transaction, before they change anything.
+ * better-sqlite3 transactions are synchronous, so the setImmediate flush runs after the commit (or
+ * the rollback): the state is compared again and, when it changed, the thread is set hidden or
+ * public (best effort, community.comment.moderate). A rolled-back write compares equal and calls
+ * nothing; a story that never had a thread calls nothing either (threads are created only when an
+ * open story is read).
+ */
+function createDiscussion({ store, publication, community, log = console }) {
+ const { db } = store;
+ const storyById = db.prepare('SELECT * FROM news_stories WHERE id = ?');
+ const citing = db.prepare('SELECT DISTINCT story_id FROM news_story_sources WHERE source_item_id = ?');
+
+ /** { open, reason: null | not_published | retracted | source_removed } */
+ function status(story) {
+ if (!story || !story.published_revision) return { open: false, reason: 'not_published' };
+ if (story.state === 'retracted') return { open: false, reason: 'retracted' };
+ if (story.state !== 'published') return { open: false, reason: 'not_published' };
+ const rev = store.revisions.get(story.id, story.published_revision);
+ if (!rev) return { open: false, reason: 'not_published' };
+ if (publication.support(rev).unsupported > 0) return { open: false, reason: 'source_removed' };
+ return { open: true, reason: null };
+ }
+
+ const pending = new Map(); // story id → open before the write
+ let scheduled = false;
+ let flushing = Promise.resolve();
+
+ async function flush() {
+ scheduled = false;
+ const batch = [...pending];
+ pending.clear();
+ for (const [id, was] of batch) {
+ const now = status(storyById.get(id)).open;
+ if (now !== was) await community.setThreadVisibility(id, now ? 'public' : 'hidden');
+ }
+ }
+
+ /** Remember whether a story is open now; after the commit, follow any change. Inside the write's transaction. */
+ function watch(storyId) {
+ if (!community.enabled) return;
+ if (!pending.has(storyId)) pending.set(storyId, status(storyById.get(storyId)).open);
+ if (!scheduled) {
+ scheduled = true;
+ setImmediate(() => { flushing = flushing.then(flush).catch((err) => log.warn(`[News] comment thread sync failed: ${err.message}`)); });
+ }
+ }
+
+ return {
+ status,
+ watch,
+ /** Every story that cites a source item (before the item changes upstream). */
+ watchSource(itemId) { for (const r of citing.all(itemId)) watch(r.story_id); },
+ /** Resolves once the scheduled visibility changes have been sent (tests, shutdown). */
+ idle: () => new Promise((resolve) => setImmediate(() => flushing.then(resolve))),
+ };
+}
+
+module.exports = { createDiscussion };
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
*
*
* Upstream changes to an item a story cites (a revision, a removal) go to stories.onUpstreamChange,
* 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.
* which flags the story and prepares a pending revision; published text is never changed silently.
+ * A removal can also hide the story's Community thread (domain/discussion.js, after the commit).
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const text = require('./text');
const text = require('./text');
@@ -28,7 +29,7 @@ const text = require('./text');
@@ -28,7 +29,7 @@ const text = require('./text');
const NEAR_TITLE = { anyOutlet: 0.9, sameOutlet: 0.75, windowMs: 48 * 3600 * 1000 };
const NEAR_TITLE = { anyOutlet: 0.9, sameOutlet: 0.75, windowMs: 48 * 3600 * 1000 };
const newItemId = (now) => `nsi_${ids.ulid(now)}`;
const newItemId = (now) => `nsi_${ids.ulid(now)}`;
-function createIngest({ store, config, clusters, outbox, sources, log = console }) {
+function createIngest({ store, config, clusters, outbox, sources, discussion = null, log = console }) {
const { db } = store;
const { db } = store;
let stories = null; // set by app.js (stories depend on ingest's items too)
let stories = null; // set by app.js (stories depend on ingest's items too)
const q = {
const q = {
@@ -183,6 +184,8 @@ function createIngest({ store, config, clusters, outbox, sources, log = console
@@ -183,6 +184,8 @@ function createIngest({ store, config, clusters, outbox, sources, log = console
const cur = q.bySourcesId.get(sourcesItemId);
const cur = q.bySourcesId.get(sourcesItemId);
if (!cur) return { outcome: 'ignored', reason: 'never ingested' };
if (!cur) return { outcome: 'ignored', reason: 'never ingested' };
if (cur.status === 'removed') return { outcome: 'unchanged', item: cur };
if (cur.status === 'removed') return { outcome: 'unchanged', item: cur };
+ // A story resting on it may stop being open for comments (domain/discussion.js).
+ if (discussion) discussion.watchSource(cur.id);
const now = store.now();
const now = store.now();
db.prepare(`UPDATE news_source_items SET status = 'removed', summary = NULL, summary_basis = 'removed_upstream', removed_at = ?, removed_reason = ?,
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 = ?`)
sources_revision = MAX(sources_revision, COALESCE(?, sources_revision)), updated_at = ? WHERE id = ?`)
@@ -54,7 +54,7 @@ function formatBody(paragraphs) {
@@ -54,7 +54,7 @@ function formatBody(paragraphs) {
return (paragraphs || []).map((p) => `${p.text}${p.sources && p.sources.length ? ` [${p.sources.join(', ')}]` : ''}`).join('\n\n');
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 }) {
+function createStories({ store, config, publication, clusters, outbox, ai = null, discussion = null, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
@@ -475,6 +475,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -475,6 +475,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
if (story.state === 'retracted') throw new ApiError(409, 'story.retracted', 'A retracted story stays retracted; open a new story');
if (story.state === 'retracted') throw new ApiError(409, 'story.retracted', 'A retracted story stays retracted; open a new story');
return store.tx(() => {
return store.tx(() => {
const cur = q.byId.get(story.id);
const cur = q.byId.get(story.id);
+ if (discussion) discussion.watch(story.id);
const rev = revision == null || revision === '' ? store.revisions.head(story.id) : store.revisions.get(story.id, parseInt(revision, 10));
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}`);
if (!rev && revision != null && revision !== '') throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
const found = problems(cur, rev);
const found = problems(cur, rev);
@@ -520,6 +521,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -520,6 +521,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
return store.tx(() => {
return store.tx(() => {
const cur = q.byId.get(story.id);
const cur = q.byId.get(story.id);
if (cur.state !== 'published' && cur.state !== 'retracted') return { changed: false, story: cur };
if (cur.state !== 'published' && cur.state !== 'retracted') return { changed: false, story: cur };
+ if (discussion) discussion.watch(story.id);
const before = publication.snapshot(cur);
const before = publication.snapshot(cur);
db.prepare("UPDATE news_stories SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), story.id);
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 };
return { changed: true, story: publication.afterChange(before, story.id, { actor: viewer, traceparent }).story };
@@ -536,6 +538,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -536,6 +538,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
const cur = q.byId.get(story.id);
const cur = q.byId.get(story.id);
if (cur.state === 'retracted') return { changed: false, story: cur };
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');
if (cur.state !== 'published') throw new ApiError(409, 'story.not_published', 'Only a published story can be retracted');
+ if (discussion) discussion.watch(story.id);
const now = store.now();
const now = store.now();
const before = publication.snapshot(cur);
const before = publication.snapshot(cur);
db.prepare("UPDATE news_stories SET state = 'retracted', retracted_at = ?, updated_at = ? WHERE id = ?").run(now, now, story.id);
db.prepare("UPDATE news_stories SET state = 'retracted', retracted_at = ?, updated_at = ? WHERE id = ?").run(now, now, story.id);
@@ -7,23 +7,29 @@
@@ -7,23 +7,29 @@
* GET /topics, /topics/:slug topics and each topic's stories
* 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
* 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
* lets the reader choose how the source table is framed
+ * POST /stories/:slug/comments comment (the story's Community thread) as the signed-in member
* GET /feed.xml, /atom.xml, /feed.json, /topics/:slug/feed.{xml,json}, /topics/:slug/atom.xml
* GET /feed.xml, /atom.xml, /feed.json, /topics/:slug/feed.{xml,json}, /topics/:slug/atom.xml
*
*
+ * Comments are the story's OpenVibe.Community thread, read from Community on every render (never
+ * copied here); a story that is not open for discussion (domain/discussion.js) shows no thread.
+ *
* Caching: a published story or a list rendered for an ANONYMOUS visitor is public for 60 s;
* 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
* 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.
* built for a viewer. Robots come from the gate: a retracted story is noindex with its reason.
*/
*/
const express = require('express');
const express = require('express');
+const rateLimit = require('express-rate-limit');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
const access = require('../domain/access');
const access = require('../domain/access');
+const { csrfToken, checkCsrf } = require('../auth/forms');
const PER_PAGE = 20;
const PER_PAGE = 20;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
- const { config, store, stories, publication, reading, topics, viewers } = ctx;
+ const { config, store, stories, publication, reading, topics, viewers, community, discussion } = ctx;
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -123,6 +129,23 @@ function createPublicRoutes(ctx) {
@@ -123,6 +129,23 @@ function createPublicRoutes(ctx) {
// ── Stories ─────────────────────────────────────────────
// ── Stories ─────────────────────────────────────────────
+ /** The story's Community thread as the page shows it: { state: ok|closed|off|unavailable, … }. */
+ async function commentsFor(req, story, headline) {
+ const st = discussion.status(story);
+ if (!st.open) return { state: 'closed', reason: st.reason };
+ if (!community.enabled) return { state: 'off' };
+ try {
+ const threadId = await community.threadFor(story, headline, req.ov);
+ if (!threadId) return { state: 'off' };
+ const data = await community.readThread(threadId, { after: req.query.comments_after, ctx: req.ov });
+ if (!data || !data.thread) throw new Error('Community returned no thread');
+ return { state: 'ok', thread: data.thread, comments: Array.isArray(data.comments) ? data.comments : [], nextCursor: data.next_cursor || null, communityUrl: community.publicUrl };
+ } catch (err) {
+ console.warn(`[News] comments for ${story.id} unavailable: ${err.message}`);
+ return { state: 'unavailable' };
+ }
+ }
+
/** Render one story (also the editor's preview of a chosen revision). */
/** Render one story (also the editor's preview of a chosen revision). */
async function renderStory(req, res, { story, rev = null, preview = false }) {
async function renderStory(req, res, { story, rev = null, preview = false }) {
const isEditor = access.isEditor(config, req.viewer);
const isEditor = access.isEditor(config, req.viewer);
@@ -131,6 +154,8 @@ function createPublicRoutes(ctx) {
@@ -131,6 +154,8 @@ function createPublicRoutes(ctx) {
const decision = preview ? publication.decide(story, m.rev, { state: 'draft' }) : m.decision;
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 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');
const cacheable = !preview && (story.state === 'published' || story.state === 'retracted');
+ const comments = preview ? null : await commentsFor(req, story, m.headline);
+ const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);
send(req, res, 200, {
send(req, res, 200, {
title: m.retraction ? `Retracted: ${m.headline}` : m.headline,
title: m.retraction ? `Retracted: ${m.headline}` : m.headline,
description: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 200) : undefined,
description: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 200) : undefined,
@@ -143,6 +168,8 @@ function createPublicRoutes(ctx) {
@@ -143,6 +168,8 @@ function createPublicRoutes(ctx) {
group, breadcrumbs: crumbs, jsonUrl: `${m.path}.json`,
group, breadcrumbs: crumbs, jsonUrl: `${m.path}.json`,
editUrl: isEditor ? `/edit/stories/${story.id}` : null,
editUrl: isEditor ? `/edit/stories/${story.id}` : null,
decisionNote: preview ? `preview of revision ${m.rev.number} · ${decision.robots}` : null,
decisionNote: preview ? `preview of revision ${m.rev.number} · ${decision.robots}` : null,
+ comments, signedIn, csrf: signedIn ? csrfToken(config, req.viewer) : '',
+ loginUrl: `/auth/login?next=${encodeURIComponent(m.path)}`,
}),
}),
}, { cacheable });
}, { cacheable });
}
}
@@ -163,6 +190,28 @@ function createPublicRoutes(ctx) {
@@ -163,6 +190,28 @@ function createPublicRoutes(ctx) {
return renderStory(req, res, { story });
return renderStory(req, res, { story });
}));
}));
+ router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
+ const story = stories.bySlug(req.params.slug);
+ if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
+ const path = publication.storyPath(story);
+ if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);
+ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });
+ if (!discussion.status(story).open) return messagePage(req, res, 409, 'Comments are closed', 'This story does not take comments.', { href: path, label: 'Back to the story' });
+ if (!community.enabled) return messagePage(req, res, 503, 'Comments are unavailable', 'Comments are not connected on this server.', { href: path, label: 'Back to the story' });
+ const message = String((req.body && req.body.message) || '').trim();
+ if (!message) return res.redirect(303, `${path}#comments`);
+ if (message.length > 5000) return messagePage(req, res, 422, 'Comment too long', 'A comment is at most 5000 characters.', { href: path, label: 'Back to the story' });
+ try {
+ const rev = store.revisions.get(story.id, story.published_revision);
+ const threadId = await community.threadFor(story, rev && rev.fields.headline, req.ov);
+ if (!threadId) throw new Error('no thread');
+ await community.comment(threadId, req.viewer.subject, { message }, req.ov);
+ } catch (err) {
+ return messagePage(req, res, err.status === 429 ? 429 : 502, 'Comment not posted', `OpenVibe.Community did not accept the comment: ${err.message}`, { href: path, label: 'Back to the story' });
+ }
+ return res.redirect(303, `${path}#comments`);
+ }));
+
return { router, renderStory, notFound, messagePage, pageDecision, send };
return { router, renderStory, notFound, messagePage, pageDecision, send };
}
}
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* Public pages (HTML bodies; layout.js wraps them). Everything a reader needs is in the HTML: the
* 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
* 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
* 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().
+ * upstream-change notice, then the story's OpenVibe.Community thread with a plain comment form.
+ * Values are escaped by ssr.html unless wrapped in raw().
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
@@ -86,8 +87,40 @@ ${rest.length ? h`<h3>Not grouped</h3>${table(rest)}` : ''}`;
@@ -86,8 +87,40 @@ ${rest.length ? h`<h3>Not grouped</h3>${table(rest)}` : ''}`;
return h`${tabs}${table(m.sources)}`;
return h`${tabs}${table(m.sources)}`;
}
}
+const CLOSED = {
+ retracted: 'Comments are closed: this story was retracted.',
+ source_removed: 'Comments are paused while editors revise this story: a source it rests on was removed.',
+ not_published: 'Comments are available on published stories only.',
+};
+
+/** The Community thread (read from Community on this render), or why there is none. */
+function commentsSection(c, { path, csrf, signedIn, loginUrl }) {
+ const wrap = (inner) => String(h`<section id="comments" class="comments" aria-labelledby="comments-h"><h2 id="comments-h">Comments</h2>${raw(String(inner))}</section>`);
+ if (c.state === 'closed') return wrap(h`<p class="empty">${CLOSED[c.reason] || CLOSED.not_published}</p>`);
+ if (c.state === 'off') return wrap(h`<p class="empty">Comments are not connected on this server.</p>`);
+ if (c.state !== 'ok') return wrap(h`<p class="notice" role="status">Comments are unavailable: they could not be loaded from OpenVibe.Community right now. Reload later.</p>`);
+ const one = (x) => h`<li class="comment" id="comment-${x.id}">
+<p class="meta"><strong>${x.deleted ? '[deleted]' : (x.display_name || 'Anonymous')}</strong>${x.origin === 'ai' ? h` <span class="badge">AI</span>` : ''}${Number.isFinite(Date.parse(x.created_at)) ? h` · ${time(Date.parse(x.created_at))}` : ''}</p>
+${x.deleted ? h`<p class="empty">This comment was deleted.</p>` : raw(`<p>${ssr.escapeHtml(String(x.message || '')).replace(/\n/g, '<br>')}</p>`)}
+${x.replies && x.replies.length ? h`<ol class="replies">${x.replies.map(one)}</ol>` : ''}
+</li>`;
+ const list = c.comments.length ? h`<ol class="comment-list">${c.comments.map(one)}</ol>` : h`<p class="empty">No comments yet.</p>`;
+ const more = c.nextCursor ? h`<p><a href="${path}?comments_after=${c.nextCursor}#comments" rel="nofollow">More comments</a></p>` : '';
+ const form = c.thread && c.thread.visibility === 'locked'
+ ? h`<p class="empty">This thread is locked.</p>`
+ : signedIn
+ ? h`<form method="post" action="${path}/comments" class="comment-form">
+<input type="hidden" name="_csrf" value="${csrf}">
+<label for="comment-message">Add a comment</label>
+<textarea id="comment-message" name="message" rows="4" maxlength="5000" required></textarea>
+<button type="submit">Post comment</button>
+</form>`
+ : h`<p><a href="${loginUrl}">Sign in with OpenVibe</a> to comment.</p>`;
+ return wrap(h`<p class="meta">Comments are hosted by <a href="${c.communityUrl}">OpenVibe.Community</a>.</p>${list}${more}${form}`);
+}
+
/** One story. */
/** One story. */
-function storyPage(m, { group = 'number', breadcrumbs, jsonUrl, editUrl = null, decisionNote = null }) {
+function storyPage(m, { group = 'number', breadcrumbs, jsonUrl, editUrl = null, decisionNote = null, comments = null, signedIn = false, csrf = '', loginUrl = '/auth/login' }) {
const notices = [];
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>`);
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) {
for (const u of m.upstream) {
@@ -112,11 +145,12 @@ ${m.perspectives.length && group !== 'perspective' ? h`<section class="perspecti
@@ -112,11 +145,12 @@ ${m.perspectives.length && group !== 'perspective' ? h`<section class="perspecti
${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>`}
${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>
</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>
<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>`);
+</article>
+${raw(comments ? commentsSection(comments, { path: m.path, csrf, signedIn, loginUrl }) : '')}`);
}
}
function message({ heading, text, action }) {
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>`);
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 };
+module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites };
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * Community discussion is referenced, not duplicated: a published story's comments are the
+ * OpenVibe.Community thread resolved once for EntityRef { service: 'news', type: 'story', id }.
+ * News stores only the thread id (news_story_discussion_refs) and reads the thread from Community
+ * on every render; a Community failure says comments are unavailable, never shows an empty or
+ * invented thread. Drafts never get a thread; retracting, unpublishing or a removed source hides it
+ * (community.comment.moderate, after the commit, best effort); members comment through a plain
+ * form, as themselves (X-OV-Subject).
+ */
+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, 3]',
+ '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);
+ 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 open = async (headline, body = BODY) => {
+ const r = await t.api('/stories', { json: { cluster: clusterId, headline, topic: 'space', body } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().story;
+ };
+ const publish = async (s, body = {}) => { const r = await t.api(`/stories/${s.id}/publish`, { json: body }); assert.strictEqual(r.status, 200, r.text); return r; };
+ const calls = (re, method) => t.community.calls.filter((c) => re.test(c.url.split('?')[0]) && (!method || c.method === method));
+ const resolves = () => calls(/\/threads\/resolve$/, 'POST');
+ const hides = () => calls(/\/visibility$/, 'PUT');
+ const settle = () => t.ctx.discussion.idle();
+ const refOf = (id) => t.db().prepare('SELECT * FROM news_story_discussion_refs WHERE entity_id = ?').get(id);
+ t.community.setProbe(() => t.db().inTransaction);
+
+ const story = await open('Europa Clipper is on its way to Jupiter');
+ const url = `/stories/${story.slug}`;
+ const member = t.network.addUser('reader', { display_name: 'Rita Reader' });
+
+ await check('a draft has no thread: nothing is resolved while a story is unpublished', async () => {
+ assert.strictEqual((await t.get(url)).status, 404);
+ const prev = await t.get(`/edit/stories/${story.id}/preview`, { as: t.editor });
+ assert.strictEqual(prev.status, 200, prev.text);
+ assert.doesNotMatch(prev.text, /id="comments"/, 'the editor preview shows no thread');
+ assert.strictEqual(resolves().length, 0);
+ assert.strictEqual(refOf(story.id), undefined);
+ });
+
+ await check('a published story resolves its thread once, for the story’s EntityRef, and stores only the id', async () => {
+ await publish(story);
+ let page = await t.get(url);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<section id="comments" class="comments"/);
+ assert.match(page.text, /No comments yet\./);
+ assert.match(page.text, /Comments are hosted by <a href="https:\/\/openvibe\.community">OpenVibe\.Community<\/a>/);
+ assert.match(page.text, /Sign in with OpenVibe<\/a> to comment/);
+ assert.strictEqual(resolves().length, 1);
+ const call = resolves()[0];
+ assert.ok(call.cap.includes('community.comment.write'), 'resolved with the write capability');
+ assert.deepStrictEqual(JSON.parse(call.body).ref, { service: 'news', type: 'story', id: story.id, label: 'Europa Clipper is on its way to Jupiter' });
+ const ref = refOf(story.id);
+ assert.strictEqual(ref.thread_id, '1');
+ assert.deepStrictEqual(Object.keys(ref).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'the reference table has no room for comments');
+ page = await t.get(url);
+ await t.get(`${url}?group=outlet`);
+ assert.strictEqual(resolves().length, 1, 'later renders reuse the stored id');
+ assert.strictEqual(calls(/\/threads\/1$/, 'GET').length, 3, 'every render reads the thread from Community');
+ });
+
+ await check('a member comments through the plain form, as themselves; the comment lives in Community only', async () => {
+ const csrf = t.csrf(member);
+ let r = await t.get(`${url}/comments`, { form: { _csrf: csrf, message: 'Anonymous?' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(url)}`);
+ r = await t.get(`${url}/comments`, { as: member, form: { message: 'No token' } });
+ assert.strictEqual(r.status, 403);
+ const page = await t.get(url, { as: member });
+ assert.match(page.text, new RegExp(`<form method="post" action="${url}/comments" class="comment-form">`));
+ assert.match(page.text, new RegExp(`name="_csrf" value="${csrf}"`));
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
+ r = await t.get(`${url}/comments`, { as: member, form: { _csrf: csrf, message: 'Great summary; the 2030 arrival <b>date</b> matters.\nThanks.' } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(r.headers.get('location'), `${url}#comments`);
+ const posted = calls(/\/threads\/1\/comments$/, 'POST');
+ assert.strictEqual(posted.length, 1);
+ assert.strictEqual(posted[0].subject, member.subject, 'commented as the member (X-OV-Subject)');
+ assert.ok(posted[0].cap.includes('community.comment.write'));
+ const after = await t.get(url);
+ assert.match(after.text, /<strong>Rita Reader<\/strong>/);
+ assert.match(after.text, /Great summary; the 2030 arrival <b>date<\/b> matters\.<br>Thanks\./, 'escaped, read from Community');
+ const dump = t.db().prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()
+ .map(({ name }) => JSON.stringify(t.db().prepare(`SELECT * FROM "${name}"`).all())).join('\n');
+ assert.doesNotMatch(dump, /Great summary|2030 arrival/, 'no comment text is copied into News');
+ });
+
+ await check('Community down: the story still serves and says comments are unavailable — no empty or invented thread', async () => {
+ t.community.setDown(true);
+ const page = await t.get(url);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /NASA launched the Europa Clipper spacecraft/);
+ assert.match(page.text, /Comments are unavailable/);
+ assert.doesNotMatch(page.text, /No comments yet|Rita Reader|comment-form/);
+ const r = await t.get(`${url}/comments`, { as: member, form: { _csrf: t.csrf(member), message: 'Lost?' } });
+ assert.strictEqual(r.status, 502);
+ assert.match(r.text, /Comment not posted/);
+ t.community.setDown(false);
+ const fresh = await open('A second Europa Clipper story');
+ await publish(fresh);
+ t.community.setDown(true);
+ const p2 = await t.get(`/stories/${fresh.slug}`);
+ assert.match(p2.text, /Comments are unavailable/);
+ assert.strictEqual(refOf(fresh.id), undefined, 'a failed resolve stores no thread id');
+ t.community.setDown(false);
+ });
+
+ await check('a removed source a paragraph rests on hides the thread after the commit; the fixed revision shows it again', async () => {
+ const before = hides().length;
+ 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);
+ await settle();
+ const now = hides().slice(before);
+ assert.strictEqual(now.length, 1, 'only the story that had a thread is hidden (the second one never resolved one)');
+ assert.deepStrictEqual(JSON.parse(now[0].body), { visibility: 'hidden' });
+ assert.strictEqual(now[0].url, '/api/v1/comments/threads/1/visibility');
+ assert.ok(now[0].cap.includes('community.comment.moderate'), 'hidden with the moderate capability');
+ assert.strictEqual(now[0].probe, false, 'called after the write committed, never inside the transaction');
+ assert.strictEqual(t.community.threads.get(1).visibility, 'hidden');
+ const reads = calls(/\/threads\/1$/, 'GET').length;
+ const page = await t.get(url);
+ assert.match(page.text, /Comments are paused while editors revise this story/);
+ assert.doesNotMatch(page.text, /comment-form|Rita Reader/);
+ assert.strictEqual(calls(/\/threads\/1$/, 'GET').length, reads, 'a closed story does not read its thread');
+ const r = await t.get(`${url}/comments`, { as: member, form: { _csrf: t.csrf(member), message: 'Still here?' } });
+ assert.strictEqual(r.status, 409);
+
+ const itemA = t.db().prepare('SELECT id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id).id;
+ assert.strictEqual((await t.api(`/stories/${story.id}/sources/${itemA}`, { method: 'DELETE' })).status, 200);
+ const head = t.ctx.store.revisions.head(story.id).number;
+ const fixed = BODY.split('\n\n').slice(0, 2).map((p) => p.replace('[1, 2]', '[2, 3]')).join('\n\n');
+ let rr = await t.api(`/stories/${story.id}/revisions`, { json: { body: fixed, expected_revision: head } });
+ assert.strictEqual(rr.status, 201, rr.text);
+ await publish(story, { revision: rr.json().revision, correction: { kind: 'update', note: 'A source withdrew its report; the paragraph based only on it was removed.' } });
+ await settle();
+ assert.deepStrictEqual(JSON.parse(hides().pop().body), { visibility: 'public' });
+ assert.strictEqual(t.community.threads.get(1).visibility, 'public');
+ rr = await t.get(url);
+ assert.match(rr.text, /Rita Reader/);
+ });
+
+ await check('retraction hides the thread and the retracted page shows none; unpublish hides, republish shows', async () => {
+ // Source [1] (wire-a) was removed above, so this story rests on the two that remain.
+ const other = await open('Europa Clipper: the launch in numbers', BODY.split('\n\n').slice(0, 2).map((p) => p.replace(/\[[\d, ]+\]$/, '[1, 2]')).join('\n\n'));
+ await publish(other);
+ await t.get(`/stories/${other.slug}`);
+ const id = refOf(other.id).thread_id;
+ let r = await t.api(`/stories/${other.id}/unpublish`, { json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ await settle();
+ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'hidden');
+ await publish(other);
+ await settle();
+ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'public');
+ r = await t.api(`/stories/${other.id}/retract`, { json: { note: 'Our sources described a rehearsal, not the launch.' } });
+ assert.strictEqual(r.status, 200, r.text);
+ await settle();
+ assert.strictEqual(t.community.threads.get(Number(id)).visibility, 'hidden');
+ const last = hides().pop();
+ assert.strictEqual(last.probe, false);
+ const page = await t.get(`/stories/${other.slug}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Comments are closed: this story was retracted\./);
+ assert.doesNotMatch(page.text, /comment-form/);
+ });
+
+ await check('a write refused inside its transaction (rolled back) calls nothing', async () => {
+ const n = hides().length;
+ const r = await t.api(`/stories/${story.id}/publish`, { json: { revision: 2 } });
+ assert.strictEqual(r.status, 409, 'revision 2 is the pending revision that still cites the removed source');
+ assert.strictEqual(r.json().code, 'story.source_removed');
+ await settle();
+ assert.strictEqual(hides().length, n);
+ assert.strictEqual(t.community.threads.get(1).visibility, 'public');
+ });
+
+ await t.close();
+ done();
+})();
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* const t = await boot(); // t.base, t.get(path, { as: user | token, form, json })
* const t = await boot(); // t.base, t.get(path, { as: user | token, form, json })
* t.editor // a Network user listed in NEWS_EDITORS
* t.editor // a Network user listed in NEWS_EDITORS
* t.sources.addItem({...}); await t.pull() // Sources items → News
* t.sources.addItem({...}); await t.pull() // Sources items → News
+ * t.community.threads / .calls // the Community mock (comment threads)
* t.deliver(event) // a signed Events webhook delivery
* t.deliver(event) // a signed Events webhook delivery
* t.clock.advance(ms)
* t.clock.advance(ms)
*/
*/
@@ -13,7 +14,7 @@ const fs = require('fs');
@@ -13,7 +14,7 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
-const { startNetwork, startSources, startAi, delivery } = require('./mocks');
+const { startNetwork, startSources, startAi, startCommunity, delivery } = require('./mocks');
const SECRET = 'test-webhook-secret-0123456789abcdef0123';
const SECRET = 'test-webhook-secret-0123456789abcdef0123';
@@ -26,6 +27,7 @@ async function boot(opts = {}) {
@@ -26,6 +27,7 @@ async function boot(opts = {}) {
const network = await startNetwork();
const network = await startNetwork();
const sources = await startSources({ network });
const sources = await startSources({ network });
const ai = await startAi({ network });
const ai = await startAi({ network });
+ const community = await startCommunity({ network });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-news-test-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-news-test-'));
const dbPath = path.join(dir, 'news.db');
const dbPath = path.join(dir, 'news.db');
const clock = opts.clock || makeClock();
const clock = opts.clock || makeClock();
@@ -36,6 +38,7 @@ async function boot(opts = {}) {
@@ -36,6 +38,7 @@ async function boot(opts = {}) {
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
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_OAUTH_CLIENT_ID: 'news', OV_OAUTH_CLIENT_SECRET: 'shh', COOKIE_SECURE: 'false',
OV_SOURCES_INTERNAL_URL: sources.url,
OV_SOURCES_INTERNAL_URL: sources.url,
+ OV_COMMUNITY_URL: 'https://openvibe.community', OV_COMMUNITY_INTERNAL_URL: community.url,
NEWS_EDITORS: editor.subject, NEWS_WORKER: 'off', NEWS_FORM_SECRET: 'test-form-secret',
NEWS_EDITORS: editor.subject, NEWS_WORKER: 'off', NEWS_FORM_SECRET: 'test-form-secret',
NEWS_EVENTS_SECRET: SECRET,
NEWS_EVENTS_SECRET: SECRET,
...(opts.ai ? { OV_AI_INTERNAL_URL: ai.url } : {}),
...(opts.ai ? { OV_AI_INTERNAL_URL: ai.url } : {}),
@@ -90,12 +93,12 @@ async function boot(opts = {}) {
@@ -90,12 +93,12 @@ async function boot(opts = {}) {
const api = (p, o = {}) => get(`/api/v1${p}`, { as: editor, ...o });
const api = (p, o = {}) => get(`/api/v1${p}`, { as: editor, ...o });
const t = {
const t = {
- network, sources, ai, clock, dbPath, editor, get, api, events, deliver, SECRET,
+ network, sources, ai, community, clock, dbPath, editor, get, api, events, deliver, SECRET,
pull: () => t.ctx.ingest.pull(),
pull: () => t.ctx.ingest.pull(),
db: () => t.ctx.store.db,
db: () => t.ctx.store.db,
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.NEWS_FORM_SECRET }, user),
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.NEWS_FORM_SECRET }, user),
async restart() { await stop(); await start(); },
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 }); },
+ async close() { await stop(); await network.close(); await sources.close(); await ai.close(); await community.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
};
await start();
await start();
return t;
return t;
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* Sources /api/v1/items (change order, category filter, include_removed), /api/v1/items/:id,
* 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
* /api/v1/sources/:key — items the test adds, revises and removes
* AI /api/v1/runs — a canned run result the test sets
* AI /api/v1/runs — a canned run result the test sets
+ * Community /api/v1/comments/threads/resolve, GET thread, POST comment, PUT visibility
* userToken()/serviceToken() mint the tokens browsers and services present to News;
* userToken()/serviceToken() mint the tokens browsers and services present to News;
* delivery() signs an Events webhook delivery.
* delivery() signs an Events webhook delivery.
*/
*/
@@ -188,6 +189,59 @@ async function startAi({ network }) {
@@ -188,6 +189,59 @@ async function startAi({ network }) {
return { ...srv, requests, setNext: (v) => { next = v; } };
return { ...srv, requests, setNext: (v) => { next = v; } };
}
}
+async function startCommunity({ network }) {
+ const threads = new Map(); // id → { ref, visibility, comments: [] }
+ const byRef = new Map();
+ const calls = [];
+ let down = false;
+ let seq = 0;
+ const box = { probe: null }; // called on every request (e.g. to see whether News is inside a transaction)
+ const srv = await listen((req, raw, json) => {
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.community' }) : null;
+ const cap = v && v.ok ? (v.claims.cap || []) : [];
+ calls.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, cap, body: raw || null, probe: box.probe ? box.probe() : null });
+ if (down) return json(503, { code: 'down' });
+ if (req.url === '/api/v1/comments/threads/resolve' && req.method === 'POST') {
+ if (!cap.includes('community.comment.write')) return json(403, { code: 'capability.denied' });
+ const { ref } = JSON.parse(raw);
+ const key = `${ref.service}:${ref.type}:${ref.id}`;
+ if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });
+ const id = ++seq;
+ threads.set(id, { ref, visibility: 'public', comments: [] });
+ byRef.set(key, id);
+ return json(201, { thread: { id, visibility: 'public' }, created: true });
+ }
+ let m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)(\?.*)?$/);
+ if (m && req.method === 'GET') {
+ const t = threads.get(Number(m[1]));
+ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });
+ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility, comment_count: t.comments.length }, comments: t.comments, next_cursor: null });
+ }
+ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/comments$/);
+ if (m && req.method === 'POST') {
+ if (!cap.includes('community.comment.write')) return json(403, { code: 'capability.denied' });
+ const t = threads.get(Number(m[1]));
+ if (!t || t.visibility !== 'public') return json(409, { code: 'thread.closed' });
+ const body = JSON.parse(raw);
+ const who = network.directory.get(req.headers['x-ov-subject']);
+ const comment = { id: t.comments.length + 1, origin: 'user', display_name: who ? who.display_name : 'Someone', message: body.message, deleted: false, created_at: '2026-09-22T12:05:00.000Z' };
+ t.comments.push(comment);
+ return json(201, { comment });
+ }
+ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
+ if (m && req.method === 'PUT') {
+ if (!cap.includes('community.comment.moderate')) return json(403, { code: 'capability.denied' });
+ const t = threads.get(Number(m[1]));
+ if (!t) return json(404, { code: 'thread.not_found' });
+ t.visibility = JSON.parse(raw).visibility;
+ return json(200, { thread: { id: Number(m[1]), visibility: t.visibility } });
+ }
+ return json(404, { code: 'route.not_found' });
+ });
+ return { ...srv, threads, calls, setDown: (v) => { down = v; }, setProbe: (fn) => { box.probe = fn; } };
+}
+
/**
/**
* An Events webhook delivery for News: { body, headers } signed with the secret (v1 and v2, as
* An Events webhook delivery for News: { body, headers } signed with the secret (v1 and v2, as
* Events sends it). `v1Only` leaves the v2 headers off; `now` (ms) backdates the v2 timestamp.
* Events sends it). `v1Only` leaves the v2 headers off; `now` (ms) backdates the v2 timestamp.
@@ -201,4 +255,4 @@ function delivery(event, secret, { seq = 1, attempt = 1, v1Only = false, now = D
@@ -201,4 +255,4 @@ function delivery(event, secret, { seq = 1, attempt = 1, v1Only = false, now = D
return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, ...v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, ...v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
}
}
-module.exports = { startNetwork, startSources, startAi, delivery, listen };
+module.exports = { startNetwork, startSources, startAi, startCommunity, delivery, listen };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
/**
* Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
* 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.
+ * databases and in-process mocks of OpenVibe.Network, Sources, AI, Community and Events deliveries;
+ * none needs the network or a running site.
*
*
* npm test # everything
* npm test # everything
* npm test -- schedule # only files whose name contains one of the words
* npm test -- schedule # only files whose name contains one of the words