openvibers/openvibe.news Commits

Community comment threads on stories, referenced not copied

OP OpenVibers committed 23 Sept 2026 · 0d95d9cd27 · 18 files, +594 −26

A story open for discussion (published, indexable or not, with no paragraph resting only on a
source removed upstream) gets the OpenVibe.Community thread resolved for EntityRef
{ service: 'news', type: 'story', id } the first time its page is read. Only the thread id is
stored (openvibe-publishing/discussion, news_story_discussion_refs); the page reads the thread
from Community on every render and says comments are unavailable when it cannot, never an empty
or invented thread. Drafts never get a thread. Signed-in members comment with a plain form
(POST /stories/:slug/comments, form token, rate-limited) as themselves (X-OV-Subject,
community.comment.write).

Retracting or unpublishing a story, or an upstream removal that leaves a paragraph without a
source, hides its thread; republishing or a revision that no longer rests on the removed source
shows it again. domain/discussion.js notes the state inside the write's transaction and compares
after the commit, so the call (community.comment.moderate) never runs inside a transaction and a
rolled-back write calls nothing; it is best effort and never fails the write.

Config OV_COMMUNITY_URL, OV_COMMUNITY_INTERNAL_URL (as in Blog). The Network does not hold
[news, community.comment.write, openvibe.community] or [news, community.comment.moderate,
openvibe.community] yet; until it does, story pages say comments are unavailable.

Tests: discussion.test.js against a Community mock (thread resolved once and only its id stored,
no comment text in the database, member comments by form, Community down, drafts, removed
source, retraction, unpublish/republish, rolled-back write).

Changes

ddiff --git a/.env.example b/.env.example
iindex 223c9ab..d5aea4a 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 578e1ab..02bafdd 100644
---- a/README.md
++++ b/README.md
@@@ -45,7 +45,9 @@ Other tables in the same database:
- 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),
-- `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 a
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.
- **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 on
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.
++- **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 an
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
@@@ -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`,
`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`.
ddiff --git a/deploy/nginx/openvibe.news.conf b/deploy/nginx/openvibe.news.conf
iindex 88e8965..679900f 100644
---- a/deploy/nginx/openvibe.news.conf
++++ b/deploy/nginx/openvibe.news.conf
@@@ -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; }
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 2302f79..cf7f10d 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -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."
}
ddiff --git a/public/css/news.css b/public/css/news.css
iindex dd101dd..b893a8e 100644
---- a/public/css/news.css
++++ b/public/css/news.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex acf505a..efa4aa2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/clients/community.js b/server/clients/community.js
nnew file mode 100644
iindex 0000000..f4ac851
---- /dev/null
++++ b/server/clients/community.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 0796582..217a03b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/db.js b/server/db.js
iindex 3413b89..53052d0 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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);
ddiff --git a/server/domain/discussion.js b/server/domain/discussion.js
nnew file mode 100644
iindex 0000000..8c731ee
---- /dev/null
++++ b/server/domain/discussion.js
@@@ -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 };
ddiff --git a/server/domain/ingest.js b/server/domain/ingest.js
iindex 7d96c91..db08700 100644
---- a/server/domain/ingest.js
++++ b/server/domain/ingest.js
@@@ -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 = console
const 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 = ?`)
ddiff --git a/server/domain/stories.js b/server/domain/stories.js
iindex e5e824e..bc3d724 100644
---- a/server/domain/stories.js
++++ b/server/domain/stories.js
@@@ -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 = null
if (story.state === 'retracted') throw new ApiError(409, 'story.retracted', 'A retracted story stays retracted; open a new story');
return store.tx(() => {
const cur = q.byId.get(story.id);
++ 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 = null
return 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 = null
const cur = q.byId.get(story.id);
if (cur.state === 'retracted') return { changed: false, story: cur };
if (cur.state !== 'published') throw new ApiError(409, 'story.not_published', 'Only a published story can be retracted');
++ 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);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex e48cd8d..862d085 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 };
}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 238757c..ea4a88b 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 };
ddiff --git a/test/discussion.test.js b/test/discussion.test.js
nnew file mode 100644
iindex 0000000..b1bf1a0
---- /dev/null
++++ b/test/discussion.test.js
@@@ -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 &lt;b&gt;date&lt;\/b&gt; 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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 76706af..1f02ec1 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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;
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 1e65b9b..a4767f8 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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 = 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) } };
}
--module.exports = { startNetwork, startSources, startAi, delivery, listen };
++module.exports = { startNetwork, startSources, startAi, startCommunity, delivery, listen };
ddiff --git a/test/run.js b/test/run.js
iindex bbde582..0f599b2 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -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