openvibers/openvibe.host Commits

Host: discussion threads on every provider and plan page (OpenVibe.Community) (#74)

OP OpenVibers committed 11 Oct 2026 · 1fde068d3f · 13 files, +267 −12

**Discussion on every provider and plan page** (plan T20 step 3, the LowEndTalk half of hosting compared).

- **Threads:** each active provider (`{service: host, type: provider, id}`) and plan (`type: plan`) gets an OpenVibe.Community comment thread on its first public read; the ref is kept in `host_catalog_discussion_refs` (migration 0006, expand).
- **Rendering:** recent comments are rendered on the server under the plan table or the price history, with the count and a "Join the discussion" link to Community; a locked thread says so.
- **Visibility:** when a provider or plan is hidden (or a plan gone), its thread is hidden after the commit, best effort, and reopened when it returns. A rolled-back write calls nothing.
- **Off by default:** without `HOST_COMMUNITY_URL` there is no Discussion section, never an error. `OV_COMMUNITY_URL` is the public link base.
- **No Community change needed:** service callers may resolve these entity types.

Built by Codex from my spec, following News' discussion integration; I added the missing migration header and ran the HTTP tests. The new `catalog-discussion` test and the catalog, perf-budget and dashboard tests pass. Network grants Host `community.comment.write` and `community.comment.moderate` (PR follows).

Changes

ddiff --git a/.env.example b/.env.example
iindex 4a011f9..0138cef 100644
---- a/.env.example
++++ b/.env.example
@@@ -122,3 +122,8 @@ HOST_FORM_SECRET=
HOST_CATALOG_INGEST=off
HOST_CATALOG_SOURCES_URL=
HOST_CATALOG_WEBHOOK_SECRETS=
++
++# Optional catalog discussion threads. Internal Community API; unset disables Discussion blocks.
++HOST_COMMUNITY_URL=
++# Public Community address used for the join link.
++OV_COMMUNITY_URL=https://openvibe.community
ddiff --git a/README.md b/README.md
iindex 59db481..c58e128 100644
---- a/README.md
++++ b/README.md
@@@ -25,6 +25,7 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id` and `/catalog/promos`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
++- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
## Purpose
ddiff --git a/STATUS.json b/STATUS.json
iindex fe56d2b..0a28b36 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -65,7 +65,8 @@
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns",
"the authority resource index (ADR-048 section 3, plan T13 step 8): GET /api/v1/resources pages Host's sites (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and custom domains (host.domain, dom_) as common.resource-summary@1, and GET /api/v1/resources/:ovrn reads one by its OVRN, for OpenVibe.Services' fan-out; guarded by the first-party host.resource.read, scoped by ?project= and paginated with an opaque cursor",
"Stage B git deploys, Phase 1: a site's Git source (repo + branch); the tenant's own CI posts each build, which lands as a source=git preview with immutable commit provenance and goes live only through activate (Host never clones, fetches, holds a credential or builds)",
-- "per-actor limits on Stage B writes (openvibe-sdk/limits)"
++ "per-actor limits on Stage B writes (openvibe-sdk/limits)",
++ "catalog provider and plan discussion threads on OpenVibe.Community (optional HOST_COMMUNITY_URL; server-rendered comments, after-commit visibility sync)"
],
"updated": "2026-10-11"
}
ddiff --git a/migrations/0006_catalog_discussion.sql b/migrations/0006_catalog_discussion.sql
nnew file mode 100644
iindex 0000000..4741e6c
---- /dev/null
++++ b/migrations/0006_catalog_discussion.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Community threads for catalog providers and plans (plan T20 step 3): which thread each entity resolved to.
++CREATE TABLE host_catalog_discussion_refs (
++ entity_id text COLLATE "C" PRIMARY KEY,
++ thread_id text NOT NULL,
++ ref jsonb NOT NULL,
++ resolved_at bigint NOT NULL
++);
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 16c1590..d9268fe 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -88,6 +88,15 @@
.hc-spec dd { margin: .15rem 0 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.hc-history { padding-left: 1.2rem; font-variant-numeric: tabular-nums; }
.hc-history time { color: var(--hs-muted); margin-right: .4rem; }
++.hc-discussion { margin: 1.75rem 0; padding: 1.1rem 1.25rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); overflow-wrap: anywhere; }
++.hc-discussion h2 { margin: 0 0 .4rem; font-size: 1.15rem; }
++.hc-discussion > p { margin: .5rem 0 1rem; }
++.hc-comments, .hc-comment ol { list-style: none; margin: 1rem 0; padding: 0; }
++.hc-comment { padding: .8rem 0; border-top: 1px solid var(--hs-line); }
++.hc-comment p { margin: .3rem 0; white-space: normal; }
++.hc-comment-meta { color: var(--hs-muted); font-size: 13px; }
++.hc-comment-meta strong { color: var(--hs-fg); }
++.hc-comment ol { margin: .5rem 0 0 1rem; }
/* The front page's hero aside: the five lowest prices. */
.hc-aside { padding: 1rem 1.1rem; border-radius: 16px; border: 1px solid var(--hs-line); background: var(--hs-card); min-width: min(100%, 20rem); }
@@@ -104,6 +113,8 @@
@media (max-width: 60rem) { .hc-plan { grid-template-columns: 1fr; } }
/* Phones: each plan becomes a card; the header row is read from each cell's label. */
@media (max-width: 44rem) {
++ .hc-discussion { padding: .9rem; }
++ .hc-discussion .button { max-width: 100%; white-space: normal; }
.hc-table-wrap { border: 0; background: none; overflow: visible; }
.hc-table caption { display: block; padding: 0 0 .5rem; border: 0; }
.hc-table, .hc-table tbody, .hc-table tr, .hc-table td, .hc-table th { display: block; }
ddiff --git a/server/app.js b/server/app.js
iindex 0cb58e1..dfb3629 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -46,6 +46,7 @@ const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createCatalog } = require('./catalog/store');
++const { createCommunity } = require('./catalog/community');
const { createCatalogIngest } = require('./catalog/ingest');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
@@@ -154,11 +155,12 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
-- const catalog = createCatalog({ store });
++ const community = createCommunity({ store, config, fetchImpl: opts.fetchImpl, log });
++ const catalog = createCatalog({ store, community, log });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const worker = createWorker({ config, store, domains, blobs, outbox, catalogIngest, log });
-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, catalog, catalogIngest, auth, viewers, tenant, worker, uploadGate, indexnow, log };
++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, catalog, community, catalogIngest, auth, viewers, tenant, worker, uploadGate, indexnow, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/catalog/community.js b/server/catalog/community.js
nnew file mode 100644
iindex 0000000..1000c1f
---- /dev/null
++++ b/server/catalog/community.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++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(base && config.oauth.clientSecret);
++ const tokens = (scope) => serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.community', scope, fetchImpl,
++ });
++ const writeTokens = enabled ? tokens('community.comment.write') : null;
++ const modTokens = enabled ? tokens('community.comment.moderate') : null;
++ const client = enabled ? createDiscussionClient({ communityUrl: base, tokenClient: writeTokens, fetchImpl }) : null;
++ const key = (type, id) => `${type}:${id}`;
++
++ async function call(method, path, { token, body, ctx } = {}) {
++ const headers = { Accept: 'application/json', ...(ctx ? http.outboundHeaders(ctx) : {}) };
++ if (token) Object.assign(headers, await token.authHeaders());
++ if (body) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(4000) });
++ const data = await res.json().catch(() => null);
++ if (res.status === 401 && token && token.invalidate) token.invalidate();
++ if (!res.ok) throw new Error((data && (data.detail || data.error)) || `Community answered ${res.status}`);
++ return data;
++ }
++
++ return {
++ enabled,
++ publicUrl: config.community.publicUrl,
++ async threadFor(type, entity, label, ctx) {
++ if (!enabled) return null;
++ const entityId = key(type, entity.id);
++ const known = await store.discussion.get(entityId);
++ if (known) return known.threadId;
++ const ref = { service: 'host', type, id: entity.id, label: String(label).slice(0, 200) };
++ const out = await store.discussion.threadFor(entityId, ref, { client, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
++ return out.threadId;
++ },
++ async readThread(threadId, ctx) {
++ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}`, { ctx });
++ },
++ async setThreadVisibility(type, id, visibility) {
++ if (!enabled) return false;
++ const known = await store.discussion.get(key(type, id));
++ if (!known) return false;
++ try {
++ await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { token: modTokens, body: { visibility } });
++ return true;
++ } catch (err) {
++ log.warn(`[Host] could not set ${type} ${id} discussion ${visibility}: ${err.message}`);
++ return false;
++ }
++ },
++ };
++}
++
++module.exports = { createCommunity };
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 47c98e0..fe0e7c5 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -46,13 +46,29 @@ const clean = (type, input, current, now) => {
}
return v;
};
--function createCatalog({ store }) {
++function createCatalog({ store, community = null, log = console }) {
const db = store.db;
++ let visibilityQueue = Promise.resolve();
++ const open = async (type, id) => {
++ const sql = type === 'provider'
++ ? 'SELECT 1 FROM catalog_providers WHERE id = ? AND status = \'active\''
++ : 'SELECT 1 FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = \'active\' AND v.status = \'active\'';
++ return Boolean(await db.prepare(sql).get(id));
++ };
++ const idle = () => new Promise((resolve) => setImmediate(() => visibilityQueue.then(resolve)));
const providerOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null });
const promoOut = (p) => ({ ...p, status: p.status === 'active' && p.ends_at != null && Number(p.ends_at) <= store.now() ? 'ended' : p.status, affiliate_rel: p.affiliate ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null });
const get = async (type, id) => { const row = await db.prepare(`SELECT * FROM catalog_${type} WHERE id = ?`).get(id); if (!row) missing(); return type === 'promos' ? promoOut(row) : row; };
const save = async (type, input, id, actor) => await db.tx(async () => {
const current = id ? await get(type, id) : null;
++ const watched = [];
++ if (community && community.enabled && current && ['providers', 'plans'].includes(type)) {
++ const kind = type === 'providers' ? 'provider' : 'plan';
++ watched.push({ kind, id, was: await open(kind, id) });
++ if (kind === 'provider') {
++ for (const row of await db.prepare('SELECT id FROM catalog_plans WHERE provider_id = ?').all(id)) watched.push({ kind: 'plan', id: row.id, was: await open('plan', row.id) });
++ }
++ }
if (current && type === 'plans' && current.origin === 'sources' && Object.keys(input).some((k) => k !== 'status')) invalid('Only status can be edited on a Sources plan');
const now = store.now();
const v = clean(type, input, current, now);
@@@ -65,6 +81,14 @@ function createCatalog({ store }) {
await db.prepare('INSERT INTO catalog_price_changes (plan_id, observed_at, currency, price_monthly_before, price_monthly_after) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING').run(id, v.checked_at, v.currency, current.price_monthly, v.price_monthly);
}
await db.prepare(`UPDATE catalog_${type} SET ${cols.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), now, id);
++ if (watched.length) db.afterCommit(() => {
++ visibilityQueue = visibilityQueue.then(async () => {
++ for (const item of watched) {
++ const isOpen = await open(item.kind, item.id);
++ if (isOpen !== item.was) await community.setThreadVisibility(item.kind, item.id, isOpen ? 'public' : 'hidden');
++ }
++ }).catch((err) => log.warn(`[Host] catalog discussion sync failed: ${err.message}`));
++ });
return await get(type, id);
}
const key = { providers: 'catalogProvider', plans: 'catalogPlan', promos: 'catalogPromo' }[type];
@@@ -150,6 +174,6 @@ function createCatalog({ store }) {
return { plans: Number(r.plans) || 0, providers: Number(r.providers) || 0, lastChecked: r.last == null ? null : Number(r.last) };
}
async function sitemapPlans() { return await db.prepare("SELECT p.id FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
-- return { save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
++ return { save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
module.exports = { createCatalog };
ddiff --git a/server/config.js b/server/config.js
iindex 788ce25..b8fd485 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -90,6 +90,10 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
},
++ community: {
++ internalUrl: trim(env.HOST_COMMUNITY_URL || ''),
++ publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
++ },
uploads: {
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
ddiff --git a/server/db.js b/server/db.js
iindex 1d1b9e4..a77c797 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -26,11 +26,12 @@
const fs = require('fs');
const path = require('path');
const { createDb } = require('openvibe-sdk/db');
++const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
++const CHARTER_TABLES = ['host_catalog_discussion_refs', 'host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
@@@ -62,6 +63,7 @@ function createStore(db, { now = () => Date.now() } = {}) {
return {
db,
now,
++ discussion: createDiscussionRefs(db, { prefix: 'host_catalog', now }),
tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 5b3dcbe..24abbfa 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -7,6 +7,7 @@ const { renderPage, esc } = require('../render/layout');
const render = require('../render/catalog');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
++const cache = require('openvibe-shared/cache-policy');
const disclosure = render.DISCLOSURE;
function createCatalogApi({ catalog, viewers, actorLimits }) {
@@@ -28,13 +29,33 @@ function createCatalogApi({ catalog, viewers, actorLimits }) {
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
}
--function createCatalogPages({ catalog, config, viewers, actorLimits }) {
++function createCatalogPages({ catalog, community, config, viewers, actorLimits, log = console }) {
const router = express.Router();
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
-- const page = (req, res, title, body, indexable = true, description) => res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, css: ['css/catalog.css'] }));
++ const cachePage = (req, res, publicPage) => {
++ res.vary('Cookie'); res.vary('Authorization');
++ res.set('Cache-Control', cache.htmlHeaders(publicPage && req.viewer.kind === 'anonymous' ? { maxAge: 60 } : { private: true }));
++ };
++ const page = (req, res, title, body, indexable = true, description) => {
++ cachePage(req, res, indexable);
++ return res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, css: ['css/catalog.css'] }));
++ };
++ async function discussionFor(req, type, entity, label) {
++ if (!community || !community.enabled) return null;
++ try {
++ const threadId = await community.threadFor(type, entity, label, req.ov);
++ const data = await community.readThread(threadId, 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 : [], communityUrl: community.publicUrl };
++ } catch (err) {
++ log.warn(`[Host] ${type} ${entity.id} discussion unavailable: ${err.message}`);
++ return { state: 'unavailable' };
++ }
++ }
const view = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
++ cachePage(req, res, false);
const status = err.status || 500;
const body = status === 404 ? '<h1>Not in the catalog</h1><p>This entry is not listed (or no longer is).</p><p><a href="/catalog">Compare plans</a></p>'
: status === 422 ? `<h1>That filter did not work</h1><p>${esc(err.message)}</p><p><a href="/catalog">Clear the filters</a></p>`
@@@ -52,11 +73,13 @@ function createCatalogPages({ catalog, config, viewers, actorLimits }) {
}));
router.get('/providers/:slug', view(async (req, res) => {
const p = await catalog.providers(req.params.slug);
-- page(req, res, `${p.name} plans and prices`, render.provider(p), true, `${p.name}'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.`);
++ const discussion = await discussionFor(req, 'provider', p, p.name);
++ page(req, res, `${p.name} plans and prices`, render.provider(p, discussion), true, `${p.name}'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.`);
}));
router.get('/plans/:id', view(async (req, res) => {
const p = await catalog.plan(req.params.id);
-- page(req, res, `${p.provider_name} ${p.name}`, render.plan(p), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`);
++ const discussion = await discussionFor(req, 'plan', p, `${p.provider_name} ${p.name}`);
++ page(req, res, `${p.provider_name} ${p.name}`, render.plan(p, discussion), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`);
}));
router.get('/promos', view(async (req, res) => {
page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 54b1984..500ff2b 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -159,7 +159,19 @@ ${list.some((p) => p.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
}
--function provider(p) {
++function discussionSection(d) {
++ if (!d) return '';
++ if (d.state !== 'ok') return '<section class="hc-discussion" aria-labelledby="hc-discussion-h"><h2 id="hc-discussion-h">Discussion</h2><p class="hc-dim" role="status">Discussion is unavailable right now. Reload later.</p></section>';
++ const count = Number(d.thread.comment_count) || 0;
++ const threadUrl = `${d.communityUrl}/c/${encodeURIComponent(d.thread.id)}`;
++ const one = (c) => `<li class="hc-comment"><p class="hc-comment-meta"><strong>${esc(c.deleted ? '[deleted]' : c.display_name || 'Anonymous')}</strong>${Number.isFinite(Date.parse(c.created_at)) ? ` · <time datetime="${esc(c.created_at)}">${esc(new Date(c.created_at).toLocaleDateString('en', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' }))}</time>` : ''}</p><p>${c.deleted ? 'This comment was deleted.' : esc(c.message || '').replace(/\n/g, '<br>')}</p>${Array.isArray(c.replies) && c.replies.length ? `<ol>${c.replies.map(one).join('')}</ol>` : ''}</li>`;
++ return `<section class="hc-discussion" aria-labelledby="hc-discussion-h"><h2 id="hc-discussion-h">Discussion <span class="hc-dim">(${count})</span></h2>
++<p class="hc-dim">Comments are hosted by ${out(d.communityUrl, 'OpenVibe.Community')}.</p>
++${d.comments.length ? `<ol class="hc-comments">${d.comments.map(one).join('')}</ol>` : '<p class="hc-dim">No comments yet.</p>'}
++<p>${d.thread.visibility === 'locked' ? 'This thread is locked.' : out(threadUrl, 'Join the discussion on OpenVibe.Community', { cls: 'button hc-button-quiet' })}</p></section>`;
++}
++
++function provider(p, discussion = null) {
const cheapest = p.plans.length ? p.plans.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), p.plans[0]) : null;
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
<header class="hc-head hc-head-row">
@@@ -168,12 +180,13 @@ function provider(p) {
${visit(p)}
</header>
${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
++${discussionSection(discussion)}
<h2>Promotions</h2>
${promoCards(p.promos, { showProvider: false })}
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
}
--function plan(p) {
++function plan(p, discussion = null) {
const spec = [
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
@@@ -199,6 +212,7 @@ ${visit(p)}
</div>
<h2>Price history</h2>
${history}
++${discussionSection(discussion)}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
}
ddiff --git a/test/catalog-discussion.test.js b/test/catalog-discussion.test.js
nnew file mode 100644
iindex 0000000..e02b5f8
---- /dev/null
++++ b/test/catalog-discussion.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++
++(async () => {
++ const calls = [];
++ const threads = new Map();
++ const communityUrl = 'https://community.stub.test';
++ const fetchImpl = async (url, options = {}) => {
++ if (!String(url).startsWith(communityUrl)) return await globalThis.fetch(url, options);
++ const path = new URL(url).pathname;
++ if (path.endsWith('/resolve')) {
++ const ref = JSON.parse(options.body).ref;
++ calls.push({ method: 'resolve', ref });
++ const key = `${ref.service}:${ref.type}:${ref.id}`;
++ if (!threads.has(key)) threads.set(key, { id: `cth_${String(threads.size + 1).padStart(22, 'a')}`, visibility: 'public', comment_count: 1 });
++ return Response.json({ thread: threads.get(key), created: true }, { status: 201 });
++ }
++ const thread = [...threads.values()].find((row) => path.includes(row.id));
++ if (options.method === 'PUT') {
++ const visibility = JSON.parse(options.body).visibility;
++ calls.push({ method: 'visibility', id: thread.id, visibility });
++ thread.visibility = visibility;
++ return Response.json({ thread });
++ }
++ calls.push({ method: 'read', id: thread.id });
++ return Response.json({ thread, comments: [{ id: 1, display_name: 'Reader', message: 'A useful comment', created_at: '2026-10-10T12:00:00Z' }] });
++ };
++ const t = await boot({ env: { HOST_COMMUNITY_URL: communityUrl, OV_COMMUNITY_URL: 'https://openvibe.community' }, fetchImpl });
++ const staff = t.user('catalog-discussion-editor', { role: 'admin' });
++ const post = async (type, json) => {
++ const r = await t.api('POST', `/api/v1/catalog/${type}`, { as: staff, json });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json()[type.slice(0, -1)];
++ };
++ try {
++ const provider = await post('providers', { slug: 'discuss', name: 'Discuss', homepage_url: 'https://example.test' });
++ const plan = await post('plans', { provider_id: provider.id, external_id: 'one', name: 'One', kind: 'vps', currency: 'USD', price_monthly: 5, source_url: 'https://example.test/prices' });
++ const hiddenProvider = await post('providers', { slug: 'secret', name: 'Secret', homepage_url: 'https://example.test', status: 'hidden' });
++ await check('public provider and plan resolve once and render Community comments', async () => {
++ for (const [type, path, id] of [['provider', '/catalog/providers/discuss', provider.id], ['plan', `/catalog/plans/${plan.id}`, plan.id]]) {
++ const first = await t.api('GET', path);
++ const second = await t.api('GET', path);
++ assert.strictEqual(first.status, 200, first.text);
++ assert.strictEqual(second.status, 200, second.text);
++ assert.match(first.text, /Discussion/);
++ assert.match(first.text, /A useful comment/);
++ assert.match(first.text, /Join the discussion on OpenVibe.Community/);
++ assert.strictEqual(calls.filter((x) => x.method === 'resolve' && x.ref.type === type).length, 1);
++ assert.deepStrictEqual(Object.fromEntries(Object.entries(calls.find((x) => x.method === 'resolve' && x.ref.type === type).ref).filter(([k]) => k !== 'label')), { service: 'host', type, id });
++ }
++ });
++ await check('provider hiding follows commit, while rollback calls nothing', async () => {
++ const before = calls.length;
++ await assert.rejects(t.ctx.store.db.tx(async () => {
++ await t.ctx.catalog.save('providers', { status: 'hidden' }, provider.id, staff.subject);
++ throw new Error('rollback');
++ }), /rollback/);
++ await t.ctx.catalog.discussionIdle();
++ assert.strictEqual(calls.length, before);
++ const r = await t.api('PATCH', `/api/v1/catalog/providers/${provider.id}`, { as: staff, json: { status: 'hidden' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await t.ctx.catalog.discussionIdle();
++ assert.ok(calls.some((x) => x.method === 'visibility' && x.visibility === 'hidden' && x.id === threads.get(`host:provider:${provider.id}`).id));
++ assert.strictEqual((await t.api('GET', '/catalog/providers/discuss')).status, 404);
++ assert.strictEqual(calls.filter((x) => x.method === 'resolve').length, 2);
++ });
++ await check('hidden provider has no page and never resolves a thread', async () => {
++ assert.strictEqual((await t.api('GET', '/catalog/providers/secret')).status, 404);
++ assert.strictEqual(calls.filter((x) => x.method === 'resolve' && x.ref.id === hiddenProvider.id).length, 0);
++ });
++ await check('restoring provider reopens provider and plan threads; gone plan hides its thread', async () => {
++ let r = await t.api('PATCH', `/api/v1/catalog/providers/${provider.id}`, { as: staff, json: { status: 'active' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await t.ctx.catalog.discussionIdle();
++ assert.strictEqual(threads.get(`host:provider:${provider.id}`).visibility, 'public');
++ assert.strictEqual(threads.get(`host:plan:${plan.id}`).visibility, 'public');
++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { status: 'gone' } });
++ assert.strictEqual(r.status, 200, r.text);
++ await t.ctx.catalog.discussionIdle();
++ assert.strictEqual(threads.get(`host:plan:${plan.id}`).visibility, 'hidden');
++ assert.strictEqual((await t.api('GET', `/catalog/plans/${plan.id}`)).status, 404);
++ });
++ } finally { await t.close(); }
++ const off = await boot();
++ try {
++ const p = await (async () => { const r = await off.api('POST', '/api/v1/catalog/providers', { as: off.user('catalog-off-editor', { role: 'admin' }), json: { slug: 'off', name: 'Off', homepage_url: 'https://example.test' } }); return r.json().provider; })();
++ await check('unset Community URL renders no Discussion and creates no thread', async () => {
++ const r = await off.api('GET', '/catalog/providers/off');
++ assert.strictEqual(r.status, 200);
++ assert.doesNotMatch(r.text, /<h2[^>]*>Discussion/);
++ assert.strictEqual(await off.ctx.store.discussion.get(`provider:${p.id}`), null);
++ });
++ await check('hidden provider returns 404 before any thread resolution', async () => {
++ await off.ctx.catalog.save('providers', { status: 'hidden' }, p.id, 'test');
++ assert.strictEqual((await off.api('GET', '/catalog/providers/off')).status, 404);
++ assert.strictEqual(await off.ctx.store.discussion.get(`provider:${p.id}`), null);
++ });
++ } finally { await off.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });