openvibers/openvibe.host Commits
Host: discussion threads on every provider and plan page (OpenVibe.Community) (#74)
Changes
@@@ -122,3 +122,8 @@ HOST_FORM_SECRET=HOST_CATALOG_INGEST=offHOST_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@@@ -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@@@ -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"}nnew file mode 100644@@@ -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++);@@@ -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; }@@@ -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');nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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).@@@ -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(),@@@ -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.');@@@ -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>`;}nnew file mode 100644@@@ -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); });
@@ -122,3 +122,8 @@ HOST_FORM_SECRET=
@@ -122,3 +122,8 @@ HOST_FORM_SECRET=
HOST_CATALOG_INGEST=off
HOST_CATALOG_INGEST=off
HOST_CATALOG_SOURCES_URL=
HOST_CATALOG_SOURCES_URL=
HOST_CATALOG_WEBHOOK_SECRETS=
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
@@ -25,6 +25,7 @@ OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is
@@ -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`.
- **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`.
- **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`.
- **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
## Purpose
@@ -65,7 +65,8 @@
@@ -65,7 +65,8 @@
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns",
"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",
"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)",
"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"
"updated": "2026-10-11"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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
+);
@@ -88,6 +88,15 @@
@@ -88,6 +88,15 @@
.hc-spec dd { margin: .15rem 0 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.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 { padding-left: 1.2rem; font-variant-numeric: tabular-nums; }
.hc-history time { color: var(--hs-muted); margin-right: .4rem; }
.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. */
/* 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); }
.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 @@
@@ -104,6 +113,8 @@
@media (max-width: 60rem) { .hc-plan { grid-template-columns: 1fr; } }
@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. */
/* Phones: each plan becomes a card; the header row is read from each cell's label. */
@media (max-width: 44rem) {
@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-wrap { border: 0; background: none; overflow: visible; }
.hc-table caption { display: block; padding: 0 0 .5rem; border: 0; }
.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; }
.hc-table, .hc-table tbody, .hc-table tr, .hc-table td, .hc-table th { display: block; }
@@ -46,6 +46,7 @@ const { createDashboard } = require('./http/dashboard');
@@ -46,6 +46,7 @@ const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
const { createCatalog } = require('./catalog/store');
const { createCatalog } = require('./catalog/store');
+const { createCommunity } = require('./catalog/community');
const { createCatalogIngest } = require('./catalog/ingest');
const { createCatalogIngest } = require('./catalog/ingest');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
@@ -154,11 +155,12 @@ async function createApp(opts = {}) {
@@ -154,11 +155,12 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, log });
const viewers = createViewerResolver({ auth, config, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
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 catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const worker = createWorker({ config, store, domains, blobs, outbox, catalogIngest, log });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
@@ -46,13 +46,29 @@ const clean = (type, input, current, now) => {
@@ -46,13 +46,29 @@ const clean = (type, input, current, now) => {
}
}
return v;
return v;
};
};
-function createCatalog({ store }) {
+function createCatalog({ store, community = null, log = console }) {
const db = store.db;
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 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 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 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 save = async (type, input, id, actor) => await db.tx(async () => {
const current = id ? await get(type, id) : null;
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');
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 now = store.now();
const v = clean(type, input, current, now);
const v = clean(type, input, current, now);
@@ -65,6 +81,14 @@ function createCatalog({ store }) {
@@ -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('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);
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);
return await get(type, id);
}
}
const key = { providers: 'catalogProvider', plans: 'catalogPlan', promos: 'catalogPromo' }[type];
const key = { providers: 'catalogProvider', plans: 'catalogPlan', promos: 'catalogPromo' }[type];
@@ -150,6 +174,6 @@ function createCatalog({ store }) {
@@ -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) };
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(); }
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 };
module.exports = { createCatalog };
@@ -90,6 +90,10 @@ function load(env = process.env) {
@@ -90,6 +90,10 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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: {
uploads: {
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
// Largest request body accepted for one deploy (the compressed archive or all multipart parts).
@@ -26,11 +26,12 @@
@@ -26,11 +26,12 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
+const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
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
* 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() } = {}) {
@@ -62,6 +63,7 @@ function createStore(db, { now = () => Date.now() } = {}) {
return {
return {
db,
db,
now,
now,
+ discussion: createDiscussionRefs(db, { prefix: 'host_catalog', now }),
tx: async (fn) => await db.tx(() => fn()),
tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
close: () => db.close(),
@@ -7,6 +7,7 @@ const { renderPage, esc } = require('../render/layout');
@@ -7,6 +7,7 @@ const { renderPage, esc } = require('../render/layout');
const render = require('../render/catalog');
const render = require('../render/catalog');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
+const cache = require('openvibe-shared/cache-policy');
const disclosure = render.DISCLOSURE;
const disclosure = render.DISCLOSURE;
function createCatalogApi({ catalog, viewers, actorLimits }) {
function createCatalogApi({ catalog, viewers, actorLimits }) {
@@ -28,13 +29,33 @@ 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); });
router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
return router;
return router;
}
}
-function createCatalogPages({ catalog, config, viewers, actorLimits }) {
+function createCatalogPages({ catalog, community, config, viewers, actorLimits, log = console }) {
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware('dashboard'));
router.use(viewers.middleware('dashboard'));
if (actorLimits) router.use(actorLimits);
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) => {
const view = (fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
try { await fn(req, res); } catch (err) {
+ cachePage(req, res, false);
const status = err.status || 500;
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>'
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>`
: 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 }) {
@@ -52,11 +73,13 @@ function createCatalogPages({ catalog, config, viewers, actorLimits }) {
}));
}));
router.get('/providers/:slug', view(async (req, res) => {
router.get('/providers/:slug', view(async (req, res) => {
const p = await catalog.providers(req.params.slug);
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) => {
router.get('/plans/:id', view(async (req, res) => {
const p = await catalog.plan(req.params.id);
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) => {
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.');
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.');
@@ -159,7 +159,19 @@ ${list.some((p) => p.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</
@@ -159,7 +159,19 @@ ${list.some((p) => p.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
<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;
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>
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
<header class="hc-head hc-head-row">
<header class="hc-head hc-head-row">
@@ -168,12 +180,13 @@ function provider(p) {
@@ -168,12 +180,13 @@ function provider(p) {
${visit(p)}
${visit(p)}
</header>
</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>'}
${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>
<h2>Promotions</h2>
${promoCards(p.promos, { showProvider: false })}
${promoCards(p.promos, { showProvider: false })}
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
${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 = [
const spec = [
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['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()}` : ''}`],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
@@ -199,6 +212,7 @@ ${visit(p)}
@@ -199,6 +212,7 @@ ${visit(p)}
</div>
</div>
<h2>Price history</h2>
<h2>Price history</h2>
${history}
${history}
+${discussionSection(discussion)}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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); });