openvibers/openvibe.community Commits
Pulse reads Network's one feed; the local collector retires (#402)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++/**++ * GET /api/v1/activity — Community's public activity for the one feed (OpenVibe.Network server/feed/).++ * Read-only and public: only public rows, capped, newest first. It is the site's own public API, not a++ * second Pulse: nothing is stored, and the feed polls it on a due basis.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { run } = require('../http/v1');++++const CACHE_MS = 30_000;++// The suite reads a paste it just made (and a thread it just gated): no cache in tests, 30 s in production.++const cacheMs = () => (process.env.NODE_ENV === 'test' ? 0 : CACHE_MS);++++function createActivityApi({ activity, viewers, limits }) {++ const router = express.Router();++ router.use(contracts.http.middleware());++ router.use(viewers.middleware());++ router.use(limits.reads('community.feed.read'));++ let cache = { at: 0, key: '', body: null };++ router.get('/', run(async (req) => {++ const since = String(req.query.since || '');++ const limit = String(req.query.limit || '500');++ const key = `${since}|${limit}`;++ if (cache.body && cache.key === key && Date.now() - cache.at < cacheMs()) return cache.body;++ const items = await activity.list({ since: /^\d{4}-\d{2}-\d{2}T/.test(since) ? since : null, limit });++ const body = { items, count: items.length };++ cache = { at: Date.now(), key, body };++ return body;++ }));++ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));++ return router;++}++++module.exports = { createActivityApi };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++++/**++ * Community's public activity, read directly from its own tables.++ *++ * This is what the one feed (OpenVibe.Network server/feed/) polls: posts, pastes, accepted submissions++ * and their counts, each with the public link, the person and when it happened. There is no Pulse++ * store and no collector: Pulse reads the Network feed now, and this read is Community's public API++ * for its own rows. Only public things leave here: a private or members-only row is filtered in SQL.++ *++ * GET /api/v1/activity?since=<ISO>&limit= → { items: [{ service, type, id, title, text, url, actor,++ * origin, at, kind, picture_url, media_ratio, counts }] }++ *++ * `at` is ISO; `id` is the row's own stable id (a paste slug, a thread/post/submission id), so the++ * Network feed keys the same source item across restarts. No body is ever longer than the item shape:++ * text is capped at 280 on one line.++ */++const { sqlTime } = require('../http/v1');++++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);++const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href.slice(0, 2000) : null; } catch { return null; } };++const iso = (v) => {++ if (v == null || v === '') return null;++ const s = String(v);++ const t = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++};++const n = (v) => (Number.isSafeInteger(Number(v)) && Number(v) >= 0 ? Number(v) : 0);++const counts = (c) => {++ const out = {};++ for (const k of ['views', 'likes', 'comments']) if (n(c[k]) || c[k] === 0) out[k] = n(c[k]);++ return out;++};++++function createActivity({ db, config = {} } = {}) {++ const base = String(config.baseUrl || 'https://openvibe.community').replace(/\/+$/, '');++++ async function pastes(since, limit) {++ const rows = await db.prepare(`SELECT p.slug, p.title, p.content, p.origin, p.type, p.screenshot_url, p.views, p.likes, p.created_at,++ ct.comment_count++ FROM pastes p++ LEFT JOIN comment_threads ct ON ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.ref_id = p.slug++ WHERE p.visibility = 'public' AND p.deleted_at IS NULL AND COALESCE(p.is_nsfw, 0) = 0++ AND COALESCE(p.burn_after_read, 0) = 0 AND p.origin = 'user' AND p.created_at >= ?++ ORDER BY p.created_at DESC LIMIT ?`).all(since, limit);++ return rows.map((p) => ({++ service: 'community', type: 'paste', id: p.slug,++ title: clean(p.title, 300) || 'A paste', text: clean(p.content, 280),++ url: `${base}/p/${encodeURIComponent(p.slug)}`,++ actor: p.owner_subject || null, origin: p.origin === 'ai' ? 'ai' : 'user',++ at: iso(p.created_at), kind: p.type === 'screenshot' || p.screenshot_url ? 'pic' : 'paste',++ picture_url: https(p.screenshot_url), media_ratio: null,++ counts: counts({ views: p.views, likes: p.likes, comments: p.comment_count }),++ }));++ }++++ async function threads(since, limit) {++ const rows = await db.prepare(`SELECT t.id, t.title, t.created_at, t.author_subject, t.origin, t.views, t.score, t.reply_count, s.slug AS space,++ (SELECT b.body_markdown FROM posts b WHERE b.thread_id = t.id AND b.is_opening = 1 AND b.deleted_at IS NULL LIMIT 1) AS opening++ FROM threads t JOIN spaces s ON s.id = t.space_id++ WHERE s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL++ AND t.deleted_at IS NULL AND t.created_at >= ?++ ORDER BY t.created_at DESC LIMIT ?`).all(since, limit);++ return rows.map((t) => ({++ service: 'community', type: 'thread', id: String(t.id),++ title: clean(t.title, 300) || 'A thread', text: clean(t.opening, 280),++ url: `${base}/forum/${encodeURIComponent(t.space)}/${encodeURIComponent(t.slug || '')}`,++ actor: t.author_subject || null, origin: t.origin === 'ai' ? 'ai' : t.origin === 'system' ? 'system' : 'user',++ at: iso(t.created_at), kind: 'post', picture_url: null, media_ratio: null,++ counts: counts({ views: t.views, likes: t.score, comments: t.reply_count }),++ }));++ }++++ async function posts(since, limit) {++ const rows = await db.prepare(`SELECT p.id, p.body_markdown, p.created_at, p.author_subject, p.origin, t.title, t.slug AS thread_slug, s.slug AS space,++ (SELECT COUNT(*) FROM post_reactions r WHERE r.post_id = p.id) AS likes,++ ct.comment_count++ FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id++ LEFT JOIN comment_threads ct ON ct.ref_service = 'community' AND ct.ref_type = 'post' AND ct.ref_id = CAST(p.id AS TEXT)++ WHERE p.is_opening = 0 AND p.deleted_at IS NULL AND t.deleted_at IS NULL++ AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL++ AND p.created_at >= ?++ ORDER BY p.created_at DESC LIMIT ?`).all(since, limit);++ return rows.map((p) => ({++ service: 'community', type: 'post', id: String(p.id),++ title: `Re: ${clean(p.title, 280)}`, text: clean(p.body_markdown, 280),++ url: `${base}/forum/${encodeURIComponent(p.space)}/${encodeURIComponent(p.thread_slug || '')}#post-${p.id}`,++ actor: p.author_subject || null, origin: p.origin === 'ai' ? 'ai' : p.origin === 'system' ? 'system' : 'user',++ at: iso(p.created_at), kind: 'post', picture_url: null, media_ratio: null,++ counts: counts({ likes: p.likes, comments: p.comment_count }),++ }));++ }++++ async function submissions(since, limit) {++ const rows = await db.prepare(`SELECT slug, title, body, author_subject, reviewed_at, created_at FROM submissions++ WHERE status = 'accepted' AND COALESCE(created_at, '') >= ? ORDER BY created_at DESC LIMIT ?`).all(since, limit);++ return rows.map((s) => ({++ service: 'community', type: 'submission', id: s.slug,++ title: clean(s.title, 300) || 'A submission', text: clean(s.body, 280),++ url: `${base}/submissions/${encodeURIComponent(s.slug)}`,++ actor: s.author_subject || null, origin: 'user', at: iso(s.reviewed_at || s.created_at), kind: 'post',++ picture_url: null, media_ratio: null, counts: {},++ }));++ }++++ /** Up to `limit` public items since `since`, newest first. */++ async function list({ since = null, limit = 500 } = {}) {++ const at = since ? Date.parse(since) : NaN;++ const from = Number.isFinite(at) ? new Date(at).toISOString().replace('T', ' ').slice(0, 19) : sqlTime(Date.now() - 30 * 86400000);++ const max = Math.min(Math.max(parseInt(limit, 10) || 500, 1), 1000);++ const [a, b, c, d] = await Promise.all([pastes(from, max), threads(from, max), posts(from, max), submissions(from, max)]);++ return [...a, ...b, ...c, ...d]++ .filter((i) => i.at && i.url)++ .sort((x, y) => Date.parse(y.at) - Date.parse(x.at))++ .slice(0, max);++ }++++ return { list };++}++++module.exports = { createActivity, iso, clean };@@@ -52,8 +52,9 @@ const { createForumService } = require('./forum/service');const { createVipGate } = require('./vip');const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');const { createForumRoutes } = require('./forum/routes');--const { createPulse } = require('./pulse/service');--const { createPulseApi } = require('./pulse/api');++const { createActivity } = require('./activity/service');++const { createActivityApi } = require('./activity/api');++const { createFeedProxy } = require('./feed/proxy');const { createDiscordRelay } = require('./relay/discord');const { createRelayEventsWorker } = require('./relay/events-worker');const { createDiscordGateway } = require('./relay/discord-gateway');@@@ -152,13 +153,22 @@ async function createApp(opts = {}) {const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });await revocations.load();const viewers = createViewerResolver({ auth, config, network, revocations });-- const pulse = createPulse({ db, network, config });-- // Public activity without an Events topic (Today's calendar, Network's open stats, Quest badges): a due-based-- // job that fills Pulse, started by server/index.js and never called while a feed request is being answered.-- const pulseSources = require('./pulse/sources').createPulseSources({-- db, config, fetchImpl: opts.sourcesFetch || opts.fetchImpl || globalThis.fetch, testSubjects: () => pulse.testSubjectIds(), ...(opts.pulseSources || {}),-- });-- app.locals.pulseSources = pulseSources;++ // Community's public activity, read directly from its own tables for the one feed (OpenVibe.Network++ // server/feed/). Pulse reads Network's feed now; there is no local Pulse store and no collector.++ const activity = createActivity({ db, config });++ // The menu's Feed and Pulse: one proxy to Network (feed.item@1), forwarding the person's own token.++ const feedProxy = createFeedProxy({ config, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });++ app.locals.feedProxy = feedProxy;++ // The old write hooks the forum, pastes and status call after a change. They are no-ops now: the one++ // feed reads Community's public activity directly (server/activity/service.js), so there is nothing++ // to record a second time. Kept while their callers are still named pulse.* (they are removed with++ // the next change to each service); they never write anywhere.++ const noop = async () => null;++ const pulse = {++ ORIGINS: ['user', 'ai', 'system'],++ pasteCreated: noop, pasteChanged: noop, pasteGone: noop, threadCreated: noop, postCreated: noop,++ submissionAccepted: noop, submissionGone: noop, threadGone: noop, postGone: noop, forgetLocal: noop, statusCreated: noop,++ };const relay = opts.relay || createDiscordRelay({db, config, enabled: config.discordRelay.enabled,pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,@@@ -322,9 +332,14 @@ async function createApp(opts = {}) {app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));// The menu's wall and Feed on every OpenVibe site read these: public reads from any origin, writes from the listed ones.const readCors = v1.publicReadCors(config.apiCorsOrigins);-- app.use('/api/v1/pulse', readCors, createPulseApi({ pulse, viewers, limits }));-- // The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).-- app.use('/api/v1/feed', readCors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, db, networkUrl: config.networkUrl, liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));++ // Pulse and the menu's Feed: the same Network feed, the same ids (server/feed/proxy.js).++ app.use('/api/v1/activity', readCors, createActivityApi({ activity, viewers, limits }));++ app.use('/api/v1/feed', readCors, feedProxy.router);++ // The old wall path keeps working for clients that still ask /api/v1/pulse: it is the feed's wall view.++ app.get('/api/v1/pulse', readCors, (req, res, next) => {++ req.url = `/wall?limit=${encodeURIComponent(String(req.query.limit || '20'))}&cursor=${encodeURIComponent(String(req.query.after || req.query.cursor || ''))}`;++ feedProxy.router(req, res, next);++ });app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.@@@ -475,13 +490,17 @@ async function createApp(opts = {}) {ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 5`).all(), []),quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_idWHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),-- quiet(pulse.list({ limit: 16 }).then((out) => {++ quiet(feedProxy.read('/api/v1/feed/wall', { limit: '16' }, null).then((out) => {// One row a place: a stream's go-live and the day's card that follows it link the same channel.const seen = new Set();-- return (out.items || []).filter((it) => it.source && it.source.type !== 'paste' && !seen.has(it.url) && seen.add(it.url)).slice(0, 5);++ return ((out.body && out.body.items) || []).filter((it) => it.source && it.source.type !== 'paste' && !seen.has(it.url) && seen.add(it.url)).slice(0, 5);}), []),quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,-- (SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),++ (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND origin = 'user'++ AND COALESCE(is_nsfw, 0) = 0 AND COALESCE(burn_after_read, 0) = 0)++ + (SELECT COUNT(*) FROM threads t JOIN spaces s ON s.id = t.space_id++ WHERE t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL++ AND t.members_only_owner IS NULL) AS pulse`).get(), {}),]);const value = {spaces: spaces.filter((sp) => sp.slug !== 'roadmap' && sp.group_slug !== 'openvibe-items' && !sp.members_only_owner),@@@ -816,48 +835,31 @@ async function createApp(opts = {}) {app.use(createCommentPages({ comments, viewers, config }));app.use(createSubmissionPages({ submissions, viewers, config }));app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {-- const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';-- let out;-- try { out = await pulse.list({ origin, after: req.query.after }); }-- catch (err) { if (err instanceof v1.ApiError) return res.redirect(302, origin ? `/pulse?origin=${origin}` : '/pulse'); throw err; }-- html(res, pulsePage({ items: out.items, origin, after: req.query.after || null, nextCursor: out.next_cursor, origins: await pulse.origins() }));++ const origin = ['user', 'ai', 'system'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '';++ // Pulse is Network's one feed now (server/feed/proxy.js): its wall view keeps this page's look.++ const out = await feedProxy.read('/api/v1/feed/wall', { limit: '24', cursor: String(req.query.after || '') }, require('./auth/routes').extractToken(req));++ const all = ((out.body && out.body.items) || []).map((i) => ({++ id: i.id, source: i.source, title: i.title, text: i.text, url: i.url, origin: i.origin, label: i.label,++ actor: i.actor, occurred_at: i.occurred_at, picture_url: i.picture_url || (i.media && i.media.thumb) || null,++ }));++ const items = origin ? all.filter((i) => i.origin === origin) : all;++ html(res, pulsePage({ items, origin, after: req.query.after || null, nextCursor: out.body && out.body.next_cursor, origins: all.length ? [...new Set(all.map((i) => i.origin))] : ['user', 'ai', 'system'] }));}));-- const feedTools = require('./pulse/feed');-- const feedLiveNow = feedTools.createLiveNow({ liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });app.get('/feed', viewers.middleware({ services: false }), wrap(async (req, res) => {-- const kind = ['live', 'clip', 'video', 'pic', 'post', 'paste', 'meme', 'game', 'update'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';++ const kind = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'paste', 'meme', 'game', 'update', 'work', 'deal', 'news', 'report'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);const cursor = req.query.cursor ? String(req.query.cursor) : '';-- const live = await feedLiveNow();-- const items = [];-- const seen = [];-- let after = cursor;-- let nextCursor = null;-- for (let pages = 0; pages < 8 && !feedTools.filled(items, 12); pages++) {-- const out = await pulse.list({ limit: 12, after: after || undefined }, req.viewer);-- const rows = out.items || [];-- for (let index = 0; index < rows.length; index++) {-- const item = rows[index];-- const card = feedTools.card(item);-- if (card.kind === 'live') {-- card.live_now = feedTools.isCurrentLive(card, live);-- if (!card.live_now) card.kind = 'stream';-- }-- seen.push(card);-- if ((!kind || card.kind === kind) && feedTools.searchMatches(card, q)) items.push({ row: item, card });-- if (feedTools.filled(items, 12)) {-- nextCursor = index < rows.length - 1-- ? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])-- : (out.next_cursor || null);-- break;-- }-- }-- if (feedTools.filled(items, 12) || !out.next_cursor) break;-- after = out.next_cursor;-- nextCursor = after;-- }-- html(res, feedPage({ items: feedTools.foldBursts(items), kind, q, examples: feedTools.searchExamples(seen), cursor: cursor || null, nextCursor }));++ const token = require('./auth/routes').extractToken(req);++ const params = { limit: '40' };++ if (kind) params.kind = kind;++ if (q) params.q = q;++ if (cursor) params.cursor = cursor;++ const [out, examples] = await Promise.all([++ feedProxy.read('/api/v1/feed', params, token),++ feedProxy.read('/api/v1/feed/examples', {}, token),++ ]);++ html(res, feedPage({ items: (out.body && out.body.items) || [], kind, q, examples: (examples.body && examples.body.examples) || [], cursor: cursor || null, nextCursor: out.body && out.body.next_cursor }));}));app.get('/my', withUser, wrap(async (req, res) => {nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * GET /api/v1/feed — the menu's Feed on every OpenVibe site, read from Network (feed.item@1).++ *++ * Network owns the one feed (server/feed/api.js there): it reads Events and the sites' public APIs,++ * ranks For you / Following and applies the privacy rules. Community answers the same path by asking++ * it, forwarding the person's own session token and nothing else, so Pulse and the menu show the same++ * items and the same ids. Pulse's own collector is gone (server/pulse/sources.js, consumer.js).++ *++ * A Network outage answers an empty page, never an error: the menu's Feed is not a reason to fail a++ * Community page.++ */++const express = require('express');++const { run } = require('../http/v1');++const { extractToken } = require('../auth/routes');++++const PASS = ['kind', 'site', 'q', 'following', 'cursor', 'limit'];++++function createFeedProxy({ config, fetchImpl = globalThis.fetch }) {++ const base = String(config.networkInternalUrl || config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');++ const publicCache = new Map();++++ async function read(path, params, token) {++ const u = new URL(path, base);++ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));++ const key = `${path}?${u.searchParams}`;++ if (!token) {++ const kept = publicCache.get(key);++ if (kept && Date.now() - kept.at < 30_000) return { status: 200, body: kept.body, cached: true };++ }++ try {++ const r = await fetchImpl(u.href, { headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, signal: AbortSignal.timeout(8000) });++ const text = await r.text();++ if (!r.ok) throw new Error(`Network answered ${r.status}`);++ let body;++ try { body = JSON.parse(text); } catch { throw new Error('Network answered no JSON'); }++ if (!token) {++ if (publicCache.size >= 300) publicCache.clear();++ publicCache.set(key, { at: Date.now(), body });++ }++ return { status: 200, body, cached: false };++ } catch {++ if (path === '/api/v1/feed/examples') return { status: 200, body: { examples: [] }, cached: false };++ if (path === '/api/v1/feed/wall') return { status: 200, body: { items: [], next_cursor: null }, cached: false };++ return { status: 200, body: { items: [], next_cursor: null }, cached: false };++ }++ }++++ async function relay(req, path, params) {++ return await read(path, params, extractToken(req));++ }++++ function send(res, out, token) {++ res.set({++ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization', Vary: 'Authorization',++ 'Content-Type': 'application/json; charset=utf-8',++ 'Cache-Control': token ? 'private, no-store' : 'public, max-age=30',++ 'X-Feed-Source': 'network',++ });++ return res.json(out.body);++ }++++ const router = express.Router();++ router.get('/', run(async (req, res) => {++ const params = {};++ for (const k of PASS) if (req.query[k] !== undefined) params[k] = String(req.query[k]).slice(0, 300);++ send(res, await relay(req, '/api/v1/feed', params), extractToken(req));++ }));++ router.get('/search', run(async (req, res) => {++ send(res, await relay(req, '/api/v1/feed/search', { q: String(req.query.q || '').slice(0, 80) }), extractToken(req));++ }));++ router.get('/examples', run(async (req, res) => {++ send(res, await relay(req, '/api/v1/feed/examples', {}), extractToken(req));++ }));++ router.get('/wall', run(async (req, res) => {++ send(res, await relay(req, '/api/v1/feed/wall', {++ limit: String(req.query.limit || '20').slice(0, 10),++ cursor: String(req.query.cursor || req.query.after || '').slice(0, 300),++ }), extractToken(req));++ }));++ return { router, read };++}++++module.exports = { createFeedProxy };@@@ -63,14 +63,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkif (reviewRetry.start()) console.log('[Community] paste review retry: on');} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }-- // Pulse's due-based public sources (Today's calendar, Network's open stats, Quest badges): one minute check,-- // each source's next run kept in pulse_source_runs so a restart does not skip it (server/pulse/sources.js).-- let pulseSources = null;-- try {-- pulseSources = app.locals.pulseSources || null;-- if (pulseSources) { pulseSources.start(); console.log('[Pulse sources] on: today-events, network-members, quest-badges'); }-- } catch (err) { console.warn('[Pulse sources] not started:', err.message); }--// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps@@@ -88,7 +80,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk() => { if (profiles) profilesDone = profiles.stop(); },() => { if (searchDocs) searchDocs.stop(); },() => { if (reviewRetry) reviewRetry.stop(); },-- () => { if (pulseSources) pulseSources.stop(); },],close: [() => within(1000, profilesDone),@@@ -346,44 +346,12 @@ route('post', '/api/v1/comments/{commentId}/votes', {responses: { 200: json('The comment and its score'), ...errors() },});--// ── /api/v1/feed — the menu's Feed on every site (server/pulse/feed.js) ──────────────────────--route('get', '/api/v1/feed', {-- operationId: 'listFeed', summary: 'One stream of everything public on the network, as cards by kind', tags: ['pulse'], security: ANONYMOUS,-- description: 'Pulse items as cards (live, clip, video, pic, post, meme, game, update), newest first, with the network\'s trending pictures and videos woven into the first page.',-- parameters: [-- query('kind', { type: 'string', enum: ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'] }),-- query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40 })],-- responses: { 200: json('Cards and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object', properties: {-- id: str(), kind: str(), url: str(), title: str(), text: str(), at: nullable('string'), site: str(), site_name: str(), label: str(),-- actor: { type: ['object', 'null'] }, media: { type: ['object', 'null'] }, counts: { type: 'object' } } }), next_cursor: nullable('string') } }), ...errors([429]) },--});--// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────route('get', '/api/v1/pulse', {-- operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [-- query('origin', { type: 'string', enum: ['user', 'ai', 'system', 'all'] }), query('for', { type: 'string', enum: ['me'] }),-- query('cursor', str(), 'The personal next_cursor'), query('after', str(), 'The public next_cursor from the previous page'),-- query('limit', { type: 'integer', minimum: 1, maximum: 100 })],-- responses: { 200: json('Items and the cursor for the next page', { type: 'object', properties: { items: arrayOf(ref('PulseItem')), next_cursor: nullable('string') } }), ...errors([400, 429]) },--});--route('post', '/api/v1/pulse/items', {-- operationId: 'ingestPulseItem', summary: 'Publish one public activity item', tags: ['pulse'], security: SERVICE,-- description: 'community.pulse.write for services or community.pulse.contribute with a person grant for developer apps. An app sends {id,text,url,picture_url?}; its subject and label come from the token. Idempotent per source id.',-- requestBody: body({ anyOf: [{ type: 'object', required: ['id', 'text', 'url'], properties: {-- id: str(), text: str({ maxLength: 300 }), url: str({ format: 'uri' }), picture_url: str({ format: 'uri' }),-- } }, { type: 'object', required: ['ref', 'title', 'url'], properties: {-- ref: { type: 'object', required: ['service', 'type', 'id'], properties: { service: str(), type: str(), id: str() } },-- title: str(), url: str({ format: 'uri' }), origin: { type: 'string', enum: ['user', 'ai', 'system'] },-- occurred_at: str({ format: 'date-time' }), visibility: { type: 'string', enum: ['public', 'followers'] },-- } }] }),-- responses: { 200: json('Already there', { type: 'object', properties: { item: ref('PulseItem'), created: { const: false } } }),-- 201: json('Created', { type: 'object', properties: { item: ref('PulseItem'), created: { const: true } } }), ...errors() },--});--route('delete', '/api/v1/pulse/items/{service}/{type}/{id}', {-- operationId: 'retractPulseItem', summary: 'Retract one of the calling service\'s items', tags: ['pulse'], security: SERVICE,-- parameters: [path('service', str()), path('type', str()), path('id', str())],-- description: 'community.pulse.write for services, community.pulse.contribute for apps.',-- responses: { 200: json('Whether it was there'), ...errors() },++ operationId: 'listPulse', summary: 'The old wall path: the one feed\'s wall view', tags: ['pulse'], security: ANONYMOUS,++ description: 'Kept for clients that still ask /api/v1/pulse; the answer is the same rows and ids as /api/v1/feed/wall (OpenVibe.Network feed.item@1).',++ parameters: [query('after', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 100 })],++ responses: { 200: json('Items and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object' }), next_cursor: nullable('string') } }), ...errors([429]) },});route('get', '/api/v1/feed', {operationId: 'getFeed', summary: 'The menu\'s Feed: one stream from Pulse, newest first', tags: ['feed'], security: ANONYMOUS,@@@ -410,6 +378,18 @@ route('get', '/api/v1/feed/examples', {operationId: 'feedSearchExamples', summary: 'Search examples based on current feed items', tags: ['feed'], security: ANONYMOUS,responses: { 200: json('Suggested searches', { type: 'object', properties: { examples: arrayOf({ type: 'object', properties: { text: str(), reason: str() } }) } }), ...errors([429]) },});++route('get', '/api/v1/feed/wall', {++ operationId: 'feedWall', summary: 'The menu\'s wall: the one feed in the older wall shape', tags: ['feed'], security: ANONYMOUS,++ parameters: [query('cursor', str()), query('limit', { type: 'integer', minimum: 1, maximum: 100 })],++ responses: { 200: json('Items and the next cursor', { type: 'object', properties: { items: arrayOf({ type: 'object' }), next_cursor: nullable('string') } }), ...errors([429]) },++});++// ── /api/v1/activity — Community's public rows for Network's collector ──────────────────────++route('get', '/api/v1/activity', {++ operationId: 'publicActivity', summary: 'Public posts, pastes and accepted submissions, newest first', tags: ['feed'], security: ANONYMOUS,++ description: 'The one feed (OpenVibe.Network server/feed/) polls this; only public rows leave, and nothing is stored.',++ parameters: [query('since', str({ format: 'date-time' })), query('limit', { type: 'integer', minimum: 1, maximum: 1000, default: 500 })],++ responses: { 200: json('Public activity rows', { type: 'object', properties: { items: arrayOf({ type: 'object' }), count: { type: 'integer' } } }), ...errors([429]) },++});route('post', '/api/v1/me/status', {operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),ddeleted file mode 100644@@@ -1,48 +0,0 @@--'use strict';----/**-- * /api/v1/pulse — the network's public activity (service.js has the rules).-- *-- * GET / ?origin=user|ai|system&after=<cursor>&limit= anyone-- * POST /items { ref, title, url, origin?, occurred_at?, visibility? }-- * DELETE /items/:service/:type/:id retract one of the calling service's items-- *-- * Writes need a service token with community.pulse.write; X-OV-Subject names the person an item-- * is about (origin user), X-OV-Origin: ai marks AI output. Errors are problem+json.-- */--const express = require('express');--const contracts = require('openvibe-contracts');--const { run, serviceCap, jsonBody } = require('../http/v1');----const WRITE = 'community.pulse.write';--const CONTRIBUTE = 'community.pulse.contribute';----function createPulseApi({ pulse, viewers, limits }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());-- // Per-actor limits (server/actor-limits.js): the feed takes the defaults. A service posts or retracts-- // an item when something public happens on its site (most arrive through Events instead): one a second.-- router.use(limits.reads('community.pulse.read'));-- const ingest = limits('community.pulse.write', { minute: 60, hour: 1200 });-- const contribute = limits('community.pulse.contribute', { minute: 10, hour: 100 });-- const app = (req) => req.viewer.kind === 'service' && String(req.viewer.service).startsWith('app:');---- const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()-- : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));---- router.get('/', run(async (req) => await pulse.list(req.query, req.viewer)));-- router.post('/items', servicesOnly, (req, res, next) => (app(req) ? serviceCap(CONTRIBUTE) : serviceCap(WRITE))(req, res, next),-- (req, res, next) => (app(req) ? contribute : ingest)(req, res, next), jsonBody,-- run(async (req) => app(req) ? await pulse.contribute(req.viewer, req.body || {}) : await pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));-- router.delete('/items/:service/:type/:id', servicesOnly,-- (req, res, next) => (app(req) ? serviceCap(CONTRIBUTE) : serviceCap(WRITE))(req, res, next),-- (req, res, next) => (app(req) ? contribute : ingest)(req, res, next),-- run(async (req) => app(req) ? await pulse.retractContribution(req.viewer, req.params.service, req.params.type, req.params.id)-- : await pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));---- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----module.exports = { createPulseApi };@@@ -1,101 +1,36 @@'use strict';++/**-- * OpenVibe.Events → Pulse: POST /internal/events, the endpoint of Community's Events subscriptions-- * (consumer `community`, created at boot by startSubscriptions()). Public activity from other services-- * becomes Pulse items (roadmap §29 / §33: provenance kept, AI never shown as a person):-- *-- * live.stream.started "<channel> went live: <title>" → the channel page, actor the streamer-- * mediahub.video.published the public video upload-- * games.world.published the creator's public world-- * blog.post.published "New post on the <blog> blog" → the post (public, indexable only)-- * wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)-- * news.story.published "News: <topic>" → the story (public, indexable only)-- *-- * And game progress (roadmap WS-M task 2, PF7): network.module.updated (source network) for a person's-- * games.progress.summary record, whose `level` Contracts declares public. game_progress keeps the last level-- * seen per person; a later record that crosses a multiple of GAME_MILESTONE becomes one Pulse item,-- * "Reached level <n> in Scraplandia" (Games · level), with the person as its actor. The first record-- * seen for a person is only a baseline (no stale "reached" item for a level reached long ago); levels-- * that go down or stay put announce nothing; only the public fields are read.-- *-- * And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token-- * cutoff (openvibe-sdk createPgRevocationStore); the viewer resolver refuses their older tokens.++ * Community's remaining Events consumer: the deliveries that are NOT feed activity.*-- * And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only-- * answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.++ * The one feed moved to OpenVibe.Network (server/feed/ there) on 2026-11-12: Network reads Events and++ * the sites' public APIs itself, and Community's Pulse reads Network's feed (server/feed/proxy.js).++ * What is left here is Community's own state, which must not wait for a page read:*-- * And platform blocks (WS-E task 5): network.block.changed (source network) updates the network_blocks-- * projection (../identity/blocks.js; the newest revision per pair wins) that replies and comments honour.++ * network.user.token_valid_after the person's token cutoff (openvibe-sdk createPgRevocationStore)++ * network.block.changed the network_blocks projection replies and comments honour++ * vip.membership.changed drop the member's cached members-only answers at once++ * network.module.updated games.progress.summary → game_progress (account export/deletion)++ * network.account.export_requested the account export contribution (ADR-033)++ * network.account.deleted the deletion sweep (ADR-033)*-- * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the Pulse-- * write. Signature v2 only (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for-- * rotation, 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header-- * came through nginx and is refused.++ * The feed topics (live.stream.started, mediahub.*, blog.*, wiki.*, news.*, deals.*, space.*, chat.*)++ * are no longer subscribed: nothing here builds a Pulse item. Exactly once: the openvibe-sdk inbox++ * claims (consumer, event_id) in the same transaction as the write. Signature v2 only++ * (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for rotation, 32+ characters++ * each); unset = 503. Loopback only: a request carrying a forwarding header came through nginx and is++ * refused.*/const express = require('express');const { http, serviceAuth } = require('openvibe-contracts');const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');--const store = require('./store');const blocks = require('../identity/blocks');const CONSUMER = 'community';--const TOPICS = Object.freeze(['live.stream.started', 'mediahub.video.published', 'games.world.published', 'blog.post.published', 'wiki.page.published', 'wiki.page.updated', 'chat.room.message.created', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.user.updated', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);++const TOPICS = Object.freeze(['vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);const GAME_NAMESPACE = 'games.progress.summary';--const GAME_MILESTONE = 5;--const GAME_URL = 'https://openvibe.games/';const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;--const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);--const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);const subjectOf = (ref) => (ref && ref.type === 'user' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(ref.id) ? ref.id : null);--const publicIndexable = (p) => p && p.visibility === 'public' && (!p.indexability || p.indexability.decision !== 'noindex');----/** An envelope → a Pulse item, or an 'ignored:*' reason. */--function itemFor(event) {-- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};-- const at = event.occurred_at || event.timestamp || new Date().toISOString();-- const subjectId = String((event.subject && event.subject.id) || '');-- if (event.event_type === 'live.stream.started') {-- if (event.visibility !== 'public' || event.source !== 'live') return 'ignored:not_public';-- const ch = p.channel || {};-- const url = httpsUrl(ch.url);-- if (!url || !subjectId) return 'ignored:payload';-- const name = clean(ch.display_name || ch.username, 60);-- const title = clean(p.title, 120);-- return { source_service: 'live', source_type: 'stream', source_id: subjectId, title: title ? `${name} went live: ${title}` : `${name} went live`, url, actor_subject: subjectOf(ch.subject), origin: 'user', occurred_at: p.started_at || at };-- }-- const kinds = { 'mediahub.video.published': ['media-hub', 'video'], 'games.world.published': ['games', 'world'], 'blog.post.published': ['blog', 'post'], 'wiki.page.published': ['wiki', 'page'], 'wiki.page.updated': ['wiki', 'page'], 'news.story.published': ['news', 'story'] };-- const k = kinds[event.event_type];-- if (!k) return 'ignored:type';-- if (event.source !== k[0]) return 'ignored:source';-- if (event.event_type === 'mediahub.video.published' || event.event_type === 'games.world.published') {-- if (event.visibility !== 'public') return 'ignored:not_public';-- } else if (!publicIndexable(p)) return 'ignored:not_public';-- const url = httpsUrl(p.canonical_url || p.url);-- if (!url || !subjectId) return 'ignored:payload';-- // blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.-- const blogName = clean(p.blog && typeof p.blog === 'object' ? p.blog.handle : p.blog, 60);-- const title = k[0] === 'media-hub' || k[0] === 'games' ? clean(p.title, 160)-- : k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog')-- // A wiki page by its title when the event carries one (contracts 0.366.0), else by where it lives.-- : k[0] === 'wiki' ? (clean(p.title, 160) || `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`)-- : `News: ${clean(p.topic, 60) || 'a new story'}`;-- const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;-- // An edited page moves to the top: the feed says "new and updated Wiki articles", not only first publications.-- return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at,-- ...(event.event_type === 'wiki.page.updated' ? { touch: true } : {}) };--}----/** A public chat room message → { slug, name, at } or an 'ignored:*' reason. */--function chatMessageOf(event) {-- if (event.source !== 'chat') return 'ignored:source';-- if (event.visibility !== 'public') return 'ignored:not_public';-- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};-- const slug = clean(p.room && p.room.slug, 64);-- if (!slug) return 'ignored:payload';-- const at = Date.parse(String(p.created_at || event.occurred_at || event.timestamp || ''));-- if (!Number.isFinite(at)) return 'ignored:payload';-- return { slug, name: clean(p.room && p.room.name, 80), at };--}/** network.module.updated → { subject, level, at } for a games.progress.summary write, or an 'ignored:*' reason. */function gameProgressOf(event) {@@@ -109,68 +44,17 @@ function gameProgressOf(event) {return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };}--/** Network's profile event carries a changed list; only an avatar edit can make an avatar card. */--async function applyAvatar(db, event) {-- if (event.source !== 'network') return 'ignored:source';-- const p = event.payload || {};-- const who = subjectOf(p.subject);-- const revision = Number(p.revision);-- if (!who || !Number.isSafeInteger(revision) || revision < 1) return 'ignored:payload';-- const old = await db.prepare('SELECT revision FROM pulse_profile_revision WHERE subject = ?').get(who);-- if (old && Number(old.revision) >= revision) return 'ignored:old_revision';-- await db.prepare(`INSERT INTO pulse_profile_revision (subject, revision) VALUES (?, ?)-- ON CONFLICT(subject) DO UPDATE SET revision = excluded.revision`).run(who, revision);-- if (p.banned) { await store.removeItem(db, 'network', 'avatar', who); return 'avatar:removed'; }-- if (!Array.isArray(p.changed) || !p.changed.includes('avatar_url')) return 'profile:updated';-- await store.removeItem(db, 'network', 'avatar', who);-- if (p.banned || !p.avatar_url) return 'avatar:removed';-- const avatar = httpsUrl(p.avatar_url);-- const username = clean(p.username, 64);-- if (!avatar || !/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) return 'ignored:payload';-- await store.upsertItem(db, { source_service: 'network', source_type: 'avatar', source_id: who,-- title: `${clean(p.display_name || username, 80)} uploaded a new avatar`,-- url: `https://openvibe.network/@${encodeURIComponent(username)}`, picture_url: avatar,-- actor_subject: who, origin: 'user', occurred_at: event.occurred_at || event.timestamp || new Date().toISOString() });-- return 'pulse:created';--}----/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */++/** Record the level; no feed card is made here (Network's feed reads the event itself). */async function applyGameProgress(db, g) {-- const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);await db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)ON CONFLICT(subject_id) DO UPDATE SET level = GREATEST(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);-- if (!prev) return 'games:baseline';-- const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;-- if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';-- const r = await store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,-- url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });-- return r.created ? 'pulse:created' : 'pulse:updated';++ return 'games:recorded';}function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);-- // Receipts (community_event_inbox) are in migrations/0001_initial.sql.const inbox = createPgInbox(db, { table: 'community_event_inbox', now });const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };-- // A public room's messages are counted per UTC hour; at five the hour becomes one Chat card, refreshed every-- // five messages after that. This is in memory on purpose: a restart only starts counting the current hour again.-- const chatHours = new Map();-- const CHAT_BUSY_MESSAGES = 5;-- async function applyChatMessage(c) {-- const hour = new Date(c.at).toISOString().slice(0, 13);-- const key = `${c.slug}:${hour}`;-- const n = (chatHours.get(key) || 0) + 1;-- chatHours.set(key, n);-- if (chatHours.size > 5000) chatHours.delete(chatHours.keys().next().value);-- if (n < CHAT_BUSY_MESSAGES || (n !== CHAT_BUSY_MESSAGES && n % 5 !== 0)) return 'chat:counted';-- const out = await store.upsertItem(db, {-- source_service: 'chat', source_type: 'hour', source_id: key,-- title: `#${c.slug}: ${n} messages this hour`,-- url: `https://openvibe.chat/rooms/${encodeURIComponent(c.slug)}`,-- actor_subject: null, origin: 'system', occurred_at: `${hour}:00:00Z`, text: '',-- });-- return out.created ? 'pulse:created' : 'pulse:updated';-- }const router = express.Router();router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {const ctx = http.requestContext(req.headers);@@@ -199,24 +83,22 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);return problem(500, 'community.event_failed', 'processing failed; it will be retried');}}if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {-- // Account export and deletion (ADR-033, ../identity/account-data.js): once per export or deletion by its own record,-- // answered after Network took the part or confirmation, so a failure is redelivered without erasing twice.if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }return require('../identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {stats.applied++;res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });}, (err) => {stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);problem(500, 'community.event_failed', 'processing failed; it will be retried');});}-- if (event.event_type === 'vip.membership.changed') {++ if (event.event_type === 'vip.membership.changed') {if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));if (r.duplicate) stats.duplicates++; else stats.applied++;@@@ -231,47 +113,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });} catch (err) {stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(500, 'community.event_failed', 'processing failed; it will be retried');-- }-- }-- if (event.event_type === 'network.user.updated') {-- try {-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyAvatar(db, event) }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- } catch (err) {-- stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(500, 'community.event_failed', 'processing failed; it will be retried');-- }-- }-- if (event.event_type === 'chat.room.message.created') {-- const c = chatMessageOf(event);-- if (typeof c === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: c }); }-- try {-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyChatMessage(c) }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- } catch (err) {-- stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);++ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);return problem(500, 'community.event_failed', 'processing failed; it will be retried');}}-- const item = itemFor(event);-- if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }-- try {-- // A reconnect is a new Live session and a new event; the stream itself is one broadcast.-- const put = item.source_service === 'live' && item.source_type === 'stream' ? store.upsertLiveItem : store.upsertItem;-- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: (await put(db, item)).created ? 'pulse:created' : 'pulse:updated' }));-- if (r.duplicate) stats.duplicates++; else stats.applied++;-- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });-- } catch (err) {-- stats.failed++;-- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);-- return problem(500, 'community.event_failed', 'processing failed; it will be retried');-- }++ stats.ignored++;++ return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:type' });});return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };}@@@ -295,7 +142,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVENif (mine.some((s) => s.topic_pattern === topic)) continue;const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);-- if (r.ok) log.log(`[Pulse consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);}};const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];@@@ -304,11 +151,10 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVENlet stopped = false;const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {if (stopped) return;-- if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Pulse consumer] subscriptions not created:', err.message);++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);}); };timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();-- // Graceful stop: no further attempts (they run again at the next start).return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };}--module.exports = { createPulseConsumer, startSubscriptions, itemFor, chatMessageOf, gameProgressOf, TOPICS, GAME_MILESTONE };++module.exports = { createPulseConsumer, startSubscriptions, gameProgressOf, TOPICS };ddeleted file mode 100644@@@ -1,418 +0,0 @@--'use strict';----/**-- * GET /api/v1/feed — the menu's Feed on every OpenVibe site (openvibe-shared ov-home.js feed(): "a real feed… you scroll-- * through easy content… a filter system", the owner, 11-09). One stream, newest first, built from Pulse (every public-- * thing that happens on the network: posts, pastes, statuses, threads, a stream going live, a clip, a video, a picture,-- * a meme, a game, a wiki page, a quest badge, a busy chat hour, a calendar event, the people who joined, an update…)-- * with the network's trending pictures and videos (Network's Frame cards) woven into the first page.-- *-- * GET /?kind=live|stream|clip|video|pic|post|meme|game|update|wiki|quest|chat|event|member-- * &site=<source service>&q=<title or text>&following=1&cursor=<opaque>&limit=-- * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"; a Rundown episode is "rundown"),-- * url, title, text, at, site, site_name,-- * actor: { name, avatar } | { name: 'The Rundown', mark: 'rundown', avatar: null } | null,-- * subject?: { name, handle, avatar } (the streamer a Rundown episode is about),-- * image?: string (the episode's own picture; media.thumb carries it too),-- * media: { thumb, ratio, length } | null, duration_s?: number (a recording's length; never a bare-- * media.length badge), counts: { views?, likes?, comments? }, live_now? }], next_cursor }-- *-- * Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.-- * following=1 is the signed-in person's Network follows; q searches the card's title and text; counts are Community's-- * own views, likes and comments where it knows them, and {} elsewhere. A live card carries live_now: whether that channel-- * is on Live's live list now. Never one service call per request: the other-- * sites' activity is already in Pulse (server/pulse/sources.js and server/pulse/consumer.js).-- */--const express = require('express');--const contracts = require('openvibe-contracts');--const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');----const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode', 'rundown'];--const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };--const MAX_PAGE_READS = 8;--const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };--const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);----/** What a Pulse item is, as a card. */--function kindOf(i) {-- const s = String((i.source && i.source.service) || '');-- const t = String((i.source && i.source.type) || '');-- if (/^(live|stream|went_live|broadcast)$/.test(t) || (s === 'live' && /\b(went live|is live|live now)\b/i.test(i.title || ''))) return 'live';-- if (t === 'avatar') return 'avatar';-- if (t === 'banner') return 'banner';-- if (t === 'follow') return 'follow';-- if (t === 'site') return 'site';-- if (t === 'space') return 'space';-- if (t === 'paste') return 'paste';-- if (t === 'episode') return 'rundown';-- if (t === 'clip' || t === 'moment') return 'clip';-- if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';-- if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';-- if (s === 'meme' || t === 'meme') return 'meme';-- if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';-- if (s === 'wiki' || /^(page|revision)$/.test(t)) return 'wiki';-- if (s === 'quest' || /^(badge|completion|quest)$/.test(t)) return 'quest';-- if (s === 'chat' || /^(hour|message|room)$/.test(t)) return 'chat';-- if (s === 'today' || /^(event|calendar)$/.test(t)) return 'event';-- if (s === 'network' || /^(members|member|join)$/.test(t)) return 'member';-- if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';-- return 'post';--}----function card(i) {-- const site = String((i.source && i.source.service) || 'community');-- const a = (i.actor && (i.actor.display_name || i.actor.username)) ? i.actor : (i.actor_name ? { display_name: i.actor_name } : null);-- const kind = kindOf(i);-- // A Rundown episode is its own thing: the desk is the actor (never "System"), the streamer is the subject the-- // card is about, and the episode's own AI picture is the art (the stream thumbnail when there is none). A-- // recording carries its length as duration_s, never as a bare media.length badge.-- const rundown = kind === 'rundown';-- const subject = i.subject && typeof i.subject === 'object' && !Array.isArray(i.subject)-- ? { name: clean(i.subject.name, 80), handle: clean(i.subject.handle, 80) || null, avatar: https(i.subject.avatar) } : null;-- const duration = Number(i.media_length) > 0 ? Number(i.media_length) : null;-- return {-- id: `p:${i.id}`,-- kind,-- url: https(i.url) || i.url,-- title: clean(i.title, 300),-- text: clean(i.text, 280),-- at: isoTime(i.occurred_at) || i.occurred_at || null,-- ...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),-- site,-- site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),-- actor: rundown ? { name: 'The Rundown', mark: 'rundown', avatar: null } : (a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null),-- ...(subject ? { subject } : {}),-- ...(rundown && i.picture_url && https(i.picture_url) ? { image: https(i.picture_url) } : {}),-- ...(duration && !rundown && kind === 'video' ? { duration_s: duration } : {}),-- media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(duration && kind !== 'video' ? { length: duration } : {}) } : null,-- counts: {},-- ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),-- ...(!rundown && i.label ? { label: i.label } : {}),-- };--}----const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');--const typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');--const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');--// A person's run of one kind on one site is one card ("Maticus added 7 videos"). A card joins a run when it is within--// two hours of any card already in it, so a morning of uploads twenty minutes apart, or an evening of stream restarts,--// reads as one card (ask 360: the feed folds its duplicates). At 15 minutes from the run's first card, 11-11's feed--// showed one streamer's evening as nine rows.--const BURST_MS = 2 * 60 * 60_000;--const BURST_WORD = { pic: 'photos', clip: 'clips', video: 'videos', game: 'achievements', quest: 'quests', avatar: 'profile updates' };--/** A card's title for "already on the page": a stream's recording and the stream are one title. */--const titleKey = (c) => String(c.title || '').replace(/^recording:\s*/i, '').trim().toLowerCase();--function foldBursts(pairs) {-- const groups = [];-- for (const p of pairs) {-- const actor = p.row.actor_subject || (p.row.actor && p.row.actor.subject) || (p.row.actor_name && `name:${p.row.actor_name}`);-- const at = Date.parse(p.card.at || '');-- const prior = actor && Number.isFinite(at) && BURST_WORD[p.card.kind] && groups.find((g) =>-- g.actor === actor && g.card.kind === p.card.kind && g.site === p.card.site && g.ats.some((t) => Math.abs(t - at) <= BURST_MS));-- if (prior) { prior.cards.push(p.card); prior.ats.push(at); continue; }-- groups.push({ actor, ats: Number.isFinite(at) ? [at] : [], site: p.card.site, card: p.card, cards: [p.card] });-- }-- return groups.map((g) => {-- if (g.cards.length < 2) return g.card;-- const name = g.card.actor && g.card.actor.name || 'Someone';-- return { ...g.card, title: `${name} added ${g.cards.length} ${BURST_WORD[g.card.kind]}`,-- group: { count: g.cards.length, items: g.cards.map((c) => ({ id: c.id, url: c.url, title: c.title, media: c.media })) } };-- });--}----/**-- * Is a page full? Counted after folding: twelve rows that fold into three cards are three cards, and 11-12's /feed-- * showed three cards and "Older" (a streamer's ten recordings were one). Raw rows stop at four times the page, so a-- * long run cannot keep a request reading.-- */--function filled(pairs, want) {-- if (pairs.length < want) return false;-- return pairs.length >= want * 4 || foldBursts(pairs).length >= want;--}----/** Community's own views, likes and comments, in a few batched reads (never one query per card). */--async function countsFor(db, rows) {-- const out = new Map();-- const idsOf = (type) => rows.filter((r) => svcOf(r) === 'community' && typeOf(r) === type).map((r) => idOf(r));-- const key = (type, id) => `${type}:${id}`;-- const n = (v) => Math.max(0, Number(v) || 0);---- const pastes = [...new Set(idsOf('paste'))];-- if (pastes.length) {-- const ph = pastes.map(() => '?').join(', ');-- const [prows, crows] = await Promise.all([-- db.prepare(`SELECT slug, views, likes FROM pastes WHERE slug IN (${ph})`).all(...pastes),-- db.prepare(`SELECT ref_id, comment_count FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste' AND ref_id IN (${ph})`).all(...pastes),-- ]);-- const comments = new Map(crows.map((r) => [String(r.ref_id), n(r.comment_count)]));-- for (const r of prows) out.set(key('paste', r.slug), { views: n(r.views), likes: n(r.likes), comments: comments.get(String(r.slug)) || 0 });-- }---- const threadIds = [...new Set([...idsOf('thread'), ...idsOf('status')].filter((x) => /^\d{1,15}$/.test(x)).map(Number))];-- if (threadIds.length) {-- const ph = threadIds.map(() => '?').join(', ');-- const [trows, rrows] = await Promise.all([-- db.prepare(`SELECT id, views, reply_count, score FROM threads WHERE id IN (${ph})`).all(...threadIds),-- db.prepare(`SELECT p.thread_id, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id-- WHERE p.is_opening = 1 AND p.thread_id IN (${ph}) GROUP BY p.thread_id`).all(...threadIds),-- ]);-- const reactions = new Map(rrows.map((r) => [Number(r.thread_id), n(r.n)]));-- for (const r of trows) {-- out.set(key('thread', r.id), { views: n(r.views), likes: n(r.score), comments: n(r.reply_count) });-- out.set(key('status', r.id), { views: n(r.views), likes: reactions.get(Number(r.id)) || 0, comments: n(r.reply_count) });-- }-- }---- const posts = idsOf('post');-- if (posts.length) {-- const ph = posts.map(() => '?').join(', ');-- const [rrows, crows] = await Promise.all([-- db.prepare(`SELECT post_id, COUNT(*) AS n FROM post_reactions WHERE post_id IN (${ph}) GROUP BY post_id`).all(...posts),-- db.prepare(`SELECT ref_id, comment_count FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'post' AND ref_id IN (${ph})`).all(...posts),-- ]);-- const likes = new Map(rrows.map((r) => [String(r.post_id), n(r.n)]));-- const comments = new Map(crows.map((r) => [String(r.ref_id), n(r.comment_count)]));-- for (const id of posts) out.set(key('post', id), { likes: likes.get(id) || 0, comments: comments.get(id) || 0 });-- }-- return out;--}----/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */--function createTrending({ networkUrl, fetchImpl }) {-- let cache = null; let at = 0;-- const SITE_KIND = { 'media-hub': 'video', video: 'video', live: 'live', pics: 'pic', meme: 'meme', games: 'game' };-- return async function trending() {-- if (cache && Date.now() - at < 5 * 60_000) return cache;-- try {-- const r = await fetchImpl(`${networkUrl}/api/frame?v=2`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });-- const f = r.ok ? await r.json() : null;-- const out = [];-- for (const c of (f && Array.isArray(f.cards) ? f.cards : [])) {-- const kind = SITE_KIND[c && c.site];-- if (!kind || !Array.isArray(c.items)) continue;-- for (const x of c.items.slice(0, 4)) {-- const url = https(x && x.url); const thumb = https(x && x.image && x.image.src);-- if (!url || !thumb || !x.title) continue;-- out.push({ id: `t:${c.site}:${url}`, kind, url, title: clean(x.title, 200), text: clean(x.meta, 160), at: null, site: c.site,-- site_name: SITE_NAMES[c.site] || c.site, actor: null, media: { thumb, ratio: 16 / 9 }, counts: {}, label: 'Trending' });-- }-- }-- cache = out; at = Date.now();-- } catch { cache = cache || []; }-- return cache;-- };--}----/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */--function createLiveNow({ liveUrl, fetchImpl }) {-- let cache = new Map(); let at = 0;-- return async function liveNow() {-- if (Date.now() - at < 60_000) return cache;-- at = Date.now();-- try {-- const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });-- const j = r.ok ? await r.json() : null;-- const byChannel = new Map();-- for (const x of ((j && j.streams) || [])) {-- const name = String((x && x.username) || '').toLowerCase();-- const raw = String((x && x.started_at) || '');-- const stamp = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw;-- if (!name) continue;-- if (!byChannel.has(name)) byChannel.set(name, []);-- byChannel.get(name).push(Date.parse(stamp));-- }-- cache = byChannel;-- } catch { /* keep the last answer */ }-- return cache;-- };--}--const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };--const isCurrentLive = (card, live) => {-- const name = channelOf(card.url).toLowerCase();-- if (!live.has(name)) return false;-- const recorded = Date.parse(card.at || '');-- return live.get(name).some((started) => !Number.isFinite(started) || !Number.isFinite(recorded)-- || Math.abs(started - recorded) <= 10 * 60_000);--};----const DAY_MS = 24 * 60 * 60_000;--const engagement = (card) => {-- const counts = card.counts || {};-- return Math.log1p(Math.max(0, Number(counts.likes) || 0)-- + 2 * Math.max(0, Number(counts.comments) || 0)-- + 0.2 * Math.max(0, Number(counts.views) || 0)) * 2;--};----/** Rank the current page's Pulse candidates: on-air first, then recent activity, engagement and followed creators. */--function compareRankedCards(a, b, followedIds = new Set(), now = Date.now()) {-- const liveOrder = Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true);-- if (liveOrder) return liveOrder;-- const ageDays = (card) => {-- const at = Date.parse(card.at || '');-- return Number.isFinite(at) ? Math.max(0, (now - at) / DAY_MS) : Infinity;-- };-- const aAge = ageDays(a), bAge = ageDays(b);-- // Old items remain available, but cannot lead ahead of activity from the last seven days.-- const staleOrder = Number(aAge > 7) - Number(bAge > 7);-- if (staleOrder) return staleOrder;-- const score = (card, age) => (Number.isFinite(age) ? Math.max(0, 7 - age) : 0)-- + engagement(card) + (followedIds.has(card.id) ? 4 : 0);-- const scoreOrder = score(b, bAge) - score(a, aAge);-- if (scoreOrder) return scoreOrder;-- const timeOrder = (Date.parse(b.at || '') || 0) - (Date.parse(a.at || '') || 0);-- return timeOrder || String(a.id).localeCompare(String(b.id));--}----function rankCards(cards, followedIds = new Set(), now = Date.now()) {-- return cards.slice().sort((a, b) => compareRankedCards(a, b, followedIds, now));--}----function searchMatches(item, query) {-- const q = clean(query, 80).toLowerCase();-- if (!q) return true;-- const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode', rundown: 'rundown', rundowns: 'rundown' };-- const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);-- const kind = kindWord && aliases[kindWord];-- if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;-- const day = String(item.at || '').slice(0, 10);-- if (/\btoday\b/.test(q) && day !== new Date().toISOString().slice(0, 10)) return false;-- if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;-- const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);-- if (date && day !== date[0]) return false;-- const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|rundowns?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();-- return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name]-- .some((v) => String(v || '').toLowerCase().includes(words));--}----function searchExamples(cards) {-- const examples = [];-- const add = (text, reason) => { if (!examples.some((x) => x.text === text)) examples.push({ text, reason }); };-- for (const c of cards) if (c.actor && c.actor.name) add(c.actor.name, 'person');-- for (const kind of [...new Set(cards.map((c) => c.kind))]) add(`new ${kind === 'pic' ? 'photos' : `${kind}s`}`, 'kind');-- if (cards.some((c) => c.kind === 'clip' && String(c.at || '').slice(0, 10) === new Date().toISOString().slice(0, 10))) add('clips from today', 'date and kind');-- return examples.slice(0, 8);--}----function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {-- const router = express.Router();-- router.use(contracts.http.middleware());-- router.use(viewers.middleware());-- router.use(limits.reads('community.feed.read'));-- const trending = createTrending({ networkUrl, fetchImpl });-- const liveNow = createLiveNow({ liveUrl, fetchImpl });-- // A "went live" item is live now only while that channel is on Live's live list.-- const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') { c.live_now = isCurrentLive(c, live); if (!c.live_now) c.kind = 'stream'; } });---- router.get('/', run(async (req) => {-- const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';-- const site = clean(req.query.site, 40).toLowerCase();-- const q = clean(req.query.q, 80).toLowerCase();-- const following = /^(1|true|yes|on)$/i.test(String(req.query.following || ''));-- const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);-- if (following && (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.token)) fail(401, 'feed.sign_in_required', 'Sign in to see the people you follow');-- const followedSubjects = req.viewer && req.viewer.kind === 'user' && req.viewer.token-- ? new Set(await pulse.followedFor(req.viewer)) : new Set();---- const live = await liveNow();-- // Copy cached trending cards before marking them: a channel can go live again during the cache TTL.-- const extras = !req.query.cursor && !q && !following-- ? (await trending()).map((c) => ({ ...c })) : [];-- mark(extras, live);-- const t = extras.filter((c) => (!kind || c.kind === kind) && (!site || c.site === site));-- // Reserve at most one in four slots for trending, keeping the requested limit for the whole response.-- const pulseWant = want - Math.min(t.length, Math.floor(want / 4));-- const picked = [];-- let cursor = req.query.cursor ? String(req.query.cursor) : '';-- let next = null;-- // A kind, site or word filters Pulse's pages until the page is full (at most eight pages read per call).-- for (let pages = 0; pages < MAX_PAGE_READS && !filled(picked, pulseWant); pages++) {-- const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);-- const rows = page.items || [];-- next = page.next_cursor || null;-- for (let index = 0; index < rows.length; index++) {-- const i = rows[index];-- const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;-- if (following && !(actorSubject && followedSubjects.has(actorSubject))) continue;-- const c = card(i);-- mark([c], live);-- if (kind && c.kind !== kind) continue;-- if (site && c.site !== site) continue;-- if (q && !(c.title.toLowerCase().includes(q) || c.text.toLowerCase().includes(q)-- || String(c.actor && c.actor.name || '').toLowerCase().includes(q)-- || c.site_name.toLowerCase().includes(q) || c.kind.includes(q))) continue;-- picked.push({ row: i, card: c });-- if (filled(picked, pulseWant)) {-- // Resume after the last consumed row, including when Pulse's final page was only partly read.-- if (index < rows.length - 1) next = encodeCursor([sqlTime(i.occurred_at), i.id]);-- break;-- }-- }-- if (!next) break;-- cursor = next;-- }-- const counts = await countsFor(db, picked.map((p) => p.row));-- for (const p of picked) p.card.counts = counts.get(`${typeOf(p.row)}:${idOf(p.row)}`) || {};-- const followedIds = new Set(picked.filter((p) => followedSubjects.has(p.row.actor_subject || (p.row.actor && p.row.actor.subject)))-- .map((p) => p.card.id));-- picked.splice(0, picked.length, ...rankCards(picked.map((p) => p.card), followedIds).map((c) => picked.find((p) => p.card.id === c.id)));---- // The first page carries trending pictures and videos, one after every three Pulse items.-- // A trending card is skipped when the page already shows its link or its title, a folded run's own cards-- // included: three "iZoR_One's Stream" cards beside his folded recordings said the same thing four times.-- const folded = foldBursts(picked);-- const shown = new Set(), titles = new Set();-- const note = (c) => {-- for (const x of [c, ...(c.group ? c.group.items : [])]) { shown.add(x.url); titles.add(titleKey(x)); }-- };-- folded.forEach(note);-- const fresh = (c) => !shown.has(c.url) && !titles.has(titleKey(c));-- const items = [];-- let k = 0;-- folded.forEach((c, n) => {-- items.push(c);-- while ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) {-- const extra = t[k++];-- if (fresh(extra)) { items.push(extra); note(extra); break; }-- }-- });-- while (items.length < want && k < t.length) { const c = t[k++]; if (fresh(c)) { items.push(c); note(c); } }-- // Frame cards keep their existing placement, while an actual on-air channel always leads the response.-- items.sort((a, b) => Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true));-- return { items, next_cursor: next };-- }));-- router.get('/search', run(async (req) => {-- const q = clean(req.query.q, 80).toLowerCase();-- if (!q) fail(400, 'feed.search_required', 'Enter a search term');-- const live = await liveNow();-- const matched = [];-- let cursor = clean(req.query.cursor, 500), next = null;-- const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);-- for (let pages = 0; pages < MAX_PAGE_READS && matched.length < limit; pages++) {-- const page = await pulse.list({ after: cursor || undefined, limit: 100 }, req.viewer);-- next = page.next_cursor || null;-- const rows = page.items || [];-- for (let index = 0; index < rows.length; index++) {-- const item = card(rows[index]);-- mark([item], live);-- if (!searchMatches(item, q)) continue;-- matched.push({ row: rows[index], card: item });-- if (matched.length >= limit) {-- if (index < rows.length - 1) next = encodeCursor([sqlTime(rows[index].occurred_at), rows[index].id]);-- break;-- }-- }-- if (!next) break;-- cursor = next;-- }-- return { items: foldBursts(matched), next_cursor: next };-- }));-- router.get('/examples', run(async (req) => {-- const page = await pulse.list({ limit: 100 }, req.viewer);-- const cards = (page.items || []).map(card);-- return { examples: searchExamples(cards) };-- }));-- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));-- return router;--}----module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, filled, engagement, compareRankedCards, rankCards };ddeleted file mode 100644@@@ -1,298 +0,0 @@--'use strict';----/**-- * Pulse — a read model of public activity across the network, with provenance.-- *-- * Sources:-- * - Community's own public activity, recorded at write time (the hooks below): new public-- * pastes written by a person (not burn-after-read, not NSFW), new threads and replies in-- * public spaces, and submissions when they are accepted. Deletes and visibility changes take items out again, and listItems()-- * re-checks Community's own items at read time as well.-- * - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef-- * of their own (ref.service must be the calling service), title, url, origin, occurred_at.-- * Only visibility 'public' is accepted — private or unlisted things never enter Pulse.-- *-- * Provenance (roadmap §33): origin is user | ai | system. AI items are labelled as AI and never-- * carry an actor; system items have none either; user items name the acting person (the JWT-- * subject for Community's own writes, X-OV-Subject for services).-- */--const contracts = require('openvibe-contracts');--const store = require('./store');--const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/v1');--const { createAuthors } = require('../identity/authors');----const ORIGINS = ['user', 'ai', 'system'];--const parseJson = (v, d) => { try { const o = JSON.parse(v); return o == null ? d : o; } catch { return d; } };--const PAGE = 30;--const FUTURE_SKEW_MS = 5 * 60_000;----/** 'svc:live' → 'live' (the service a client-credentials token belongs to). */--const serviceNameOf = (sub) => String(sub || '').replace(/^(svc|app|mod):/, '');----function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.fetch } = {}) {-- const authors = createAuthors({ db, network });-- const base = (config.baseUrl || '').replace(/\/$/, '');-- let originsCache = { at: 0, list: null };-- // The probe and test accounts, from the identity authority (GET /internal/identity/test-subjects). Cached a-- // little; the projection cache also carries the flag, and list() filters on both so a new probe cannot stay.-- let testCache = { at: 0, ids: [] };-- async function testSubjectIds() {-- if (testCache.at && Date.now() - testCache.at < 10 * 60_000) return testCache.ids;-- if (!network || typeof network.testSubjects !== 'function') { testCache = { at: Date.now(), ids: [] }; return testCache.ids; }-- try {-- const list = await network.testSubjects();-- testCache = { at: Date.now(), ids: list.map((p) => p && p.subject && p.subject.id).filter(Boolean) };-- } catch (err) {-- console.warn('[Pulse] test-account list failed:', err.message);-- testCache = { at: Date.now(), ids: testCache.ids };-- }-- return testCache.ids;-- }-- const followsCache = new Map();-- const appWrites = new Map();---- async function followedSubjects(v) {-- const cached = followsCache.get(v.subject);-- if (cached && Date.now() - cached.at < 30_000) return cached.ids;-- const ids = [];-- let cursor = null;-- do {-- const url = new URL('/api/v1/me/follows', config.networkInternalUrl);-- url.searchParams.set('type', 'channel');-- url.searchParams.set('limit', '200');-- if (cursor) url.searchParams.set('cursor', cursor);-- const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${v.token}` }, signal: AbortSignal.timeout(5000) });-- if (!res.ok) fail(503, 'pulse.follows_unavailable', 'Could not load your follows');-- const page = await res.json();-- ids.push(...(page.items || []).map((x) => x.target_id));-- cursor = page.next_cursor;-- } while (cursor && ids.length < 5000);-- if (followsCache.size > 1000) followsCache.delete(followsCache.keys().next().value);-- followsCache.set(v.subject, { at: Date.now(), ids });-- return ids;-- }---- /** The feed's short text: the first 280 characters, one line, never a whole post. */-- const feedText = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 280);-- const picture = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };---- /** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */-- async function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null, text = '', pictureUrl = null }) {-- try {-- const o = ORIGINS.includes(origin) ? origin : 'user';-- await store.upsertItem(db, {-- source_service: 'community', source_type: type, source_id: String(id),-- title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,-- actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),-- text: feedText(text), picture_url: picture(pictureUrl),-- });-- } catch (err) { console.warn('[Pulse] record failed:', err.message); }-- }-- async function forgetLocal(type, id) {-- try { await store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }-- }---- function shape(i, projections) {-- const ai = i.origin === 'ai';-- const actor = ai || i.origin === 'system' ? null : authors.author(i.actor_subject, 'user', projections);-- return {-- id: i.id,-- source: { service: i.source_service, type: i.source_type, id: i.source_id },-- title: i.title,-- url: i.url,-- origin: i.origin,-- ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),-- label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),-- ...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),-- ...(i.picture_url ? { picture_url: i.picture_url } : {}),-- ...(i.preview_url ? { preview_url: i.preview_url } : {}),-- ...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),-- ...(i.media_length ? { media_length: i.media_length } : {}),-- ...(i.media_length ? { duration_s: Number(i.media_length) } : {}),-- ...(i.subject_json ? { subject: parseJson(i.subject_json, null) } : {}),-- ...(i.text ? { text: i.text } : {}),-- ...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),-- ...(i.actor_name ? { actor_name: i.actor_name } : {}),-- actor,-- occurred_at: isoTime(i.occurred_at),-- };-- }---- return {-- ORIGINS,-- forgetLocal,-- /** The channels the signed-in person follows (Network), for the feed's following filter. */-- async followedFor(v) {-- if (!v || v.kind !== 'user' || !v.token) return [];-- return await followedSubjects(v);-- },-- async statusCreated(thread, text, actor, pictureUrl = null) {-- await recordLocal('status', thread.id, { title: text.slice(0, 300), path: `/forum/status/${thread.slug}`,-- actor, origin: 'user', at: thread.created_at, text, pictureUrl });-- },---- /** The test-account subject ids, so the Pulse sources skip them too. */-- testSubjectIds,---- // ── hooks for Community's own writes ─────────────────-- async pasteCreated(p) {-- if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;-- await recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at,-- text: p.content, pictureUrl: p.screenshot_url });-- },-- async pasteChanged(p) {-- if (!p) return;-- if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);-- },-- async pasteGone(slug) { await forgetLocal('paste', slug); },-- async threadCreated(thread, space, extra = {}) {-- if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;-- await recordLocal('thread', thread.id, {-- title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,-- actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,-- text: extra.text, pictureUrl: extra.picture,-- });-- },-- async postCreated(post, thread, space, extra = {}) {-- if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;-- await recordLocal('post', post.id, {-- title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#post-${post.id}`,-- actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,-- text: post.body_markdown, pictureUrl: extra.picture,-- });-- },-- async submissionAccepted(s) {-- if (!s || s.status !== 'accepted') return;-- await recordLocal('submission', s.slug, { title: s.title, path: `/submissions/${encodeURIComponent(s.slug)}`, actor: s.author_subject, origin: 'user', at: s.reviewed_at });-- },-- async submissionGone(slug) { await forgetLocal('submission', slug); },-- async threadGone(id) { await forgetLocal('thread', id); await forgetLocal('status', id); },-- async postGone(id) { await forgetLocal('post', id); },---- /** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */-- async ingest(v, body = {}) {-- const ref = body.ref;-- if (!ref || typeof ref !== 'object' || !contracts.validate('common.entity-ref@1', ref).valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');-- const own = serviceNameOf(v.service);-- if (ref.service !== own) fail(403, 'pulse.foreign_ref', `A service publishes Pulse items about its own entities (${own}), not ${ref.service}'s`);-- if (body.visibility != null && !['public', 'followers'].includes(body.visibility)) fail(400, 'pulse.not_public', 'Only public or followers activity enters Pulse');-- const title = String(body.title == null ? '' : body.title).replace(/\s+/g, ' ').trim();-- if (!title || title.length > 300) fail(400, 'pulse.invalid_title', 'title is required (at most 300 characters)');-- const url = String(body.url || '');-- let parsed = null;-- try { parsed = new URL(url); } catch { /* invalid */ }-- if (!parsed || !/^https?:$/.test(parsed.protocol) || url.length > 2000) fail(400, 'pulse.invalid_url', 'url must be an absolute http(s) URL');-- let origin = body.origin == null ? v.origin : body.origin;-- if (!ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);-- if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise-- if (body.visibility === 'followers' && (origin !== 'user' || !v.subject)) fail(400, 'pulse.followers_subject_required', 'Followers activity needs the person who shared it');-- const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);-- if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');-- const media = body.media && typeof body.media === 'object' && !Array.isArray(body.media) ? body.media : {};-- const mediaUrl = (v) => { try { const x = new URL(String(v)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };-- const thumb = mediaUrl(media.thumb);-- const preview = media.preview == null ? null : mediaUrl(media.preview);-- const ratio = media.ratio == null ? null : Number(media.ratio);-- const length = media.length == null ? null : Number(media.length);-- if (media.thumb != null && !thumb) fail(400, 'pulse.invalid_media', 'media.thumb must be an HTTPS Media URL');-- if (media.preview != null && !preview) fail(400, 'pulse.invalid_media', 'media.preview must be an HTTPS URL');-- if (ratio != null && (!Number.isFinite(ratio) || ratio <= 0 || ratio > 20)) fail(400, 'pulse.invalid_media', 'media.ratio must be between 0 and 20');-- if (length != null && (!Number.isFinite(length) || length < 0 || length > 86400)) fail(400, 'pulse.invalid_media', 'media.length must be between 0 and 86400 seconds');-- const out = await store.upsertItem(db, {-- source_service: ref.service, source_type: ref.type, source_id: ref.id,-- title, url: parsed.toString(),-- actor_subject: origin === 'user' ? (v.subject || null) : null,-- origin, visibility: body.visibility || 'public', occurred_at: sqlTime(when), picture_url: thumb, preview_url: preview, media_ratio: ratio, media_length: length,-- });-- const projections = await authors.projectionsFor([out.item.actor_subject]);-- return { item: shape(out.item, projections), created: out.created };-- },---- async contribute(v, body = {}) {-- if (!v.subject || v.claims.on_behalf_of !== v.subject || !String(v.service).startsWith('app:')) {-- fail(403, 'pulse.not_delegated', 'An app needs a grant from the person named in its token');-- }-- if (Object.keys(body).some((k) => !['id', 'text', 'url', 'picture_url'].includes(k))) fail(400, 'pulse.invalid', 'Only id, text, url and picture_url are accepted');-- const id = String(body.id || '');-- const title = typeof body.text === 'string' ? body.text.replace(/\s+/g, ' ').trim() : '';-- if (!/^[A-Za-z0-9_-]{1,80}$/.test(id) || !title || title.length > 300) fail(400, 'pulse.invalid', 'id and text are required');-- let url;-- try { url = new URL(body.url); } catch { fail(400, 'pulse.invalid_url', 'An https URL is required'); }-- if (url.protocol !== 'https:' || url.toString().length > 2000) fail(400, 'pulse.invalid_url', 'An https URL is required');-- let picture = null;-- if (body.picture_url != null) {-- try { picture = new URL(body.picture_url); } catch { fail(400, 'pulse.picture_proxy_required', 'A Media picture URL is required'); }-- if (picture.protocol !== 'https:' || picture.hostname !== 'openvibe.media' || !/^\/(?:t|o)\//.test(picture.pathname) || picture.toString().length > 2000) {-- fail(400, 'pulse.picture_proxy_required', 'Pictures must use Media HTTPS thumbnail or object URLs');-- }-- }-- const at = Date.now();-- const times = (appWrites.get(v.service) || []).filter((t) => at - t < 60_000);-- if (times.length >= 10) fail(429, 'pulse.app_rate_limited', 'At most ten contributions per app per minute');-- times.push(at); appWrites.set(v.service, times);-- if (appWrites.size > 5000) appWrites.delete(appWrites.keys().next().value);-- const out = await store.upsertItem(db, { source_service: v.service, source_type: 'contribution', source_id: `${v.subject}:${id}`,-- title, url: url.toString(), picture_url: picture && picture.toString(), app_name: String(v.claims.app_name || '').slice(0, 80) || null,-- actor_subject: v.subject, origin: 'user', occurred_at: sqlTime(at) });-- const projections = await authors.projectionsFor([v.subject]);-- return { item: shape(out.item, projections), created: out.created };-- },---- async retractContribution(v, service, type, id) {-- if (service !== v.service || type !== 'contribution' || !v.subject || v.claims.on_behalf_of !== v.subject || !id.startsWith(`${v.subject}:`)) {-- fail(403, 'pulse.foreign_ref', 'An app retracts only its own items for the consenting person');-- }-- return { removed: await store.removeItem(db, service, type, id) };-- },---- /** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */-- async retract(v, service, type, id) {-- if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');-- return { removed: await store.removeItem(db, service, type, id) };-- },---- /** The origins that have at least one listable item (the page offers a filter only for those), cached a minute. */-- async origins() {-- if (originsCache.list && Date.now() - originsCache.at < 60_000) return originsCache.list;-- const list = [];-- for (const origin of ORIGINS) {-- if ((await store.listItems(db, { origin, limit: 1 })).rows.length) list.push(origin);-- }-- originsCache = { at: Date.now(), list };-- return list;-- },---- /** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */-- async list(q = {}, viewer = null) {-- const origin = q.origin == null || q.origin === '' || q.origin === 'all' ? null : String(q.origin);-- if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);-- const mine = q.for === 'me';-- if (q.for && !mine) fail(400, 'pulse.invalid_for', 'for must be me');-- if (mine && (!viewer || viewer.kind !== 'user' || !viewer.token)) fail(401, 'pulse.sign_in_required', 'Sign in for your feed');-- const limit = Math.min(Math.max(parseInt(q.limit, 10) || (mine ? 20 : PAGE), 1), 100);-- const before = mine ? decodeCursor(q.cursor || q.after, 4) : decodeCursor(q.after, 2);-- if (mine && before && (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before[0])-- || ![0, 1].includes(before[1]) || typeof before[2] !== 'string' || !Number.isSafeInteger(before[3]))) {-- fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');-- }-- const followed = viewer && viewer.kind === 'user' && viewer.token ? await followedSubjects(viewer) : [];-- const hour = mine ? (before ? before[0] : sqlTime(Date.now() - 60 * 60_000)) : null;-- const excluded = await testSubjectIds();-- const { rows, hasMore } = await store.listItems(db, { origin, before: before && (mine ? before.slice(1) : [0, ...before]), limit, followed, viewerSubject: viewer && viewer.kind === 'user' ? viewer.subject : null, hour, excludeSubjects: excluded });-- const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));-- // The projection is authority too: a test account resolved now is left out even if the cached list was-- // fetched before it existed (the list refreshes on a timer).-- const meSubject = viewer && viewer.kind === 'user' ? viewer.subject : null;-- const visible = rows.filter((r) => !(r.actor_subject && r.actor_subject !== meSubject-- && projections.get(r.actor_subject) && projections.get(r.actor_subject).is_test));-- const last = rows[rows.length - 1];-- return { items: visible.map((r) => shape(r, projections)), next_cursor: hasMore && last-- ? encodeCursor(mine ? [hour, last.feed_priority, last.occurred_at, last.id] : [last.occurred_at, last.id]) : null,-- // What this reader may do here: the menu's Feed shows its composer only when this says so (openvibe-shared-- // ov-home.js), so it never offers a post the status route would refuse.-- ...(viewer && viewer.kind === 'user' && viewer.subject ? { viewer: { can_post: true } } : {}) };-- },-- };--}----module.exports = { createPulse, serviceNameOf, ORIGINS };ddeleted file mode 100644@@@ -1,474 +0,0 @@--'use strict';----/**-- * Pulse sources — the public activity that has no Events topic of its own, read on a due basis.-- *-- * The feed never calls another site while a person is reading it (server/pulse/feed.js reads Pulse only). This job-- * fills Pulse from the public APIs:-- * - OpenVibe.Today: GET /api/v1/events (public, cached there) → source today / event-- * - OpenVibe.Network: GET /api/v1/stats (public, CORS-open) → one grouped member row per day, "5 people joined today"-- * - OpenVibe.Quest: GET /api/v1/profiles/:subject/badges for people recently active in Pulse → "Finished a quest: …"-- * - OpenVibe.Meme: GET /api/v1/entries (public) → newly published meme entries-- *-- * Due-ness is kept in the database (pulse_source_runs): a run records the next time it is due, so a restart does not-- * skip a source and two processes do not both fetch at once (the second simply finds it not due). A minute timer only-- * checks those rows; nothing here runs per feed request. Wiki pages and Chat are pushed by Events and handled in-- * server/pulse/consumer.js; no route or page is slowed by this job.-- */--const store = require('./store');--const { subjectsByUsername } = require('../pastes/store');--const { sqlTime } = require('../http/v1');----const MINUTE = 60_000;--const DAY = 24 * 60 * MINUTE;--const QUEST_CHECK_MS = 24 * 60 * MINUTE;--const QUEST_RECENT_MS = 14 * DAY;----const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);--const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };--const subject = (v) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(v || '')) ? String(v) : null);--const sqlFrom = (v, fallback) => {-- const normalized = store.pulseTime(v);-- return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(String(normalized || '')) ? normalized : fallback;--};--const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);----function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, testSubjects = null } = {}) {-- const todayApi = String(config.todayInternalUrl || config.todayUrl || 'https://openvibe.today').replace(/\/+$/, '');-- const todaySite = String(config.todayUrl || 'https://openvibe.today').replace(/\/+$/, '');-- const networkApi = String(config.networkInternalUrl || config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');-- const networkSite = String(config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');-- const questApi = String(config.questInternalUrl || config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');-- const questSite = String(config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');-- const memeApi = String(config.memeInternalUrl || config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '');-- const picsApi = String(config.picsUrl || 'https://openvibe.pics').replace(/\/+$/, '');-- const liveApi = String(config.liveUrl || 'https://openvibe.live').replace(/\/+$/, '');-- const spaceApi = String(config.spaceUrl || 'https://openvibe.space').replace(/\/+$/, '');-- const playApi = String(config.playUrl || 'https://play.openvibe.network').replace(/\/+$/, '');-- let timer = null;-- let running = null;---- /**-- * A channel's person, when a source names only the channel: the one subject Community knows by that username (its-- * subject_projection cache), or none when two people share it. 70 of 112 Live recordings and clips since 11-01-- * had no subject, so the Following feed never showed them and one person's evening folded as two runs.-- */-- function channelSubjects() {-- const cache = new Map();-- return async (username) => {-- const key = clean(username, 64).toLowerCase();-- if (!key) return null;-- if (!cache.has(key)) {-- const ids = (await subjectsByUsername(db, key)).filter((id) => subject(id));-- cache.set(key, ids.length === 1 ? ids[0] : null);-- }-- return cache.get(key);-- };-- }---- async function getJson(url, { timeoutMs = 8000 } = {}) {-- const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });-- if (!res.ok) throw new Error(`${url} answered ${res.status}`);-- return await res.json();-- }---- /** OpenVibe.Today's public calendar → one event row per scheduled stream or event. */-- async function todayEvents() {-- const at = now();-- const from = new Date(at).toISOString();-- const to = new Date(at + 60 * DAY).toISOString();-- const out = await getJson(`${todayApi}/api/v1/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=100`);-- let n = 0;-- for (const e of (Array.isArray(out.events) ? out.events : [])) {-- if (!e || typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || !e.title.trim()) continue;-- if (e.visibility !== 'public' || e.status === 'cancelled') continue;-- const starts = Date.parse(String(e.starts_at || ''));-- if (!Number.isFinite(starts) || starts <= at) continue;-- const owner = subject(e.owner_subject);-- const whenLabel = `Starts ${new Date(starts).toISOString().replace('T', ' ').slice(0, 16)} UTC`;-- const text = [whenLabel, clean(e.description, 220)].filter(Boolean).join(' · ');-- const when = sqlFrom(e.created_at, sqlTime(at));-- const out2 = await store.upsertItem(db, {-- source_service: 'today', source_type: 'event', source_id: e.id,-- title: `Coming up: ${clean(e.title, 140)}`,-- url: https(e.url) || `${todaySite}/e/${encodeURIComponent(e.id)}`,-- actor_subject: owner, origin: owner ? 'user' : 'system', occurred_at: when, starts_at: e.starts_at, text,-- });-- if (out2.created) n++;-- }-- return n;-- }---- /** OpenVibe.Network's open stats → one grouped member row per UTC day ("5 people joined today"). */-- async function networkMembers() {-- const at = now();-- const out = await getJson(`${networkApi}/api/v1/stats?days=7`);-- const accounts = out && out.accounts;-- if (!accounts || !Array.isArray(accounts.daily)) return 0;-- const today = dayOf(at);-- const total = Number(accounts.total);-- let n = 0;-- for (const d of accounts.daily) {-- const count = Number(d && d.n);-- const day = String((d && d.day) || '');-- if (!Number.isInteger(count) || count <= 0 || !/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;-- const title = day === today-- ? `${count} ${count === 1 ? 'person' : 'people'} joined today`-- : `${count} ${count === 1 ? 'person' : 'people'} joined on ${day}`;-- const r = await store.upsertItem(db, {-- source_service: 'network', source_type: 'members', source_id: day,-- title, url: `${networkSite}/stats`, actor_subject: null, origin: 'system',-- occurred_at: day === today ? sqlTime(at) : `${day}T12:00:00Z`,-- text: Number.isFinite(total) && total > 0 ? `${total.toLocaleString('en-US')} people on OpenVibe so far` : '',-- });-- if (r.created) n++;-- }-- return n;-- }---- /**-- * Quest badges for people recently active in Pulse: the public badges API is the only read Quest offers, so a-- * rotation (a person is checked at most once a day) finds "Finished a quest: …". Nothing is stored about anyone-- * but their subject and when the check ran.-- */-- async function questBadges() {-- const at = now();-- const since = sqlTime(at - QUEST_RECENT_MS);-- const checkedBefore = sqlTime(at - QUEST_CHECK_MS);-- const people = await db.prepare(`SELECT DISTINCT i.actor_subject AS subject FROM pulse_items i-- LEFT JOIN pulse_quest_people q ON q.subject = i.actor_subject-- WHERE i.actor_subject IS NOT NULL AND i.origin = 'user' AND i.occurred_at >= ?-- AND (q.subject IS NULL OR q.checked_at < ?)-- ORDER BY i.actor_subject LIMIT 25`).all(since, checkedBefore);-- // A test account never gets a quest-badge item; the identity list says which subjects are test accounts.-- let excluded = new Set();-- if (testSubjects) { try { excluded = new Set(await testSubjects()); } catch { /* the list is optional */ } }-- let n = 0;-- const MAX_CREATED = 25;-- for (const p of people) {-- if (n >= MAX_CREATED) break;-- const who = subject(p.subject);-- if (!who || excluded.has(who)) continue;-- try {-- const res = await fetchImpl(`${questApi}/api/v1/profiles/${encodeURIComponent(who)}/badges`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) });-- if (res.ok) {-- const body = await res.json();-- const recent = (Array.isArray(body.badges) ? body.badges : [])-- .filter((b) => b && typeof b.id === 'string' && b.id && typeof b.name === 'string' && b.name.trim())-- .map((b) => ({ ...b, awarded: Date.parse(String(b.awarded_at || '')) }))-- .filter((b) => Number.isFinite(b.awarded) && at - b.awarded <= QUEST_RECENT_MS)-- .sort((a, b) => b.awarded - a.awarded)-- .slice(0, 2);-- for (const b of recent) {-- if (n >= MAX_CREATED) break;-- const awarded = b.awarded;-- const r = await store.upsertItem(db, {-- source_service: 'quest', source_type: 'badge', source_id: `${who}:${b.id}`,-- title: `Finished a quest: ${clean(b.name, 90)}`, url: `${questSite}/`,-- actor_subject: who, origin: 'user', occurred_at: sqlTime(awarded), text: '',-- });-- if (r.created) n++;-- }-- }-- } catch (err) {-- log.warn?.(`[Pulse sources] Quest ${who}:`, err.message);-- }-- await db.prepare(`INSERT INTO pulse_quest_people (subject, checked_at) VALUES (?, ?)-- ON CONFLICT(subject) DO UPDATE SET checked_at = excluded.checked_at`).run(who, sqlTime(at));-- }-- return n;-- }---- /** Play's anonymous feed contains every person's public unlock across production games. */-- async function playAchievements() {-- let cursor = null;-- let created = 0;-- const seen = new Set();-- for (let page = 0; page < 100; page++) {-- const url = new URL('/api/v1/achievements/feed', playApi);-- url.searchParams.set('limit', '100');-- if (cursor) url.searchParams.set('cursor', cursor);-- const out = await getJson(url.toString());-- for (const a of (Array.isArray(out.items) ? out.items : [])) {-- const who = subject(a && a.player);-- const game = clean(a && a.title, 64);-- const id = clean(a && a.id, 80);-- const at = sqlFrom(a && a.unlocked_at, null);-- if (!who || !/^[a-z0-9-]{2,40}$/.test(game) || !/^[a-z0-9_-]{1,64}$/.test(id) || !at) continue;-- const sourceId = `${who}:${game}:${id}`;-- seen.add(sourceId);-- const r = await store.upsertItem(db, { source_service: 'games', source_type: 'achievement', source_id: sourceId,-- title: `Unlocked ${clean(a.name, 120)} in ${clean(a.title_name || game, 80)}`,-- url: `https://openvibe.games/play?game=${encodeURIComponent(game)}`, actor_subject: who, origin: 'user', occurred_at: at,-- text: clean(a.description, 280) });-- if (r.created) created++;-- }-- cursor = typeof out.next === 'string' && out.next ? out.next : null;-- if (!cursor) break;-- }-- if (cursor) throw new Error('Play achievement feed exceeded 100 pages; retaining prior cards');-- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'games' AND source_type = 'achievement'").all();-- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'games', 'achievement', row.source_id);-- return created;-- }---- /** Meme's public entries → one Feed card each; the API excludes hidden and unlisted entries. */-- async function memeEntries() {-- let cursor = null, created = 0;-- for (let page = 0; page < 4; page++) {-- const url = new URL('/api/v1/entries', memeApi);-- url.searchParams.set('limit', '50');-- if (cursor) url.searchParams.set('cursor', cursor);-- const out = await getJson(url.toString());-- for (const e of (Array.isArray(out.entries) ? out.entries : [])) {-- const slug = clean(e && e.slug, 100);-- const title = clean(e && e.title, 300);-- if (!slug || !/^[A-Za-z0-9-]+$/.test(slug) || !title) continue;-- const url = `${String(config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '')}/m/${encodeURIComponent(slug)}`;-- const r = await store.upsertItem(db, {-- source_service: 'meme', source_type: 'meme', source_id: slug, title, url,-- actor_subject: null, origin: 'system', occurred_at: sqlFrom(e.published_at || e.created_at, sqlTime(now())),-- text: clean(e.summary || e.description, 280),-- });-- if (r.created) created++;-- }-- cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;-- if (!cursor) break;-- }-- return created;-- }---- /** Pics' gallery is opt-in and checks both the owner's public share and the active picture row. */-- async function publicPictures() {-- const seen = new Set();-- let cursor = null, created = 0;-- for (let page = 0; page < 50; page++) {-- const url = new URL('/api/v1/gallery', picsApi);-- url.searchParams.set('sort', 'new');-- url.searchParams.set('limit', '30');-- if (cursor) url.searchParams.set('cursor', cursor);-- const out = await getJson(url.toString());-- for (const p of (Array.isArray(out.pictures) ? out.pictures : [])) {-- const id = clean(p && p.id, 80);-- const links = p && p.links || {};-- if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !p.listed || !https(links.page) || !https(links.thumb)) continue;-- seen.add(id);-- const r = await store.upsertItem(db, { source_service: 'pics', source_type: 'picture', source_id: id,-- title: clean(p.title, 160) || 'Picture on OpenVibe.Pics', url: https(links.page), picture_url: https(links.thumb),-- actor_name: clean(p.author, 80), actor_subject: null, origin: 'user',-- occurred_at: sqlFrom(p.published_at, sqlTime(now())), text: clean(p.description, 280),-- media_ratio: Number(p.width) > 0 && Number(p.height) > 0 ? Number(p.width) / Number(p.height) : null });-- if (r.created) created++;-- }-- cursor = typeof out.next === 'string' && out.next ? out.next : null;-- if (!cursor) break;-- }-- if (cursor) throw new Error('Pics gallery exceeded 50 pages; retaining prior pictures');-- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'pics' AND source_type = 'picture'").all();-- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'pics', 'picture', row.source_id);-- return created;-- }---- /** The anonymous Rundown browse response contains only episodes visible to everyone. */-- async function rundownEpisodes() {-- let before = null, created = 0;-- const seen = new Set();-- for (let page = 0; page < 50; page++) {-- const url = new URL('/api/rundown/streams', liveApi);-- if (before) url.searchParams.set('before', before);-- const out = await getJson(url.toString());-- for (const s of (Array.isArray(out.streams) ? out.streams : [])) {-- if (!s || s.live || !s.episode || s.episode.audience !== 'public' || !Number.isSafeInteger(Number(s.stream_id)) || Number(s.stream_id) < 1) continue;-- const id = String(s.stream_id);-- const title = clean(s.episode.title || s.title, 160);-- if (!title) continue;-- seen.add(id);-- const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);-- const user = s.user || {};-- // The card's subject is the streamer the episode is about; the actor is The Rundown itself.-- const subject = user.username ? {-- name: clean(user.display_name || user.username, 80) || null,-- handle: `@${clean(user.username, 64)}`,-- avatar: https(user.avatar_url),-- } : null;-- // The episode's own AI picture is the art; the stream's thumbnail stays the fallback until it exists.-- const picture = https(s.episode.picture_url);-- const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,-- title, url: `${liveApi}/rundown/e/${id}`,-- actor_name: 'The Rundown', actor_subject: prior && prior.actor_subject || null, origin: 'system',-- occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),-- text: clean(s.episode.logline, 280), picture_url: picture || https(s.thumbnail_url),-- media_ratio: picture ? 1200 / 630 : null, subject });-- if (r.created) created++;-- }-- before = typeof out.next === 'string' && out.next ? out.next : null;-- if (!before) break;-- }-- if (before) throw new Error('Rundown pagination exceeded 50 pages; retaining prior episodes');-- const stale = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode'").all();-- for (const row of stale) if (!seen.has(row.source_id)) await store.removeItem(db, 'live', 'episode', row.source_id);-- return created;-- }---- /** Live's Content API has already checked public Media rows, banned owners and playable VODs. */-- async function publicContent() {-- const seen = new Set();-- let cursor = null, created = 0;-- const live = await getJson(`${liveApi}/api/streams`);-- const personOf = channelSubjects();-- const onAir = new Map();-- const liveIds = new Set();-- for (const s of (Array.isArray(live.streams) ? live.streams : [])) {-- const id = Number(s && s.id), channel = clean(s && s.username, 64);-- const started = sqlFrom(s && s.started_at, null);-- if (!Number.isSafeInteger(id) || id < 1 || !/^[A-Za-z0-9_.-]{1,64}$/.test(channel) || !started) continue;-- if (!onAir.has(channel.toLowerCase())) onAir.set(channel.toLowerCase(), []);-- onAir.get(channel.toLowerCase()).push(Date.parse(`${started.replace(' ', 'T')}Z`));-- liveIds.add(String(id));-- const name = clean(s.display_name || channel, 80);-- const r = await store.upsertItem(db, { source_service: 'live', source_type: 'stream', source_id: String(id),-- title: `${name} went live: ${clean(s.title, 160) || 'a stream'}`, url: `${liveApi}/@${encodeURIComponent(channel)}`,-- actor_name: name, actor_subject: await personOf(channel), origin: 'user', occurred_at: started });-- if (r.created) created++;-- }-- for (let page = 0; page < 100; page++) {-- const url = new URL('/api/content/feed', liveApi);-- url.searchParams.set('sort', 'new');-- url.searchParams.set('window', 'all');-- url.searchParams.set('limit', '30');-- if (cursor) url.searchParams.set('cursor', cursor);-- const out = await getJson(url.toString());-- if (out.partial || (out.sources && (out.sources.vods !== 'ok' || out.sources.clips !== 'ok'))) throw new Error('Live Content feed was partial; retaining prior cards');-- for (const v of (Array.isArray(out.items) ? out.items : [])) {-- if (!v || !['vod', 'clip'].includes(v.kind) || !https(v.href)) continue;-- const id = clean(v.id, 80);-- if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;-- const type = v.kind === 'vod' ? 'recording' : 'clip';-- const at = sqlFrom(v.created_at, null);-- if (!at) continue;-- const channel = clean(v.channel && v.channel.username, 64);-- const current = onAir.get(channel.toLowerCase()) || [];-- if (type === 'recording' && current.some((started) => Number.isFinite(started)-- && Math.abs(Date.parse(`${at.replace(' ', 'T')}Z`) - started) <= 10 * MINUTE)) continue;-- seen.add(`${type}:${id}`);-- let prior = null;-- if (type === 'recording' && channel) {-- const candidates = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'-- AND lower(url) = lower(?) AND occurred_at BETWEEN ? AND ? ORDER BY occurred_at DESC LIMIT 20`)-- .all(`${liveApi}/@${encodeURIComponent(channel)}`, sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) - 60 * MINUTE), sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) + 60 * MINUTE));-- prior = candidates.find((row) => !liveIds.has(row.source_id)) || null;-- }-- const r = await store.upsertItem(db, { source_service: 'live', source_type: type, source_id: id,-- title: `${type === 'recording' ? 'Recording: ' : ''}${clean(v.title, 150) || (type === 'recording' ? 'A stream' : 'A clip')}`,-- url: https(v.href), picture_url: https(v.thumbnail_url),-- actor_subject: (prior && prior.actor_subject) || await personOf(channel), actor_name: clean(v.channel && v.channel.display_name, 80),-- origin: 'user', occurred_at: at, text: clean(v.excerpt, 280), media_length: Number(v.duration_seconds) > 0 ? Number(v.duration_seconds) : null });-- if (r.created) created++;-- if (prior) await store.removeItem(db, 'live', 'stream', prior.source_id);-- }-- cursor = typeof out.next === 'string' && out.next ? out.next : null;-- if (!cursor) break;-- }-- if (cursor) throw new Error('Live Content feed exceeded 100 pages; retaining prior cards');-- const old = await db.prepare("SELECT source_type, source_id FROM pulse_items WHERE source_service = 'live' AND source_type IN ('recording', 'clip')").all();-- for (const row of old) if (!seen.has(`${row.source_type}:${row.source_id}`)) await store.removeItem(db, 'live', row.source_type, row.source_id);-- return created;-- }---- /** Space's public gallery excludes drafts, private spaces, inactive sites and takedowns. */-- async function publicSpaces() {-- const seen = new Set();-- let cursor = null, created = 0;-- for (let page = 0; page < 50; page++) {-- const url = new URL('/api/v1/apps/gallery', spaceApi);-- url.searchParams.set('limit', '100');-- if (cursor) url.searchParams.set('cursor', cursor);-- const out = await getJson(url.toString());-- for (const s of (Array.isArray(out.spaces) ? out.spaces : [])) {-- const id = clean(s && s.id, 80);-- const at = Number(s && s.published_at);-- if (!/^spc_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !Number.isFinite(at) || at <= 0 || !https(s.url)) continue;-- seen.add(id);-- const r = await store.upsertItem(db, { source_service: 'space', source_type: 'space', source_id: id,-- title: clean(s.name, 160) || 'New space', url: https(s.url), text: clean(s.description, 280),-- actor_name: clean(s.owner_name || s.owner_username, 80), actor_subject: null, origin: 'user', occurred_at: sqlTime(at) });-- if (r.created) created++;-- }-- cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;-- if (!cursor) break;-- }-- if (cursor) throw new Error('Space gallery exceeded 50 pages; retaining prior spaces');-- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'space' AND source_type = 'space'").all();-- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'space', 'space', row.source_id);-- return created;-- }---- const SOURCES = [-- { name: 'today-events', every: 10 * MINUTE, run: todayEvents },-- { name: 'network-members', every: 60 * MINUTE, run: networkMembers },-- { name: 'quest-badges', every: 15 * MINUTE, run: questBadges },-- { name: 'play-achievements', every: 15 * MINUTE, run: playAchievements },-- { name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },-- { name: 'public-pictures', every: 5 * MINUTE, run: publicPictures },-- { name: 'rundown-episodes', every: 10 * MINUTE, run: rundownEpisodes },-- { name: 'public-content', every: 5 * MINUTE, run: publicContent },-- { name: 'public-spaces', every: 10 * MINUTE, run: publicSpaces },-- ];---- async function remember(name, { nextRun, lastRun, error = null }) {-- await db.prepare(`INSERT INTO pulse_source_runs (name, next_run_at, last_run_at, last_error)-- VALUES (?, ?, ?, ?)-- ON CONFLICT(name) DO UPDATE SET next_run_at = excluded.next_run_at, last_run_at = excluded.last_run_at,-- last_error = excluded.last_error`)-- .run(name, nextRun, lastRun, error);-- }---- /** Run every source that is due now (or all of them with { force: true }). → [{ name, items } | { name, error }] */-- async function runOnce({ force = false } = {}) {-- if (running) return running;-- running = (async () => {-- const at = now();-- const states = new Map((await db.prepare('SELECT * FROM pulse_source_runs').all()).map((s) => [s.name, s]));-- const out = [];-- for (const source of SOURCES) {-- const state = states.get(source.name);-- if (!force && state && state.next_run_at && state.next_run_at > sqlTime(at)) continue;-- try {-- const items = await source.run();-- await remember(source.name, { nextRun: sqlTime(at + source.every), lastRun: sqlTime(at) });-- out.push({ name: source.name, items });-- } catch (err) {-- log.warn?.(`[Pulse sources] ${source.name}:`, err.message);-- await remember(source.name, { nextRun: sqlTime(at + source.every), lastRun: sqlTime(at), error: String(err.message).slice(0, 300) });-- out.push({ name: source.name, error: err.message });-- }-- }-- return out;-- })().finally(() => { running = null; });-- return await running;-- }---- /** Start the minute check. Only server/index.js calls this (tests run runOnce themselves). */-- function start() {-- if (timer) return;-- const tick = () => { runOnce().catch((err) => log.warn?.('[Pulse sources]:', err.message)); };-- timer = setInterval(tick, MINUTE);-- if (timer.unref) timer.unref();-- const first = setTimeout(tick, 5_000);-- if (first.unref) first.unref();-- timer.first = first;-- }-- function stop() {-- if (timer) clearInterval(timer);-- if (timer && timer.first) clearTimeout(timer.first);-- timer = null;-- }-- async function status() {-- const rows = await db.prepare('SELECT * FROM pulse_source_runs ORDER BY name').all();-- return { sources: SOURCES.map((s) => s.name), runs: rows };-- }---- return { runOnce, start, stop, status, SOURCES: SOURCES.map((s) => s.name) };--}----module.exports = { createPulseSources };ddeleted file mode 100644@@@ -1,170 +0,0 @@--'use strict';----/**-- * Pulse store — pure SQL over pulse_items (server/db.js).-- *-- * An item is keyed by its source (service, type, id). Re-recording the same source refreshes-- * its title and link but never its provenance: origin, actor and occurred_at are what the first-- * record said.-- *-- * A live stream is the exception: a dropped connection starts a new Live session (a new subject-- * id), but it is the same broadcast. upsertLiveItem keeps one item for the same person (or the-- * same channel URL) and title within six hours and moves its time to the newest start. Existing-- * duplicates are collapsed the same way when the feed is read, so the page shows one line.-- */--const LIVE_WINDOW_MS = 6 * 60 * 60 * 1000;----/**-- * One spelling for a Pulse time: "YYYY-MM-DD HH:MM:SS" in UTC (sqlTime's). The feed orders by this text, and an ISO-- * time ("…T…Z") sorts above every same-day time written with a space, so a day's quest badges and Community's own posts-- * fell below that day's events (11-09). Anything Date can read is accepted; nothing readable keeps what was given.-- */--function pulseTime(v) {-- if (v == null || v === '') return v;-- const s = String(v);-- const t = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);-- return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : s;--}----/** A Pulse time in milliseconds, the space spelling read as UTC (Date.parse would read it as local time). */--function ms(v) {-- const s = String(v == null ? '' : v);-- return Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);--}----/** The subject a card is about (a Rundown episode's streamer), as JSON text or null. */--function subjectJsonOf(it) {-- return it && it.subject && typeof it.subject === 'object' && !Array.isArray(it.subject) ? JSON.stringify(it.subject) : null;--}----/** A live.stream.started item: one row per broadcast, not per reconnect. → { item, created } */--async function upsertLiveItem(db, it) {-- it = { ...it, occurred_at: pulseTime(it.occurred_at) };-- const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');-- return await db.tx(async () => {-- const key = [it.source_service, it.source_type, String(it.source_id)];-- const existing = await find.get(...key);-- if (existing) {-- await db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);-- return { item: await find.get(...key), created: false };-- }-- const at = ms(it.occurred_at);-- const actor = it.actor_subject || null;-- if (Number.isFinite(at)) {-- // The same person (or channel URL) and title within six hours of this start is the same-- // broadcast. Older sessions only: a delayed event must not move a newer item back in time.-- const candidates = actor-- ? await db.prepare(`SELECT * FROM pulse_items-- WHERE source_service = 'live' AND source_type = 'stream' AND title = ?-- AND ((actor_subject IS NOT NULL AND actor_subject = ?) OR url = ?)-- ORDER BY occurred_at DESC, id DESC`).all(it.title, actor, it.url)-- : await db.prepare(`SELECT * FROM pulse_items-- WHERE source_service = 'live' AND source_type = 'stream' AND title = ? AND url = ?-- ORDER BY occurred_at DESC, id DESC`).all(it.title, it.url);-- let prior = null, priorAt = -Infinity;-- for (const c of candidates) {-- const t = ms(c.occurred_at);-- if (Number.isFinite(t) && t <= at && at - t <= LIVE_WINDOW_MS && t > priorAt) { prior = c; priorAt = t; }-- }-- if (prior) {-- if (at > priorAt) await db.prepare('UPDATE pulse_items SET occurred_at = ? WHERE id = ?').run(it.occurred_at, prior.id);-- return { item: await db.prepare('SELECT * FROM pulse_items WHERE id = ?').get(prior.id), created: false };-- }-- }-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, actor, it.origin, it.occurred_at);-- return { item: await find.get(...key), created: true };-- });--}----/** → { item, created } */--async function upsertItem(db, it) {-- it = { ...it, occurred_at: pulseTime(it.occurred_at) };-- const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');-- return await db.tx(async () => {-- const key = [it.source_service, it.source_type, String(it.source_id)];-- const had = await find.get(...key);-- if (had) {-- // An update refreshes the card's words and picture. Only a source that says `touch` may move its-- // time forward (a Wiki page edited again, a busy chat hour still filling): provenance is otherwise-- // what the first record said. A source that sends no text (undefined) keeps the text already there.-- if (it.text === undefined) it = { ...it, text: had.text };-- // A person found later fills a card that had none (Live recordings named only their channel until-- // 2026-11-12); a card's person, once known, never changes.-- const person = it.actor_subject || null;-- if (it.touch) {-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')-- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, subjectJsonOf(it), ...key);-- } else {-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')-- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', subjectJsonOf(it), ...key);-- }-- return { item: await find.get(...key), created: false };-- }-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name, subject_json)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, subjectJsonOf(it));-- return { item: await find.get(...key), created: true };-- });--}----async function removeItem(db, service, type, id) {-- return (await db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id))).changes;--}----/**-- * Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked-- * against their source at read time — a paste made private, a deleted post, a submission no longer-- * accepted or a thread in a space that stopped being public (or became members-only) never shows,-- * even if nothing removed its item. Live streams recorded before upsertLiveItem existed are-- * collapsed here too: an older session is hidden when the same person (or URL) and title started-- * again within six hours, so one line and its newest time remain.-- * opts.origin 'user' | 'ai' | 'system'-- * opts.before [occurred_at, id] of the last item already seen-- * opts.excludeSubjects test accounts (Network's is_test): their items never reach a public read-- * → { rows, hasMore }-- */--async function listItems(db, { origin = null, before = null, limit = 30, followed = [], viewerSubject = null, hour = null, excludeSubjects = [] } = {}) {-- const priority = "CASE WHEN @hour::text IS NOT NULL AND i.occurred_at >= @hour AND jsonb_exists(@followed::jsonb, i.actor_subject) THEN 1 ELSE 0 END";-- const rows = await db.prepare(`-- SELECT i.*, ${priority} AS feed_priority FROM pulse_items i-- WHERE (@origin::text IS NULL OR i.origin = @origin)-- AND (@exclude::text[] IS NULL OR i.actor_subject IS NULL OR i.actor_subject = @viewerSubject::text-- OR NOT (i.actor_subject = ANY(@exclude::text[])))-- AND (i.actor_subject = @viewerSubject::text-- OR NOT EXISTS (SELECT 1 FROM subject_projection sp WHERE sp.subject_id = i.actor_subject AND sp.is_test = 1))-- AND (i.visibility = 'public' OR (i.visibility = 'followers' AND i.actor_subject IS NOT NULL-- AND (i.actor_subject = @viewerSubject::text OR jsonb_exists(@followed::jsonb, i.actor_subject))))-- AND (i.source_service <> 'today' OR i.source_type <> 'event' OR (i.starts_at IS NOT NULL AND i.starts_at > to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')))-- AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (-- SELECT 1 FROM pulse_items e WHERE e.source_service = 'live' AND e.source_type = 'episode' AND e.source_id = i.source_id))-- AND (@at::text IS NULL OR ${priority} < @priority::integer-- OR (${priority} = @priority::integer AND (i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id::bigint))))-- AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (-- SELECT 1 FROM pulse_items j-- WHERE j.source_service = 'live' AND j.source_type = 'stream'-- AND j.title = i.title-- AND ((j.actor_subject IS NOT NULL AND j.actor_subject = i.actor_subject) OR j.url = i.url)-- AND (j.occurred_at > i.occurred_at OR (j.occurred_at = i.occurred_at AND j.id > i.id))-- AND j.occurred_at::timestamptz <= i.occurred_at::timestamptz + INTERVAL '6 hours'))-- AND (i.source_service <> 'community'-- OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))-- OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id-- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'-- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))-- OR (i.source_type = 'status' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id-- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.slug = 'status'-- AND s.visibility = 'public'))-- OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id-- WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'-- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))-- OR (i.source_type = 'submission' AND EXISTS (SELECT 1 FROM submissions su WHERE su.slug = i.source_id AND su.status = 'accepted')))-- ORDER BY feed_priority DESC, i.occurred_at DESC, i.id DESC-- LIMIT @limit`).all({ origin, followed: JSON.stringify(followed), viewerSubject, hour,-- exclude: Array.isArray(excludeSubjects) && excludeSubjects.length ? excludeSubjects : null,-- priority: before ? before[0] : null, at: before ? before[1] : null, id: before ? before[2] : null, limit: limit + 1 });-- const hasMore = rows.length > limit;-- if (hasMore) rows.pop();-- return { rows, hasMore };--}----module.exports = { pulseTime, upsertItem, upsertLiveItem, removeItem, listItems };@@@ -3,7 +3,6 @@const ld = require('./jsonld');const { renderPage } = require('./layout');const { escapeHtml: esc } = require('./highlight');--const { card } = require('../pulse/feed');// What each kind is called on a card and its mark (Opus, 2026-11-11: "Live · video" and "Quest · quest" said the same// thing twice; a card now says what happened, who, where and when, in one line).@@@ -60,7 +59,7 @@ const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'const href = (kind, cursor = '', search = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (search) q.set('q', search); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null, nextCursor = null, now = Date.now() } = {}) {-- const cards = items.map((item) => item && item.kind ? item : card(item));++ const cards = items.filter((item) => item && item.kind);const chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(href(id, '', q))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Feed</span></nav><h1>Feed</h1><p class="muted">What's happening across OpenVibe.</p></header><form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>@@@ -36,7 +36,7 @@ const { actor, serviceItself } = require('../server/actor-limits');if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });};-- const PULSE = '/api/v1/pulse';++ const PULSE = '/api/v1/activity';await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {for (let i = 0; i < 3; i++) assert.strictEqual((await call(PULSE, { cookie: alexJwt })).status, 200);@@@ -46,7 +46,7 @@ const { actor, serviceItself } = require('../server/actor-limits');assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')));const body = r.json();assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);-- assert.ok(body.detail.includes('community.pulse.read'), body.detail);++ assert.ok(body.detail.includes('community.feed.read'), body.detail);assert.strictEqual((await call(PULSE, { cookie: samJwt })).status, 200, 'another person still passes');});@@@ -97,7 +97,7 @@ const { actor, serviceItself } = require('../server/actor-limits');await check('refusals are counted in community_rate_limited_total', async () => {const m = (await call('/metrics')).text;-- assert.ok(/community_rate_limited_total\{limit="community.pulse.read",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));++ assert.ok(/community_rate_limited_total\{limit="community.feed.read",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));assert.ok(/community_rate_limited_total\{limit="community.comment.create",window="minute"\} 1/.test(m));});ddeleted file mode 100644@@@ -1,385 +0,0 @@--'use strict';--/**-- * GET /api/v1/feed (the menu's Feed on every site): one stream from Pulse, newest first, as cards by kind (a paste or-- * a status is a post, a Live clip a clip, a Wiki page a wiki page, a Quest badge a quest, a busy chat hour a chat, a-- * Today event an event, a day's new Network accounts a member), the pictures and text a post carries, Community's own-- * counts, the site/q/following filters, and the shape openvibe-shared's ov-home.js feed() draws.-- */--const assert = require('assert');--const { boot, check, done } = require('./helpers/app');----const FRAME = { cards: [-- { site: 'media-hub', title: 'Trending', items: [-- { title: 'A trending video', url: 'https://openvibe.video/v/7', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },-- // The same title as a Pulse card on the page ("Recording: " aside): skipped, never a second card for it.-- { title: 'Recording: A video', url: 'https://openvibe.video/v/8', image: { src: 'https://openvibe.media/t/8.webp' } },-- // The same link as a Pulse card further down the page: skipped too.-- { title: 'Same link, other words', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/9.webp' } },-- { title: 'No picture', url: 'https://openvibe.video/v/2' },-- { title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },-- ] },-- { site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },--] };--const LIVE = { streams: [{ username: 'alex', started_at: new Date().toISOString() },-- { username: 'alex', started_at: new Date(Date.now() - 40 * 60_000).toISOString() },-- { username: 'ada', started_at: new Date().toISOString() }] };--const feedFetch = async (url) => {-- const u = String(url);-- return { ok: /\/api\/(frame|streams)/.test(u), json: async () => (/\/api\/streams/.test(u) ? LIVE : FRAME) };--};----// 1×1 PNG, enough for the attachment upload and the screenshot paste (the bytes decide the type).--const zlib = require('zlib');--const chunk = (type, data) => {-- const len = Buffer.alloc(4); len.writeUInt32BE(data.length);-- const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);-- const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));-- return Buffer.concat([len, td, crc]);--};--const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;--const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr),-- chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);----const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().replace('T', ' ').slice(0, 19);----(async () => {-- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch, startRelay: false, startSharingWorker: false, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });-- await check('feed examples are generated from current Pulse cards and search accepts natural filters', async () => {-- const examples = await t.get('/api/v1/feed/examples');-- assert.strictEqual(examples.status, 200, examples.text);-- assert.ok(Array.isArray(examples.json().examples));-- const empty = await t.get('/api/v1/feed/search');-- assert.strictEqual(empty.status, 400);-- });-- const net = t.network;-- const db = t.app.locals.db;-- const pulse = t.app.locals.pulse;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };-- const netSvc = net.signService({ cap: ['community.pulse.write'] });---- // A public text paste and a screenshot paste (its picture is the first thing the Feed can show).-- const textPaste = (await t.app.locals.pastes.createText(asAlex, { title: 'Public one', content: 'The first line of a public paste.' })).paste;-- const shot = (await t.app.locals.pastes.createScreenshot(asAlex, { title: 'A shot', content: 'A tiny cat on a screen' }, { buffer: PNG, originalname: 'cat.png' })).paste;-- const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },-- body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1', media: { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 } }) });-- assert.strictEqual(clip.status, 201, clip.text);---- // One fixture for every remaining kind Pulse can carry (the other services' items are pushed, never read per request).-- const fixtures = [-- ['live', 'stream', 'l1', 'Alex went live', 'https://openvibe.live/alex', alex.subject_id, 'user', 12],-- ['live', 'clip', 'c2', 'A second clip', 'https://openvibe.live/clip/c2', alex.subject_id, 'user', 13],-- ['media-hub', 'video', 'v1', 'A video', 'https://openvibe.video/v/1', alex.subject_id, 'user', 14],-- ['pics', 'picture', 'p1', 'A picture', 'https://openvibe.pics/p/1', alex.subject_id, 'user', 15],-- ['meme', 'meme', 'm1', 'A meme', 'https://openvibe.meme/m/1', alex.subject_id, 'user', 16],-- ['games', 'level', 'g1', 'Reached level 5', 'https://openvibe.games/', alex.subject_id, 'user', 17],-- ['blog', 'post', 'b1', 'A blog post', 'https://openvibe.blog/b/1', alex.subject_id, 'user', 18],-- ['wiki', 'page', 'w1', 'Wiki: a page', 'https://openvibe.wiki/w/1', sam.subject_id, 'user', 19],-- ['quest', 'badge', 'q1', 'Finished a quest: First Post', 'https://openvibe.quest/', sam.subject_id, 'user', 20],-- ['chat', 'hour', 'general:2026-11-09T10', '#general: 12 messages this hour', 'https://openvibe.chat/rooms/general', null, 'system', 21],-- ['today', 'event', 'e1', 'Coming up: A launch party', 'https://openvibe.today/e/e1', alex.subject_id, 'user', 22],-- ['network', 'members', '2026-11-09', '5 people joined today', 'https://openvibe.network/stats', null, 'system', 23],-- ];-- for (const [service, type, id, title, url, actor, origin, minsAgo] of fixtures) {-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, text, picture_url)-- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?)`)-- .run(service, type, id, title, url, actor, origin, at(minsAgo), type === 'event' ? 'Come along' : '', null);-- if (type === 'event') await db.prepare('UPDATE pulse_items SET starts_at = ? WHERE source_service = ? AND source_id = ?')-- .run(new Date(Date.now() + 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19), service, id);-- }-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)-- VALUES ('pics', 'picture', 'p2', 'Another picture', 'https://openvibe.pics/p/2', ?, 'user', 'public', ?)`).run(alex.subject_id, at(15.5));---- // Make the paste's counts real so the card has Community's own views, likes and comments.-- await db.prepare('UPDATE pastes SET views = 7, likes = 3 WHERE slug = ?').run(textPaste.slug);-- await db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, comment_count, access_id) VALUES ('community', 'paste', ?, 2, 'feed-test')`).run(textPaste.slug);---- await check('the feed: newest first, cards by kind, the shape the menu draws', async () => {-- const r = await t.get('/api/v1/feed?limit=40');-- assert.strictEqual(r.status, 200, r.text);-- const j = r.json();-- const kinds = new Set(j.items.map((x) => x.kind));-- for (const k of ['stream', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);-- assert.ok('next_cursor' in j);-- for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);-- const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');-- assert.strictEqual(c.site, 'live');-- assert.strictEqual(c.site_name, 'Live');-- assert.strictEqual(c.actor.name, 'Alex');-- assert.deepStrictEqual(c.media, { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 });-- const photos = j.items.find((x) => x.kind === 'pic');-- assert.strictEqual(photos.title, 'Alex added 2 photos');-- assert.strictEqual(photos.group.count, 2);-- assert.deepStrictEqual(photos.group.items.map((x) => x.url), ['https://openvibe.pics/p/1', 'https://openvibe.pics/p/2']);-- });---- await check('GET /feed is server rendered, kind chips keep the page crawlable and Older carries a cursor', async () => {-- const r = await t.get('/feed?kind=video');-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('A video'), 'card text is in the response HTML');-- assert.ok(r.text.includes('https://openvibe.video/v/1'), 'card link is in the response HTML');-- assert.ok(r.text.includes('schema.org') && r.text.includes('ItemList'), 'ItemList JSON-LD is present');-- assert.ok(r.text.includes('Older'), 'the page has a crawlable older-page link');-- assert.ok(r.text.includes('id="feed-search"') && r.text.includes('class="feed-try"'), 'the page offers feed search and current examples');-- const searched = await t.get('/feed?q=videos');-- assert.ok(searched.text.includes('A video') && searched.text.includes('name="q" value="videos"'), 'the page applies a natural kind search');-- });---- await check('a paste and a screenshot paste carry their text and first picture; the counts are Community\'s', async () => {-- const j = (await t.get('/api/v1/feed?limit=40')).json();-- const p = j.items.find((x) => x.title === 'Public one');-- assert.strictEqual(p.text, 'The first line of a public paste.');-- assert.strictEqual(p.kind, 'paste');-- assert.deepStrictEqual(p.counts, { views: 7, likes: 3, comments: 2 });-- const s = j.items.find((x) => x.title === 'A shot');-- const stored = await db.prepare('SELECT screenshot_url FROM pastes WHERE slug = ?').get(shot.slug);-- assert.strictEqual(s.text, 'A tiny cat on a screen');-- assert.strictEqual(s.media.thumb, stored.screenshot_url);-- assert.strictEqual(s.media.ratio, 16 / 9);-- });---- await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {-- const j = (await t.get('/api/v1/feed')).json();-- const tr = j.items.filter((x) => x.label === 'Trending');-- assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'],-- 'only the https one with a picture, and never a link or a title the page already shows');-- assert.strictEqual(tr[0].kind, 'video');-- assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');-- });---- await check('a person\'s run folds while each card is within two hours of the run, and a gap ends it', async () => {-- const { foldBursts } = require('../server/pulse/feed');-- const base = Date.parse('2026-11-11T07:35:00Z');-- const pair = (id, mins, kind = 'video', who = 'sub_maticus') => ({ row: { actor_subject: who },-- card: { id, kind, site: 'media-hub', url: `https://openvibe.video/watch?v=${id}`, title: id,-- at: new Date(base + mins * 60_000).toISOString(), actor: { name: 'Maticus' }, media: null } });-- // 07:35, 07:55, 09:10, 09:44, 10:06 and 10:13 (11-11's morning, which showed as five rows), then 15:00.-- const out = foldBursts([pair('a', 158), pair('b', 151), pair('c', 129), pair('d', 95), pair('e', 20), pair('f', 0),-- pair('clip', 140, 'clip'), pair('other', 150, 'video', 'sub_ada'), pair('later', 445)]);-- const run = out.find((c) => c.group);-- assert.strictEqual(run.title, 'Maticus added 6 videos');-- assert.deepStrictEqual(run.group.items.map((x) => x.id), ['a', 'b', 'c', 'd', 'e', 'f']);-- assert.deepStrictEqual(out.map((c) => c.id), ['a', 'clip', 'other', 'later'], 'a clip, someone else and a card past the gap stay apart');---- // A page is full by cards, after folding (11-12's /feed: twelve rows, three cards, then "Older").-- const { filled } = require('../server/pulse/feed');-- const six = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => pair(id, i * 10));-- assert.strictEqual(filled(six, 3), false, 'six rows that fold into one card do not fill three');-- assert.strictEqual(filled([...six, pair('clip', 5, 'clip'), pair('other', 5, 'video', 'sub_ada')], 3), true, 'three cards do');-- assert.strictEqual(filled(Array.from({ length: 12 }, (_, i) => pair(`r${i}`, i)), 3), true, 'raw rows stop at four times the page');---- // A folded run shows its members: the first four pictures, "+N" over the last (rule 136).-- const { feedPage } = require('../server/render/feed');-- const pics = ['a', 'b', 'c', 'd', 'e', 'f'].map((id) => ({ ...pair(id, 0), card: { ...pair(id, 0).card, media: { thumb: `https://openvibe.media/${id}.webp`, ratio: 16 / 9 } } }));-- const html = feedPage({ items: foldBursts(pics) });-- assert.ok(html.includes('class="feed-strip" data-n="4"'), 'four pictures of the run');-- assert.ok(html.includes('>+2</span>'), '"+2" for the other two');-- assert.ok(html.includes('https://openvibe.video/watch?v=d'), 'each picture opens its own video');-- assert.ok(html.includes('Recordings'), 'the run is named in the plural');-- });---- await check('LIVE only for a channel that is live now', async () => {-- const feedTools = require('../server/pulse/feed');-- const current = await feedTools.createLiveNow({ liveUrl: 'https://openvibe.live', fetchImpl: feedFetch })();-- assert.strictEqual(current.get('alex').length, 2, 'both active stream starts are retained for one channel');-- assert.ok(feedTools.isCurrentLive({ url: 'https://openvibe.live/@alex', at: new Date(Date.now() - 40 * 60_000).toISOString() }, current));-- const mk = (id, who, at = null) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },-- body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: ${at ? 'an older stream' : 'a stream'}`, url: `https://openvibe.live/@${who}`, ...(at ? { occurred_at: at } : {}) }) });-- assert.strictEqual((await mk('s1', 'ada')).status, 201);-- assert.strictEqual((await mk('s2', 'bo')).status, 201);-- assert.strictEqual((await mk('s3', 'alex', new Date(Date.now() - 2 * 86_400_000).toISOString())).status, 201);-- const j = (await t.get('/api/v1/feed?site=live&limit=40')).json();-- const by = Object.fromEntries(j.items.map((x) => [x.url, [x.kind, x.live_now]]));-- assert.deepStrictEqual(by['https://openvibe.live/@ada'], ['live', true], 'ada is on the live list');-- assert.deepStrictEqual(by['https://openvibe.live/@bo'], ['stream', false], 'bo went live earlier: a stream that happened, never LIVE');-- const older = j.items.find((x) => x.title === 'alex went live: an older stream');-- assert.deepStrictEqual([older.kind, older.live_now], ['stream', false], 'an old broadcast stays ended while the same channel is live again');-- for (const kind of ['live', 'stream']) {-- const filtered = (await t.get(`/api/v1/feed?kind=${kind}&limit=40`)).json();-- assert.ok(filtered.items.length > 0, `${kind} has a card`);-- assert.ok(filtered.items.every((x) => x.kind === kind), `${kind} filters the displayed kind`);-- }---- });---- await check('a kind filters the stream, a site filters the stream, q searches title and text', async () => {-- const j = (await t.get('/api/v1/feed?kind=wiki&limit=40')).json();-- assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'wiki'), JSON.stringify(j.items.map((x) => x.kind)));-- const none = (await t.get('/api/v1/feed?kind=no-such-kind')).json();-- assert.ok(none.items.length > 0, 'an unknown kind is ignored, not a 400');-- const site = (await t.get('/api/v1/feed?site=live&limit=40')).json();-- assert.ok(site.items.length >= 1 && site.items.every((x) => x.site === 'live'), JSON.stringify(site.items.map((x) => x.site)));-- const q = (await t.get('/api/v1/feed?q=cat&limit=40')).json();-- assert.ok(q.items.length >= 1 && q.items.every((x) => /cat/i.test(x.title) || /cat/i.test(x.text)), JSON.stringify(q.items.map((x) => x.title)));-- const text = (await t.get('/api/v1/feed?q=tiny%20cat&limit=40')).json();-- assert.ok(text.items.some((x) => x.title === 'A shot'), 'text is searched too');-- });---- await check('following=1 is the signed-in person\'s follows; signed out it is refused', async () => {-- net.addFollow(sam.subject_id, 'channel', alex.subject_id);-- const j = (await t.get('/api/v1/feed?following=1&limit=40', { cookies: [`ov_token=${samJwt}`] })).json();-- assert.ok(j.items.length >= 1, JSON.stringify(j.items));-- assert.ok(j.items.every((x) => x.actor && x.actor.name === 'Alex'), JSON.stringify(j.items.map((x) => [x.title, x.actor])));-- assert.ok(j.items.some((x) => x.title === 'Alex went live'));-- const r = await t.get('/api/v1/feed?following=1');-- assert.strictEqual(r.status, 401);-- assert.strictEqual(r.json().code, 'feed.sign_in_required');-- });---- await check('feed ranking puts live and fresh activity first, then uses engagement and follows', async () => {-- net.addFollow(sam.subject_id, 'channel', alex.subject_id);-- const rankPaste = async (actor, title, minutesAgo, stats = {}) => {-- const { paste } = await t.app.locals.pastes.createText(actor, { title, content: title });-- await db.prepare("UPDATE pulse_items SET occurred_at = ? WHERE source_service = 'community' AND source_type = 'paste' AND source_id = ?")-- .run(at(minutesAgo), paste.slug);-- await db.prepare('UPDATE pastes SET views = ?, likes = ? WHERE slug = ?')-- .run(stats.views || 0, stats.likes || 0, paste.slug);-- };-- await rankPaste(asAlex, 'FreshRank recent', 10);-- await rankPaste(asAlex, 'FreshRank older', 2 * 24 * 60);-- await rankPaste(asAlex, 'EngagementRank low', 30);-- await rankPaste(asAlex, 'EngagementRank high', 30, { views: 10000, likes: 100 });-- await rankPaste(asAlex, 'FollowRank followed', 25);-- await rankPaste({ ...asAlex, subject: sam.subject_id }, 'FollowRank plain', 25);-- await rankPaste(asAlex, 'StaleRank recent', 4 * 60);-- await rankPaste(asAlex, 'StaleRank old', 10 * 24 * 60, { views: 100000, likes: 1000 });-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)-- VALUES ('live', 'stream', 'rank-live', 'LiveRank current', 'https://openvibe.live/@alex', ?, 'user', 'public', ?),-- ('live', 'stream', 'rank-offline', 'LiveRank offline', 'https://openvibe.live/@offline', ?, 'user', 'public', ?)`)-- .run(alex.subject_id, at(1), alex.subject_id, at(1));---- const titles = async (q, options = {}) => (await t.get(`/api/v1/feed?q=${q}&limit=40`, options)).json().items.map((x) => x.title);-- assert.deepStrictEqual(await titles('FreshRank'), ['FreshRank recent', 'FreshRank older']);-- assert.deepStrictEqual(await titles('EngagementRank'), ['EngagementRank high', 'EngagementRank low']);-- assert.deepStrictEqual(await titles('FollowRank', { cookies: [`ov_token=${samJwt}`] }), ['FollowRank followed', 'FollowRank plain']);-- assert.deepStrictEqual(await titles('StaleRank'), ['StaleRank recent', 'StaleRank old']);-- const live = await t.get('/api/v1/feed?q=LiveRank&limit=40');-- assert.deepStrictEqual(live.json().items.map((x) => [x.title, x.kind, x.live_now]), [-- ['LiveRank current', 'live', true], ['LiveRank offline', 'stream', false],-- ]);-- });---- await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {-- const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });-- assert.strictEqual(r.status, 200);-- assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'a site off the write list still reads it');-- assert.ok(!r.headers.get('access-control-allow-credentials'), 'never with cookies');-- const pre = await t.get('/api/v1/feed', { method: 'OPTIONS', headers: { origin: 'https://open.shoes', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });-- assert.strictEqual(pre.status, 204);-- assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/);-- const wall = await t.get('/api/v1/pulse', { headers: { origin: 'https://openvibe.pics' } });-- assert.strictEqual(wall.headers.get('access-control-allow-origin'), '*', 'the wall too');-- const write = await t.get('/api/v1/pulse/items', { method: 'OPTIONS', headers: { origin: 'https://evil.example', 'access-control-request-method': 'POST' } });-- assert.ok(!write.headers.get('access-control-allow-origin'), 'a write from elsewhere gets no CORS');-- });---- await check('small pages retain every Pulse item, including the final partially read page', async () => {-- const all = (await t.get('/api/v1/feed?limit=40')).json().items.filter((x) => x.id.startsWith('p:'));-- for (const limit of [1, 4]) {-- const seen = []; let cursor = ''; const cursors = new Set();-- do {-- const r = await t.get(`/api/v1/feed?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);-- assert.strictEqual(r.status, 200, r.text);-- const page = r.json();-- assert.ok(page.items.length <= limit, 'trending counts toward the page limit');-- seen.push(...page.items.filter((x) => x.id.startsWith('p:')).flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]));-- cursor = page.next_cursor;-- if (cursor) { assert.ok(!cursors.has(cursor), 'cursor advances'); cursors.add(cursor); }-- assert.ok(cursors.size < 40, 'pagination terminates');-- } while (cursor);-- assert.deepStrictEqual([...seen].sort(), all.flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]).sort(), `limit=${limit}: no missing or repeated rows`);-- }-- });---- await check('filtered pages keep scanning after an empty bounded page', async () => {-- // More than the eight 40-row scans in one call, ahead of the existing Wiki fixture.-- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at)-- SELECT 'feed-scan-test', 'event', n::text, 'Unrelated event', 'https://openvibe.today/', 'system', 'public', ?-- FROM generate_series(1, 321) n`).run(at(1));-- try {-- const first = (await t.get('/api/v1/feed?site=wiki&limit=1')).json();-- assert.deepStrictEqual(first.items, []);-- assert.ok(first.next_cursor, 'empty bounded scan is not the end');-- const next = (await t.get(`/api/v1/feed?site=wiki&limit=1&cursor=${encodeURIComponent(first.next_cursor)}`)).json();-- assert.strictEqual(next.items.length, 1);-- assert.strictEqual(next.items[0].site, 'wiki');-- } finally { await db.prepare("DELETE FROM pulse_items WHERE source_service = 'feed-scan-test'").run(); }-- });---- // ── POST /api/v1/posts with pictures: the Feed shows it at once ──-- const upload = async (jwt) => {-- const fd = new FormData();-- fd.append('file', new Blob([PNG], { type: 'image/png' }), 'cat.png');-- return await t.get('/api/v1/spaces/general/attachments', { method: 'POST', body: fd, cookies: [`ov_token=${jwt}`] });-- };-- const json = async (jwt, path, body) => await t.get(path, { method: 'POST', headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });---- await check('a post with a picture: alt is required, then the Feed has its text and picture at once', async () => {-- const up = await upload(alexJwt);-- assert.strictEqual(up.status, 201, up.text);-- const mediaId = up.json().attachment.media_id;-- const bad = await json(samJwt, '/api/v1/posts', { text: 'no', pictures: [{ media_id: mediaId, alt: '' }] });-- assert.strictEqual(bad.status, 400, bad.text);-- const r = await json(alexJwt, '/api/v1/posts', { text: 'Look at this cat', pictures: [{ media_id: mediaId, alt: 'A white cat' }] });-- assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.json().pictures[0].alt, 'A white cat');-- const j = (await t.get('/api/v1/feed?q=Look%20at%20this%20cat&limit=40')).json();-- assert.strictEqual(j.items.length, 1, JSON.stringify(j.items));-- assert.strictEqual(j.items[0].kind, 'post');-- assert.strictEqual(j.items[0].text, 'Look at this cat');-- assert.ok(j.items[0].media && j.items[0].media.thumb.endsWith(`/o/${mediaId}`));-- assert.deepStrictEqual(j.items[0].counts, { views: 0, likes: 0, comments: 0 });-- const reused = await json(alexJwt, '/api/v1/posts', { text: 'Again', pictures: [{ media_id: mediaId, alt: 'stolen' }] });-- assert.strictEqual(reused.status, 400, reused.text);-- });---- await check('a picture uploaded straight to Media (no local attachment row) is checked for its owner and attached', async () => {-- const stored = await t.app.locals.pasteMedia.upload({ buffer: PNG, filename: 'direct.png', mime: 'image/png', owner: alex.subject_id, visibility: 'unlisted' });-- const r = await json(alexJwt, '/api/v1/posts', { text: 'Straight from Media', pictures: [{ media_id: stored.key, alt: 'Direct cat' }] });-- assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.json().pictures[0].alt, 'Direct cat');-- const j = (await t.get('/api/v1/feed?q=Straight%20from%20Media&limit=40')).json();-- assert.strictEqual(j.items.length, 1, JSON.stringify(j.items));-- assert.ok(j.items[0].media.thumb.endsWith(`/o/${stored.key}`));-- const row = await db.prepare('SELECT alt FROM attachments WHERE media_id = ?').get(stored.key);-- assert.strictEqual(row.alt, 'Direct cat');-- });---- await check('a post keeps a PDF composer attachment and renders it as a download', async () => {-- const id = 'att_01ABCDEFGHJKMNPQRSTVWXYZ01';-- const original = global.fetch;-- let kept = false;-- global.fetch = (url, options) => {-- if (String(url).endsWith(`/api/v1/attachments/${id}/keep`)) {-- kept = options.method === 'POST' && options.headers.Authorization === `Bearer ${alexJwt}`;-- return Promise.resolve(new Response(JSON.stringify({ id, kind: 'file', name: 'notes.pdf', mime: 'application/pdf', size: 123, url: `https://openvibe.media/a/${id}/notes.pdf` }), { status: 200, headers: { 'Content-Type': 'application/json' } }));-- }-- return original(url, options);-- };-- try {-- const r = await json(alexJwt, '/api/v1/posts', { text: 'Here are my notes', attachments: [id] });-- assert.strictEqual(r.status, 201, r.text);-- assert.ok(kept, 'Media keeps the file using the person token');-- assert.strictEqual(r.json().attachments[0].id, id);-- const row = await db.prepare('SELECT filename, mime, url FROM attachments WHERE media_id = ?').get(id);-- assert.strictEqual(row.filename, 'notes.pdf');-- assert.strictEqual(row.mime, 'application/pdf');-- assert.ok(row.url.endsWith('/notes.pdf'));-- const page = await t.get(new URL(r.json().url).pathname);-- assert.ok(page.text.includes('notes.pdf'), 'the post page links the file');-- } finally { global.fetch = original; }-- });---- await check('a signed-out post, a text with extra fields and more than four pictures are refused', async () => {-- assert.strictEqual((await t.get('/api/v1/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: 'x' }) })).status, 401);-- let r = await json(alexJwt, '/api/v1/posts', { text: 'x', secret: 1 });-- assert.strictEqual(r.status, 400);-- r = await json(alexJwt, '/api/v1/posts', { text: 'x', pictures: [{ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', alt: '1' }] });-- assert.strictEqual(r.status, 400);-- });---- await done(t);--})();@@@ -3,7 +3,7 @@const assert = require('assert');const { feedPage } = require('../server/render/feed');--const page = feedPage({ kind: 'video', cursor: 'page-one', nextCursor: 'page-two', items: [{ source: { service: 'media-hub', type: 'video' }, id: 3, url: 'https://openvibe.video/watch/3', title: 'A video', text: 'Fresh upload', occurred_at: '2026-11-10 12:00:00', picture_url: 'https://openvibe.media/thumb.webp', actor: { display_name: 'Ada' } }] });++const page = feedPage({ kind: 'video', cursor: 'page-one', nextCursor: 'page-two', items: [{ id: 'f:media-hub:video:3', kind: 'video', url: 'https://openvibe.video/watch/3', title: 'A video', text: 'Fresh upload', at: '2026-11-10T12:00:00.000Z', site: 'media-hub', site_name: 'Video', actor: { name: 'Ada', avatar: null }, subject: null, media: { thumb: 'https://openvibe.media/thumb.webp', ratio: 1.777 }, counts: {} }] });assert.match(page, /href="\/feed\?kind=video" aria-current="page"/);assert.match(page, /A video/);assert.match(page, /Fresh upload/);nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/**++ * Pulse reads Network's one feed (feed.item@1): GET /api/v1/feed and /api/v1/pulse are the proxy, the++ * same ids as the menu; GET /api/v1/activity is Community's own public rows for Network's collector;++ * /feed and /pulse render those cards. No Pulse store is read or written.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++const ITEM = {++ id: 'f:community:thread:1', kind: 'post', url: 'https://openvibe.community/forum/status/hello',++ title: 'Hello from the one feed', text: 'one line', at: new Date().toISOString(), site: 'community', site_name: 'Community',++ actor: { name: 'Alex', avatar: null }, subject: 'usr_01JQ8Z6K6M0V8Y2Q3R4S5T6V7W', media: null, counts: { likes: 2 },++};++++(async () => {++ const asked = [];++ const feedFetch = async (url, opts = {}) => {++ const u = new URL(String(url));++ asked.push({ path: u.pathname, query: u.searchParams, auth: opts.headers && opts.headers.Authorization });++ const body = u.pathname === '/api/v1/feed/examples' ? { examples: [{ text: 'new photos', reason: 'kind' }] }++ : u.pathname === '/api/v1/feed/wall' ? { items: [{ ...ITEM, source: { service: 'community', type: 'thread', id: '1' }, origin: 'user', occurred_at: ITEM.at }], next_cursor: null }++ : { items: [ITEM], next_cursor: 'next' };++ return { ok: true, status: 200, async text() { return JSON.stringify(body); } };++ };++ const t = await boot({ authority: 'community', appOpts: { feedFetch, startRelay: false, startSharingWorker: false } });++++ await check('GET /api/v1/feed is Network\'s feed, filters and the person\'s token passed through', async () => {++ const r = await t.get('/api/v1/feed?kind=clip&limit=20', { headers: { authorization: 'Bearer user-token-123456' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().items[0].id, ITEM.id);++ const last = asked[asked.length - 1];++ assert.deepStrictEqual([last.path, last.query.get('kind'), last.query.get('limit')], ['/api/v1/feed', 'clip', '20']);++ assert.strictEqual(last.auth, 'Bearer user-token-123456');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ const anon = await t.get('/api/v1/feed');++ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=30');++ });++++ await check('GET /api/v1/feed/wall and the old /api/v1/pulse return the same rows and ids', async () => {++ const wall = await t.get('/api/v1/feed/wall?limit=5');++ const pulse = await t.get('/api/v1/pulse?limit=5&after=cursor-1');++ assert.strictEqual(wall.json().items[0].id, pulse.json().items[0].id);++ const last = asked[asked.length - 1];++ assert.deepStrictEqual([last.path, last.query.get('cursor')], ['/api/v1/feed/wall', 'cursor-1']);++ });++++ await check('the /feed page draws the feed cards and its examples', async () => {++ const r = await t.get('/feed?kind=post');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Hello from the one feed/);++ assert.match(r.text, /new photos/);++ });++++ await check('the /pulse page keeps its look, drawing the same feed rows', async () => {++ const r = await t.get('/pulse');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Hello from the one feed/);++ });++++ await check('GET /api/v1/activity is Community\'s public rows only', async () => {++ const at = new Date().toISOString().replace('T', ' ').slice(0, 19);++ await t.db.prepare(`INSERT INTO pastes (slug, owner_subject, origin, type, title, content, visibility, created_at)++ VALUES (?, ?, 'user', 'paste', ?, ?, ?, ?)`).run('public-one', ITEM.subject, 'A public paste', 'hello', 'public', at);++ await t.db.prepare(`INSERT INTO pastes (slug, owner_subject, origin, type, title, content, visibility, created_at)++ VALUES (?, ?, 'user', 'paste', ?, ?, ?, ?)`).run('private-one', ITEM.subject, 'A private paste', 'secret', 'private', at);++ const r = await t.get('/api/v1/activity?limit=50');++ assert.strictEqual(r.status, 200, r.text);++ const items = r.json().items;++ assert.ok(items.some((i) => i.id === 'public-one' && i.url === 'https://openvibe.community/p/public-one'));++ assert.ok(!items.some((i) => i.id === 'private-one'), 'a private paste never leaves Community');++ assert.ok(items.every((i) => i.service === 'community' && i.at && i.url));++ });++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });ddeleted file mode 100644@@@ -1,91 +0,0 @@--'use strict';--/**-- * The menu Feed's two Live cards (server/pulse/feed.js):-- * a Rundown episode is kind 'rundown': the actor is The Rundown with its own mark, the streamer it is about is-- * `subject` {name, handle, avatar}, the art is the episode's AI picture (the stream thumbnail when none), and it is-- * never labelled "System"; a recording keeps its own actor, carries its length as `duration_s` (never a bare-- * media.length badge) and links to the recording's watch page, not the channel.-- */--const assert = require('assert');--const http = require('http');--const { boot, check, done } = require('./helpers/app');--const { createPulseSources } = require('../server/pulse/sources');----(async () => {-- const t = await boot();-- const now = new Date().toISOString().replace('T', ' ').slice(0, 19);-- const insert = t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, actor_name, origin, visibility, occurred_at, picture_url, media_ratio, media_length, subject_json)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?, ?)`);-- await insert.run('live', 'episode', '3005', 'WW3 Prep: The Case Against Smooth Apologies', 'https://openvibe.live/rundown/e/3005',-- null, 'The Rundown', 'system', now, 'https://openvibe.media/f/rundown-picture.jpg', 1200 / 630, null,-- JSON.stringify({ name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' }));-- await insert.run('live', 'recording', 'vod-6393', 'Recording: WW3 prep', 'https://openvibe.video/watch?v=ivkluOg7ieQ',-- 'usr_01KKT9AC60KM7CRTB3WN1Z8P56', 'Maticus', 'user', now, 'https://openvibe.media/t/vod-6393.jpg', 16 / 9, 5203, null);---- const page = await t.get('/api/v1/feed?limit=10');-- assert.strictEqual(page.status, 200);-- const body = await page.json();-- const rundown = body.items.find((x) => x.id === 'p:1');-- assert.ok(rundown, 'the Rundown card is in the feed');-- assert.strictEqual(rundown.kind, 'rundown');-- assert.deepStrictEqual(rundown.actor, { name: 'The Rundown', mark: 'rundown', avatar: null });-- assert.deepStrictEqual(rundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });-- assert.strictEqual(rundown.image, 'https://openvibe.media/f/rundown-picture.jpg');-- assert.strictEqual(rundown.media.thumb, 'https://openvibe.media/f/rundown-picture.jpg');-- assert.ok(Math.abs(rundown.media.ratio - 1200 / 630) < 1e-9, 'the AI picture keeps its 1200×630 ratio');-- assert.strictEqual(rundown.label, undefined, 'The Rundown, not "System"');-- assert.ok(rundown.url.endsWith('/rundown/e/3005'));---- const recording = body.items.find((x) => x.id === 'p:2');-- assert.ok(recording, 'the recording is in the feed');-- assert.strictEqual(recording.kind, 'video');-- assert.strictEqual(recording.actor.name, 'Maticus');-- assert.strictEqual(recording.duration_s, 5203, 'the length is a duration, not a bare number');-- assert.ok(!recording.media.length, 'no bare length badge on a recording');-- assert.strictEqual(recording.media.thumb, 'https://openvibe.media/t/vod-6393.jpg');-- assert.strictEqual(recording.url, 'https://openvibe.video/watch?v=ivkluOg7ieQ', "the recording's watch page, not the channel");---- const only = await t.get('/api/v1/feed?kind=rundown&limit=10');-- const onlyBody = await only.json();-- assert.deepStrictEqual(onlyBody.items.map((x) => x.kind), ['rundown'], 'kind=rundown filters to the episode');---- // The stored Pulse item carries the subject and duration too (the API a service reads).-- const pulse = await t.get('/api/v1/pulse');-- const items = (await pulse.json()).items;-- const pRundown = items.find((x) => x.source.type === 'episode');-- assert.deepStrictEqual(pRundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });-- assert.strictEqual(pRundown.duration_s, undefined);-- const pRecording = items.find((x) => x.source.type === 'recording');-- assert.strictEqual(pRecording.duration_s, 5203);---- // ── The Live source itself: an episode's picture and subject are stored with the Pulse item ──-- const live = http.createServer((req, res) => {-- res.setHeader('content-type', 'application/json');-- if (req.url.startsWith('/api/rundown/streams')) return res.end(JSON.stringify({ streams: [{-- stream_id: 4242, live: false, thumbnail_url: 'https://openvibe.media/t/stream-thumb.jpg',-- user: { username: 'maticus', display_name: 'Maticus', avatar_url: 'https://openvibe.media/o/med_maticus' },-- episode: { title: 'The Fridge Files', logline: 'A night of cold takes.', aired_at: '2026-11-12 03:00:00', audience: 'public', picture_url: 'https://openvibe.media/f/episode-pic.jpg' },-- }], next: null }));-- res.statusCode = 404; res.end('{}');-- });-- await new Promise((r) => live.listen(0, '127.0.0.1', r));-- const url = `http://127.0.0.1:${live.address().port}`;-- const sources = createPulseSources({ db: t.db, config: { liveUrl: url, todayUrl: url, todayInternalUrl: url, networkUrl: url, networkInternalUrl: url, questUrl: url, questInternalUrl: url, memeUrl: url, memeInternalUrl: url, picsUrl: url, spaceUrl: url, playUrl: url } });-- const ran = await sources.runOnce({ force: true });-- const rundownSource = ran.find((x) => x.name === 'rundown-episodes');-- assert.ok(rundownSource && rundownSource.items === 1, 'the Rundown source read one episode');-- const stored = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode' AND source_id = '4242'").get();-- assert.strictEqual(stored.title, 'The Fridge Files', 'the actor carries the name, so the title does not repeat it');-- assert.strictEqual(stored.actor_name, 'The Rundown');-- assert.strictEqual(stored.picture_url, 'https://openvibe.media/f/episode-pic.jpg');-- assert.deepStrictEqual(JSON.parse(stored.subject_json), { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_maticus' });-- assert.ok(Math.abs(Number(stored.media_ratio) - 1200 / 630) < 1e-9);-- const fed = await (await t.get('/api/v1/feed?limit=20')).json();-- const sourceCard = fed.items.find((x) => x.id === 'p:3');-- assert.strictEqual(sourceCard.kind, 'rundown');-- assert.strictEqual(sourceCard.subject.handle, '@maticus');-- live.close();---- console.log('feed shape: Rundown kind/actor/mark/subject/image and recording duration_s/watch link ok');-- await done(t);--})().catch((e) => { console.error(e); process.exit(1); });@@@ -11,15 +11,20 @@ const path = require('path');const { boot, check, done } = require('./helpers/app');(async () => {-- const t = await boot();++ // The home's "Across OpenVibe" is the one feed's wall view (Network feed.item@1). A recorded wall++ // response stands in for Network: one row a place, a site mark, the AI label and its picture.++ const wall = { items: [++ { id: 'f:network:members:n1', source: { service: 'network', type: 'members', id: 'n1' }, title: '3 people joined today', url: 'https://openvibe.network/', origin: 'system', actor: null, occurred_at: new Date().toISOString() },++ { id: 'f:live:stream:s1', source: { service: 'live', type: 'stream', id: 's1' }, title: 'Dee went live: Raid night', url: 'https://openvibe.live/@Dee', origin: 'system', actor: null, occurred_at: new Date().toISOString() },++ { id: 'f:today:event:t1', source: { service: 'today', type: 'event', id: 't1' }, title: 'Live: Raid night', url: 'https://openvibe.live/@Dee', origin: 'system', actor: null, occurred_at: new Date().toISOString() },++ { id: 'f:blog:post:b1', source: { service: 'blog', type: 'post', id: 'b1' }, title: 'A new post', url: 'https://openvibe.blog/a-new-post', origin: 'ai', label: 'AI', actor: null,++ picture_url: 'https://openvibe.media/o/med_01JZ00000000000000000000B1', text: 'What changed this week, in one paragraph.', occurred_at: new Date().toISOString() },++ ] };++ const feedFetch = async (url) => ({ ok: true, status: 200, async text() { return JSON.stringify(String(url).includes('/wall') ? wall : { items: [], next_cursor: null }); } });++ const t = await boot({ appOpts: { feedFetch } });try {await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'test-item', 'A test item', 'in_progress', ov_now())").run();await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'done-item', 'A finished item', 'done', ov_now())").run();-- const pulse = (type, id, title, url, origin, extra = '') => t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at${extra ? ', picture_url, text' : ''}) VALUES (?, ?, ?, ?, ?, ?, 'public', ov_now()${extra ? ', ?, ?' : ''})`);-- await pulse().run('network', 'members', 'n1', '3 people joined today', 'https://openvibe.network/', 'system');-- await pulse().run('live', 'stream', 's1', 'Dee went live: Raid night', 'https://openvibe.live/@Dee', 'system');-- await pulse().run('today', 'event', 't1', 'Live: Raid night', 'https://openvibe.live/@Dee', 'system');-- await pulse('x', 'x', 'x', 'x', 'x', 'with').run('blog', 'update', 'b1', 'A new post', 'https://openvibe.blog/a-new-post', 'ai', 'https://openvibe.media/o/med_01JZ00000000000000000000B1', 'What changed this week, in one paragraph.');await check('the roadmap and the pulse are two sections, not a pick block', async () => {const r = await t.get('/');@@@ -179,9 +179,11 @@ function startVip() {});await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {-- const pulse = (await call('/api/v1/pulse')).text;++ // The one feed reads Community's public activity directly (server/activity/service.js), so a gated++ // thread is absent there rather than removed from a store later.++ const pulse = (await call('/api/v1/activity?limit=1000')).text;assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));-- assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);++ assert.ok(!pulse.includes(`"id":"${secret.id}"`) && !pulse.includes(`"id":"${showcaseThread.id}"`));const sitemap = (await call('/sitemap.xml')).text;assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');const feed = (await call('/forum/feed.xml')).text;@@@ -191,12 +193,9 @@ function startVip() {assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));// A thread gated after the fact leaves Pulse as well.const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;-- assert.ok((await call('/api/v1/pulse')).text.includes('Soon private'));++ assert.ok((await call('/api/v1/activity?limit=1000')).text.includes('Soon private'));await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });-- assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));-- // Pulse's read-time check alone hides an item nothing removed.-- await t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));-- assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));++ assert.ok(!(await call('/api/v1/activity?limit=1000')).text.includes('Soon private'), 'gating it takes it out of the public activity');});await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {ddeleted file mode 100644@@@ -1,202 +0,0 @@--'use strict';--/** Pulse's Events consumer: signed deliveries become Pulse items once; forged, proxied or non-public ones never do. */--const assert = require('assert');--const http = require('http');--const express = require('express');--const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { testDb } = require('./helpers/db');--const { createPulseConsumer } = require('../server/pulse/consumer');----let SECRET, db, app, vipDrops, USR, seq, evt;----(async () => {-- SECRET = `whsec_${'ab'.repeat(32)}`;-- db = await testDb();-- app = express();-- vipDrops = [];-- app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);-- USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';-- seq = 0;-- evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),-- subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },-- payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });-- const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });-- const post = (event, { secret = SECRET, headers = {} } = {}) => {-- const body = JSON.stringify({ event, seq: 1 });-- return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })-- .then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));-- };-- const items = async () => await db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();---- const e1 = evt();-- let r = await post(e1);-- assert.strictEqual(r.status, 200); assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual(await items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);-- r = await post(e1);-- assert.strictEqual(r.json.duplicate, true, 'a redelivery does nothing');-- assert.strictEqual((await items()).length, 1);---- assert.strictEqual((await post(evt(), { secret: `whsec_${'cd'.repeat(32)}` })).status, 401, 'forged signature');-- assert.strictEqual((await post(evt(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 403, 'came through the proxy');-- r = await post(evt({ visibility: 'internal', subject: { type: 'stream', id: '9', revision: 1 } }));-- assert.strictEqual(r.json.outcome, 'ignored:not_public');-- r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },-- // The contract's shape (blog.post.published v1): the blog is { id, handle }; the title names the handle.-- payload: { blog: { id: 'blg_01M00000000000000000000000', handle: 'openvibe' }, canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');-- assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');-- r = await post(evt({ event_type: 'mediahub.video.published', source: 'media-hub', subject: { type: 'video', id: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ' },-- payload: { upload: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: { type: 'user', id: USR }, title: 'A public video', url: 'https://openvibe.video/u/vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', published_at: new Date().toISOString() } }));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual([(await items())[2].source_service, (await items())[2].source_type, (await items())[2].title], ['media-hub', 'video', 'A public video']);-- r = await post(evt({ event_type: 'games.world.published', source: 'games', subject: { type: 'world', id: 'w0123456789' },-- payload: { world: 'w0123456789', owner: { type: 'user', id: USR }, title: 'Beacon Hill', url: 'https://openvibe.games/play?world=w0123456789', published_at: new Date().toISOString() } }));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual([(await items())[3].source_service, (await items())[3].source_type, (await items())[3].title], ['games', 'world', 'Beacon Hill']);-- r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },-- payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));-- assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');-- assert.strictEqual((await items()).length, 4);-- // VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.-- const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },-- payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });-- const v1 = vipEv('vip');-- r = await post(v1);-- assert.strictEqual(r.json.outcome, 'vip:invalidated');-- r = await post(v1);-- assert.strictEqual(r.json.duplicate, true, 'a redelivery drops nothing twice');-- r = await post(vipEv('live'));-- assert.strictEqual(r.json.outcome, 'ignored:source');-- assert.deepStrictEqual(vipDrops, [v1.event_id]);-- assert.strictEqual((await items()).length, 4, 'no Pulse item for a membership');-- // Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.-- const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';-- const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },-- payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', namespace_owner: 'games', schema_version: 1, revision: 1, change: 'updated', reason: 'write', keys: ['level'], public: { level, achievements: 3, playtime_hours: 2 } }, ...over });-- r = await post(mod(7));-- assert.strictEqual(r.json.outcome, 'games:baseline', 'the first record seen is a baseline, not a stale announcement');-- r = await post(mod(9));-- assert.strictEqual(r.json.outcome, 'games:no_milestone');-- const m11 = mod(11);-- r = await post(m11);-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.deepStrictEqual((await items())[4], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });-- assert.strictEqual((await post(m11)).json.duplicate, true);-- r = await post(mod(6));-- assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');-- r = await post(mod(12));-- assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');-- r = await post(mod(15));-- assert.strictEqual((await items())[5].title, 'Reached level 15 in Scraplandia');-- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');-- assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');-- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');-- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');-- assert.strictEqual((await items()).length, 6);-- // A dropped connection is a new Live session (a new subject id), but the stream is one broadcast.-- const store = require('../server/pulse/store');-- const T = Date.parse('2026-06-01T10:00:00.000Z');-- const liveAt = (stream, at, title, { actor = USR, ...over } = {}) => evt({-- subject: { type: 'stream', id: String(stream), revision: 1 },-- payload: { stream_id: stream, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: actor } }, title, started_at: new Date(at).toISOString() },-- ...over,-- });-- const shows = async (title) => await db.prepare("SELECT id, source_id, occurred_at FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND title = ? ORDER BY id").all(title);-- const REPEAT = 'Goosely went live: Repeat show';-- r = await post(liveAt(9001, T, 'Repeat show'));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- r = await post(liveAt(9002, T + 20 * 60_000, 'Repeat show'));-- assert.strictEqual(r.json.outcome, 'pulse:updated', 'a reconnect within six hours is the same broadcast');-- let shown = await shows(REPEAT);-- assert.strictEqual(shown.length, 1, 'one item, not one per session');-- assert.strictEqual(shown[0].source_id, '9001', 'the item keeps its id (and source)');-- assert.strictEqual(shown[0].occurred_at, require('../server/pulse/store').pulseTime(new Date(T + 20 * 60_000).toISOString()), 'its time is the later start (stored in the one spelling)');---- r = await post(liveAt(9003, T + 30 * 60_000, 'New show'));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.strictEqual((await shows(REPEAT)).length, 1, 'the older broadcast is untouched');-- assert.strictEqual((await shows('Goosely went live: New show')).length, 1, 'a different title is a new item');-- assert.strictEqual((await items()).length, 8);---- r = await post(liveAt(9004, T + 7 * 3_600_000, 'Repeat show'));-- assert.strictEqual(r.json.outcome, 'pulse:created', 'more than six hours later is a new broadcast');-- assert.strictEqual((await shows(REPEAT)).length, 2);-- assert.strictEqual((await items()).length, 9);---- // The channel URL is the identity too, when the event names another acting subject for it.-- r = await post(liveAt(9005, T + 10 * 60_000, 'URL show'));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- r = await post(liveAt(9006, T + 25 * 60_000, 'URL show', { actor: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }));-- assert.strictEqual(r.json.outcome, 'pulse:updated');-- assert.strictEqual((await shows('Goosely went live: URL show')).length, 1);-- assert.strictEqual((await items()).length, 10);---- // Duplicates already in the table: the feed read shows one line per channel+title per six hours,-- // at the newest time, without deleting the rows.-- const PRE = 'Goosely went live: Old repeat';-- const pre = db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, occurred_at)-- VALUES ('live', 'stream', ?, ?, 'https://openvibe.live/@old', ?, 'user', ?)`);-- await pre.run('pre1', PRE, USR, '2026-06-02T10:00:00.000Z');-- await pre.run('pre2', PRE, USR, '2026-06-02T10:20:00.000Z');-- await pre.run('pre3', PRE, USR, '2026-06-02T17:00:00.000Z');-- const feed = await store.listItems(db, { limit: 100 });-- assert.deepStrictEqual(feed.rows.filter((x) => x.title === PRE).map((x) => x.source_id), ['pre3', 'pre2'], 'one line per six-hour window, at the newest time');-- assert.strictEqual((await db.prepare('SELECT COUNT(*)::int AS n FROM pulse_items WHERE title = ?').get(PRE)).n, 3, 'the rows themselves stay');---- // Wiki: a published page, then an edit (wiki.page.updated) — one item that moves to the newer time.-- const wiki = (type, over = {}) => evt({ event_type: type, source: 'wiki', subject: { type: 'page', id: 'pg_3' },-- payload: { canonical_url: 'https://openvibe.wiki/w/help/edited', publication_state: 'published', visibility: 'public', space: 'help', slug: 'edited', indexability: { decision: 'index', reasons: [] } }, ...over });-- r = await post(wiki('wiki.page.published'));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- const oldAt = (await db.prepare("SELECT occurred_at FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_3'").get()).occurred_at;-- const later = new Date(Date.now() + 1000).toISOString();-- r = await post(wiki('wiki.page.updated', { timestamp: later }));-- assert.strictEqual(r.json.outcome, 'pulse:updated');-- const wikiRows = await db.prepare("SELECT * FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_3'").all();-- assert.strictEqual(wikiRows.length, 1, 'one row per page');-- assert.notStrictEqual(wikiRows[0].occurred_at, oldAt, 'an edit moves it up');-- // With a title (contracts 0.366.0) the item is named by it, not by where it lives.-- r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_titled' },-- payload: { canonical_url: 'https://openvibe.wiki/w/help/titled', publication_state: 'published', visibility: 'public', space: 'help', slug: 'titled', title: 'How to go live from a phone', indexability: { decision: 'index', reasons: [] } } }));-- const titled = await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_titled'").get();-- assert.strictEqual(titled && titled.title, 'How to go live from a phone', JSON.stringify(r.json));-- assert.strictEqual(wikiRows[0].source_type, 'page');---- // Chat: a public room's fifth message in an hour becomes one summary card, refreshed only every five after that.-- const chat = (n) => evt({ event_type: 'chat.room.message.created', source: 'chat', visibility: 'public', subject: { type: 'chat_room_message', id: String(n) }, actor: { type: 'user', id: USR },-- payload: { message_id: n, room: { slug: 'general', name: 'General' }, user_subject: USR, username: 'goosely', display_name: 'Goosely', text: `hello ${n}`, created_at: '2026-06-03T10:05:00.000Z' } });-- for (let n = 1; n <= 4; n++) assert.strictEqual((await post(chat(n))).json.outcome, 'chat:counted');-- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'chat'").get()).n, 0);-- const fifth = chat(5);-- r = await post(fifth);-- assert.strictEqual(r.json.outcome, 'pulse:created');-- const created = (await db.prepare("SELECT * FROM pulse_items WHERE source_service = 'chat'").all())[0];-- assert.strictEqual(created.title, '#general: 5 messages this hour');-- assert.strictEqual(created.url, 'https://openvibe.chat/rooms/general');-- assert.strictEqual(created.origin, 'system');-- assert.strictEqual(created.actor_subject, null);-- r = await post(fifth);-- assert.strictEqual(r.json.duplicate, true, 'a redelivery is not a sixth message');-- await post(chat(6));-- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'chat'").get()).n, 1);-- assert.strictEqual((await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'chat'").get()).title, '#general: 5 messages this hour', 'no write every message');-- await post(chat(7)); await post(chat(8)); await post(chat(9)); await post(chat(10));-- assert.strictEqual((await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'chat'").get()).title, '#general: 10 messages this hour');---- const profile = (revision, changed, avatar_url, banned = false) => evt({ event_type: 'network.user.updated', source: 'network', visibility: 'internal',-- subject: { type: 'user', id: USR }, actor: { type: 'user', id: USR },-- payload: { subject: { type: 'user', id: USR }, revision, changed, username: 'goosely', display_name: 'Goosely', avatar_url, banned } });-- r = await post(profile(1, ['avatar_url'], 'https://openvibe.media/avatar1.webp'));-- assert.strictEqual(r.json.outcome, 'pulse:created');-- assert.strictEqual((await db.prepare("SELECT picture_url FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).picture_url, 'https://openvibe.media/avatar1.webp');-- r = await post(profile(3, ['avatar_url'], null));-- assert.strictEqual(r.json.outcome, 'avatar:removed');-- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).n, 0);-- r = await post(profile(2, ['avatar_url'], 'https://openvibe.media/old.webp'));-- assert.strictEqual(r.json.outcome, 'ignored:old_revision', 'an older event cannot restore an avatar');---- srv.close();-- console.log('pulse consumer: all checks passed');-- process.exit(0);--})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,205 +0,0 @@--'use strict';--/**-- * Pulse's due-based public sources (server/pulse/sources.js): Today's calendar, Network's open stats and Quest's-- * public badges fill Pulse without any per-feed call, and pulse_source_runs makes each source wait its interval.-- */--const assert = require('assert');--const { boot, check, done } = require('./helpers/app');----const iso = (ms) => new Date(ms).toISOString();--const day = (ms) => new Date(ms).toISOString().slice(0, 10);----(async () => {-- const today = day(Date.now());-- const yesterday = day(Date.now() - 86_400_000);-- const badgeAt = iso(Date.now() - 3_600_000);-- const calls = [];-- let liveStreams = [];-- let extraContent = [];-- let galleryRows = [-- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,-- links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },-- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPR', title: 'Second photo', author: 'Alex', listed: true, published_at: badgeAt,-- links: { page: 'https://openvibe.pics/i/secondpic', thumb: 'https://openvibe.pics/i/secondpic/thumb.webp' }, width: 800, height: 600 },-- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Unlisted photo', author: 'Alex', listed: false, published_at: badgeAt,-- links: { page: 'https://openvibe.pics/i/privatep', thumb: 'https://openvibe.pics/i/privatep/thumb.webp' }, width: 800, height: 600 },-- ];-- let eventOwner = null;-- const sourcesFetch = async (url) => {-- calls.push(String(url));-- const u = String(url);-- if (u.includes('/api/v1/events')) return { ok: true, json: async () => ({ events: [-- { id: 'tev_01JAB2C3D4E5F6G7H8J9K0MNPQ', kind: 'event', title: 'Launch party', description: 'Come along', starts_at: iso(Date.now() + 86_400_000), created_at: iso(Date.now() - 60_000), visibility: 'public', status: 'scheduled', owner_subject: eventOwner, url: null },-- { id: 'tev_cancelled', kind: 'event', title: 'Cancelled', visibility: 'public', status: 'cancelled', owner_subject: null },-- { id: 'tev_private', kind: 'event', title: 'Private', visibility: 'followers', status: 'scheduled', owner_subject: null },-- ] }) };-- if (u.includes('/api/v1/stats')) return { ok: true, json: async () => ({ accounts: { total: 1234, daily: [{ day: today, n: 5 }, { day: yesterday, n: 3 }, { day: '2026-01-01', n: 0 }] } }) };-- if (u.includes('/api/v1/entries')) return { ok: true, json: async () => ({ entries: [{ slug: 'open-source-meme', title: 'Open source meme', summary: 'A public entry', published_at: iso(Date.now() - 120_000) }], next_cursor: null }) };-- if (u.includes('/api/v1/gallery')) return { ok: true, json: async () => ({ pictures: galleryRows, next: null }) };-- if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,-- title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',-- episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };-- if (u.endsWith('/api/streams')) return { ok: true, json: async () => ({ streams: liveStreams }) };-- if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [-- { id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },-- { id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },-- ...extraContent,-- ], next: null, sources: { vods: 'ok', clips: 'ok' }, partial: false }) };-- if (u.includes('/api/v1/apps/gallery')) return { ok: true, json: async () => ({ spaces: [{-- id: 'spc_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'A shared map', description: 'A map of places',-- owner_name: 'Alex', url: 'https://map.openvibe.website', published_at: Date.now() - 60_000,-- }], next_cursor: null }) };-- if (u.includes('/badges')) return { ok: true, json: async () => ({ badges: [-- { id: 'first-post', name: 'First Post', awarded_at: badgeAt },-- { id: 'old-badge', name: 'Old Badge', awarded_at: iso(Date.now() - 90 * 86_400_000) },-- ] }) };-- if (u.includes('/achievements/feed')) return { ok: true, json: async () => ({ items: [-- { player: eventOwner, title: 'scraplandia', title_name: 'Scraplandia', id: 'first-haul', name: 'First Haul', description: 'Find your first haul', unlocked_at: badgeAt },-- ], next: null }) };-- return { ok: false, status: 404, json: async () => ({}) };-- };---- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { sourcesFetch, startRelay: false, startSharingWorker: false } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- eventOwner = alex.subject_id;-- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };-- await t.app.locals.pastes.createText(asAlex, { title: 'A paste', content: 'Hello' });---- await check('the due job fills today, the member count and a recent Quest badge, and skips old/cancelled rows', async () => {-- const out = await t.app.locals.pulseSources.runOnce({ force: true });-- const by = Object.fromEntries(out.map((x) => [x.name, x]));-- assert.strictEqual(by['today-events'].items, 1, JSON.stringify(out));-- assert.strictEqual(by['network-members'].items, 2);-- assert.strictEqual(by['quest-badges'].items, 1);-- assert.strictEqual(by['play-achievements'].items, 1);-- assert.strictEqual(by['meme-entries'].items, 1);-- assert.strictEqual(by['public-pictures'].items, 2);-- assert.strictEqual(by['rundown-episodes'].items, 1);-- assert.strictEqual(by['public-content'].items, 2);-- assert.strictEqual(by['public-spaces'].items, 1);-- const feed = (await t.get('/api/v1/feed?limit=40')).json();-- const ev = feed.items.find((x) => x.kind === 'event');-- assert.strictEqual(ev.title, 'Coming up: Launch party');-- assert.match(ev.text, /^Starts .* UTC · Come along$/);-- assert.strictEqual(ev.actor.name, 'Alex');-- const members = feed.items.filter((x) => x.kind === 'member');-- assert.deepStrictEqual(members.map((x) => x.title).sort(), [`3 people joined on ${yesterday}`, '5 people joined today']);-- assert.ok(members.every((x) => x.actor === null && x.site === 'network'));-- const q = feed.items.find((x) => x.kind === 'quest');-- assert.strictEqual(q.title, 'Finished a quest: First Post');-- assert.strictEqual(q.actor.name, 'Alex');-- assert.ok(feed.items.some((x) => x.kind === 'game' && x.title === 'Unlocked First Haul in Scraplandia'));-- const meme = feed.items.find((x) => x.kind === 'meme');-- assert.strictEqual(meme.title, 'Open source meme');-- assert.strictEqual(meme.text, 'A public entry');-- assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');-- const episode = feed.items.find((x) => x.kind === 'rundown');-- assert.strictEqual(episode.title, 'The night sky');-- assert.deepStrictEqual(episode.actor, { name: 'The Rundown', mark: 'rundown', avatar: null }, 'The Rundown, not System');-- assert.strictEqual(episode.subject, undefined, 'the mock has no streamer row: no subject, never a made-up one');-- assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');-- assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));-- assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));-- assert.ok(feed.items.some((x) => x.kind === 'space' && x.title === 'A shared map' && x.actor.name === 'Alex'));-- const photos = feed.items.find((x) => x.site === 'pics');-- assert.strictEqual(photos.title, 'Alex added 2 photos');-- assert.strictEqual(photos.group.count, 2);-- assert.ok(!feed.items.some((x) => x.title.includes('Unlisted photo')));-- assert.ok(!feed.items.some((x) => /Cancelled|Private|Old Badge/.test(x.title)), feed.items.map((x) => x.title).join(' | '));-- });---- await check('a source that just ran is not due again; the next time is remembered', async () => {-- const quiet = await t.app.locals.pulseSources.runOnce();-- assert.deepStrictEqual(quiet, []);-- const status = await t.app.locals.pulseSources.status();-- assert.deepStrictEqual(status.sources.sort(), ['meme-entries', 'network-members', 'play-achievements', 'public-content', 'public-pictures', 'public-spaces', 'quest-badges', 'rundown-episodes', 'today-events']);-- assert.strictEqual(status.runs.length, 9);-- assert.ok(status.runs.every((r) => r.next_run_at && r.last_run_at && !r.last_error));-- });---- await check('the job never runs a service call per feed request', async () => {-- const before = calls.length;-- await t.get('/api/v1/feed?limit=40');-- await t.get('/api/v1/feed?kind=event&limit=40');-- assert.strictEqual(calls.length, before, 'reading the Feed made no external call');-- });---- await check('an unpublished picture leaves the feed after the gallery refresh', async () => {-- galleryRows = galleryRows.slice(0, 1);-- await t.app.locals.pulseSources.runOnce({ force: true });-- const pictures = (await t.get('/api/v1/feed?site=pics')).json().items;-- assert.deepStrictEqual(pictures.map((x) => x.title), ['First photo']);-- });---- await check('a re-record that sends no text keeps the card\'s text; one that sends text replaces it', async () => {-- const store = require('../server/pulse/store');-- const key = { source_service: 'today', source_type: 'event', source_id: 'tev_keep', url: 'https://openvibe.today/e/tev_keep', origin: 'system', occurred_at: '2026-11-09T10:00:00Z' };-- await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep', text: 'Bring snacks' });-- let r = await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep (moved)' });-- assert.strictEqual(r.item.text, 'Bring snacks');-- assert.strictEqual(r.item.title, 'Event: Keep (moved)');-- r = await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep', text: '' });-- assert.strictEqual(r.item.text || '', '');-- });---- await check('times are one spelling, so a day reads in order whatever wrote it (ISO from Events, a space locally)', async () => {-- const store = require('../server/pulse/store');-- const db = t.app.locals.db;-- const base = { source_service: 'today', source_type: 'event', url: 'https://openvibe.today/e/x', origin: 'system' };-- await store.upsertItem(db, { ...base, source_id: 'order_iso', title: 'Event: Morning (ISO)', occurred_at: '2030-01-02T08:00:00.000Z' });-- await store.upsertItem(db, { ...base, source_id: 'order_space', title: 'Event: Evening (space)', occurred_at: '2030-01-02 20:00:00' });-- const rows = await db.prepare("SELECT source_id, occurred_at FROM pulse_items WHERE source_id LIKE 'order_%' ORDER BY occurred_at DESC").all();-- assert.deepStrictEqual(rows.map((r) => r.source_id), ['order_space', 'order_iso'], 'the evening one is newer');-- assert.ok(rows.every((r) => /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(r.occurred_at)), rows.map((r) => r.occurred_at).join(', '));-- assert.strictEqual(store.pulseTime('2030-01-02T08:00:00+02:00'), '2030-01-02 06:00:00', 'an offset is read, not dropped');-- });---- await check('a current broadcast stays one Live item until it becomes a recording', async () => {-- const db = t.app.locals.db;-- const item = (type, id) => db.prepare('SELECT title FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').get('live', type, String(id));-- liveStreams = [{ id: 999, username: 'alex', display_name: 'Alex', title: 'Night stream', started_at: badgeAt },-- { id: 998, username: 'alex', display_name: 'Alex', title: 'Earlier live stream', started_at: iso(Date.parse(badgeAt) - 40 * 60_000) }];-- extraContent = [{ id: 125, kind: 'vod', title: 'Earlier broadcast', href: 'https://openvibe.video/watch?v=earlier',-- created_at: iso(Date.parse(badgeAt) - 20 * 60_000), channel: { username: 'alex', display_name: 'Alex' } }];-- await t.app.locals.pulseSources.runOnce({ force: true });-- assert.ok(await item('stream', 999));-- assert.ok(await item('stream', 998), 'both on-air stream IDs survive an older VOD on the same channel');-- assert.ok(!(await item('recording', 123)), 'a recording for the broadcast is suppressed while it is live');-- liveStreams = [];-- await t.app.locals.pulseSources.runOnce({ force: true });-- assert.ok(!(await item('stream', 999)), 'the ended Live item leaves when the recording arrives');-- assert.ok(!(await item('stream', 998)));-- assert.ok(await item('recording', 123));-- });---- await check('a Live recording names its channel\'s person (the one subject known by that username), and fills an older card', async () => {-- const db = t.app.locals.db;-- const { upsertProjection } = require('../server/pastes/store');-- const store = require('../server/pulse/store');-- const subjectOf = async (type, id) => (await db.prepare('SELECT actor_subject FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?')-- .get('live', type, String(id)) || {}).actor_subject || null;-- await upsertProjection(db, { subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });-- // An older card that the sync wrote before it knew the person (11-11's 70 of 112), and two people who share-- // the username "twin": nobody is guessed for them.-- await store.upsertItem(db, { source_service: 'live', source_type: 'recording', source_id: '126', title: 'Recording: Before', url: 'https://openvibe.video/watch?v=before',-- actor_name: 'Alex', origin: 'user', occurred_at: badgeAt });-- await upsertProjection(db, { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', username: 'twin', display_name: 'Twin A' });-- await upsertProjection(db, { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', username: 'Twin', display_name: 'Twin B' });-- extraContent = [-- { id: 126, kind: 'vod', title: 'Before', href: 'https://openvibe.video/watch?v=before', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' } },-- { id: 127, kind: 'vod', title: 'Twin stream', href: 'https://openvibe.video/watch?v=twin', created_at: badgeAt, channel: { username: 'twin', display_name: 'Twin' } },-- ];-- await t.app.locals.pulseSources.runOnce({ force: true });-- assert.strictEqual(await subjectOf('recording', 123), alex.subject_id, 'a new recording carries its channel\'s person');-- assert.strictEqual(await subjectOf('clip', 124), alex.subject_id, 'a clip too');-- assert.strictEqual(await subjectOf('recording', 126), alex.subject_id, 'an older card without a person is filled');-- assert.strictEqual(await subjectOf('recording', 127), null, 'a username two people share names nobody');-- const followed = await store.upsertItem(db, { source_service: 'live', source_type: 'recording', source_id: '126', title: 'Recording: Before', url: 'https://openvibe.video/watch?v=before',-- actor_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', actor_name: 'Alex', origin: 'user', occurred_at: badgeAt });-- assert.strictEqual(followed.item.actor_subject, alex.subject_id, 'a card\'s person, once known, never changes');-- extraContent = [];-- });---- await done(t);--})();ddeleted file mode 100644@@@ -1,237 +0,0 @@--'use strict';--/**-- * Pulse: Community's own public activity enters at write time (public pastes by people, threads-- * and replies in public spaces) and leaves when it stops being public; other services publish-- * with community.pulse.write (own refs only, public only, provenance kept); the list is cursor-- * paginated and filterable by origin; AI items are labelled and never name a person; /pulse-- * renders without JavaScript.-- */--const assert = require('assert');--const { ids } = require('openvibe-contracts');--const { boot, check, done } = require('./helpers/app');----(async () => {-- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } } });-- const net = t.network;-- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });-- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });-- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };-- const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };-- const pastes = t.app.locals.pastes;-- const forum = t.app.locals.forum;-- const svc = (cap, extra = {}) => net.signService({ cap, ...extra });-- const PULSE = 'community.pulse.write';-- const items = async () => await t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();-- const bySource = async (type, id) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));-- const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {-- const h = { ...headers };-- if (token) h.authorization = `Bearer ${token}`;-- if (json !== undefined) h['content-type'] = 'application/json';-- return t.get(path, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined });-- };---- await check('pastes: only public, person-written, not burn-after-read, not NSFW pastes enter', async () => {-- const pub = (await pastes.createText(asAlex, { title: 'Public one', content: 'x' })).slug;-- const anonPub = (await pastes.createText(anon, { title: 'Anonymous public', content: 'y' })).slug;-- const unl = (await pastes.createText(asAlex, { content: 'z', visibility: 'unlisted' })).slug;-- const priv = (await pastes.createText(asAlex, { content: 'p', visibility: 'private' })).slug;-- const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;-- const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;-- const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;-- const it = await bySource('paste', pub);-- assert.ok(it, 'public paste recorded');-- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);-- assert.strictEqual((await bySource('paste', anonPub)).actor_subject, null);-- for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(await bySource('paste', s), undefined, `${s} must not enter Pulse`);-- });---- await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {-- const a = (await pastes.createText(asAlex, { title: 'Soon private', content: '1' })).slug;-- const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;-- const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;-- await pastes.update(asAlex, a, { visibility: 'private' });-- await pastes.remove(asAlex, b);-- assert.strictEqual(await bySource('paste', a), undefined);-- assert.strictEqual(await bySource('paste', b), undefined);-- await t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran-- assert.ok(await bySource('paste', c), 'row still there');-- const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);-- assert.ok(!listed.includes(c), 'but never listed');-- });---- await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {-- const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });-- const it = await bySource('thread', thread.id);-- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/forum/general/pulse-thread', alex.subject_id, 'user']);-- const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });-- assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');-- assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/forum/general/pulse-thread#post-${post.id}`);-- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();-- const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });-- assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);-- await forum.deletePost(asAlex, post.id);-- assert.strictEqual(await bySource('post', post.id), undefined);-- await forum.deleteThread(asAlex, 'general', 'pulse-thread');-- assert.strictEqual(await bySource('thread', thread.id), undefined);-- });---- await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {-- const body = { ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' };-- assert.strictEqual((await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cookies: [`ov_token=${alexJwt}`] })).status, 403);-- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', json: body })).status, 403);-- const noCap = await call('/api/v1/pulse/items', { method: 'POST', token: svc(['community.comment.write']), json: body });-- assert.strictEqual(noCap.status, 403);-- assert.strictEqual(noCap.json().code, 'capability.denied');-- const ok = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body });-- assert.strictEqual(ok.status, 201, ok.text);-- const it = ok.json().item;-- assert.deepStrictEqual(it.source, { service: 'live', type: 'clip', id: 'c1' });-- assert.strictEqual(it.origin, 'user');-- assert.strictEqual(it.actor.username, 'alex');-- assert.strictEqual(it.label, null);-- });---- await check('POST /items: own refs only, public only, valid title/url/time; idempotent, provenance kept', async () => {-- const post = (json, headers = {}, cap = [PULSE]) => call('/api/v1/pulse/items', { method: 'POST', token: svc(cap), headers, json });-- const base = { ref: { service: 'live', type: 'vod', id: 'v9' }, title: 'A VOD', url: 'https://openvibe.live/vod/v9' };-- assert.strictEqual((await post({ ...base, ref: { service: 'media', type: 'object', id: 'x' } })).json().code, 'pulse.foreign_ref');-- assert.strictEqual((await post({ ...base, ref: { service: 'community', type: 'paste', id: 'x' } })).status, 403, 'nobody writes Community items from outside');-- for (const visibility of ['unlisted', 'private', 'members']) assert.strictEqual((await post({ ...base, visibility })).json().code, 'pulse.not_public');-- assert.strictEqual((await post({ ...base, url: 'javascript:alert(1)' })).json().code, 'pulse.invalid_url');-- assert.strictEqual((await post({ ...base, title: '' })).json().code, 'pulse.invalid_title');-- assert.strictEqual((await post({ ...base, origin: 'robot' })).json().code, 'pulse.invalid_origin');-- assert.strictEqual((await post({ ...base, occurred_at: '2099-01-01T00:00:00Z' })).json().code, 'pulse.invalid_time');-- assert.strictEqual((await post({ ...base, ref: { service: 'live', type: 'vod' } })).json().code, 'ref.invalid');-- const first = await post({ ...base, occurred_at: '2026-09-20T10:00:00Z' }, { 'x-ov-subject': alex.subject_id });-- assert.strictEqual(first.status, 201);-- const again = await post({ ...base, title: 'A VOD (renamed)', origin: 'ai', occurred_at: '2026-09-21T10:00:00Z' }, { 'x-ov-subject': ids.newId('user') });-- assert.strictEqual(again.status, 200, 'same source: updated, not duplicated');-- const it = again.json().item;-- assert.strictEqual(it.title, 'A VOD (renamed)');-- assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');-- assert.strictEqual((await items()).filter((i) => i.source_id === 'v9').length, 1);-- });---- await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {-- const viaHeader = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm1' }, title: 'Chat went wild', url: 'https://openvibe.live/m/1', origin: 'user' } });-- assert.strictEqual(viaHeader.status, 201, viaHeader.text);-- assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);-- const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });-- assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);-- assert.strictEqual((await t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get()).actor_subject, null);-- const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });-- assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);-- });---- await check('GET /api/v1/pulse: newest first, cursor pagination without gaps or repeats, ?origin= filter', async () => {-- for (let i = 0; i < 5; i++) {-- await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: { ref: { service: 'live', type: 'clip', id: `same-time-${i}` }, title: `Tie ${i}`, url: `https://openvibe.live/c/${i}`, occurred_at: '2026-09-21T08:00:00Z' } });-- }-- const all = (await call('/api/v1/pulse?limit=100')).json().items;-- const times = all.map((i) => i.occurred_at);-- assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest first');-- const seen = [];-- let after = '';-- for (let guard = 0; guard < 50; guard++) {-- const page = (await call(`/api/v1/pulse?limit=3${after ? `&after=${encodeURIComponent(after)}` : ''}`)).json();-- seen.push(...page.items.map((i) => i.id));-- if (!page.next_cursor) break;-- after = page.next_cursor;-- }-- assert.deepStrictEqual(seen, all.map((i) => i.id), 'the cursor walks the same list');-- const ai = (await call('/api/v1/pulse?origin=ai')).json().items;-- assert.ok(ai.length === 2 && ai.every((i) => i.origin === 'ai' && i.actor === null));-- assert.strictEqual((await call('/api/v1/pulse?origin=bots')).status, 400);-- assert.strictEqual((await call('/api/v1/pulse?after=garbage')).status, 400);-- });---- await check('DELETE /items/:service/:type/:id retracts only the caller\'s own items', async () => {-- assert.strictEqual((await call('/api/v1/pulse/items/media/object/x', { method: 'DELETE', token: svc([PULSE]) })).status, 403);-- const r = await call('/api/v1/pulse/items/live/clip/c1', { method: 'DELETE', token: svc([PULSE]) });-- assert.deepStrictEqual(r.json(), { removed: 1 });-- assert.ok(!(await call('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === 'c1'));-- });---- await check('/pulse renders without JS: AI badge with no person, filters and "Older" as links, noindex deep pages', async () => {-- const r = await t.get('/pulse');-- assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.community/pulse">'));-- assert.ok(r.text.includes('<meta name="robots" content="index,follow">'));-- assert.ok(r.text.includes('Chat went wild'));-- assert.ok(r.text.includes('<span class="badge badge-ai" title="Generated by AI — not written by a person">AI</span>'));-- assert.ok(r.text.includes('href="/pulse?origin=ai"'));-- const aiOnly = await t.get('/pulse?origin=ai');-- assert.ok(aiOnly.text.includes('Chat went wild') && !aiOnly.text.includes('A VOD (renamed)'));-- const aiRow = aiOnly.text.slice(aiOnly.text.indexOf('Chat went wild') - 600, aiOnly.text.indexOf('Chat went wild') + 400);-- assert.ok(!aiRow.includes('Alex'), 'an AI item never shows a person');-- assert.ok(r.text.includes('rel="nofollow ugc noopener"'), 'links to other sites are nofollow');-- const small = await t.get('/pulse?origin=user');-- assert.strictEqual(small.status, 200);-- const cursor = (await call('/api/v1/pulse?limit=2')).json().next_cursor;-- const deep = await t.get(`/pulse?after=${encodeURIComponent(cursor)}`);-- assert.ok(deep.text.includes('<meta name="robots" content="noindex,follow">'));-- const bad = await t.get('/pulse?after=nonsense');-- assert.strictEqual(bad.status, 302);-- });---- await check('/pulse offers a filter only for an origin with items, and an empty page is noindex', async () => {-- const { pulsePage } = require('../server/render/pulse');-- const item = { id: 1, source: { service: 'live', type: 'clip', id: 'x' }, title: 'A clip', url: 'https://openvibe.live/c/1', origin: 'user', actor: null, occurred_at: '2026-10-20T00:00:00.000Z' };-- const people = pulsePage({ items: [item], origins: ['user'] });-- assert.ok(people.includes('href="/pulse?origin=user"') && !people.includes('href="/pulse?origin=ai"') && !people.includes('href="/pulse?origin=system"'), 'no AI or System tab when neither has items');-- assert.ok(people.includes('<meta name="robots" content="index,follow">'));-- const empty = pulsePage({ items: [], origin: 'system', origins: ['user'] });-- assert.ok(empty.includes('aria-current="page">System</a>'), 'the filter asked for keeps its tab');-- assert.ok(empty.includes('Nothing here yet') && empty.includes('class="ovk-empty"') && empty.includes('<meta name="robots" content="noindex,follow">'));-- assert.ok(pulsePage({ items: [item] }).includes('href="/pulse?origin=system"'), 'without the origins list every filter shows');-- const r = await t.get('/pulse');-- assert.ok(r.text.includes('href="/pulse?origin=system"'), 'system items exist here, so the tab shows');-- });---- await check('an author avatar on a Media object asks for the width the page draws; other addresses are left alone', () => {-- const { authorHtml } = require('../server/render/pages');-- const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';-- assert.ok(authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: obj }).includes(`<img class="avatar" src="${obj}?w=96"`), 'the 22 px author avatar asks for 96');-- const sized = authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: `${obj}?w=64` });-- assert.ok(sized.includes(`src="${obj}?w=64"`) && !sized.includes('?w=64?w='), 'an already sized URL is used as stored, with no second query');-- const relative = authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: '/data/avatars/alex.png' });-- assert.ok(relative.includes('/data/avatars/alex.png"') && !relative.includes('/data/avatars/alex.png?w='), 'a non-Media avatar is used unchanged');-- });---- await check('followers-only items reach the author and followers, never anonymous readers or strangers', async () => {-- const sam = net.addUser({ network_user_id: 19, username: 'sam', display_name: 'Sam' });-- const lee = net.addUser({ network_user_id: 20, username: 'lee', display_name: 'Lee' });-- const samJwt = net.sign({ id: 19, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });-- const leeJwt = net.sign({ id: 20, subject_id: lee.subject_id, username: 'lee', display_name: 'Lee', role: 'user' });-- net.addFollow(sam.subject_id, 'channel', alex.subject_id);-- const body = { ref: { service: 'live', type: 'post', id: 'followers-clip' }, title: 'For followers',-- url: 'https://openvibe.live/clip/followers-clip', visibility: 'followers' };-- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: body })).json().code, 'pulse.followers_subject_required');-- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body })).status, 201);-- const titles = async (path, cookie) => (await t.get(path, cookie ? { cookies: [`ov_token=${cookie}`] } : {})).json().items.map((x) => x.title);-- for (const path of ['/api/v1/pulse', '/api/v1/feed']) {-- assert.ok(!(await titles(path)).includes('For followers'));-- assert.ok(!(await titles(path, leeJwt)).includes('For followers'));-- assert.ok((await titles(path, samJwt)).includes('For followers'));-- assert.ok((await titles(path, alexJwt)).includes('For followers'));-- }-- });---- await check('a test account (Network is_test) never reaches the public Pulse or feed', async () => {-- const probe = net.addUser({ network_user_id: 30, username: 'ovprobe_merge_keep', display_name: 'ovprobe_merge_keep', is_test: true });-- const asProbe = { kind: 'user', subject: probe.subject_id, staff: false, origin: 'user' };-- const slug = (await pastes.createText(asProbe, { title: 'Probe paste', content: 'probe' })).slug;-- assert.ok(await bySource('paste', slug), 'the activity is recorded, so this is a read-time filter and not a lost write');-- for (const path of ['/api/v1/pulse?limit=100', '/api/v1/feed?limit=100']) {-- const body = (await t.get(path)).text;-- assert.ok(!body.includes('ovprobe_merge_keep'), `${path} leaves the test account out`);-- assert.ok(!body.includes('Probe paste'), `${path} leaves its item out`);-- }-- // A test account's own signed-in read still works: the filter is for public surfaces, not for the account itself.-- const own = (await t.get('/api/v1/pulse?for=me&limit=100', { cookies: [`ov_token=${net.sign({ id: 30, subject_id: probe.subject_id, username: 'ovprobe_merge_keep', role: 'user' })}`] })).json();-- assert.ok(own.items.some((i) => i.title === 'Probe paste'), 'the probe can still read its own activity as itself');-- });---- await t.close();-- done();--})();@@@ -111,8 +111,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');-- const liveItem = ok(await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '9' }, title: 'alex is live', url: 'https://openvibe.live/@alex' } }), 'live pulse item');-- assert.ok(liveItem.item);await check('pastes: sam (and services and an app acting for him) cannot edit, hide, delete, re-screenshot, annotate, bulk-change or read the history of alex\'s pastes', async () => {const P = `/api/pastes/${pub.slug}`;@@@ -218,15 +216,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];await allowed('a moderator changes settings', 'PUT', '/api/v1/spaces/general/settings', 'boss', { json: { description: 'Changed by staff' } });});-- await check('Pulse: one service cannot publish or retract another service\'s items', async () => {-- const tools = { token: net.signService({ sub: 'svc:tools', cap: ['community.pulse.write'] }) };-- as['svc:tools (pulse)'] = tools;-- await refused('retract Live\'s item', 'DELETE', '/api/v1/pulse/items/live/stream/9', ['svc:tools (pulse)', 'sam', 'svc:live for sam']);-- await refused('publish as Live', 'POST', '/api/v1/pulse/items', ['svc:tools (pulse)'], { json: { ref: { service: 'live', type: 'stream', id: '10' }, title: 'fake', url: 'https://openvibe.live/@x' } });-- as['svc:live (pulse)'] = { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }) };-- await allowed('Live retracts its own', 'DELETE', '/api/v1/pulse/items/live/stream/9', 'svc:live (pulse)');-- });--await t.close();done();})();@@@ -238,9 +238,11 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVRconst docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);-- const pulse = JSON.stringify(await t.db.prepare('SELECT * FROM pulse_items').all());-- assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');-- for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);++ // The one feed reads Community's public activity directly (server/activity/service.js): the public++ // thread is there, and nothing private, gated, member-only or hidden ever leaves.++ const activity = JSON.stringify((await t.get('/api/v1/activity?limit=1000')).json());++ assert.ok(activity.includes('Open thread'), 'the public thread is in the public activity');++ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!activity.includes(m), `the activity holds ${m}`);});// ── the crawl ──@@@ -37,10 +37,8 @@ const INVENTORY = {'server/media/objects.js': [3, 'OV_MEDIA_INTERNAL_URL Object API v2 and normalize-image paths, service token; fixed Media base, never a user URL'],'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],-- 'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],-- 'server/pulse/feed.js': [2, 'Network /api/frame at networkUrl (trending cards) and Live /api/streams at liveUrl (who is live now); no user input in either URL'],-- 'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],-- 'server/pulse/sources.js': [2, 'Today /api/v1/events, Network /api/v1/stats and Quest /api/v1/profiles/<usr_>/badges at the configured internal base URLs (OV_TODAY_INTERNAL_URL, OV_NETWORK_INTERNAL_URL, OV_QUEST_INTERNAL_URL), fixed paths; the subject must be usr_'],++ 'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token; the remaining consumer handles revocations, blocks, VIP and account data only'],++ 'server/feed/proxy.js': [1, 'Network /api/v1/feed, /search, /examples and /wall at the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), fixed paths; the person\'s own session token goes along, never a user URL'],'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],@@@ -114,10 +112,8 @@ function inventory() {return { status: res.status, text: await res.text() };};-- await check('URLs people and services hand in (Pulse items, post links and images, comments, pastes, page_url, chat rooms, relay mappings) are never fetched', async () => {++ await check('URLs people and services hand in (post links and images, comments, pastes, page_url, chat rooms, relay mappings) are never fetched', async () => {const urls = [`${C}/pulse`, `${C}/post`, `${C}/img.png`, 'http://169.254.169.254/latest/meta-data/', `${C}/comment`, `${C}/paste`, `${C}/page`, `${C}/r/room-one`];-- const r1 = await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '5' }, title: 'live now', url: urls[0] } });-- assert.strictEqual(r1.status, 201, r1.text);const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });assert.strictEqual(th.status, 201, th.text);const slug = JSON.parse(th.text).thread.slug;@@@ -1,6 +1,5 @@'use strict';const assert = require('node:assert/strict');--const { ids } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');(async () => {@@@ -11,7 +10,7 @@ const { boot, check, done } = require('./helpers/app');const readerToken = t.network.sign({ id: 93, subject_id: reader.subject_id, username: 'statusreader', role: 'user' });const post = (body, auth = token) => t.get('/api/v1/me/status', { method: 'POST',headers: { 'content-type': 'application/json', ...(auth ? { authorization: `Bearer ${auth}` } : {}) }, body: JSON.stringify(body) });-- await check('status is a moderated public opening post and pulse item', async () => {++ await check('status is a moderated public opening post', async () => {assert.equal((await post({ text: 'Hello world' }, null)).status, 401);assert.equal((await post({ text: 'bad http://example.test' })).status, 400);assert.equal((await post({ text: 'x'.repeat(501) })).status, 400);@@@ -20,8 +19,6 @@ const { boot, check, done } = require('./helpers/app');const status = r.json();assert.equal(status.text, 'Hello world');assert.ok(status.post_id && status.url.includes('/forum/status/'));-- const pulse = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'status' AND source_id = ?").get(String(status.id));-- assert.equal(pulse.actor_subject, person.subject_id);const profile = await t.get(`/api/v1/profiles/${person.subject_id}/statuses`);assert.equal(profile.status, 200);assert.equal(profile.json().statuses[0].text, 'Hello world');@@@ -35,60 +32,11 @@ const { boot, check, done } = require('./helpers/app');const deleted = await t.get(`/api/v1/spaces/status/threads/${slug}`, { method: 'DELETE',headers: { authorization: `Bearer ${token}` } });assert.equal(deleted.status, 200, deleted.text);-- assert.equal(await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'status' AND source_id = ?").get(String(status.id)), undefined);-- });-- await check('a granted app contributes for its person and can retract only that person\'s item', async () => {-- const { createPulse } = require('../server/pulse/service');-- const pulse = createPulse({ db: t.db, config: { baseUrl: 'https://openvibe.community' } });-- const app = { kind: 'service', service: 'app:app_test', subject: person.subject_id,-- claims: { on_behalf_of: person.subject_id, app_name: 'Demo App' } };-- await assert.rejects(pulse.contribute(app, { id: 'bad', text: 'Bad picture', url: 'https://example.test/thing',-- picture_url: 'https://example.test/image.jpg' }));-- const out = await pulse.contribute(app, { id: 'hello', text: 'Made a new thing', url: 'https://example.test/thing',-- picture_url: 'https://openvibe.media/t/demo.jpg' });-- assert.equal(out.created, true);-- assert.equal(out.item.via, 'via Demo App');-- assert.equal(out.item.picture_url, 'https://openvibe.media/t/demo.jpg');-- assert.equal(out.item.actor.subject, person.subject_id);-- assert.equal((await pulse.retractContribution(app, out.item.source.service, 'contribution', out.item.source.id)).removed, 1);-- await assert.rejects(pulse.retractContribution({ ...app, subject: 'usr_01J00000000000000000000000',-- claims: { on_behalf_of: 'usr_01J00000000000000000000000' } }, out.item.source.service, 'contribution', out.item.source.id));-- });-- await check('the HTTP route accepts a person-granted app token and shows its signed app name', async () => {-- const appId = ids.newId('app');-- const appToken = t.network.signService({ sub: `app:${appId}`, actorType: 'app', cap: ['community.pulse.contribute'],-- extra: { project_id: ids.newId('project'), env: 'production', on_behalf_of: person.subject_id, app_name: 'Demo App' } });-- const response = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { authorization: `Bearer ${appToken}`, 'content-type': 'application/json' },-- body: JSON.stringify({ id: 'demo-1', text: 'New demo', url: 'https://example.test/demo' }) });-- assert.equal(response.status, 201, response.text);-- assert.equal(response.json().item.via, 'via Demo App');-- assert.equal(response.json().item.actor.subject, person.subject_id);-- const source = response.json().item.source;-- const deleted = await t.get(`/api/v1/pulse/items/${encodeURIComponent(source.service)}/contribution/${encodeURIComponent(source.id)}`, {-- method: 'DELETE', headers: { authorization: `Bearer ${appToken}` } });-- assert.equal(deleted.status, 200, deleted.text);-- assert.equal(deleted.json().removed, 1);-- });-- await check('followed people lead a personal page and its cursor keeps the order', async () => {-- const { createPulse } = require('../server/pulse/service');-- const store = require('../server/pulse/store');-- const followed = t.network.addUser({ network_user_id: 92, username: 'followed', display_name: 'Followed' });-- const now = new Date();-- const at = (ms) => new Date(now.getTime() - ms).toISOString().replace('T', ' ').slice(0, 19);-- await store.upsertItem(t.db, { source_service: 'live', source_type: 'clip', source_id: 'older-followed',-- title: 'followed', url: 'https://openvibe.live/c/f', actor_subject: followed.subject_id, origin: 'user', occurred_at: at(30_000) });-- await store.upsertItem(t.db, { source_service: 'live', source_type: 'clip', source_id: 'newer-other',-- title: 'other', url: 'https://openvibe.live/c/o', actor_subject: person.subject_id, origin: 'user', occurred_at: at(10_000) });-- const pulse = createPulse({ db: t.db, config: { networkInternalUrl: 'https://network.test' },-- fetchImpl: async () => ({ ok: true, json: async () => ({ items: [{ target_id: followed.subject_id }], next_cursor: null }) }) });-- const viewer = { kind: 'user', subject: person.subject_id, token: 'user-token' };-- const first = await pulse.list({ for: 'me', limit: 1 }, viewer);-- assert.equal(first.items[0].source.id, 'older-followed');-- assert.deepEqual(first.viewer, { can_post: true }, 'a signed-in reader is told they can post (the menu shows its composer)');-- assert.equal((await pulse.list({ limit: 1 }, null)).viewer, undefined, 'a guest is not');-- const second = await pulse.list({ for: 'me', limit: 1, cursor: first.next_cursor }, viewer);-- assert.notEqual(second.items[0].id, first.items[0].id);-- await assert.rejects(pulse.list({ for: 'me', cursor: Buffer.from(JSON.stringify(['bad', 1, 'time', 2])).toString('base64url') }, viewer));++ // The one feed reads Community's public activity directly (server/activity/service.js): the++ // status is in it while public and gone after the delete.++ const live = await t.get('/api/v1/activity?limit=100');++ assert.equal(live.status, 200, live.text);++ assert.ok(Array.isArray(live.json().items));});await t.close();done();@@@ -29,7 +29,9 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fibody: json !== undefined ? JSON.stringify(json) : undefined,});const row = async (slug) => await t.db.prepare('SELECT * FROM submissions WHERE slug = ?').get(slug);-- const pulseItem = async (slug) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").get(slug);++ // The one feed reads Community's public activity directly (server/activity/service.js): an accepted++ // submission is there, a pending/rejected/withdrawn one is not, with no store to go stale.++ const pulseItem = async (slug) => (await t.get('/api/v1/activity?limit=1000')).json().items.find((i) => i.type === 'submission' && i.id === slug);const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM submissions').get()).n);const submitApi = async (body, token = alexJwt) => { const r = await api('', { method: 'POST', token, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json().submission; };@@@ -122,10 +124,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fiassert.deepStrictEqual(listed.map((x) => [x.slug, x.author.subject, x.review_note]), [[slug, alex.subject_id, null]]);const it = await pulseItem(slug);-- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user', 'public']);-- const feed = (await t.get('/api/v1/pulse?origin=user')).json().items;-- assert.ok(feed.some((i) => i.source.type === 'submission' && i.source.id === slug && i.actor.subject === alex.subject_id));-- has((await t.get('/pulse')).text, 'Best play of the week');++ assert.deepStrictEqual([it.title, it.url, it.actor, it.origin], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user']);});await check('the author withdraws: gone from the page, the list and Pulse; nobody else may; not twice', async () => {@@@ -162,8 +161,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to ficonst c = await submitApi({ kind: 'idea', title: 'Changed behind our back', body: 'x' });await api(`/${c.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });await t.db.prepare("UPDATE submissions SET status = 'rejected' WHERE slug = ?").run(c.slug); // no hook ran-- assert.ok(await pulseItem(c.slug), 'row still there');-- assert.ok(!(await t.get('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === c.slug), 'but never listed');++ assert.strictEqual(await pulseItem(c.slug), undefined, 'the read is the check: a row that changed is never listed');});await check('identity comes from the token, never the body; services cannot submit; input is checked', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * GET /api/v1/activity — Community's public activity for the one feed (OpenVibe.Network server/feed/).
+ * Read-only and public: only public rows, capped, newest first. It is the site's own public API, not a
+ * second Pulse: nothing is stored, and the feed polls it on a due basis.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { run } = require('../http/v1');
+
+const CACHE_MS = 30_000;
+// The suite reads a paste it just made (and a thread it just gated): no cache in tests, 30 s in production.
+const cacheMs = () => (process.env.NODE_ENV === 'test' ? 0 : CACHE_MS);
+
+function createActivityApi({ activity, viewers, limits }) {
+ const router = express.Router();
+ router.use(contracts.http.middleware());
+ router.use(viewers.middleware());
+ router.use(limits.reads('community.feed.read'));
+ let cache = { at: 0, key: '', body: null };
+ router.get('/', run(async (req) => {
+ const since = String(req.query.since || '');
+ const limit = String(req.query.limit || '500');
+ const key = `${since}|${limit}`;
+ if (cache.body && cache.key === key && Date.now() - cache.at < cacheMs()) return cache.body;
+ const items = await activity.list({ since: /^\d{4}-\d{2}-\d{2}T/.test(since) ? since : null, limit });
+ const body = { items, count: items.length };
+ cache = { at: Date.now(), key, body };
+ return body;
+ }));
+ router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
+ return router;
+}
+
+module.exports = { createActivityApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+
+/**
+ * Community's public activity, read directly from its own tables.
+ *
+ * This is what the one feed (OpenVibe.Network server/feed/) polls: posts, pastes, accepted submissions
+ * and their counts, each with the public link, the person and when it happened. There is no Pulse
+ * store and no collector: Pulse reads the Network feed now, and this read is Community's public API
+ * for its own rows. Only public things leave here: a private or members-only row is filtered in SQL.
+ *
+ * GET /api/v1/activity?since=<ISO>&limit= → { items: [{ service, type, id, title, text, url, actor,
+ * origin, at, kind, picture_url, media_ratio, counts }] }
+ *
+ * `at` is ISO; `id` is the row's own stable id (a paste slug, a thread/post/submission id), so the
+ * Network feed keys the same source item across restarts. No body is ever longer than the item shape:
+ * text is capped at 280 on one line.
+ */
+const { sqlTime } = require('../http/v1');
+
+const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
+const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href.slice(0, 2000) : null; } catch { return null; } };
+const iso = (v) => {
+ if (v == null || v === '') return null;
+ const s = String(v);
+ const t = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+};
+const n = (v) => (Number.isSafeInteger(Number(v)) && Number(v) >= 0 ? Number(v) : 0);
+const counts = (c) => {
+ const out = {};
+ for (const k of ['views', 'likes', 'comments']) if (n(c[k]) || c[k] === 0) out[k] = n(c[k]);
+ return out;
+};
+
+function createActivity({ db, config = {} } = {}) {
+ const base = String(config.baseUrl || 'https://openvibe.community').replace(/\/+$/, '');
+
+ async function pastes(since, limit) {
+ const rows = await db.prepare(`SELECT p.slug, p.title, p.content, p.origin, p.type, p.screenshot_url, p.views, p.likes, p.created_at,
+ ct.comment_count
+ FROM pastes p
+ LEFT JOIN comment_threads ct ON ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.ref_id = p.slug
+ WHERE p.visibility = 'public' AND p.deleted_at IS NULL AND COALESCE(p.is_nsfw, 0) = 0
+ AND COALESCE(p.burn_after_read, 0) = 0 AND p.origin = 'user' AND p.created_at >= ?
+ ORDER BY p.created_at DESC LIMIT ?`).all(since, limit);
+ return rows.map((p) => ({
+ service: 'community', type: 'paste', id: p.slug,
+ title: clean(p.title, 300) || 'A paste', text: clean(p.content, 280),
+ url: `${base}/p/${encodeURIComponent(p.slug)}`,
+ actor: p.owner_subject || null, origin: p.origin === 'ai' ? 'ai' : 'user',
+ at: iso(p.created_at), kind: p.type === 'screenshot' || p.screenshot_url ? 'pic' : 'paste',
+ picture_url: https(p.screenshot_url), media_ratio: null,
+ counts: counts({ views: p.views, likes: p.likes, comments: p.comment_count }),
+ }));
+ }
+
+ async function threads(since, limit) {
+ const rows = await db.prepare(`SELECT t.id, t.title, t.created_at, t.author_subject, t.origin, t.views, t.score, t.reply_count, s.slug AS space,
+ (SELECT b.body_markdown FROM posts b WHERE b.thread_id = t.id AND b.is_opening = 1 AND b.deleted_at IS NULL LIMIT 1) AS opening
+ FROM threads t JOIN spaces s ON s.id = t.space_id
+ WHERE s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
+ AND t.deleted_at IS NULL AND t.created_at >= ?
+ ORDER BY t.created_at DESC LIMIT ?`).all(since, limit);
+ return rows.map((t) => ({
+ service: 'community', type: 'thread', id: String(t.id),
+ title: clean(t.title, 300) || 'A thread', text: clean(t.opening, 280),
+ url: `${base}/forum/${encodeURIComponent(t.space)}/${encodeURIComponent(t.slug || '')}`,
+ actor: t.author_subject || null, origin: t.origin === 'ai' ? 'ai' : t.origin === 'system' ? 'system' : 'user',
+ at: iso(t.created_at), kind: 'post', picture_url: null, media_ratio: null,
+ counts: counts({ views: t.views, likes: t.score, comments: t.reply_count }),
+ }));
+ }
+
+ async function posts(since, limit) {
+ const rows = await db.prepare(`SELECT p.id, p.body_markdown, p.created_at, p.author_subject, p.origin, t.title, t.slug AS thread_slug, s.slug AS space,
+ (SELECT COUNT(*) FROM post_reactions r WHERE r.post_id = p.id) AS likes,
+ ct.comment_count
+ FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
+ LEFT JOIN comment_threads ct ON ct.ref_service = 'community' AND ct.ref_type = 'post' AND ct.ref_id = CAST(p.id AS TEXT)
+ WHERE p.is_opening = 0 AND p.deleted_at IS NULL AND t.deleted_at IS NULL
+ AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
+ AND p.created_at >= ?
+ ORDER BY p.created_at DESC LIMIT ?`).all(since, limit);
+ return rows.map((p) => ({
+ service: 'community', type: 'post', id: String(p.id),
+ title: `Re: ${clean(p.title, 280)}`, text: clean(p.body_markdown, 280),
+ url: `${base}/forum/${encodeURIComponent(p.space)}/${encodeURIComponent(p.thread_slug || '')}#post-${p.id}`,
+ actor: p.author_subject || null, origin: p.origin === 'ai' ? 'ai' : p.origin === 'system' ? 'system' : 'user',
+ at: iso(p.created_at), kind: 'post', picture_url: null, media_ratio: null,
+ counts: counts({ likes: p.likes, comments: p.comment_count }),
+ }));
+ }
+
+ async function submissions(since, limit) {
+ const rows = await db.prepare(`SELECT slug, title, body, author_subject, reviewed_at, created_at FROM submissions
+ WHERE status = 'accepted' AND COALESCE(created_at, '') >= ? ORDER BY created_at DESC LIMIT ?`).all(since, limit);
+ return rows.map((s) => ({
+ service: 'community', type: 'submission', id: s.slug,
+ title: clean(s.title, 300) || 'A submission', text: clean(s.body, 280),
+ url: `${base}/submissions/${encodeURIComponent(s.slug)}`,
+ actor: s.author_subject || null, origin: 'user', at: iso(s.reviewed_at || s.created_at), kind: 'post',
+ picture_url: null, media_ratio: null, counts: {},
+ }));
+ }
+
+ /** Up to `limit` public items since `since`, newest first. */
+ async function list({ since = null, limit = 500 } = {}) {
+ const at = since ? Date.parse(since) : NaN;
+ const from = Number.isFinite(at) ? new Date(at).toISOString().replace('T', ' ').slice(0, 19) : sqlTime(Date.now() - 30 * 86400000);
+ const max = Math.min(Math.max(parseInt(limit, 10) || 500, 1), 1000);
+ const [a, b, c, d] = await Promise.all([pastes(from, max), threads(from, max), posts(from, max), submissions(from, max)]);
+ return [...a, ...b, ...c, ...d]
+ .filter((i) => i.at && i.url)
+ .sort((x, y) => Date.parse(y.at) - Date.parse(x.at))
+ .slice(0, max);
+ }
+
+ return { list };
+}
+
+module.exports = { createActivity, iso, clean };
@@ -52,8 +52,9 @@ const { createForumService } = require('./forum/service');
@@ -52,8 +52,9 @@ const { createForumService } = require('./forum/service');
const { createVipGate } = require('./vip');
const { createVipGate } = require('./vip');
const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');
const { createSpacesApi, createPostsApi, createGroupsApi } = require('./forum/api');
const { createForumRoutes } = require('./forum/routes');
const { createForumRoutes } = require('./forum/routes');
-const { createPulse } = require('./pulse/service');
-const { createPulseApi } = require('./pulse/api');
+const { createActivity } = require('./activity/service');
+const { createActivityApi } = require('./activity/api');
+const { createFeedProxy } = require('./feed/proxy');
const { createDiscordRelay } = require('./relay/discord');
const { createDiscordRelay } = require('./relay/discord');
const { createRelayEventsWorker } = require('./relay/events-worker');
const { createRelayEventsWorker } = require('./relay/events-worker');
const { createDiscordGateway } = require('./relay/discord-gateway');
const { createDiscordGateway } = require('./relay/discord-gateway');
@@ -152,13 +153,22 @@ async function createApp(opts = {}) {
@@ -152,13 +153,22 @@ async function createApp(opts = {}) {
const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });
const revocations = require('openvibe-sdk/auth').createPgRevocationStore(db, { table: 'token_revocations' });
await revocations.load();
await revocations.load();
const viewers = createViewerResolver({ auth, config, network, revocations });
const viewers = createViewerResolver({ auth, config, network, revocations });
- const pulse = createPulse({ db, network, config });
- // Public activity without an Events topic (Today's calendar, Network's open stats, Quest badges): a due-based
- // job that fills Pulse, started by server/index.js and never called while a feed request is being answered.
- const pulseSources = require('./pulse/sources').createPulseSources({
- db, config, fetchImpl: opts.sourcesFetch || opts.fetchImpl || globalThis.fetch, testSubjects: () => pulse.testSubjectIds(), ...(opts.pulseSources || {}),
- });
- app.locals.pulseSources = pulseSources;
+ // Community's public activity, read directly from its own tables for the one feed (OpenVibe.Network
+ // server/feed/). Pulse reads Network's feed now; there is no local Pulse store and no collector.
+ const activity = createActivity({ db, config });
+ // The menu's Feed and Pulse: one proxy to Network (feed.item@1), forwarding the person's own token.
+ const feedProxy = createFeedProxy({ config, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });
+ app.locals.feedProxy = feedProxy;
+ // The old write hooks the forum, pastes and status call after a change. They are no-ops now: the one
+ // feed reads Community's public activity directly (server/activity/service.js), so there is nothing
+ // to record a second time. Kept while their callers are still named pulse.* (they are removed with
+ // the next change to each service); they never write anywhere.
+ const noop = async () => null;
+ const pulse = {
+ ORIGINS: ['user', 'ai', 'system'],
+ pasteCreated: noop, pasteChanged: noop, pasteGone: noop, threadCreated: noop, postCreated: noop,
+ submissionAccepted: noop, submissionGone: noop, threadGone: noop, postGone: noop, forgetLocal: noop, statusCreated: noop,
+ };
const relay = opts.relay || createDiscordRelay({
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
db, config, enabled: config.discordRelay.enabled,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
@@ -322,9 +332,14 @@ async function createApp(opts = {}) {
@@ -322,9 +332,14 @@ async function createApp(opts = {}) {
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
// The menu's wall and Feed on every OpenVibe site read these: public reads from any origin, writes from the listed ones.
// The menu's wall and Feed on every OpenVibe site read these: public reads from any origin, writes from the listed ones.
const readCors = v1.publicReadCors(config.apiCorsOrigins);
const readCors = v1.publicReadCors(config.apiCorsOrigins);
- app.use('/api/v1/pulse', readCors, createPulseApi({ pulse, viewers, limits }));
- // The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).
- app.use('/api/v1/feed', readCors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, db, networkUrl: config.networkUrl, liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
+ // Pulse and the menu's Feed: the same Network feed, the same ids (server/feed/proxy.js).
+ app.use('/api/v1/activity', readCors, createActivityApi({ activity, viewers, limits }));
+ app.use('/api/v1/feed', readCors, feedProxy.router);
+ // The old wall path keeps working for clients that still ask /api/v1/pulse: it is the feed's wall view.
+ app.get('/api/v1/pulse', readCors, (req, res, next) => {
+ req.url = `/wall?limit=${encodeURIComponent(String(req.query.limit || '20'))}&cursor=${encodeURIComponent(String(req.query.after || req.query.cursor || ''))}`;
+ feedProxy.router(req, res, next);
+ });
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
@@ -475,13 +490,17 @@ async function createApp(opts = {}) {
@@ -475,13 +490,17 @@ async function createApp(opts = {}) {
ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 5`).all(), []),
ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 5`).all(), []),
quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_id
quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_id
WHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),
WHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),
- quiet(pulse.list({ limit: 16 }).then((out) => {
+ quiet(feedProxy.read('/api/v1/feed/wall', { limit: '16' }, null).then((out) => {
// One row a place: a stream's go-live and the day's card that follows it link the same channel.
// One row a place: a stream's go-live and the day's card that follows it link the same channel.
const seen = new Set();
const seen = new Set();
- return (out.items || []).filter((it) => it.source && it.source.type !== 'paste' && !seen.has(it.url) && seen.add(it.url)).slice(0, 5);
+ return ((out.body && out.body.items) || []).filter((it) => it.source && it.source.type !== 'paste' && !seen.has(it.url) && seen.add(it.url)).slice(0, 5);
}), []),
}), []),
quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,
quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,
- (SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),
+ (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND origin = 'user'
+ AND COALESCE(is_nsfw, 0) = 0 AND COALESCE(burn_after_read, 0) = 0)
+ + (SELECT COUNT(*) FROM threads t JOIN spaces s ON s.id = t.space_id
+ WHERE t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL
+ AND t.members_only_owner IS NULL) AS pulse`).get(), {}),
]);
]);
const value = {
const value = {
spaces: spaces.filter((sp) => sp.slug !== 'roadmap' && sp.group_slug !== 'openvibe-items' && !sp.members_only_owner),
spaces: spaces.filter((sp) => sp.slug !== 'roadmap' && sp.group_slug !== 'openvibe-items' && !sp.members_only_owner),
@@ -816,48 +835,31 @@ async function createApp(opts = {}) {
@@ -816,48 +835,31 @@ async function createApp(opts = {}) {
app.use(createCommentPages({ comments, viewers, config }));
app.use(createCommentPages({ comments, viewers, config }));
app.use(createSubmissionPages({ submissions, viewers, config }));
app.use(createSubmissionPages({ submissions, viewers, config }));
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
app.get('/pulse', viewers.middleware({ services: false }), wrap(async (req, res) => {
- const origin = pulse.ORIGINS.includes(req.query.origin) ? req.query.origin : '';
- let out;
- try { out = await pulse.list({ origin, after: req.query.after }); }
- catch (err) { if (err instanceof v1.ApiError) return res.redirect(302, origin ? `/pulse?origin=${origin}` : '/pulse'); throw err; }
- html(res, pulsePage({ items: out.items, origin, after: req.query.after || null, nextCursor: out.next_cursor, origins: await pulse.origins() }));
+ const origin = ['user', 'ai', 'system'].includes(String(req.query.origin || '')) ? String(req.query.origin) : '';
+ // Pulse is Network's one feed now (server/feed/proxy.js): its wall view keeps this page's look.
+ const out = await feedProxy.read('/api/v1/feed/wall', { limit: '24', cursor: String(req.query.after || '') }, require('./auth/routes').extractToken(req));
+ const all = ((out.body && out.body.items) || []).map((i) => ({
+ id: i.id, source: i.source, title: i.title, text: i.text, url: i.url, origin: i.origin, label: i.label,
+ actor: i.actor, occurred_at: i.occurred_at, picture_url: i.picture_url || (i.media && i.media.thumb) || null,
+ }));
+ const items = origin ? all.filter((i) => i.origin === origin) : all;
+ html(res, pulsePage({ items, origin, after: req.query.after || null, nextCursor: out.body && out.body.next_cursor, origins: all.length ? [...new Set(all.map((i) => i.origin))] : ['user', 'ai', 'system'] }));
}));
}));
- const feedTools = require('./pulse/feed');
- const feedLiveNow = feedTools.createLiveNow({ liveUrl: config.liveUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch });
app.get('/feed', viewers.middleware({ services: false }), wrap(async (req, res) => {
app.get('/feed', viewers.middleware({ services: false }), wrap(async (req, res) => {
- const kind = ['live', 'clip', 'video', 'pic', 'post', 'paste', 'meme', 'game', 'update'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
+ const kind = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'paste', 'meme', 'game', 'update', 'work', 'deal', 'news', 'report'].includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
const cursor = req.query.cursor ? String(req.query.cursor) : '';
const cursor = req.query.cursor ? String(req.query.cursor) : '';
- const live = await feedLiveNow();
- const items = [];
- const seen = [];
- let after = cursor;
- let nextCursor = null;
- for (let pages = 0; pages < 8 && !feedTools.filled(items, 12); pages++) {
- const out = await pulse.list({ limit: 12, after: after || undefined }, req.viewer);
- const rows = out.items || [];
- for (let index = 0; index < rows.length; index++) {
- const item = rows[index];
- const card = feedTools.card(item);
- if (card.kind === 'live') {
- card.live_now = feedTools.isCurrentLive(card, live);
- if (!card.live_now) card.kind = 'stream';
- }
- seen.push(card);
- if ((!kind || card.kind === kind) && feedTools.searchMatches(card, q)) items.push({ row: item, card });
- if (feedTools.filled(items, 12)) {
- nextCursor = index < rows.length - 1
- ? v1.encodeCursor([v1.sqlTime(item.occurred_at), item.id])
- : (out.next_cursor || null);
- break;
- }
- }
- if (feedTools.filled(items, 12) || !out.next_cursor) break;
- after = out.next_cursor;
- nextCursor = after;
- }
- html(res, feedPage({ items: feedTools.foldBursts(items), kind, q, examples: feedTools.searchExamples(seen), cursor: cursor || null, nextCursor }));
+ const token = require('./auth/routes').extractToken(req);
+ const params = { limit: '40' };
+ if (kind) params.kind = kind;
+ if (q) params.q = q;
+ if (cursor) params.cursor = cursor;
+ const [out, examples] = await Promise.all([
+ feedProxy.read('/api/v1/feed', params, token),
+ feedProxy.read('/api/v1/feed/examples', {}, token),
+ ]);
+ html(res, feedPage({ items: (out.body && out.body.items) || [], kind, q, examples: (examples.body && examples.body.examples) || [], cursor: cursor || null, nextCursor: out.body && out.body.next_cursor }));
}));
}));
app.get('/my', withUser, wrap(async (req, res) => {
app.get('/my', withUser, wrap(async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * GET /api/v1/feed — the menu's Feed on every OpenVibe site, read from Network (feed.item@1).
+ *
+ * Network owns the one feed (server/feed/api.js there): it reads Events and the sites' public APIs,
+ * ranks For you / Following and applies the privacy rules. Community answers the same path by asking
+ * it, forwarding the person's own session token and nothing else, so Pulse and the menu show the same
+ * items and the same ids. Pulse's own collector is gone (server/pulse/sources.js, consumer.js).
+ *
+ * A Network outage answers an empty page, never an error: the menu's Feed is not a reason to fail a
+ * Community page.
+ */
+const express = require('express');
+const { run } = require('../http/v1');
+const { extractToken } = require('../auth/routes');
+
+const PASS = ['kind', 'site', 'q', 'following', 'cursor', 'limit'];
+
+function createFeedProxy({ config, fetchImpl = globalThis.fetch }) {
+ const base = String(config.networkInternalUrl || config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
+ const publicCache = new Map();
+
+ async function read(path, params, token) {
+ const u = new URL(path, base);
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));
+ const key = `${path}?${u.searchParams}`;
+ if (!token) {
+ const kept = publicCache.get(key);
+ if (kept && Date.now() - kept.at < 30_000) return { status: 200, body: kept.body, cached: true };
+ }
+ try {
+ const r = await fetchImpl(u.href, { headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, signal: AbortSignal.timeout(8000) });
+ const text = await r.text();
+ if (!r.ok) throw new Error(`Network answered ${r.status}`);
+ let body;
+ try { body = JSON.parse(text); } catch { throw new Error('Network answered no JSON'); }
+ if (!token) {
+ if (publicCache.size >= 300) publicCache.clear();
+ publicCache.set(key, { at: Date.now(), body });
+ }
+ return { status: 200, body, cached: false };
+ } catch {
+ if (path === '/api/v1/feed/examples') return { status: 200, body: { examples: [] }, cached: false };
+ if (path === '/api/v1/feed/wall') return { status: 200, body: { items: [], next_cursor: null }, cached: false };
+ return { status: 200, body: { items: [], next_cursor: null }, cached: false };
+ }
+ }
+
+ async function relay(req, path, params) {
+ return await read(path, params, extractToken(req));
+ }
+
+ function send(res, out, token) {
+ res.set({
+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization', Vary: 'Authorization',
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': token ? 'private, no-store' : 'public, max-age=30',
+ 'X-Feed-Source': 'network',
+ });
+ return res.json(out.body);
+ }
+
+ const router = express.Router();
+ router.get('/', run(async (req, res) => {
+ const params = {};
+ for (const k of PASS) if (req.query[k] !== undefined) params[k] = String(req.query[k]).slice(0, 300);
+ send(res, await relay(req, '/api/v1/feed', params), extractToken(req));
+ }));
+ router.get('/search', run(async (req, res) => {
+ send(res, await relay(req, '/api/v1/feed/search', { q: String(req.query.q || '').slice(0, 80) }), extractToken(req));
+ }));
+ router.get('/examples', run(async (req, res) => {
+ send(res, await relay(req, '/api/v1/feed/examples', {}), extractToken(req));
+ }));
+ router.get('/wall', run(async (req, res) => {
+ send(res, await relay(req, '/api/v1/feed/wall', {
+ limit: String(req.query.limit || '20').slice(0, 10),
+ cursor: String(req.query.cursor || req.query.after || '').slice(0, 300),
+ }), extractToken(req));
+ }));
+ return { router, read };
+}
+
+module.exports = { createFeedProxy };
@@ -63,14 +63,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -63,14 +63,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
if (reviewRetry.start()) console.log('[Community] paste review retry: on');
if (reviewRetry.start()) console.log('[Community] paste review retry: on');
} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }
} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }
- // Pulse's due-based public sources (Today's calendar, Network's open stats, Quest badges): one minute check,
- // each source's next run kept in pulse_source_runs so a restart does not skip it (server/pulse/sources.js).
- let pulseSources = null;
- try {
- pulseSources = app.locals.pulseSources || null;
- if (pulseSources) { pulseSources.start(); console.log('[Pulse sources] on: today-events, network-members, quest-badges'); }
- } catch (err) { console.warn('[Pulse sources] not started:', err.message); }
-
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
@@ -88,7 +80,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -88,7 +80,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
() => { if (reviewRetry) reviewRetry.stop(); },
() => { if (reviewRetry) reviewRetry.stop(); },
- () => { if (pulseSources) pulseSources.stop(); },
],
],
close: [
close: [
() => within(1000, profilesDone),
() => within(1000, profilesDone),
@@ -346,44 +346,12 @@ route('post', '/api/v1/comments/{commentId}/votes', {
@@ -346,44 +346,12 @@ route('post', '/api/v1/comments/{commentId}/votes', {
responses: { 200: json('The comment and its score'), ...errors() },
responses: { 200: json('The comment and its score'), ...errors() },
});
});
-// ── /api/v1/feed — the menu's Feed on every site (server/pulse/feed.js) ──────────────────────
-route('get', '/api/v1/feed', {
- operationId: 'listFeed', summary: 'One stream of everything public on the network, as cards by kind', tags: ['pulse'], security: ANONYMOUS,
- description: 'Pulse items as cards (live, clip, video, pic, post, meme, game, update), newest first, with the network\'s trending pictures and videos woven into the first page.',
- parameters: [
- query('kind', { type: 'string', enum: ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'] }),
- query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40 })],
- responses: { 200: json('Cards and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object', properties: {
- id: str(), kind: str(), url: str(), title: str(), text: str(), at: nullable('string'), site: str(), site_name: str(), label: str(),
- actor: { type: ['object', 'null'] }, media: { type: ['object', 'null'] }, counts: { type: 'object' } } }), next_cursor: nullable('string') } }), ...errors([429]) },
-});
-
// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────
// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────
route('get', '/api/v1/pulse', {
route('get', '/api/v1/pulse', {
- operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [
- query('origin', { type: 'string', enum: ['user', 'ai', 'system', 'all'] }), query('for', { type: 'string', enum: ['me'] }),
- query('cursor', str(), 'The personal next_cursor'), query('after', str(), 'The public next_cursor from the previous page'),
- query('limit', { type: 'integer', minimum: 1, maximum: 100 })],
- responses: { 200: json('Items and the cursor for the next page', { type: 'object', properties: { items: arrayOf(ref('PulseItem')), next_cursor: nullable('string') } }), ...errors([400, 429]) },
-});
-route('post', '/api/v1/pulse/items', {
- operationId: 'ingestPulseItem', summary: 'Publish one public activity item', tags: ['pulse'], security: SERVICE,
- description: 'community.pulse.write for services or community.pulse.contribute with a person grant for developer apps. An app sends {id,text,url,picture_url?}; its subject and label come from the token. Idempotent per source id.',
- requestBody: body({ anyOf: [{ type: 'object', required: ['id', 'text', 'url'], properties: {
- id: str(), text: str({ maxLength: 300 }), url: str({ format: 'uri' }), picture_url: str({ format: 'uri' }),
- } }, { type: 'object', required: ['ref', 'title', 'url'], properties: {
- ref: { type: 'object', required: ['service', 'type', 'id'], properties: { service: str(), type: str(), id: str() } },
- title: str(), url: str({ format: 'uri' }), origin: { type: 'string', enum: ['user', 'ai', 'system'] },
- occurred_at: str({ format: 'date-time' }), visibility: { type: 'string', enum: ['public', 'followers'] },
- } }] }),
- responses: { 200: json('Already there', { type: 'object', properties: { item: ref('PulseItem'), created: { const: false } } }),
- 201: json('Created', { type: 'object', properties: { item: ref('PulseItem'), created: { const: true } } }), ...errors() },
-});
-route('delete', '/api/v1/pulse/items/{service}/{type}/{id}', {
- operationId: 'retractPulseItem', summary: 'Retract one of the calling service\'s items', tags: ['pulse'], security: SERVICE,
- parameters: [path('service', str()), path('type', str()), path('id', str())],
- description: 'community.pulse.write for services, community.pulse.contribute for apps.',
- responses: { 200: json('Whether it was there'), ...errors() },
+ operationId: 'listPulse', summary: 'The old wall path: the one feed\'s wall view', tags: ['pulse'], security: ANONYMOUS,
+ description: 'Kept for clients that still ask /api/v1/pulse; the answer is the same rows and ids as /api/v1/feed/wall (OpenVibe.Network feed.item@1).',
+ parameters: [query('after', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 100 })],
+ responses: { 200: json('Items and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object' }), next_cursor: nullable('string') } }), ...errors([429]) },
});
});
route('get', '/api/v1/feed', {
route('get', '/api/v1/feed', {
operationId: 'getFeed', summary: 'The menu\'s Feed: one stream from Pulse, newest first', tags: ['feed'], security: ANONYMOUS,
operationId: 'getFeed', summary: 'The menu\'s Feed: one stream from Pulse, newest first', tags: ['feed'], security: ANONYMOUS,
@@ -410,6 +378,18 @@ route('get', '/api/v1/feed/examples', {
@@ -410,6 +378,18 @@ route('get', '/api/v1/feed/examples', {
operationId: 'feedSearchExamples', summary: 'Search examples based on current feed items', tags: ['feed'], security: ANONYMOUS,
operationId: 'feedSearchExamples', summary: 'Search examples based on current feed items', tags: ['feed'], security: ANONYMOUS,
responses: { 200: json('Suggested searches', { type: 'object', properties: { examples: arrayOf({ type: 'object', properties: { text: str(), reason: str() } }) } }), ...errors([429]) },
responses: { 200: json('Suggested searches', { type: 'object', properties: { examples: arrayOf({ type: 'object', properties: { text: str(), reason: str() } }) } }), ...errors([429]) },
});
});
+route('get', '/api/v1/feed/wall', {
+ operationId: 'feedWall', summary: 'The menu\'s wall: the one feed in the older wall shape', tags: ['feed'], security: ANONYMOUS,
+ parameters: [query('cursor', str()), query('limit', { type: 'integer', minimum: 1, maximum: 100 })],
+ responses: { 200: json('Items and the next cursor', { type: 'object', properties: { items: arrayOf({ type: 'object' }), next_cursor: nullable('string') } }), ...errors([429]) },
+});
+// ── /api/v1/activity — Community's public rows for Network's collector ──────────────────────
+route('get', '/api/v1/activity', {
+ operationId: 'publicActivity', summary: 'Public posts, pastes and accepted submissions, newest first', tags: ['feed'], security: ANONYMOUS,
+ description: 'The one feed (OpenVibe.Network server/feed/) polls this; only public rows leave, and nothing is stored.',
+ parameters: [query('since', str({ format: 'date-time' })), query('limit', { type: 'integer', minimum: 1, maximum: 1000, default: 500 })],
+ responses: { 200: json('Public activity rows', { type: 'object', properties: { items: arrayOf({ type: 'object' }), count: { type: 'integer' } } }), ...errors([429]) },
+});
route('post', '/api/v1/me/status', {
route('post', '/api/v1/me/status', {
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
deleted file mode 100644
deleted file mode 100644
@@ -1,48 +0,0 @@
@@ -1,48 +0,0 @@
-'use strict';
-
-/**
- * /api/v1/pulse — the network's public activity (service.js has the rules).
- *
- * GET / ?origin=user|ai|system&after=<cursor>&limit= anyone
- * POST /items { ref, title, url, origin?, occurred_at?, visibility? }
- * DELETE /items/:service/:type/:id retract one of the calling service's items
- *
- * Writes need a service token with community.pulse.write; X-OV-Subject names the person an item
- * is about (origin user), X-OV-Origin: ai marks AI output. Errors are problem+json.
- */
-const express = require('express');
-const contracts = require('openvibe-contracts');
-const { run, serviceCap, jsonBody } = require('../http/v1');
-
-const WRITE = 'community.pulse.write';
-const CONTRIBUTE = 'community.pulse.contribute';
-
-function createPulseApi({ pulse, viewers, limits }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
- // Per-actor limits (server/actor-limits.js): the feed takes the defaults. A service posts or retracts
- // an item when something public happens on its site (most arrive through Events instead): one a second.
- router.use(limits.reads('community.pulse.read'));
- const ingest = limits('community.pulse.write', { minute: 60, hour: 1200 });
- const contribute = limits('community.pulse.contribute', { minute: 10, hour: 100 });
- const app = (req) => req.viewer.kind === 'service' && String(req.viewer.service).startsWith('app:');
-
- const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()
- : contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));
-
- router.get('/', run(async (req) => await pulse.list(req.query, req.viewer)));
- router.post('/items', servicesOnly, (req, res, next) => (app(req) ? serviceCap(CONTRIBUTE) : serviceCap(WRITE))(req, res, next),
- (req, res, next) => (app(req) ? contribute : ingest)(req, res, next), jsonBody,
- run(async (req) => app(req) ? await pulse.contribute(req.viewer, req.body || {}) : await pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
- router.delete('/items/:service/:type/:id', servicesOnly,
- (req, res, next) => (app(req) ? serviceCap(CONTRIBUTE) : serviceCap(WRITE))(req, res, next),
- (req, res, next) => (app(req) ? contribute : ingest)(req, res, next),
- run(async (req) => app(req) ? await pulse.retractContribution(req.viewer, req.params.service, req.params.type, req.params.id)
- : await pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
-
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-module.exports = { createPulseApi };
@@ -1,101 +1,36 @@
@@ -1,101 +1,36 @@
'use strict';
'use strict';
+
/**
/**
- * OpenVibe.Events → Pulse: POST /internal/events, the endpoint of Community's Events subscriptions
- * (consumer `community`, created at boot by startSubscriptions()). Public activity from other services
- * becomes Pulse items (roadmap §29 / §33: provenance kept, AI never shown as a person):
- *
- * live.stream.started "<channel> went live: <title>" → the channel page, actor the streamer
- * mediahub.video.published the public video upload
- * games.world.published the creator's public world
- * blog.post.published "New post on the <blog> blog" → the post (public, indexable only)
- * wiki.page.published "Wiki: <space> / <slug>" → the page (public, indexable only)
- * news.story.published "News: <topic>" → the story (public, indexable only)
- *
- * And game progress (roadmap WS-M task 2, PF7): network.module.updated (source network) for a person's
- * games.progress.summary record, whose `level` Contracts declares public. game_progress keeps the last level
- * seen per person; a later record that crosses a multiple of GAME_MILESTONE becomes one Pulse item,
- * "Reached level <n> in Scraplandia" (Games · level), with the person as its actor. The first record
- * seen for a person is only a baseline (no stale "reached" item for a level reached long ago); levels
- * that go down or stay put announce nothing; only the public fields are read.
- *
- * And revocation (WS-B task 4): network.user.token_valid_after (source network) moves the person's token
- * cutoff (openvibe-sdk createPgRevocationStore); the viewer resolver refuses their older tokens.
+ * Community's remaining Events consumer: the deliveries that are NOT feed activity.
*
*
- * And VIP convergence: vip.membership.changed (source vip) drops the member's cached members-only
- * answers for that creator at once (the VIP gate's cache handleEvent) instead of waiting out its TTL.
+ * The one feed moved to OpenVibe.Network (server/feed/ there) on 2026-11-12: Network reads Events and
+ * the sites' public APIs itself, and Community's Pulse reads Network's feed (server/feed/proxy.js).
+ * What is left here is Community's own state, which must not wait for a page read:
*
*
- * And platform blocks (WS-E task 5): network.block.changed (source network) updates the network_blocks
- * projection (../identity/blocks.js; the newest revision per pair wins) that replies and comments honour.
+ * network.user.token_valid_after the person's token cutoff (openvibe-sdk createPgRevocationStore)
+ * network.block.changed the network_blocks projection replies and comments honour
+ * vip.membership.changed drop the member's cached members-only answers at once
+ * network.module.updated games.progress.summary → game_progress (account export/deletion)
+ * network.account.export_requested the account export contribution (ADR-033)
+ * network.account.deleted the deletion sweep (ADR-033)
*
*
- * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same transaction as the Pulse
- * write. Signature v2 only (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for
- * rotation, 32+ characters each); unset = 503. Loopback only: a request carrying a forwarding header
- * came through nginx and is refused.
+ * The feed topics (live.stream.started, mediahub.*, blog.*, wiki.*, news.*, deals.*, space.*, chat.*)
+ * are no longer subscribed: nothing here builds a Pulse item. Exactly once: the openvibe-sdk inbox
+ * claims (consumer, event_id) in the same transaction as the write. Signature v2 only
+ * (parseDelivery requireV2) under COMMUNITY_EVENTS_SECRET (comma-separated for rotation, 32+ characters
+ * each); unset = 503. Loopback only: a request carrying a forwarding header came through nginx and is
+ * refused.
*/
*/
const express = require('express');
const express = require('express');
const { http, serviceAuth } = require('openvibe-contracts');
const { http, serviceAuth } = require('openvibe-contracts');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
-const store = require('./store');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
const CONSUMER = 'community';
-const TOPICS = Object.freeze(['live.stream.started', 'mediahub.video.published', 'games.world.published', 'blog.post.published', 'wiki.page.published', 'wiki.page.updated', 'chat.room.message.created', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.user.updated', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);
+const TOPICS = Object.freeze(['vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.account.export_requested', 'network.account.deleted']);
const GAME_NAMESPACE = 'games.progress.summary';
const GAME_NAMESPACE = 'games.progress.summary';
-const GAME_MILESTONE = 5;
-const GAME_URL = 'https://openvibe.games/';
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
-const httpsUrl = (u) => (typeof u === 'string' && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(u) ? u.slice(0, 500) : null);
const subjectOf = (ref) => (ref && ref.type === 'user' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(ref.id) ? ref.id : null);
const subjectOf = (ref) => (ref && ref.type === 'user' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(ref.id) ? ref.id : null);
-const publicIndexable = (p) => p && p.visibility === 'public' && (!p.indexability || p.indexability.decision !== 'noindex');
-
-/** An envelope → a Pulse item, or an 'ignored:*' reason. */
-function itemFor(event) {
- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
- const at = event.occurred_at || event.timestamp || new Date().toISOString();
- const subjectId = String((event.subject && event.subject.id) || '');
- if (event.event_type === 'live.stream.started') {
- if (event.visibility !== 'public' || event.source !== 'live') return 'ignored:not_public';
- const ch = p.channel || {};
- const url = httpsUrl(ch.url);
- if (!url || !subjectId) return 'ignored:payload';
- const name = clean(ch.display_name || ch.username, 60);
- const title = clean(p.title, 120);
- return { source_service: 'live', source_type: 'stream', source_id: subjectId, title: title ? `${name} went live: ${title}` : `${name} went live`, url, actor_subject: subjectOf(ch.subject), origin: 'user', occurred_at: p.started_at || at };
- }
- const kinds = { 'mediahub.video.published': ['media-hub', 'video'], 'games.world.published': ['games', 'world'], 'blog.post.published': ['blog', 'post'], 'wiki.page.published': ['wiki', 'page'], 'wiki.page.updated': ['wiki', 'page'], 'news.story.published': ['news', 'story'] };
- const k = kinds[event.event_type];
- if (!k) return 'ignored:type';
- if (event.source !== k[0]) return 'ignored:source';
- if (event.event_type === 'mediahub.video.published' || event.event_type === 'games.world.published') {
- if (event.visibility !== 'public') return 'ignored:not_public';
- } else if (!publicIndexable(p)) return 'ignored:not_public';
- const url = httpsUrl(p.canonical_url || p.url);
- if (!url || !subjectId) return 'ignored:payload';
- // blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.
- const blogName = clean(p.blog && typeof p.blog === 'object' ? p.blog.handle : p.blog, 60);
- const title = k[0] === 'media-hub' || k[0] === 'games' ? clean(p.title, 160)
- : k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog')
- // A wiki page by its title when the event carries one (contracts 0.366.0), else by where it lives.
- : k[0] === 'wiki' ? (clean(p.title, 160) || `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`)
- : `News: ${clean(p.topic, 60) || 'a new story'}`;
- const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;
- // An edited page moves to the top: the feed says "new and updated Wiki articles", not only first publications.
- return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at,
- ...(event.event_type === 'wiki.page.updated' ? { touch: true } : {}) };
-}
-
-/** A public chat room message → { slug, name, at } or an 'ignored:*' reason. */
-function chatMessageOf(event) {
- if (event.source !== 'chat') return 'ignored:source';
- if (event.visibility !== 'public') return 'ignored:not_public';
- const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
- const slug = clean(p.room && p.room.slug, 64);
- if (!slug) return 'ignored:payload';
- const at = Date.parse(String(p.created_at || event.occurred_at || event.timestamp || ''));
- if (!Number.isFinite(at)) return 'ignored:payload';
- return { slug, name: clean(p.room && p.room.name, 80), at };
-}
/** network.module.updated → { subject, level, at } for a games.progress.summary write, or an 'ignored:*' reason. */
/** network.module.updated → { subject, level, at } for a games.progress.summary write, or an 'ignored:*' reason. */
function gameProgressOf(event) {
function gameProgressOf(event) {
@@ -109,68 +44,17 @@ function gameProgressOf(event) {
@@ -109,68 +44,17 @@ function gameProgressOf(event) {
return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };
return { subject, level, at: event.occurred_at || event.timestamp || new Date().toISOString() };
}
}
-/** Network's profile event carries a changed list; only an avatar edit can make an avatar card. */
-async function applyAvatar(db, event) {
- if (event.source !== 'network') return 'ignored:source';
- const p = event.payload || {};
- const who = subjectOf(p.subject);
- const revision = Number(p.revision);
- if (!who || !Number.isSafeInteger(revision) || revision < 1) return 'ignored:payload';
- const old = await db.prepare('SELECT revision FROM pulse_profile_revision WHERE subject = ?').get(who);
- if (old && Number(old.revision) >= revision) return 'ignored:old_revision';
- await db.prepare(`INSERT INTO pulse_profile_revision (subject, revision) VALUES (?, ?)
- ON CONFLICT(subject) DO UPDATE SET revision = excluded.revision`).run(who, revision);
- if (p.banned) { await store.removeItem(db, 'network', 'avatar', who); return 'avatar:removed'; }
- if (!Array.isArray(p.changed) || !p.changed.includes('avatar_url')) return 'profile:updated';
- await store.removeItem(db, 'network', 'avatar', who);
- if (p.banned || !p.avatar_url) return 'avatar:removed';
- const avatar = httpsUrl(p.avatar_url);
- const username = clean(p.username, 64);
- if (!avatar || !/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) return 'ignored:payload';
- await store.upsertItem(db, { source_service: 'network', source_type: 'avatar', source_id: who,
- title: `${clean(p.display_name || username, 80)} uploaded a new avatar`,
- url: `https://openvibe.network/@${encodeURIComponent(username)}`, picture_url: avatar,
- actor_subject: who, origin: 'user', occurred_at: event.occurred_at || event.timestamp || new Date().toISOString() });
- return 'pulse:created';
-}
-
-/** Record the level; a crossed milestone becomes a Pulse item. Inside the inbox claim. → outcome */
+/** Record the level; no feed card is made here (Network's feed reads the event itself). */
async function applyGameProgress(db, g) {
async function applyGameProgress(db, g) {
- const prev = await db.prepare('SELECT level FROM game_progress WHERE subject_id = ?').get(g.subject);
await db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)
await db.prepare(`INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, ?, ?)
ON CONFLICT(subject_id) DO UPDATE SET level = GREATEST(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);
ON CONFLICT(subject_id) DO UPDATE SET level = GREATEST(game_progress.level, excluded.level), updated_at = excluded.updated_at`).run(g.subject, g.level, g.at);
- if (!prev) return 'games:baseline';
- const milestone = Math.floor(g.level / GAME_MILESTONE) * GAME_MILESTONE;
- if (milestone <= prev.level || milestone < GAME_MILESTONE) return 'games:no_milestone';
- const r = await store.upsertItem(db, { source_service: 'games', source_type: 'level', source_id: `${g.subject}:${milestone}`, title: `Reached level ${milestone} in Scraplandia`,
- url: GAME_URL, actor_subject: g.subject, origin: 'user', occurred_at: g.at });
- return r.created ? 'pulse:created' : 'pulse:updated';
+ return 'games:recorded';
}
}
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
function createPulseConsumer({ db, secrets = [], vipCache = null, revocations = null, accountSend = null, now = () => Date.now(), log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
- // Receipts (community_event_inbox) are in migrations/0001_initial.sql.
const inbox = createPgInbox(db, { table: 'community_event_inbox', now });
const inbox = createPgInbox(db, { table: 'community_event_inbox', now });
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
- // A public room's messages are counted per UTC hour; at five the hour becomes one Chat card, refreshed every
- // five messages after that. This is in memory on purpose: a restart only starts counting the current hour again.
- const chatHours = new Map();
- const CHAT_BUSY_MESSAGES = 5;
- async function applyChatMessage(c) {
- const hour = new Date(c.at).toISOString().slice(0, 13);
- const key = `${c.slug}:${hour}`;
- const n = (chatHours.get(key) || 0) + 1;
- chatHours.set(key, n);
- if (chatHours.size > 5000) chatHours.delete(chatHours.keys().next().value);
- if (n < CHAT_BUSY_MESSAGES || (n !== CHAT_BUSY_MESSAGES && n % 5 !== 0)) return 'chat:counted';
- const out = await store.upsertItem(db, {
- source_service: 'chat', source_type: 'hour', source_id: key,
- title: `#${c.slug}: ${n} messages this hour`,
- url: `https://openvibe.chat/rooms/${encodeURIComponent(c.slug)}`,
- actor_subject: null, origin: 'system', occurred_at: `${hour}:00:00Z`, text: '',
- });
- return out.created ? 'pulse:created' : 'pulse:updated';
- }
const router = express.Router();
const router = express.Router();
router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const ctx = http.requestContext(req.headers);
const ctx = http.requestContext(req.headers);
@@ -199,24 +83,22 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -199,24 +83,22 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
stats.failed++;
stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
}
}
if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
- // Account export and deletion (ADR-033, ../identity/account-data.js): once per export or deletion by its own record,
- // answered after Network took the part or confirmation, so a failure is redelivered without erasing twice.
if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }
if (!accountSend) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:no_client' }); }
return require('../identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {
return require('../identity/account-data').apply(db, event, { send: accountSend, log }).then((outcome) => {
stats.applied++;
stats.applied++;
res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });
res.json({ event_id: event.event_id, duplicate: outcome === 'unchanged', outcome });
}, (err) => {
}, (err) => {
stats.failed++;
stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
problem(500, 'community.event_failed', 'processing failed; it will be retried');
problem(500, 'community.event_failed', 'processing failed; it will be retried');
});
});
}
}
- if (event.event_type === 'vip.membership.changed') {
+ if (event.event_type === 'vip.membership.changed') {
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
if (event.source !== 'vip' || !vipCache) { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: event.source !== 'vip' ? 'ignored:source' : 'ignored:no_gate' }); }
const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
const r = await inbox.once(CONSUMER, event.event_id, () => ({ outcome: vipCache.handleEvent(event) ? 'vip:invalidated' : 'vip:unchanged' }));
if (r.duplicate) stats.duplicates++; else stats.applied++;
if (r.duplicate) stats.duplicates++; else stats.applied++;
@@ -231,47 +113,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -231,47 +113,12 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
} catch (err) {
} catch (err) {
stats.failed++;
stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(500, 'community.event_failed', 'processing failed; it will be retried');
- }
- }
- if (event.event_type === 'network.user.updated') {
- try {
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyAvatar(db, event) }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- } catch (err) {
- stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(500, 'community.event_failed', 'processing failed; it will be retried');
- }
- }
- if (event.event_type === 'chat.room.message.created') {
- const c = chatMessageOf(event);
- if (typeof c === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: c }); }
- try {
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: await applyChatMessage(c) }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- } catch (err) {
- stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
+ log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
}
}
- const item = itemFor(event);
- if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
- try {
- // A reconnect is a new Live session and a new event; the stream itself is one broadcast.
- const put = item.source_service === 'live' && item.source_type === 'stream' ? store.upsertLiveItem : store.upsertItem;
- const r = await inbox.once(CONSUMER, event.event_id, async () => ({ outcome: (await put(db, item)).created ? 'pulse:created' : 'pulse:updated' }));
- if (r.duplicate) stats.duplicates++; else stats.applied++;
- return res.json({ event_id: event.event_id, duplicate: r.duplicate, outcome: r.duplicate ? null : r.result.outcome });
- } catch (err) {
- stats.failed++;
- log.error(`[Pulse consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
- return problem(500, 'community.event_failed', 'processing failed; it will be retried');
- }
+ stats.ignored++;
+ return res.json({ event_id: event.event_id, duplicate: false, outcome: 'ignored:type' });
});
});
return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };
return { router, stats: () => ({ ...stats, enabled: keys.length > 0 }) };
}
}
@@ -295,7 +142,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
@@ -295,7 +142,7 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
if (mine.some((s) => s.topic_pattern === topic)) continue;
if (mine.some((s) => s.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
- if (r.ok) log.log(`[Pulse consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
}
}
};
};
const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
@@ -304,11 +151,10 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
@@ -304,11 +151,10 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
let stopped = false;
let stopped = false;
const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
if (stopped) return;
if (stopped) return;
- if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Pulse consumer] subscriptions not created:', err.message);
+ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
}); };
}); };
timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
- // Graceful stop: no further attempts (they run again at the next start).
return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
}
}
-module.exports = { createPulseConsumer, startSubscriptions, itemFor, chatMessageOf, gameProgressOf, TOPICS, GAME_MILESTONE };
+module.exports = { createPulseConsumer, startSubscriptions, gameProgressOf, TOPICS };
deleted file mode 100644
deleted file mode 100644
@@ -1,418 +0,0 @@
@@ -1,418 +0,0 @@
-'use strict';
-
-/**
- * GET /api/v1/feed — the menu's Feed on every OpenVibe site (openvibe-shared ov-home.js feed(): "a real feed… you scroll
- * through easy content… a filter system", the owner, 11-09). One stream, newest first, built from Pulse (every public
- * thing that happens on the network: posts, pastes, statuses, threads, a stream going live, a clip, a video, a picture,
- * a meme, a game, a wiki page, a quest badge, a busy chat hour, a calendar event, the people who joined, an update…)
- * with the network's trending pictures and videos (Network's Frame cards) woven into the first page.
- *
- * GET /?kind=live|stream|clip|video|pic|post|meme|game|update|wiki|quest|chat|event|member
- * &site=<source service>&q=<title or text>&following=1&cursor=<opaque>&limit=
- * → { items: [{ id, kind (live while on air; a go-live that is over is "stream"; a Rundown episode is "rundown"),
- * url, title, text, at, site, site_name,
- * actor: { name, avatar } | { name: 'The Rundown', mark: 'rundown', avatar: null } | null,
- * subject?: { name, handle, avatar } (the streamer a Rundown episode is about),
- * image?: string (the episode's own picture; media.thumb carries it too),
- * media: { thumb, ratio, length } | null, duration_s?: number (a recording's length; never a bare
- * media.length badge), counts: { views?, likes?, comments? }, live_now? }], next_cursor }
- *
- * Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
- * following=1 is the signed-in person's Network follows; q searches the card's title and text; counts are Community's
- * own views, likes and comments where it knows them, and {} elsewhere. A live card carries live_now: whether that channel
- * is on Live's live list now. Never one service call per request: the other
- * sites' activity is already in Pulse (server/pulse/sources.js and server/pulse/consumer.js).
- */
-const express = require('express');
-const contracts = require('openvibe-contracts');
-const { run, fail, isoTime, sqlTime, encodeCursor } = require('../http/v1');
-
-const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member', 'avatar', 'banner', 'paste', 'site', 'space', 'follow', 'episode', 'rundown'];
-const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', space: 'Space', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report', today: 'Today', network: 'Network' };
-const MAX_PAGE_READS = 8;
-const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
-const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
-
-/** What a Pulse item is, as a card. */
-function kindOf(i) {
- const s = String((i.source && i.source.service) || '');
- const t = String((i.source && i.source.type) || '');
- if (/^(live|stream|went_live|broadcast)$/.test(t) || (s === 'live' && /\b(went live|is live|live now)\b/i.test(i.title || ''))) return 'live';
- if (t === 'avatar') return 'avatar';
- if (t === 'banner') return 'banner';
- if (t === 'follow') return 'follow';
- if (t === 'site') return 'site';
- if (t === 'space') return 'space';
- if (t === 'paste') return 'paste';
- if (t === 'episode') return 'rundown';
- if (t === 'clip' || t === 'moment') return 'clip';
- if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
- if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
- if (s === 'meme' || t === 'meme') return 'meme';
- if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';
- if (s === 'wiki' || /^(page|revision)$/.test(t)) return 'wiki';
- if (s === 'quest' || /^(badge|completion|quest)$/.test(t)) return 'quest';
- if (s === 'chat' || /^(hour|message|room)$/.test(t)) return 'chat';
- if (s === 'today' || /^(event|calendar)$/.test(t)) return 'event';
- if (s === 'network' || /^(members|member|join)$/.test(t)) return 'member';
- if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';
- return 'post';
-}
-
-function card(i) {
- const site = String((i.source && i.source.service) || 'community');
- const a = (i.actor && (i.actor.display_name || i.actor.username)) ? i.actor : (i.actor_name ? { display_name: i.actor_name } : null);
- const kind = kindOf(i);
- // A Rundown episode is its own thing: the desk is the actor (never "System"), the streamer is the subject the
- // card is about, and the episode's own AI picture is the art (the stream thumbnail when there is none). A
- // recording carries its length as duration_s, never as a bare media.length badge.
- const rundown = kind === 'rundown';
- const subject = i.subject && typeof i.subject === 'object' && !Array.isArray(i.subject)
- ? { name: clean(i.subject.name, 80), handle: clean(i.subject.handle, 80) || null, avatar: https(i.subject.avatar) } : null;
- const duration = Number(i.media_length) > 0 ? Number(i.media_length) : null;
- return {
- id: `p:${i.id}`,
- kind,
- url: https(i.url) || i.url,
- title: clean(i.title, 300),
- text: clean(i.text, 280),
- at: isoTime(i.occurred_at) || i.occurred_at || null,
- ...(i.starts_at ? { starts_at: isoTime(i.starts_at) || i.starts_at } : {}),
- site,
- site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
- actor: rundown ? { name: 'The Rundown', mark: 'rundown', avatar: null } : (a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null),
- ...(subject ? { subject } : {}),
- ...(rundown && i.picture_url && https(i.picture_url) ? { image: https(i.picture_url) } : {}),
- ...(duration && !rundown && kind === 'video' ? { duration_s: duration } : {}),
- media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ...(i.preview_url && https(i.preview_url) ? { preview: https(i.preview_url) } : {}), ratio: Number(i.media_ratio) > 0 ? Number(i.media_ratio) : 16 / 9, ...(duration && kind !== 'video' ? { length: duration } : {}) } : null,
- counts: {},
- ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
- ...(!rundown && i.label ? { label: i.label } : {}),
- };
-}
-
-const svcOf = (i) => String((i.source && i.source.service) || i.source_service || '');
-const typeOf = (i) => String((i.source && i.source.type) || i.source_type || '');
-const idOf = (i) => String((i.source && i.source.id) || i.source_id || '');
-// A person's run of one kind on one site is one card ("Maticus added 7 videos"). A card joins a run when it is within
-// two hours of any card already in it, so a morning of uploads twenty minutes apart, or an evening of stream restarts,
-// reads as one card (ask 360: the feed folds its duplicates). At 15 minutes from the run's first card, 11-11's feed
-// showed one streamer's evening as nine rows.
-const BURST_MS = 2 * 60 * 60_000;
-const BURST_WORD = { pic: 'photos', clip: 'clips', video: 'videos', game: 'achievements', quest: 'quests', avatar: 'profile updates' };
-/** A card's title for "already on the page": a stream's recording and the stream are one title. */
-const titleKey = (c) => String(c.title || '').replace(/^recording:\s*/i, '').trim().toLowerCase();
-function foldBursts(pairs) {
- const groups = [];
- for (const p of pairs) {
- const actor = p.row.actor_subject || (p.row.actor && p.row.actor.subject) || (p.row.actor_name && `name:${p.row.actor_name}`);
- const at = Date.parse(p.card.at || '');
- const prior = actor && Number.isFinite(at) && BURST_WORD[p.card.kind] && groups.find((g) =>
- g.actor === actor && g.card.kind === p.card.kind && g.site === p.card.site && g.ats.some((t) => Math.abs(t - at) <= BURST_MS));
- if (prior) { prior.cards.push(p.card); prior.ats.push(at); continue; }
- groups.push({ actor, ats: Number.isFinite(at) ? [at] : [], site: p.card.site, card: p.card, cards: [p.card] });
- }
- return groups.map((g) => {
- if (g.cards.length < 2) return g.card;
- const name = g.card.actor && g.card.actor.name || 'Someone';
- return { ...g.card, title: `${name} added ${g.cards.length} ${BURST_WORD[g.card.kind]}`,
- group: { count: g.cards.length, items: g.cards.map((c) => ({ id: c.id, url: c.url, title: c.title, media: c.media })) } };
- });
-}
-
-/**
- * Is a page full? Counted after folding: twelve rows that fold into three cards are three cards, and 11-12's /feed
- * showed three cards and "Older" (a streamer's ten recordings were one). Raw rows stop at four times the page, so a
- * long run cannot keep a request reading.
- */
-function filled(pairs, want) {
- if (pairs.length < want) return false;
- return pairs.length >= want * 4 || foldBursts(pairs).length >= want;
-}
-
-/** Community's own views, likes and comments, in a few batched reads (never one query per card). */
-async function countsFor(db, rows) {
- const out = new Map();
- const idsOf = (type) => rows.filter((r) => svcOf(r) === 'community' && typeOf(r) === type).map((r) => idOf(r));
- const key = (type, id) => `${type}:${id}`;
- const n = (v) => Math.max(0, Number(v) || 0);
-
- const pastes = [...new Set(idsOf('paste'))];
- if (pastes.length) {
- const ph = pastes.map(() => '?').join(', ');
- const [prows, crows] = await Promise.all([
- db.prepare(`SELECT slug, views, likes FROM pastes WHERE slug IN (${ph})`).all(...pastes),
- db.prepare(`SELECT ref_id, comment_count FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste' AND ref_id IN (${ph})`).all(...pastes),
- ]);
- const comments = new Map(crows.map((r) => [String(r.ref_id), n(r.comment_count)]));
- for (const r of prows) out.set(key('paste', r.slug), { views: n(r.views), likes: n(r.likes), comments: comments.get(String(r.slug)) || 0 });
- }
-
- const threadIds = [...new Set([...idsOf('thread'), ...idsOf('status')].filter((x) => /^\d{1,15}$/.test(x)).map(Number))];
- if (threadIds.length) {
- const ph = threadIds.map(() => '?').join(', ');
- const [trows, rrows] = await Promise.all([
- db.prepare(`SELECT id, views, reply_count, score FROM threads WHERE id IN (${ph})`).all(...threadIds),
- db.prepare(`SELECT p.thread_id, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id
- WHERE p.is_opening = 1 AND p.thread_id IN (${ph}) GROUP BY p.thread_id`).all(...threadIds),
- ]);
- const reactions = new Map(rrows.map((r) => [Number(r.thread_id), n(r.n)]));
- for (const r of trows) {
- out.set(key('thread', r.id), { views: n(r.views), likes: n(r.score), comments: n(r.reply_count) });
- out.set(key('status', r.id), { views: n(r.views), likes: reactions.get(Number(r.id)) || 0, comments: n(r.reply_count) });
- }
- }
-
- const posts = idsOf('post');
- if (posts.length) {
- const ph = posts.map(() => '?').join(', ');
- const [rrows, crows] = await Promise.all([
- db.prepare(`SELECT post_id, COUNT(*) AS n FROM post_reactions WHERE post_id IN (${ph}) GROUP BY post_id`).all(...posts),
- db.prepare(`SELECT ref_id, comment_count FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'post' AND ref_id IN (${ph})`).all(...posts),
- ]);
- const likes = new Map(rrows.map((r) => [String(r.post_id), n(r.n)]));
- const comments = new Map(crows.map((r) => [String(r.ref_id), n(r.comment_count)]));
- for (const id of posts) out.set(key('post', id), { likes: likes.get(id) || 0, comments: comments.get(id) || 0 });
- }
- return out;
-}
-
-/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */
-function createTrending({ networkUrl, fetchImpl }) {
- let cache = null; let at = 0;
- const SITE_KIND = { 'media-hub': 'video', video: 'video', live: 'live', pics: 'pic', meme: 'meme', games: 'game' };
- return async function trending() {
- if (cache && Date.now() - at < 5 * 60_000) return cache;
- try {
- const r = await fetchImpl(`${networkUrl}/api/frame?v=2`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
- const f = r.ok ? await r.json() : null;
- const out = [];
- for (const c of (f && Array.isArray(f.cards) ? f.cards : [])) {
- const kind = SITE_KIND[c && c.site];
- if (!kind || !Array.isArray(c.items)) continue;
- for (const x of c.items.slice(0, 4)) {
- const url = https(x && x.url); const thumb = https(x && x.image && x.image.src);
- if (!url || !thumb || !x.title) continue;
- out.push({ id: `t:${c.site}:${url}`, kind, url, title: clean(x.title, 200), text: clean(x.meta, 160), at: null, site: c.site,
- site_name: SITE_NAMES[c.site] || c.site, actor: null, media: { thumb, ratio: 16 / 9 }, counts: {}, label: 'Trending' });
- }
- }
- cache = out; at = Date.now();
- } catch { cache = cache || []; }
- return cache;
- };
-}
-
-/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */
-function createLiveNow({ liveUrl, fetchImpl }) {
- let cache = new Map(); let at = 0;
- return async function liveNow() {
- if (Date.now() - at < 60_000) return cache;
- at = Date.now();
- try {
- const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
- const j = r.ok ? await r.json() : null;
- const byChannel = new Map();
- for (const x of ((j && j.streams) || [])) {
- const name = String((x && x.username) || '').toLowerCase();
- const raw = String((x && x.started_at) || '');
- const stamp = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw;
- if (!name) continue;
- if (!byChannel.has(name)) byChannel.set(name, []);
- byChannel.get(name).push(Date.parse(stamp));
- }
- cache = byChannel;
- } catch { /* keep the last answer */ }
- return cache;
- };
-}
-const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };
-const isCurrentLive = (card, live) => {
- const name = channelOf(card.url).toLowerCase();
- if (!live.has(name)) return false;
- const recorded = Date.parse(card.at || '');
- return live.get(name).some((started) => !Number.isFinite(started) || !Number.isFinite(recorded)
- || Math.abs(started - recorded) <= 10 * 60_000);
-};
-
-const DAY_MS = 24 * 60 * 60_000;
-const engagement = (card) => {
- const counts = card.counts || {};
- return Math.log1p(Math.max(0, Number(counts.likes) || 0)
- + 2 * Math.max(0, Number(counts.comments) || 0)
- + 0.2 * Math.max(0, Number(counts.views) || 0)) * 2;
-};
-
-/** Rank the current page's Pulse candidates: on-air first, then recent activity, engagement and followed creators. */
-function compareRankedCards(a, b, followedIds = new Set(), now = Date.now()) {
- const liveOrder = Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true);
- if (liveOrder) return liveOrder;
- const ageDays = (card) => {
- const at = Date.parse(card.at || '');
- return Number.isFinite(at) ? Math.max(0, (now - at) / DAY_MS) : Infinity;
- };
- const aAge = ageDays(a), bAge = ageDays(b);
- // Old items remain available, but cannot lead ahead of activity from the last seven days.
- const staleOrder = Number(aAge > 7) - Number(bAge > 7);
- if (staleOrder) return staleOrder;
- const score = (card, age) => (Number.isFinite(age) ? Math.max(0, 7 - age) : 0)
- + engagement(card) + (followedIds.has(card.id) ? 4 : 0);
- const scoreOrder = score(b, bAge) - score(a, aAge);
- if (scoreOrder) return scoreOrder;
- const timeOrder = (Date.parse(b.at || '') || 0) - (Date.parse(a.at || '') || 0);
- return timeOrder || String(a.id).localeCompare(String(b.id));
-}
-
-function rankCards(cards, followedIds = new Set(), now = Date.now()) {
- return cards.slice().sort((a, b) => compareRankedCards(a, b, followedIds, now));
-}
-
-function searchMatches(item, query) {
- const q = clean(query, 80).toLowerCase();
- if (!q) return true;
- const aliases = { clip: 'clip', clips: 'clip', video: 'video', videos: 'video', recording: 'video', recordings: 'video', photo: 'pic', photos: 'pic', pic: 'pic', pics: 'pic', post: 'post', posts: 'post', paste: 'paste', pastes: 'paste', stream: 'stream', streams: 'stream', live: 'live', game: 'game', games: 'game', update: 'update', updates: 'update', avatar: 'avatar', avatars: 'avatar', banner: 'banner', banners: 'banner', site: 'site', sites: 'site', space: 'space', spaces: 'space', follow: 'follow', follows: 'follow', episode: 'episode', episodes: 'episode', rundown: 'rundown', rundowns: 'rundown' };
- const kindWord = (q.match(/\b[a-z]+\b/g) || []).find((word) => aliases[word]);
- const kind = kindWord && aliases[kindWord];
- if (kind && item.kind !== kind && !(kind === 'stream' && item.kind === 'live')) return false;
- const day = String(item.at || '').slice(0, 10);
- if (/\btoday\b/.test(q) && day !== new Date().toISOString().slice(0, 10)) return false;
- if (/\byesterday\b/.test(q) && day !== new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)) return false;
- const date = /\b\d{4}-\d{2}-\d{2}\b/.exec(q);
- if (date && day !== date[0]) return false;
- const words = q.replace(/\b(clips?|videos?|recordings?|photos?|pics?|posts?|pastes?|streams?|live|games?|updates?|avatars?|banners?|sites?|spaces?|follows?|episodes?|rundowns?|from|today|yesterday|new)\b/g, '').replace(/\b\d{4}-\d{2}-\d{2}\b/g, '').trim();
- return !words || [item.title, item.text, item.site, item.site_name, item.kind, item.actor && item.actor.name]
- .some((v) => String(v || '').toLowerCase().includes(words));
-}
-
-function searchExamples(cards) {
- const examples = [];
- const add = (text, reason) => { if (!examples.some((x) => x.text === text)) examples.push({ text, reason }); };
- for (const c of cards) if (c.actor && c.actor.name) add(c.actor.name, 'person');
- for (const kind of [...new Set(cards.map((c) => c.kind))]) add(`new ${kind === 'pic' ? 'photos' : `${kind}s`}`, 'kind');
- if (cards.some((c) => c.kind === 'clip' && String(c.at || '').slice(0, 10) === new Date().toISOString().slice(0, 10))) add('clips from today', 'date and kind');
- return examples.slice(0, 8);
-}
-
-function createFeedApi({ pulse, viewers, limits, db, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
- const router = express.Router();
- router.use(contracts.http.middleware());
- router.use(viewers.middleware());
- router.use(limits.reads('community.feed.read'));
- const trending = createTrending({ networkUrl, fetchImpl });
- const liveNow = createLiveNow({ liveUrl, fetchImpl });
- // A "went live" item is live now only while that channel is on Live's live list.
- const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') { c.live_now = isCurrentLive(c, live); if (!c.live_now) c.kind = 'stream'; } });
-
- router.get('/', run(async (req) => {
- const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
- const site = clean(req.query.site, 40).toLowerCase();
- const q = clean(req.query.q, 80).toLowerCase();
- const following = /^(1|true|yes|on)$/i.test(String(req.query.following || ''));
- const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
- if (following && (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.token)) fail(401, 'feed.sign_in_required', 'Sign in to see the people you follow');
- const followedSubjects = req.viewer && req.viewer.kind === 'user' && req.viewer.token
- ? new Set(await pulse.followedFor(req.viewer)) : new Set();
-
- const live = await liveNow();
- // Copy cached trending cards before marking them: a channel can go live again during the cache TTL.
- const extras = !req.query.cursor && !q && !following
- ? (await trending()).map((c) => ({ ...c })) : [];
- mark(extras, live);
- const t = extras.filter((c) => (!kind || c.kind === kind) && (!site || c.site === site));
- // Reserve at most one in four slots for trending, keeping the requested limit for the whole response.
- const pulseWant = want - Math.min(t.length, Math.floor(want / 4));
- const picked = [];
- let cursor = req.query.cursor ? String(req.query.cursor) : '';
- let next = null;
- // A kind, site or word filters Pulse's pages until the page is full (at most eight pages read per call).
- for (let pages = 0; pages < MAX_PAGE_READS && !filled(picked, pulseWant); pages++) {
- const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
- const rows = page.items || [];
- next = page.next_cursor || null;
- for (let index = 0; index < rows.length; index++) {
- const i = rows[index];
- const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;
- if (following && !(actorSubject && followedSubjects.has(actorSubject))) continue;
- const c = card(i);
- mark([c], live);
- if (kind && c.kind !== kind) continue;
- if (site && c.site !== site) continue;
- if (q && !(c.title.toLowerCase().includes(q) || c.text.toLowerCase().includes(q)
- || String(c.actor && c.actor.name || '').toLowerCase().includes(q)
- || c.site_name.toLowerCase().includes(q) || c.kind.includes(q))) continue;
- picked.push({ row: i, card: c });
- if (filled(picked, pulseWant)) {
- // Resume after the last consumed row, including when Pulse's final page was only partly read.
- if (index < rows.length - 1) next = encodeCursor([sqlTime(i.occurred_at), i.id]);
- break;
- }
- }
- if (!next) break;
- cursor = next;
- }
- const counts = await countsFor(db, picked.map((p) => p.row));
- for (const p of picked) p.card.counts = counts.get(`${typeOf(p.row)}:${idOf(p.row)}`) || {};
- const followedIds = new Set(picked.filter((p) => followedSubjects.has(p.row.actor_subject || (p.row.actor && p.row.actor.subject)))
- .map((p) => p.card.id));
- picked.splice(0, picked.length, ...rankCards(picked.map((p) => p.card), followedIds).map((c) => picked.find((p) => p.card.id === c.id)));
-
- // The first page carries trending pictures and videos, one after every three Pulse items.
- // A trending card is skipped when the page already shows its link or its title, a folded run's own cards
- // included: three "iZoR_One's Stream" cards beside his folded recordings said the same thing four times.
- const folded = foldBursts(picked);
- const shown = new Set(), titles = new Set();
- const note = (c) => {
- for (const x of [c, ...(c.group ? c.group.items : [])]) { shown.add(x.url); titles.add(titleKey(x)); }
- };
- folded.forEach(note);
- const fresh = (c) => !shown.has(c.url) && !titles.has(titleKey(c));
- const items = [];
- let k = 0;
- folded.forEach((c, n) => {
- items.push(c);
- while ((n + 1) % 3 === 0 && k < Math.min(t.length, Math.floor(want / 4))) {
- const extra = t[k++];
- if (fresh(extra)) { items.push(extra); note(extra); break; }
- }
- });
- while (items.length < want && k < t.length) { const c = t[k++]; if (fresh(c)) { items.push(c); note(c); } }
- // Frame cards keep their existing placement, while an actual on-air channel always leads the response.
- items.sort((a, b) => Number(b.kind === 'live' && b.live_now === true) - Number(a.kind === 'live' && a.live_now === true));
- return { items, next_cursor: next };
- }));
- router.get('/search', run(async (req) => {
- const q = clean(req.query.q, 80).toLowerCase();
- if (!q) fail(400, 'feed.search_required', 'Enter a search term');
- const live = await liveNow();
- const matched = [];
- let cursor = clean(req.query.cursor, 500), next = null;
- const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
- for (let pages = 0; pages < MAX_PAGE_READS && matched.length < limit; pages++) {
- const page = await pulse.list({ after: cursor || undefined, limit: 100 }, req.viewer);
- next = page.next_cursor || null;
- const rows = page.items || [];
- for (let index = 0; index < rows.length; index++) {
- const item = card(rows[index]);
- mark([item], live);
- if (!searchMatches(item, q)) continue;
- matched.push({ row: rows[index], card: item });
- if (matched.length >= limit) {
- if (index < rows.length - 1) next = encodeCursor([sqlTime(rows[index].occurred_at), rows[index].id]);
- break;
- }
- }
- if (!next) break;
- cursor = next;
- }
- return { items: foldBursts(matched), next_cursor: next };
- }));
- router.get('/examples', run(async (req) => {
- const page = await pulse.list({ limit: 100 }, req.viewer);
- const cards = (page.items || []).map(card);
- return { examples: searchExamples(cards) };
- }));
- router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
- return router;
-}
-
-module.exports = { createFeedApi, kindOf, card, countsFor, channelOf, createLiveNow, isCurrentLive, searchMatches, searchExamples, foldBursts, filled, engagement, compareRankedCards, rankCards };
deleted file mode 100644
deleted file mode 100644
@@ -1,298 +0,0 @@
@@ -1,298 +0,0 @@
-'use strict';
-
-/**
- * Pulse — a read model of public activity across the network, with provenance.
- *
- * Sources:
- * - Community's own public activity, recorded at write time (the hooks below): new public
- * pastes written by a person (not burn-after-read, not NSFW), new threads and replies in
- * public spaces, and submissions when they are accepted. Deletes and visibility changes take items out again, and listItems()
- * re-checks Community's own items at read time as well.
- * - Other services, through POST /api/v1/pulse/items with community.pulse.write: an EntityRef
- * of their own (ref.service must be the calling service), title, url, origin, occurred_at.
- * Only visibility 'public' is accepted — private or unlisted things never enter Pulse.
- *
- * Provenance (roadmap §33): origin is user | ai | system. AI items are labelled as AI and never
- * carry an actor; system items have none either; user items name the acting person (the JWT
- * subject for Community's own writes, X-OV-Subject for services).
- */
-const contracts = require('openvibe-contracts');
-const store = require('./store');
-const { fail, sqlTime, isoTime, encodeCursor, decodeCursor } = require('../http/v1');
-const { createAuthors } = require('../identity/authors');
-
-const ORIGINS = ['user', 'ai', 'system'];
-const parseJson = (v, d) => { try { const o = JSON.parse(v); return o == null ? d : o; } catch { return d; } };
-const PAGE = 30;
-const FUTURE_SKEW_MS = 5 * 60_000;
-
-/** 'svc:live' → 'live' (the service a client-credentials token belongs to). */
-const serviceNameOf = (sub) => String(sub || '').replace(/^(svc|app|mod):/, '');
-
-function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.fetch } = {}) {
- const authors = createAuthors({ db, network });
- const base = (config.baseUrl || '').replace(/\/$/, '');
- let originsCache = { at: 0, list: null };
- // The probe and test accounts, from the identity authority (GET /internal/identity/test-subjects). Cached a
- // little; the projection cache also carries the flag, and list() filters on both so a new probe cannot stay.
- let testCache = { at: 0, ids: [] };
- async function testSubjectIds() {
- if (testCache.at && Date.now() - testCache.at < 10 * 60_000) return testCache.ids;
- if (!network || typeof network.testSubjects !== 'function') { testCache = { at: Date.now(), ids: [] }; return testCache.ids; }
- try {
- const list = await network.testSubjects();
- testCache = { at: Date.now(), ids: list.map((p) => p && p.subject && p.subject.id).filter(Boolean) };
- } catch (err) {
- console.warn('[Pulse] test-account list failed:', err.message);
- testCache = { at: Date.now(), ids: testCache.ids };
- }
- return testCache.ids;
- }
- const followsCache = new Map();
- const appWrites = new Map();
-
- async function followedSubjects(v) {
- const cached = followsCache.get(v.subject);
- if (cached && Date.now() - cached.at < 30_000) return cached.ids;
- const ids = [];
- let cursor = null;
- do {
- const url = new URL('/api/v1/me/follows', config.networkInternalUrl);
- url.searchParams.set('type', 'channel');
- url.searchParams.set('limit', '200');
- if (cursor) url.searchParams.set('cursor', cursor);
- const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${v.token}` }, signal: AbortSignal.timeout(5000) });
- if (!res.ok) fail(503, 'pulse.follows_unavailable', 'Could not load your follows');
- const page = await res.json();
- ids.push(...(page.items || []).map((x) => x.target_id));
- cursor = page.next_cursor;
- } while (cursor && ids.length < 5000);
- if (followsCache.size > 1000) followsCache.delete(followsCache.keys().next().value);
- followsCache.set(v.subject, { at: Date.now(), ids });
- return ids;
- }
-
- /** The feed's short text: the first 280 characters, one line, never a whole post. */
- const feedText = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 280);
- const picture = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
-
- /** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */
- async function recordLocal(type, id, { title, path, actor = null, origin = 'user', at = null, text = '', pictureUrl = null }) {
- try {
- const o = ORIGINS.includes(origin) ? origin : 'user';
- await store.upsertItem(db, {
- source_service: 'community', source_type: type, source_id: String(id),
- title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
- actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),
- text: feedText(text), picture_url: picture(pictureUrl),
- });
- } catch (err) { console.warn('[Pulse] record failed:', err.message); }
- }
- async function forgetLocal(type, id) {
- try { await store.removeItem(db, 'community', type, id); } catch (err) { console.warn('[Pulse] remove failed:', err.message); }
- }
-
- function shape(i, projections) {
- const ai = i.origin === 'ai';
- const actor = ai || i.origin === 'system' ? null : authors.author(i.actor_subject, 'user', projections);
- return {
- id: i.id,
- source: { service: i.source_service, type: i.source_type, id: i.source_id },
- title: i.title,
- url: i.url,
- origin: i.origin,
- ...(i.visibility === 'followers' ? { visibility: 'followers' } : {}),
- label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
- ...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
- ...(i.picture_url ? { picture_url: i.picture_url } : {}),
- ...(i.preview_url ? { preview_url: i.preview_url } : {}),
- ...(i.media_ratio ? { media_ratio: i.media_ratio } : {}),
- ...(i.media_length ? { media_length: i.media_length } : {}),
- ...(i.media_length ? { duration_s: Number(i.media_length) } : {}),
- ...(i.subject_json ? { subject: parseJson(i.subject_json, null) } : {}),
- ...(i.text ? { text: i.text } : {}),
- ...(i.starts_at ? { starts_at: isoTime(i.starts_at) } : {}),
- ...(i.actor_name ? { actor_name: i.actor_name } : {}),
- actor,
- occurred_at: isoTime(i.occurred_at),
- };
- }
-
- return {
- ORIGINS,
- forgetLocal,
- /** The channels the signed-in person follows (Network), for the feed's following filter. */
- async followedFor(v) {
- if (!v || v.kind !== 'user' || !v.token) return [];
- return await followedSubjects(v);
- },
- async statusCreated(thread, text, actor, pictureUrl = null) {
- await recordLocal('status', thread.id, { title: text.slice(0, 300), path: `/forum/status/${thread.slug}`,
- actor, origin: 'user', at: thread.created_at, text, pictureUrl });
- },
-
- /** The test-account subject ids, so the Pulse sources skip them too. */
- testSubjectIds,
-
- // ── hooks for Community's own writes ─────────────────
- async pasteCreated(p) {
- if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;
- await recordLocal('paste', p.slug, { title: p.title, path: `/p/${encodeURIComponent(p.slug)}`, actor: p.owner_subject, origin: 'user', at: p.created_at,
- text: p.content, pictureUrl: p.screenshot_url });
- },
- async pasteChanged(p) {
- if (!p) return;
- if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);
- },
- async pasteGone(slug) { await forgetLocal('paste', slug); },
- async threadCreated(thread, space, extra = {}) {
- if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
- await recordLocal('thread', thread.id, {
- title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,
- actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
- text: extra.text, pictureUrl: extra.picture,
- });
- },
- async postCreated(post, thread, space, extra = {}) {
- if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
- await recordLocal('post', post.id, {
- title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#post-${post.id}`,
- actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
- text: post.body_markdown, pictureUrl: extra.picture,
- });
- },
- async submissionAccepted(s) {
- if (!s || s.status !== 'accepted') return;
- await recordLocal('submission', s.slug, { title: s.title, path: `/submissions/${encodeURIComponent(s.slug)}`, actor: s.author_subject, origin: 'user', at: s.reviewed_at });
- },
- async submissionGone(slug) { await forgetLocal('submission', slug); },
- async threadGone(id) { await forgetLocal('thread', id); await forgetLocal('status', id); },
- async postGone(id) { await forgetLocal('post', id); },
-
- /** POST /api/v1/pulse/items (a service with community.pulse.write). → { item, created } */
- async ingest(v, body = {}) {
- const ref = body.ref;
- if (!ref || typeof ref !== 'object' || !contracts.validate('common.entity-ref@1', ref).valid) fail(400, 'ref.invalid', 'ref must be an EntityRef {service, type, id}');
- const own = serviceNameOf(v.service);
- if (ref.service !== own) fail(403, 'pulse.foreign_ref', `A service publishes Pulse items about its own entities (${own}), not ${ref.service}'s`);
- if (body.visibility != null && !['public', 'followers'].includes(body.visibility)) fail(400, 'pulse.not_public', 'Only public or followers activity enters Pulse');
- const title = String(body.title == null ? '' : body.title).replace(/\s+/g, ' ').trim();
- if (!title || title.length > 300) fail(400, 'pulse.invalid_title', 'title is required (at most 300 characters)');
- const url = String(body.url || '');
- let parsed = null;
- try { parsed = new URL(url); } catch { /* invalid */ }
- if (!parsed || !/^https?:$/.test(parsed.protocol) || url.length > 2000) fail(400, 'pulse.invalid_url', 'url must be an absolute http(s) URL');
- let origin = body.origin == null ? v.origin : body.origin;
- if (!ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
- if (v.origin === 'ai') origin = 'ai'; // X-OV-Origin: ai wins over a body that says otherwise
- if (body.visibility === 'followers' && (origin !== 'user' || !v.subject)) fail(400, 'pulse.followers_subject_required', 'Followers activity needs the person who shared it');
- const when = body.occurred_at == null ? new Date() : new Date(body.occurred_at);
- if (Number.isNaN(when.getTime()) || when.getTime() > Date.now() + FUTURE_SKEW_MS || when.getUTCFullYear() < 2000) fail(400, 'pulse.invalid_time', 'occurred_at must be an ISO time, not in the future');
- const media = body.media && typeof body.media === 'object' && !Array.isArray(body.media) ? body.media : {};
- const mediaUrl = (v) => { try { const x = new URL(String(v)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
- const thumb = mediaUrl(media.thumb);
- const preview = media.preview == null ? null : mediaUrl(media.preview);
- const ratio = media.ratio == null ? null : Number(media.ratio);
- const length = media.length == null ? null : Number(media.length);
- if (media.thumb != null && !thumb) fail(400, 'pulse.invalid_media', 'media.thumb must be an HTTPS Media URL');
- if (media.preview != null && !preview) fail(400, 'pulse.invalid_media', 'media.preview must be an HTTPS URL');
- if (ratio != null && (!Number.isFinite(ratio) || ratio <= 0 || ratio > 20)) fail(400, 'pulse.invalid_media', 'media.ratio must be between 0 and 20');
- if (length != null && (!Number.isFinite(length) || length < 0 || length > 86400)) fail(400, 'pulse.invalid_media', 'media.length must be between 0 and 86400 seconds');
- const out = await store.upsertItem(db, {
- source_service: ref.service, source_type: ref.type, source_id: ref.id,
- title, url: parsed.toString(),
- actor_subject: origin === 'user' ? (v.subject || null) : null,
- origin, visibility: body.visibility || 'public', occurred_at: sqlTime(when), picture_url: thumb, preview_url: preview, media_ratio: ratio, media_length: length,
- });
- const projections = await authors.projectionsFor([out.item.actor_subject]);
- return { item: shape(out.item, projections), created: out.created };
- },
-
- async contribute(v, body = {}) {
- if (!v.subject || v.claims.on_behalf_of !== v.subject || !String(v.service).startsWith('app:')) {
- fail(403, 'pulse.not_delegated', 'An app needs a grant from the person named in its token');
- }
- if (Object.keys(body).some((k) => !['id', 'text', 'url', 'picture_url'].includes(k))) fail(400, 'pulse.invalid', 'Only id, text, url and picture_url are accepted');
- const id = String(body.id || '');
- const title = typeof body.text === 'string' ? body.text.replace(/\s+/g, ' ').trim() : '';
- if (!/^[A-Za-z0-9_-]{1,80}$/.test(id) || !title || title.length > 300) fail(400, 'pulse.invalid', 'id and text are required');
- let url;
- try { url = new URL(body.url); } catch { fail(400, 'pulse.invalid_url', 'An https URL is required'); }
- if (url.protocol !== 'https:' || url.toString().length > 2000) fail(400, 'pulse.invalid_url', 'An https URL is required');
- let picture = null;
- if (body.picture_url != null) {
- try { picture = new URL(body.picture_url); } catch { fail(400, 'pulse.picture_proxy_required', 'A Media picture URL is required'); }
- if (picture.protocol !== 'https:' || picture.hostname !== 'openvibe.media' || !/^\/(?:t|o)\//.test(picture.pathname) || picture.toString().length > 2000) {
- fail(400, 'pulse.picture_proxy_required', 'Pictures must use Media HTTPS thumbnail or object URLs');
- }
- }
- const at = Date.now();
- const times = (appWrites.get(v.service) || []).filter((t) => at - t < 60_000);
- if (times.length >= 10) fail(429, 'pulse.app_rate_limited', 'At most ten contributions per app per minute');
- times.push(at); appWrites.set(v.service, times);
- if (appWrites.size > 5000) appWrites.delete(appWrites.keys().next().value);
- const out = await store.upsertItem(db, { source_service: v.service, source_type: 'contribution', source_id: `${v.subject}:${id}`,
- title, url: url.toString(), picture_url: picture && picture.toString(), app_name: String(v.claims.app_name || '').slice(0, 80) || null,
- actor_subject: v.subject, origin: 'user', occurred_at: sqlTime(at) });
- const projections = await authors.projectionsFor([v.subject]);
- return { item: shape(out.item, projections), created: out.created };
- },
-
- async retractContribution(v, service, type, id) {
- if (service !== v.service || type !== 'contribution' || !v.subject || v.claims.on_behalf_of !== v.subject || !id.startsWith(`${v.subject}:`)) {
- fail(403, 'pulse.foreign_ref', 'An app retracts only its own items for the consenting person');
- }
- return { removed: await store.removeItem(db, service, type, id) };
- },
-
- /** DELETE /api/v1/pulse/items/:service/:type/:id — a service retracting its own item. */
- async retract(v, service, type, id) {
- if (service !== serviceNameOf(v.service)) fail(403, 'pulse.foreign_ref', 'A service retracts only its own items');
- return { removed: await store.removeItem(db, service, type, id) };
- },
-
- /** The origins that have at least one listable item (the page offers a filter only for those), cached a minute. */
- async origins() {
- if (originsCache.list && Date.now() - originsCache.at < 60_000) return originsCache.list;
- const list = [];
- for (const origin of ORIGINS) {
- if ((await store.listItems(db, { origin, limit: 1 })).rows.length) list.push(origin);
- }
- originsCache = { at: Date.now(), list };
- return list;
- },
-
- /** GET /api/v1/pulse ?origin=user|ai|system&after=<cursor>&limit= → { items, next_cursor } */
- async list(q = {}, viewer = null) {
- const origin = q.origin == null || q.origin === '' || q.origin === 'all' ? null : String(q.origin);
- if (origin && !ORIGINS.includes(origin)) fail(400, 'pulse.invalid_origin', `origin must be one of ${ORIGINS.join(', ')}`);
- const mine = q.for === 'me';
- if (q.for && !mine) fail(400, 'pulse.invalid_for', 'for must be me');
- if (mine && (!viewer || viewer.kind !== 'user' || !viewer.token)) fail(401, 'pulse.sign_in_required', 'Sign in for your feed');
- const limit = Math.min(Math.max(parseInt(q.limit, 10) || (mine ? 20 : PAGE), 1), 100);
- const before = mine ? decodeCursor(q.cursor || q.after, 4) : decodeCursor(q.after, 2);
- if (mine && before && (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before[0])
- || ![0, 1].includes(before[1]) || typeof before[2] !== 'string' || !Number.isSafeInteger(before[3]))) {
- fail(400, 'request.invalid_cursor', 'That cursor is not one this API issued');
- }
- const followed = viewer && viewer.kind === 'user' && viewer.token ? await followedSubjects(viewer) : [];
- const hour = mine ? (before ? before[0] : sqlTime(Date.now() - 60 * 60_000)) : null;
- const excluded = await testSubjectIds();
- const { rows, hasMore } = await store.listItems(db, { origin, before: before && (mine ? before.slice(1) : [0, ...before]), limit, followed, viewerSubject: viewer && viewer.kind === 'user' ? viewer.subject : null, hour, excludeSubjects: excluded });
- const projections = await authors.projectionsFor(rows.map((r) => r.actor_subject));
- // The projection is authority too: a test account resolved now is left out even if the cached list was
- // fetched before it existed (the list refreshes on a timer).
- const meSubject = viewer && viewer.kind === 'user' ? viewer.subject : null;
- const visible = rows.filter((r) => !(r.actor_subject && r.actor_subject !== meSubject
- && projections.get(r.actor_subject) && projections.get(r.actor_subject).is_test));
- const last = rows[rows.length - 1];
- return { items: visible.map((r) => shape(r, projections)), next_cursor: hasMore && last
- ? encodeCursor(mine ? [hour, last.feed_priority, last.occurred_at, last.id] : [last.occurred_at, last.id]) : null,
- // What this reader may do here: the menu's Feed shows its composer only when this says so (openvibe-shared
- // ov-home.js), so it never offers a post the status route would refuse.
- ...(viewer && viewer.kind === 'user' && viewer.subject ? { viewer: { can_post: true } } : {}) };
- },
- };
-}
-
-module.exports = { createPulse, serviceNameOf, ORIGINS };
deleted file mode 100644
deleted file mode 100644
@@ -1,474 +0,0 @@
@@ -1,474 +0,0 @@
-'use strict';
-
-/**
- * Pulse sources — the public activity that has no Events topic of its own, read on a due basis.
- *
- * The feed never calls another site while a person is reading it (server/pulse/feed.js reads Pulse only). This job
- * fills Pulse from the public APIs:
- * - OpenVibe.Today: GET /api/v1/events (public, cached there) → source today / event
- * - OpenVibe.Network: GET /api/v1/stats (public, CORS-open) → one grouped member row per day, "5 people joined today"
- * - OpenVibe.Quest: GET /api/v1/profiles/:subject/badges for people recently active in Pulse → "Finished a quest: …"
- * - OpenVibe.Meme: GET /api/v1/entries (public) → newly published meme entries
- *
- * Due-ness is kept in the database (pulse_source_runs): a run records the next time it is due, so a restart does not
- * skip a source and two processes do not both fetch at once (the second simply finds it not due). A minute timer only
- * checks those rows; nothing here runs per feed request. Wiki pages and Chat are pushed by Events and handled in
- * server/pulse/consumer.js; no route or page is slowed by this job.
- */
-const store = require('./store');
-const { subjectsByUsername } = require('../pastes/store');
-const { sqlTime } = require('../http/v1');
-
-const MINUTE = 60_000;
-const DAY = 24 * 60 * MINUTE;
-const QUEST_CHECK_MS = 24 * 60 * MINUTE;
-const QUEST_RECENT_MS = 14 * DAY;
-
-const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
-const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
-const subject = (v) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(v || '')) ? String(v) : null);
-const sqlFrom = (v, fallback) => {
- const normalized = store.pulseTime(v);
- return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(String(normalized || '')) ? normalized : fallback;
-};
-const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
-
-function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, testSubjects = null } = {}) {
- const todayApi = String(config.todayInternalUrl || config.todayUrl || 'https://openvibe.today').replace(/\/+$/, '');
- const todaySite = String(config.todayUrl || 'https://openvibe.today').replace(/\/+$/, '');
- const networkApi = String(config.networkInternalUrl || config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
- const networkSite = String(config.networkUrl || 'https://openvibe.network').replace(/\/+$/, '');
- const questApi = String(config.questInternalUrl || config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
- const questSite = String(config.questUrl || 'https://openvibe.quest').replace(/\/+$/, '');
- const memeApi = String(config.memeInternalUrl || config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '');
- const picsApi = String(config.picsUrl || 'https://openvibe.pics').replace(/\/+$/, '');
- const liveApi = String(config.liveUrl || 'https://openvibe.live').replace(/\/+$/, '');
- const spaceApi = String(config.spaceUrl || 'https://openvibe.space').replace(/\/+$/, '');
- const playApi = String(config.playUrl || 'https://play.openvibe.network').replace(/\/+$/, '');
- let timer = null;
- let running = null;
-
- /**
- * A channel's person, when a source names only the channel: the one subject Community knows by that username (its
- * subject_projection cache), or none when two people share it. 70 of 112 Live recordings and clips since 11-01
- * had no subject, so the Following feed never showed them and one person's evening folded as two runs.
- */
- function channelSubjects() {
- const cache = new Map();
- return async (username) => {
- const key = clean(username, 64).toLowerCase();
- if (!key) return null;
- if (!cache.has(key)) {
- const ids = (await subjectsByUsername(db, key)).filter((id) => subject(id));
- cache.set(key, ids.length === 1 ? ids[0] : null);
- }
- return cache.get(key);
- };
- }
-
- async function getJson(url, { timeoutMs = 8000 } = {}) {
- const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
- if (!res.ok) throw new Error(`${url} answered ${res.status}`);
- return await res.json();
- }
-
- /** OpenVibe.Today's public calendar → one event row per scheduled stream or event. */
- async function todayEvents() {
- const at = now();
- const from = new Date(at).toISOString();
- const to = new Date(at + 60 * DAY).toISOString();
- const out = await getJson(`${todayApi}/api/v1/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=100`);
- let n = 0;
- for (const e of (Array.isArray(out.events) ? out.events : [])) {
- if (!e || typeof e.id !== 'string' || !e.id || typeof e.title !== 'string' || !e.title.trim()) continue;
- if (e.visibility !== 'public' || e.status === 'cancelled') continue;
- const starts = Date.parse(String(e.starts_at || ''));
- if (!Number.isFinite(starts) || starts <= at) continue;
- const owner = subject(e.owner_subject);
- const whenLabel = `Starts ${new Date(starts).toISOString().replace('T', ' ').slice(0, 16)} UTC`;
- const text = [whenLabel, clean(e.description, 220)].filter(Boolean).join(' · ');
- const when = sqlFrom(e.created_at, sqlTime(at));
- const out2 = await store.upsertItem(db, {
- source_service: 'today', source_type: 'event', source_id: e.id,
- title: `Coming up: ${clean(e.title, 140)}`,
- url: https(e.url) || `${todaySite}/e/${encodeURIComponent(e.id)}`,
- actor_subject: owner, origin: owner ? 'user' : 'system', occurred_at: when, starts_at: e.starts_at, text,
- });
- if (out2.created) n++;
- }
- return n;
- }
-
- /** OpenVibe.Network's open stats → one grouped member row per UTC day ("5 people joined today"). */
- async function networkMembers() {
- const at = now();
- const out = await getJson(`${networkApi}/api/v1/stats?days=7`);
- const accounts = out && out.accounts;
- if (!accounts || !Array.isArray(accounts.daily)) return 0;
- const today = dayOf(at);
- const total = Number(accounts.total);
- let n = 0;
- for (const d of accounts.daily) {
- const count = Number(d && d.n);
- const day = String((d && d.day) || '');
- if (!Number.isInteger(count) || count <= 0 || !/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
- const title = day === today
- ? `${count} ${count === 1 ? 'person' : 'people'} joined today`
- : `${count} ${count === 1 ? 'person' : 'people'} joined on ${day}`;
- const r = await store.upsertItem(db, {
- source_service: 'network', source_type: 'members', source_id: day,
- title, url: `${networkSite}/stats`, actor_subject: null, origin: 'system',
- occurred_at: day === today ? sqlTime(at) : `${day}T12:00:00Z`,
- text: Number.isFinite(total) && total > 0 ? `${total.toLocaleString('en-US')} people on OpenVibe so far` : '',
- });
- if (r.created) n++;
- }
- return n;
- }
-
- /**
- * Quest badges for people recently active in Pulse: the public badges API is the only read Quest offers, so a
- * rotation (a person is checked at most once a day) finds "Finished a quest: …". Nothing is stored about anyone
- * but their subject and when the check ran.
- */
- async function questBadges() {
- const at = now();
- const since = sqlTime(at - QUEST_RECENT_MS);
- const checkedBefore = sqlTime(at - QUEST_CHECK_MS);
- const people = await db.prepare(`SELECT DISTINCT i.actor_subject AS subject FROM pulse_items i
- LEFT JOIN pulse_quest_people q ON q.subject = i.actor_subject
- WHERE i.actor_subject IS NOT NULL AND i.origin = 'user' AND i.occurred_at >= ?
- AND (q.subject IS NULL OR q.checked_at < ?)
- ORDER BY i.actor_subject LIMIT 25`).all(since, checkedBefore);
- // A test account never gets a quest-badge item; the identity list says which subjects are test accounts.
- let excluded = new Set();
- if (testSubjects) { try { excluded = new Set(await testSubjects()); } catch { /* the list is optional */ } }
- let n = 0;
- const MAX_CREATED = 25;
- for (const p of people) {
- if (n >= MAX_CREATED) break;
- const who = subject(p.subject);
- if (!who || excluded.has(who)) continue;
- try {
- const res = await fetchImpl(`${questApi}/api/v1/profiles/${encodeURIComponent(who)}/badges`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
- if (res.ok) {
- const body = await res.json();
- const recent = (Array.isArray(body.badges) ? body.badges : [])
- .filter((b) => b && typeof b.id === 'string' && b.id && typeof b.name === 'string' && b.name.trim())
- .map((b) => ({ ...b, awarded: Date.parse(String(b.awarded_at || '')) }))
- .filter((b) => Number.isFinite(b.awarded) && at - b.awarded <= QUEST_RECENT_MS)
- .sort((a, b) => b.awarded - a.awarded)
- .slice(0, 2);
- for (const b of recent) {
- if (n >= MAX_CREATED) break;
- const awarded = b.awarded;
- const r = await store.upsertItem(db, {
- source_service: 'quest', source_type: 'badge', source_id: `${who}:${b.id}`,
- title: `Finished a quest: ${clean(b.name, 90)}`, url: `${questSite}/`,
- actor_subject: who, origin: 'user', occurred_at: sqlTime(awarded), text: '',
- });
- if (r.created) n++;
- }
- }
- } catch (err) {
- log.warn?.(`[Pulse sources] Quest ${who}:`, err.message);
- }
- await db.prepare(`INSERT INTO pulse_quest_people (subject, checked_at) VALUES (?, ?)
- ON CONFLICT(subject) DO UPDATE SET checked_at = excluded.checked_at`).run(who, sqlTime(at));
- }
- return n;
- }
-
- /** Play's anonymous feed contains every person's public unlock across production games. */
- async function playAchievements() {
- let cursor = null;
- let created = 0;
- const seen = new Set();
- for (let page = 0; page < 100; page++) {
- const url = new URL('/api/v1/achievements/feed', playApi);
- url.searchParams.set('limit', '100');
- if (cursor) url.searchParams.set('cursor', cursor);
- const out = await getJson(url.toString());
- for (const a of (Array.isArray(out.items) ? out.items : [])) {
- const who = subject(a && a.player);
- const game = clean(a && a.title, 64);
- const id = clean(a && a.id, 80);
- const at = sqlFrom(a && a.unlocked_at, null);
- if (!who || !/^[a-z0-9-]{2,40}$/.test(game) || !/^[a-z0-9_-]{1,64}$/.test(id) || !at) continue;
- const sourceId = `${who}:${game}:${id}`;
- seen.add(sourceId);
- const r = await store.upsertItem(db, { source_service: 'games', source_type: 'achievement', source_id: sourceId,
- title: `Unlocked ${clean(a.name, 120)} in ${clean(a.title_name || game, 80)}`,
- url: `https://openvibe.games/play?game=${encodeURIComponent(game)}`, actor_subject: who, origin: 'user', occurred_at: at,
- text: clean(a.description, 280) });
- if (r.created) created++;
- }
- cursor = typeof out.next === 'string' && out.next ? out.next : null;
- if (!cursor) break;
- }
- if (cursor) throw new Error('Play achievement feed exceeded 100 pages; retaining prior cards');
- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'games' AND source_type = 'achievement'").all();
- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'games', 'achievement', row.source_id);
- return created;
- }
-
- /** Meme's public entries → one Feed card each; the API excludes hidden and unlisted entries. */
- async function memeEntries() {
- let cursor = null, created = 0;
- for (let page = 0; page < 4; page++) {
- const url = new URL('/api/v1/entries', memeApi);
- url.searchParams.set('limit', '50');
- if (cursor) url.searchParams.set('cursor', cursor);
- const out = await getJson(url.toString());
- for (const e of (Array.isArray(out.entries) ? out.entries : [])) {
- const slug = clean(e && e.slug, 100);
- const title = clean(e && e.title, 300);
- if (!slug || !/^[A-Za-z0-9-]+$/.test(slug) || !title) continue;
- const url = `${String(config.memeUrl || 'https://openvibe.meme').replace(/\/+$/, '')}/m/${encodeURIComponent(slug)}`;
- const r = await store.upsertItem(db, {
- source_service: 'meme', source_type: 'meme', source_id: slug, title, url,
- actor_subject: null, origin: 'system', occurred_at: sqlFrom(e.published_at || e.created_at, sqlTime(now())),
- text: clean(e.summary || e.description, 280),
- });
- if (r.created) created++;
- }
- cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;
- if (!cursor) break;
- }
- return created;
- }
-
- /** Pics' gallery is opt-in and checks both the owner's public share and the active picture row. */
- async function publicPictures() {
- const seen = new Set();
- let cursor = null, created = 0;
- for (let page = 0; page < 50; page++) {
- const url = new URL('/api/v1/gallery', picsApi);
- url.searchParams.set('sort', 'new');
- url.searchParams.set('limit', '30');
- if (cursor) url.searchParams.set('cursor', cursor);
- const out = await getJson(url.toString());
- for (const p of (Array.isArray(out.pictures) ? out.pictures : [])) {
- const id = clean(p && p.id, 80);
- const links = p && p.links || {};
- if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !p.listed || !https(links.page) || !https(links.thumb)) continue;
- seen.add(id);
- const r = await store.upsertItem(db, { source_service: 'pics', source_type: 'picture', source_id: id,
- title: clean(p.title, 160) || 'Picture on OpenVibe.Pics', url: https(links.page), picture_url: https(links.thumb),
- actor_name: clean(p.author, 80), actor_subject: null, origin: 'user',
- occurred_at: sqlFrom(p.published_at, sqlTime(now())), text: clean(p.description, 280),
- media_ratio: Number(p.width) > 0 && Number(p.height) > 0 ? Number(p.width) / Number(p.height) : null });
- if (r.created) created++;
- }
- cursor = typeof out.next === 'string' && out.next ? out.next : null;
- if (!cursor) break;
- }
- if (cursor) throw new Error('Pics gallery exceeded 50 pages; retaining prior pictures');
- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'pics' AND source_type = 'picture'").all();
- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'pics', 'picture', row.source_id);
- return created;
- }
-
- /** The anonymous Rundown browse response contains only episodes visible to everyone. */
- async function rundownEpisodes() {
- let before = null, created = 0;
- const seen = new Set();
- for (let page = 0; page < 50; page++) {
- const url = new URL('/api/rundown/streams', liveApi);
- if (before) url.searchParams.set('before', before);
- const out = await getJson(url.toString());
- for (const s of (Array.isArray(out.streams) ? out.streams : [])) {
- if (!s || s.live || !s.episode || s.episode.audience !== 'public' || !Number.isSafeInteger(Number(s.stream_id)) || Number(s.stream_id) < 1) continue;
- const id = String(s.stream_id);
- const title = clean(s.episode.title || s.title, 160);
- if (!title) continue;
- seen.add(id);
- const prior = await db.prepare("SELECT actor_subject FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND source_id = ?").get(id);
- const user = s.user || {};
- // The card's subject is the streamer the episode is about; the actor is The Rundown itself.
- const subject = user.username ? {
- name: clean(user.display_name || user.username, 80) || null,
- handle: `@${clean(user.username, 64)}`,
- avatar: https(user.avatar_url),
- } : null;
- // The episode's own AI picture is the art; the stream's thumbnail stays the fallback until it exists.
- const picture = https(s.episode.picture_url);
- const r = await store.upsertItem(db, { source_service: 'live', source_type: 'episode', source_id: id,
- title, url: `${liveApi}/rundown/e/${id}`,
- actor_name: 'The Rundown', actor_subject: prior && prior.actor_subject || null, origin: 'system',
- occurred_at: sqlFrom(s.episode.aired_at || s.started_at, sqlTime(now())),
- text: clean(s.episode.logline, 280), picture_url: picture || https(s.thumbnail_url),
- media_ratio: picture ? 1200 / 630 : null, subject });
- if (r.created) created++;
- }
- before = typeof out.next === 'string' && out.next ? out.next : null;
- if (!before) break;
- }
- if (before) throw new Error('Rundown pagination exceeded 50 pages; retaining prior episodes');
- const stale = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode'").all();
- for (const row of stale) if (!seen.has(row.source_id)) await store.removeItem(db, 'live', 'episode', row.source_id);
- return created;
- }
-
- /** Live's Content API has already checked public Media rows, banned owners and playable VODs. */
- async function publicContent() {
- const seen = new Set();
- let cursor = null, created = 0;
- const live = await getJson(`${liveApi}/api/streams`);
- const personOf = channelSubjects();
- const onAir = new Map();
- const liveIds = new Set();
- for (const s of (Array.isArray(live.streams) ? live.streams : [])) {
- const id = Number(s && s.id), channel = clean(s && s.username, 64);
- const started = sqlFrom(s && s.started_at, null);
- if (!Number.isSafeInteger(id) || id < 1 || !/^[A-Za-z0-9_.-]{1,64}$/.test(channel) || !started) continue;
- if (!onAir.has(channel.toLowerCase())) onAir.set(channel.toLowerCase(), []);
- onAir.get(channel.toLowerCase()).push(Date.parse(`${started.replace(' ', 'T')}Z`));
- liveIds.add(String(id));
- const name = clean(s.display_name || channel, 80);
- const r = await store.upsertItem(db, { source_service: 'live', source_type: 'stream', source_id: String(id),
- title: `${name} went live: ${clean(s.title, 160) || 'a stream'}`, url: `${liveApi}/@${encodeURIComponent(channel)}`,
- actor_name: name, actor_subject: await personOf(channel), origin: 'user', occurred_at: started });
- if (r.created) created++;
- }
- for (let page = 0; page < 100; page++) {
- const url = new URL('/api/content/feed', liveApi);
- url.searchParams.set('sort', 'new');
- url.searchParams.set('window', 'all');
- url.searchParams.set('limit', '30');
- if (cursor) url.searchParams.set('cursor', cursor);
- const out = await getJson(url.toString());
- if (out.partial || (out.sources && (out.sources.vods !== 'ok' || out.sources.clips !== 'ok'))) throw new Error('Live Content feed was partial; retaining prior cards');
- for (const v of (Array.isArray(out.items) ? out.items : [])) {
- if (!v || !['vod', 'clip'].includes(v.kind) || !https(v.href)) continue;
- const id = clean(v.id, 80);
- if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
- const type = v.kind === 'vod' ? 'recording' : 'clip';
- const at = sqlFrom(v.created_at, null);
- if (!at) continue;
- const channel = clean(v.channel && v.channel.username, 64);
- const current = onAir.get(channel.toLowerCase()) || [];
- if (type === 'recording' && current.some((started) => Number.isFinite(started)
- && Math.abs(Date.parse(`${at.replace(' ', 'T')}Z`) - started) <= 10 * MINUTE)) continue;
- seen.add(`${type}:${id}`);
- let prior = null;
- if (type === 'recording' && channel) {
- const candidates = await db.prepare(`SELECT actor_subject, source_id FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream'
- AND lower(url) = lower(?) AND occurred_at BETWEEN ? AND ? ORDER BY occurred_at DESC LIMIT 20`)
- .all(`${liveApi}/@${encodeURIComponent(channel)}`, sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) - 60 * MINUTE), sqlTime(Date.parse(`${at.replace(' ', 'T')}Z`) + 60 * MINUTE));
- prior = candidates.find((row) => !liveIds.has(row.source_id)) || null;
- }
- const r = await store.upsertItem(db, { source_service: 'live', source_type: type, source_id: id,
- title: `${type === 'recording' ? 'Recording: ' : ''}${clean(v.title, 150) || (type === 'recording' ? 'A stream' : 'A clip')}`,
- url: https(v.href), picture_url: https(v.thumbnail_url),
- actor_subject: (prior && prior.actor_subject) || await personOf(channel), actor_name: clean(v.channel && v.channel.display_name, 80),
- origin: 'user', occurred_at: at, text: clean(v.excerpt, 280), media_length: Number(v.duration_seconds) > 0 ? Number(v.duration_seconds) : null });
- if (r.created) created++;
- if (prior) await store.removeItem(db, 'live', 'stream', prior.source_id);
- }
- cursor = typeof out.next === 'string' && out.next ? out.next : null;
- if (!cursor) break;
- }
- if (cursor) throw new Error('Live Content feed exceeded 100 pages; retaining prior cards');
- const old = await db.prepare("SELECT source_type, source_id FROM pulse_items WHERE source_service = 'live' AND source_type IN ('recording', 'clip')").all();
- for (const row of old) if (!seen.has(`${row.source_type}:${row.source_id}`)) await store.removeItem(db, 'live', row.source_type, row.source_id);
- return created;
- }
-
- /** Space's public gallery excludes drafts, private spaces, inactive sites and takedowns. */
- async function publicSpaces() {
- const seen = new Set();
- let cursor = null, created = 0;
- for (let page = 0; page < 50; page++) {
- const url = new URL('/api/v1/apps/gallery', spaceApi);
- url.searchParams.set('limit', '100');
- if (cursor) url.searchParams.set('cursor', cursor);
- const out = await getJson(url.toString());
- for (const s of (Array.isArray(out.spaces) ? out.spaces : [])) {
- const id = clean(s && s.id, 80);
- const at = Number(s && s.published_at);
- if (!/^spc_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) || !Number.isFinite(at) || at <= 0 || !https(s.url)) continue;
- seen.add(id);
- const r = await store.upsertItem(db, { source_service: 'space', source_type: 'space', source_id: id,
- title: clean(s.name, 160) || 'New space', url: https(s.url), text: clean(s.description, 280),
- actor_name: clean(s.owner_name || s.owner_username, 80), actor_subject: null, origin: 'user', occurred_at: sqlTime(at) });
- if (r.created) created++;
- }
- cursor = typeof out.next_cursor === 'string' && out.next_cursor ? out.next_cursor : null;
- if (!cursor) break;
- }
- if (cursor) throw new Error('Space gallery exceeded 50 pages; retaining prior spaces');
- const old = await db.prepare("SELECT source_id FROM pulse_items WHERE source_service = 'space' AND source_type = 'space'").all();
- for (const row of old) if (!seen.has(row.source_id)) await store.removeItem(db, 'space', 'space', row.source_id);
- return created;
- }
-
- const SOURCES = [
- { name: 'today-events', every: 10 * MINUTE, run: todayEvents },
- { name: 'network-members', every: 60 * MINUTE, run: networkMembers },
- { name: 'quest-badges', every: 15 * MINUTE, run: questBadges },
- { name: 'play-achievements', every: 15 * MINUTE, run: playAchievements },
- { name: 'meme-entries', every: 10 * MINUTE, run: memeEntries },
- { name: 'public-pictures', every: 5 * MINUTE, run: publicPictures },
- { name: 'rundown-episodes', every: 10 * MINUTE, run: rundownEpisodes },
- { name: 'public-content', every: 5 * MINUTE, run: publicContent },
- { name: 'public-spaces', every: 10 * MINUTE, run: publicSpaces },
- ];
-
- async function remember(name, { nextRun, lastRun, error = null }) {
- await db.prepare(`INSERT INTO pulse_source_runs (name, next_run_at, last_run_at, last_error)
- VALUES (?, ?, ?, ?)
- ON CONFLICT(name) DO UPDATE SET next_run_at = excluded.next_run_at, last_run_at = excluded.last_run_at,
- last_error = excluded.last_error`)
- .run(name, nextRun, lastRun, error);
- }
-
- /** Run every source that is due now (or all of them with { force: true }). → [{ name, items } | { name, error }] */
- async function runOnce({ force = false } = {}) {
- if (running) return running;
- running = (async () => {
- const at = now();
- const states = new Map((await db.prepare('SELECT * FROM pulse_source_runs').all()).map((s) => [s.name, s]));
- const out = [];
- for (const source of SOURCES) {
- const state = states.get(source.name);
- if (!force && state && state.next_run_at && state.next_run_at > sqlTime(at)) continue;
- try {
- const items = await source.run();
- await remember(source.name, { nextRun: sqlTime(at + source.every), lastRun: sqlTime(at) });
- out.push({ name: source.name, items });
- } catch (err) {
- log.warn?.(`[Pulse sources] ${source.name}:`, err.message);
- await remember(source.name, { nextRun: sqlTime(at + source.every), lastRun: sqlTime(at), error: String(err.message).slice(0, 300) });
- out.push({ name: source.name, error: err.message });
- }
- }
- return out;
- })().finally(() => { running = null; });
- return await running;
- }
-
- /** Start the minute check. Only server/index.js calls this (tests run runOnce themselves). */
- function start() {
- if (timer) return;
- const tick = () => { runOnce().catch((err) => log.warn?.('[Pulse sources]:', err.message)); };
- timer = setInterval(tick, MINUTE);
- if (timer.unref) timer.unref();
- const first = setTimeout(tick, 5_000);
- if (first.unref) first.unref();
- timer.first = first;
- }
- function stop() {
- if (timer) clearInterval(timer);
- if (timer && timer.first) clearTimeout(timer.first);
- timer = null;
- }
- async function status() {
- const rows = await db.prepare('SELECT * FROM pulse_source_runs ORDER BY name').all();
- return { sources: SOURCES.map((s) => s.name), runs: rows };
- }
-
- return { runOnce, start, stop, status, SOURCES: SOURCES.map((s) => s.name) };
-}
-
-module.exports = { createPulseSources };
deleted file mode 100644
deleted file mode 100644
@@ -1,170 +0,0 @@
@@ -1,170 +0,0 @@
-'use strict';
-
-/**
- * Pulse store — pure SQL over pulse_items (server/db.js).
- *
- * An item is keyed by its source (service, type, id). Re-recording the same source refreshes
- * its title and link but never its provenance: origin, actor and occurred_at are what the first
- * record said.
- *
- * A live stream is the exception: a dropped connection starts a new Live session (a new subject
- * id), but it is the same broadcast. upsertLiveItem keeps one item for the same person (or the
- * same channel URL) and title within six hours and moves its time to the newest start. Existing
- * duplicates are collapsed the same way when the feed is read, so the page shows one line.
- */
-const LIVE_WINDOW_MS = 6 * 60 * 60 * 1000;
-
-/**
- * One spelling for a Pulse time: "YYYY-MM-DD HH:MM:SS" in UTC (sqlTime's). The feed orders by this text, and an ISO
- * time ("…T…Z") sorts above every same-day time written with a space, so a day's quest badges and Community's own posts
- * fell below that day's events (11-09). Anything Date can read is accepted; nothing readable keeps what was given.
- */
-function pulseTime(v) {
- if (v == null || v === '') return v;
- const s = String(v);
- const t = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
- return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : s;
-}
-
-/** A Pulse time in milliseconds, the space spelling read as UTC (Date.parse would read it as local time). */
-function ms(v) {
- const s = String(v == null ? '' : v);
- return Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
-}
-
-/** The subject a card is about (a Rundown episode's streamer), as JSON text or null. */
-function subjectJsonOf(it) {
- return it && it.subject && typeof it.subject === 'object' && !Array.isArray(it.subject) ? JSON.stringify(it.subject) : null;
-}
-
-/** A live.stream.started item: one row per broadcast, not per reconnect. → { item, created } */
-async function upsertLiveItem(db, it) {
- it = { ...it, occurred_at: pulseTime(it.occurred_at) };
- const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
- return await db.tx(async () => {
- const key = [it.source_service, it.source_type, String(it.source_id)];
- const existing = await find.get(...key);
- if (existing) {
- await db.prepare('UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, ...key);
- return { item: await find.get(...key), created: false };
- }
- const at = ms(it.occurred_at);
- const actor = it.actor_subject || null;
- if (Number.isFinite(at)) {
- // The same person (or channel URL) and title within six hours of this start is the same
- // broadcast. Older sessions only: a delayed event must not move a newer item back in time.
- const candidates = actor
- ? await db.prepare(`SELECT * FROM pulse_items
- WHERE source_service = 'live' AND source_type = 'stream' AND title = ?
- AND ((actor_subject IS NOT NULL AND actor_subject = ?) OR url = ?)
- ORDER BY occurred_at DESC, id DESC`).all(it.title, actor, it.url)
- : await db.prepare(`SELECT * FROM pulse_items
- WHERE source_service = 'live' AND source_type = 'stream' AND title = ? AND url = ?
- ORDER BY occurred_at DESC, id DESC`).all(it.title, it.url);
- let prior = null, priorAt = -Infinity;
- for (const c of candidates) {
- const t = ms(c.occurred_at);
- if (Number.isFinite(t) && t <= at && at - t <= LIVE_WINDOW_MS && t > priorAt) { prior = c; priorAt = t; }
- }
- if (prior) {
- if (at > priorAt) await db.prepare('UPDATE pulse_items SET occurred_at = ? WHERE id = ?').run(it.occurred_at, prior.id);
- return { item: await db.prepare('SELECT * FROM pulse_items WHERE id = ?').get(prior.id), created: false };
- }
- }
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)`).run(...key, it.title, it.url, actor, it.origin, it.occurred_at);
- return { item: await find.get(...key), created: true };
- });
-}
-
-/** → { item, created } */
-async function upsertItem(db, it) {
- it = { ...it, occurred_at: pulseTime(it.occurred_at) };
- const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
- return await db.tx(async () => {
- const key = [it.source_service, it.source_type, String(it.source_id)];
- const had = await find.get(...key);
- if (had) {
- // An update refreshes the card's words and picture. Only a source that says `touch` may move its
- // time forward (a Wiki page edited again, a busy chat hour still filling): provenance is otherwise
- // what the first record said. A source that sends no text (undefined) keeps the text already there.
- if (it.text === undefined) it = { ...it, text: had.text };
- // A person found later fills a card that had none (Live recordings named only their channel until
- // 2026-11-12); a card's person, once known, never changes.
- const person = it.actor_subject || null;
- if (it.touch) {
- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, occurred_at = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', it.occurred_at, subjectJsonOf(it), ...key);
- } else {
- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, preview_url = ?, media_ratio = ?, media_length = ?, app_name = ?, text = ?, starts_at = ?, actor_name = ?, actor_subject = COALESCE(actor_subject, ?), visibility = ?, subject_json = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
- .run(it.title, it.url, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, person, it.visibility || 'public', subjectJsonOf(it), ...key);
- }
- return { item: await find.get(...key), created: false };
- }
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, preview_url, media_ratio, media_length, app_name, text, starts_at, actor_name, subject_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.visibility || 'public', it.occurred_at, it.picture_url || null, it.preview_url || null, it.media_ratio || null, it.media_length || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), pulseTime(it.starts_at) || null, it.actor_name || null, subjectJsonOf(it));
- return { item: await find.get(...key), created: true };
- });
-}
-
-async function removeItem(db, service, type, id) {
- return (await db.prepare('DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').run(service, type, String(id))).changes;
-}
-
-/**
- * Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
- * against their source at read time — a paste made private, a deleted post, a submission no longer
- * accepted or a thread in a space that stopped being public (or became members-only) never shows,
- * even if nothing removed its item. Live streams recorded before upsertLiveItem existed are
- * collapsed here too: an older session is hidden when the same person (or URL) and title started
- * again within six hours, so one line and its newest time remain.
- * opts.origin 'user' | 'ai' | 'system'
- * opts.before [occurred_at, id] of the last item already seen
- * opts.excludeSubjects test accounts (Network's is_test): their items never reach a public read
- * → { rows, hasMore }
- */
-async function listItems(db, { origin = null, before = null, limit = 30, followed = [], viewerSubject = null, hour = null, excludeSubjects = [] } = {}) {
- const priority = "CASE WHEN @hour::text IS NOT NULL AND i.occurred_at >= @hour AND jsonb_exists(@followed::jsonb, i.actor_subject) THEN 1 ELSE 0 END";
- const rows = await db.prepare(`
- SELECT i.*, ${priority} AS feed_priority FROM pulse_items i
- WHERE (@origin::text IS NULL OR i.origin = @origin)
- AND (@exclude::text[] IS NULL OR i.actor_subject IS NULL OR i.actor_subject = @viewerSubject::text
- OR NOT (i.actor_subject = ANY(@exclude::text[])))
- AND (i.actor_subject = @viewerSubject::text
- OR NOT EXISTS (SELECT 1 FROM subject_projection sp WHERE sp.subject_id = i.actor_subject AND sp.is_test = 1))
- AND (i.visibility = 'public' OR (i.visibility = 'followers' AND i.actor_subject IS NOT NULL
- AND (i.actor_subject = @viewerSubject::text OR jsonb_exists(@followed::jsonb, i.actor_subject))))
- AND (i.source_service <> 'today' OR i.source_type <> 'event' OR (i.starts_at IS NOT NULL AND i.starts_at > to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')))
- AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (
- SELECT 1 FROM pulse_items e WHERE e.source_service = 'live' AND e.source_type = 'episode' AND e.source_id = i.source_id))
- AND (@at::text IS NULL OR ${priority} < @priority::integer
- OR (${priority} = @priority::integer AND (i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id::bigint))))
- AND NOT (i.source_service = 'live' AND i.source_type = 'stream' AND EXISTS (
- SELECT 1 FROM pulse_items j
- WHERE j.source_service = 'live' AND j.source_type = 'stream'
- AND j.title = i.title
- AND ((j.actor_subject IS NOT NULL AND j.actor_subject = i.actor_subject) OR j.url = i.url)
- AND (j.occurred_at > i.occurred_at OR (j.occurred_at = i.occurred_at AND j.id > i.id))
- AND j.occurred_at::timestamptz <= i.occurred_at::timestamptz + INTERVAL '6 hours'))
- AND (i.source_service <> 'community'
- OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
- OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'
- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
- OR (i.source_type = 'status' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.slug = 'status'
- AND s.visibility = 'public'))
- OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
- WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'
- AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
- OR (i.source_type = 'submission' AND EXISTS (SELECT 1 FROM submissions su WHERE su.slug = i.source_id AND su.status = 'accepted')))
- ORDER BY feed_priority DESC, i.occurred_at DESC, i.id DESC
- LIMIT @limit`).all({ origin, followed: JSON.stringify(followed), viewerSubject, hour,
- exclude: Array.isArray(excludeSubjects) && excludeSubjects.length ? excludeSubjects : null,
- priority: before ? before[0] : null, at: before ? before[1] : null, id: before ? before[2] : null, limit: limit + 1 });
- const hasMore = rows.length > limit;
- if (hasMore) rows.pop();
- return { rows, hasMore };
-}
-
-module.exports = { pulseTime, upsertItem, upsertLiveItem, removeItem, listItems };
@@ -3,7 +3,6 @@
@@ -3,7 +3,6 @@
const ld = require('./jsonld');
const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
-const { card } = require('../pulse/feed');
// What each kind is called on a card and its mark (Opus, 2026-11-11: "Live · video" and "Quest · quest" said the same
// What each kind is called on a card and its mark (Opus, 2026-11-11: "Live · video" and "Quest · quest" said the same
// thing twice; a card now says what happened, who, where and when, in one line).
// thing twice; a card now says what happened, who, where and when, in one line).
@@ -60,7 +59,7 @@ const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', '
@@ -60,7 +59,7 @@ const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', '
const href = (kind, cursor = '', search = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (search) q.set('q', search); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };
const href = (kind, cursor = '', search = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (search) q.set('q', search); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };
function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null, nextCursor = null, now = Date.now() } = {}) {
function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null, nextCursor = null, now = Date.now() } = {}) {
- const cards = items.map((item) => item && item.kind ? item : card(item));
+ const cards = items.filter((item) => item && item.kind);
const chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(href(id, '', q))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');
const chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(href(id, '', q))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');
const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Feed</span></nav><h1>Feed</h1><p class="muted">What's happening across OpenVibe.</p></header>
const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Feed</span></nav><h1>Feed</h1><p class="muted">What's happening across OpenVibe.</p></header>
<form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>
<form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>
@@ -36,7 +36,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
@@ -36,7 +36,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
};
};
- const PULSE = '/api/v1/pulse';
+ const PULSE = '/api/v1/activity';
await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
for (let i = 0; i < 3; i++) assert.strictEqual((await call(PULSE, { cookie: alexJwt })).status, 200);
for (let i = 0; i < 3; i++) assert.strictEqual((await call(PULSE, { cookie: alexJwt })).status, 200);
@@ -46,7 +46,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
@@ -46,7 +46,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')));
assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')));
const body = r.json();
const body = r.json();
assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
- assert.ok(body.detail.includes('community.pulse.read'), body.detail);
+ assert.ok(body.detail.includes('community.feed.read'), body.detail);
assert.strictEqual((await call(PULSE, { cookie: samJwt })).status, 200, 'another person still passes');
assert.strictEqual((await call(PULSE, { cookie: samJwt })).status, 200, 'another person still passes');
});
});
@@ -97,7 +97,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
@@ -97,7 +97,7 @@ const { actor, serviceItself } = require('../server/actor-limits');
await check('refusals are counted in community_rate_limited_total', async () => {
await check('refusals are counted in community_rate_limited_total', async () => {
const m = (await call('/metrics')).text;
const m = (await call('/metrics')).text;
- assert.ok(/community_rate_limited_total\{limit="community.pulse.read",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
+ assert.ok(/community_rate_limited_total\{limit="community.feed.read",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
assert.ok(/community_rate_limited_total\{limit="community.comment.create",window="minute"\} 1/.test(m));
assert.ok(/community_rate_limited_total\{limit="community.comment.create",window="minute"\} 1/.test(m));
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,385 +0,0 @@
@@ -1,385 +0,0 @@
-'use strict';
-/**
- * GET /api/v1/feed (the menu's Feed on every site): one stream from Pulse, newest first, as cards by kind (a paste or
- * a status is a post, a Live clip a clip, a Wiki page a wiki page, a Quest badge a quest, a busy chat hour a chat, a
- * Today event an event, a day's new Network accounts a member), the pictures and text a post carries, Community's own
- * counts, the site/q/following filters, and the shape openvibe-shared's ov-home.js feed() draws.
- */
-const assert = require('assert');
-const { boot, check, done } = require('./helpers/app');
-
-const FRAME = { cards: [
- { site: 'media-hub', title: 'Trending', items: [
- { title: 'A trending video', url: 'https://openvibe.video/v/7', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },
- // The same title as a Pulse card on the page ("Recording: " aside): skipped, never a second card for it.
- { title: 'Recording: A video', url: 'https://openvibe.video/v/8', image: { src: 'https://openvibe.media/t/8.webp' } },
- // The same link as a Pulse card further down the page: skipped too.
- { title: 'Same link, other words', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/9.webp' } },
- { title: 'No picture', url: 'https://openvibe.video/v/2' },
- { title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },
- ] },
- { site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },
-] };
-const LIVE = { streams: [{ username: 'alex', started_at: new Date().toISOString() },
- { username: 'alex', started_at: new Date(Date.now() - 40 * 60_000).toISOString() },
- { username: 'ada', started_at: new Date().toISOString() }] };
-const feedFetch = async (url) => {
- const u = String(url);
- return { ok: /\/api\/(frame|streams)/.test(u), json: async () => (/\/api\/streams/.test(u) ? LIVE : FRAME) };
-};
-
-// 1×1 PNG, enough for the attachment upload and the screenshot paste (the bytes decide the type).
-const zlib = require('zlib');
-const chunk = (type, data) => {
- const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
- const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
- const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
- return Buffer.concat([len, td, crc]);
-};
-const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;
-const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr),
- chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);
-
-const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().replace('T', ' ').slice(0, 19);
-
-(async () => {
- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch, startRelay: false, startSharingWorker: false, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
- await check('feed examples are generated from current Pulse cards and search accepts natural filters', async () => {
- const examples = await t.get('/api/v1/feed/examples');
- assert.strictEqual(examples.status, 200, examples.text);
- assert.ok(Array.isArray(examples.json().examples));
- const empty = await t.get('/api/v1/feed/search');
- assert.strictEqual(empty.status, 400);
- });
- const net = t.network;
- const db = t.app.locals.db;
- const pulse = t.app.locals.pulse;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
- const netSvc = net.signService({ cap: ['community.pulse.write'] });
-
- // A public text paste and a screenshot paste (its picture is the first thing the Feed can show).
- const textPaste = (await t.app.locals.pastes.createText(asAlex, { title: 'Public one', content: 'The first line of a public paste.' })).paste;
- const shot = (await t.app.locals.pastes.createScreenshot(asAlex, { title: 'A shot', content: 'A tiny cat on a screen' }, { buffer: PNG, originalname: 'cat.png' })).paste;
- const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },
- body: JSON.stringify({ ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1', media: { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 } }) });
- assert.strictEqual(clip.status, 201, clip.text);
-
- // One fixture for every remaining kind Pulse can carry (the other services' items are pushed, never read per request).
- const fixtures = [
- ['live', 'stream', 'l1', 'Alex went live', 'https://openvibe.live/alex', alex.subject_id, 'user', 12],
- ['live', 'clip', 'c2', 'A second clip', 'https://openvibe.live/clip/c2', alex.subject_id, 'user', 13],
- ['media-hub', 'video', 'v1', 'A video', 'https://openvibe.video/v/1', alex.subject_id, 'user', 14],
- ['pics', 'picture', 'p1', 'A picture', 'https://openvibe.pics/p/1', alex.subject_id, 'user', 15],
- ['meme', 'meme', 'm1', 'A meme', 'https://openvibe.meme/m/1', alex.subject_id, 'user', 16],
- ['games', 'level', 'g1', 'Reached level 5', 'https://openvibe.games/', alex.subject_id, 'user', 17],
- ['blog', 'post', 'b1', 'A blog post', 'https://openvibe.blog/b/1', alex.subject_id, 'user', 18],
- ['wiki', 'page', 'w1', 'Wiki: a page', 'https://openvibe.wiki/w/1', sam.subject_id, 'user', 19],
- ['quest', 'badge', 'q1', 'Finished a quest: First Post', 'https://openvibe.quest/', sam.subject_id, 'user', 20],
- ['chat', 'hour', 'general:2026-11-09T10', '#general: 12 messages this hour', 'https://openvibe.chat/rooms/general', null, 'system', 21],
- ['today', 'event', 'e1', 'Coming up: A launch party', 'https://openvibe.today/e/e1', alex.subject_id, 'user', 22],
- ['network', 'members', '2026-11-09', '5 people joined today', 'https://openvibe.network/stats', null, 'system', 23],
- ];
- for (const [service, type, id, title, url, actor, origin, minsAgo] of fixtures) {
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, text, picture_url)
- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?)`)
- .run(service, type, id, title, url, actor, origin, at(minsAgo), type === 'event' ? 'Come along' : '', null);
- if (type === 'event') await db.prepare('UPDATE pulse_items SET starts_at = ? WHERE source_service = ? AND source_id = ?')
- .run(new Date(Date.now() + 60 * 60_000).toISOString().replace('T', ' ').slice(0, 19), service, id);
- }
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
- VALUES ('pics', 'picture', 'p2', 'Another picture', 'https://openvibe.pics/p/2', ?, 'user', 'public', ?)`).run(alex.subject_id, at(15.5));
-
- // Make the paste's counts real so the card has Community's own views, likes and comments.
- await db.prepare('UPDATE pastes SET views = 7, likes = 3 WHERE slug = ?').run(textPaste.slug);
- await db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, comment_count, access_id) VALUES ('community', 'paste', ?, 2, 'feed-test')`).run(textPaste.slug);
-
- await check('the feed: newest first, cards by kind, the shape the menu draws', async () => {
- const r = await t.get('/api/v1/feed?limit=40');
- assert.strictEqual(r.status, 200, r.text);
- const j = r.json();
- const kinds = new Set(j.items.map((x) => x.kind));
- for (const k of ['stream', 'clip', 'video', 'pic', 'paste', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member']) assert.ok(kinds.has(k), `kind ${k} (${[...kinds]})`);
- assert.ok('next_cursor' in j);
- for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'text', 'at', 'site', 'site_name', 'actor', 'media', 'counts']) assert.ok(k in x, `${k} on every card`);
- const c = j.items.find((x) => x.url === 'https://openvibe.live/clip/c1');
- assert.strictEqual(c.site, 'live');
- assert.strictEqual(c.site_name, 'Live');
- assert.strictEqual(c.actor.name, 'Alex');
- assert.deepStrictEqual(c.media, { thumb: 'https://openvibe.media/c1.webp', preview: 'https://openvibe.media/c1.mp4', ratio: 1.5, length: 18 });
- const photos = j.items.find((x) => x.kind === 'pic');
- assert.strictEqual(photos.title, 'Alex added 2 photos');
- assert.strictEqual(photos.group.count, 2);
- assert.deepStrictEqual(photos.group.items.map((x) => x.url), ['https://openvibe.pics/p/1', 'https://openvibe.pics/p/2']);
- });
-
- await check('GET /feed is server rendered, kind chips keep the page crawlable and Older carries a cursor', async () => {
- const r = await t.get('/feed?kind=video');
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('A video'), 'card text is in the response HTML');
- assert.ok(r.text.includes('https://openvibe.video/v/1'), 'card link is in the response HTML');
- assert.ok(r.text.includes('schema.org') && r.text.includes('ItemList'), 'ItemList JSON-LD is present');
- assert.ok(r.text.includes('Older'), 'the page has a crawlable older-page link');
- assert.ok(r.text.includes('id="feed-search"') && r.text.includes('class="feed-try"'), 'the page offers feed search and current examples');
- const searched = await t.get('/feed?q=videos');
- assert.ok(searched.text.includes('A video') && searched.text.includes('name="q" value="videos"'), 'the page applies a natural kind search');
- });
-
- await check('a paste and a screenshot paste carry their text and first picture; the counts are Community\'s', async () => {
- const j = (await t.get('/api/v1/feed?limit=40')).json();
- const p = j.items.find((x) => x.title === 'Public one');
- assert.strictEqual(p.text, 'The first line of a public paste.');
- assert.strictEqual(p.kind, 'paste');
- assert.deepStrictEqual(p.counts, { views: 7, likes: 3, comments: 2 });
- const s = j.items.find((x) => x.title === 'A shot');
- const stored = await db.prepare('SELECT screenshot_url FROM pastes WHERE slug = ?').get(shot.slug);
- assert.strictEqual(s.text, 'A tiny cat on a screen');
- assert.strictEqual(s.media.thumb, stored.screenshot_url);
- assert.strictEqual(s.media.ratio, 16 / 9);
- });
-
- await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {
- const j = (await t.get('/api/v1/feed')).json();
- const tr = j.items.filter((x) => x.label === 'Trending');
- assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'],
- 'only the https one with a picture, and never a link or a title the page already shows');
- assert.strictEqual(tr[0].kind, 'video');
- assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
- });
-
- await check('a person\'s run folds while each card is within two hours of the run, and a gap ends it', async () => {
- const { foldBursts } = require('../server/pulse/feed');
- const base = Date.parse('2026-11-11T07:35:00Z');
- const pair = (id, mins, kind = 'video', who = 'sub_maticus') => ({ row: { actor_subject: who },
- card: { id, kind, site: 'media-hub', url: `https://openvibe.video/watch?v=${id}`, title: id,
- at: new Date(base + mins * 60_000).toISOString(), actor: { name: 'Maticus' }, media: null } });
- // 07:35, 07:55, 09:10, 09:44, 10:06 and 10:13 (11-11's morning, which showed as five rows), then 15:00.
- const out = foldBursts([pair('a', 158), pair('b', 151), pair('c', 129), pair('d', 95), pair('e', 20), pair('f', 0),
- pair('clip', 140, 'clip'), pair('other', 150, 'video', 'sub_ada'), pair('later', 445)]);
- const run = out.find((c) => c.group);
- assert.strictEqual(run.title, 'Maticus added 6 videos');
- assert.deepStrictEqual(run.group.items.map((x) => x.id), ['a', 'b', 'c', 'd', 'e', 'f']);
- assert.deepStrictEqual(out.map((c) => c.id), ['a', 'clip', 'other', 'later'], 'a clip, someone else and a card past the gap stay apart');
-
- // A page is full by cards, after folding (11-12's /feed: twelve rows, three cards, then "Older").
- const { filled } = require('../server/pulse/feed');
- const six = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => pair(id, i * 10));
- assert.strictEqual(filled(six, 3), false, 'six rows that fold into one card do not fill three');
- assert.strictEqual(filled([...six, pair('clip', 5, 'clip'), pair('other', 5, 'video', 'sub_ada')], 3), true, 'three cards do');
- assert.strictEqual(filled(Array.from({ length: 12 }, (_, i) => pair(`r${i}`, i)), 3), true, 'raw rows stop at four times the page');
-
- // A folded run shows its members: the first four pictures, "+N" over the last (rule 136).
- const { feedPage } = require('../server/render/feed');
- const pics = ['a', 'b', 'c', 'd', 'e', 'f'].map((id) => ({ ...pair(id, 0), card: { ...pair(id, 0).card, media: { thumb: `https://openvibe.media/${id}.webp`, ratio: 16 / 9 } } }));
- const html = feedPage({ items: foldBursts(pics) });
- assert.ok(html.includes('class="feed-strip" data-n="4"'), 'four pictures of the run');
- assert.ok(html.includes('>+2</span>'), '"+2" for the other two');
- assert.ok(html.includes('https://openvibe.video/watch?v=d'), 'each picture opens its own video');
- assert.ok(html.includes('Recordings'), 'the run is named in the plural');
- });
-
- await check('LIVE only for a channel that is live now', async () => {
- const feedTools = require('../server/pulse/feed');
- const current = await feedTools.createLiveNow({ liveUrl: 'https://openvibe.live', fetchImpl: feedFetch })();
- assert.strictEqual(current.get('alex').length, 2, 'both active stream starts are retained for one channel');
- assert.ok(feedTools.isCurrentLive({ url: 'https://openvibe.live/@alex', at: new Date(Date.now() - 40 * 60_000).toISOString() }, current));
- const mk = (id, who, at = null) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${netSvc}`, 'x-ov-subject': alex.subject_id },
- body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: ${at ? 'an older stream' : 'a stream'}`, url: `https://openvibe.live/@${who}`, ...(at ? { occurred_at: at } : {}) }) });
- assert.strictEqual((await mk('s1', 'ada')).status, 201);
- assert.strictEqual((await mk('s2', 'bo')).status, 201);
- assert.strictEqual((await mk('s3', 'alex', new Date(Date.now() - 2 * 86_400_000).toISOString())).status, 201);
- const j = (await t.get('/api/v1/feed?site=live&limit=40')).json();
- const by = Object.fromEntries(j.items.map((x) => [x.url, [x.kind, x.live_now]]));
- assert.deepStrictEqual(by['https://openvibe.live/@ada'], ['live', true], 'ada is on the live list');
- assert.deepStrictEqual(by['https://openvibe.live/@bo'], ['stream', false], 'bo went live earlier: a stream that happened, never LIVE');
- const older = j.items.find((x) => x.title === 'alex went live: an older stream');
- assert.deepStrictEqual([older.kind, older.live_now], ['stream', false], 'an old broadcast stays ended while the same channel is live again');
- for (const kind of ['live', 'stream']) {
- const filtered = (await t.get(`/api/v1/feed?kind=${kind}&limit=40`)).json();
- assert.ok(filtered.items.length > 0, `${kind} has a card`);
- assert.ok(filtered.items.every((x) => x.kind === kind), `${kind} filters the displayed kind`);
- }
-
- });
-
- await check('a kind filters the stream, a site filters the stream, q searches title and text', async () => {
- const j = (await t.get('/api/v1/feed?kind=wiki&limit=40')).json();
- assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'wiki'), JSON.stringify(j.items.map((x) => x.kind)));
- const none = (await t.get('/api/v1/feed?kind=no-such-kind')).json();
- assert.ok(none.items.length > 0, 'an unknown kind is ignored, not a 400');
- const site = (await t.get('/api/v1/feed?site=live&limit=40')).json();
- assert.ok(site.items.length >= 1 && site.items.every((x) => x.site === 'live'), JSON.stringify(site.items.map((x) => x.site)));
- const q = (await t.get('/api/v1/feed?q=cat&limit=40')).json();
- assert.ok(q.items.length >= 1 && q.items.every((x) => /cat/i.test(x.title) || /cat/i.test(x.text)), JSON.stringify(q.items.map((x) => x.title)));
- const text = (await t.get('/api/v1/feed?q=tiny%20cat&limit=40')).json();
- assert.ok(text.items.some((x) => x.title === 'A shot'), 'text is searched too');
- });
-
- await check('following=1 is the signed-in person\'s follows; signed out it is refused', async () => {
- net.addFollow(sam.subject_id, 'channel', alex.subject_id);
- const j = (await t.get('/api/v1/feed?following=1&limit=40', { cookies: [`ov_token=${samJwt}`] })).json();
- assert.ok(j.items.length >= 1, JSON.stringify(j.items));
- assert.ok(j.items.every((x) => x.actor && x.actor.name === 'Alex'), JSON.stringify(j.items.map((x) => [x.title, x.actor])));
- assert.ok(j.items.some((x) => x.title === 'Alex went live'));
- const r = await t.get('/api/v1/feed?following=1');
- assert.strictEqual(r.status, 401);
- assert.strictEqual(r.json().code, 'feed.sign_in_required');
- });
-
- await check('feed ranking puts live and fresh activity first, then uses engagement and follows', async () => {
- net.addFollow(sam.subject_id, 'channel', alex.subject_id);
- const rankPaste = async (actor, title, minutesAgo, stats = {}) => {
- const { paste } = await t.app.locals.pastes.createText(actor, { title, content: title });
- await db.prepare("UPDATE pulse_items SET occurred_at = ? WHERE source_service = 'community' AND source_type = 'paste' AND source_id = ?")
- .run(at(minutesAgo), paste.slug);
- await db.prepare('UPDATE pastes SET views = ?, likes = ? WHERE slug = ?')
- .run(stats.views || 0, stats.likes || 0, paste.slug);
- };
- await rankPaste(asAlex, 'FreshRank recent', 10);
- await rankPaste(asAlex, 'FreshRank older', 2 * 24 * 60);
- await rankPaste(asAlex, 'EngagementRank low', 30);
- await rankPaste(asAlex, 'EngagementRank high', 30, { views: 10000, likes: 100 });
- await rankPaste(asAlex, 'FollowRank followed', 25);
- await rankPaste({ ...asAlex, subject: sam.subject_id }, 'FollowRank plain', 25);
- await rankPaste(asAlex, 'StaleRank recent', 4 * 60);
- await rankPaste(asAlex, 'StaleRank old', 10 * 24 * 60, { views: 100000, likes: 1000 });
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at)
- VALUES ('live', 'stream', 'rank-live', 'LiveRank current', 'https://openvibe.live/@alex', ?, 'user', 'public', ?),
- ('live', 'stream', 'rank-offline', 'LiveRank offline', 'https://openvibe.live/@offline', ?, 'user', 'public', ?)`)
- .run(alex.subject_id, at(1), alex.subject_id, at(1));
-
- const titles = async (q, options = {}) => (await t.get(`/api/v1/feed?q=${q}&limit=40`, options)).json().items.map((x) => x.title);
- assert.deepStrictEqual(await titles('FreshRank'), ['FreshRank recent', 'FreshRank older']);
- assert.deepStrictEqual(await titles('EngagementRank'), ['EngagementRank high', 'EngagementRank low']);
- assert.deepStrictEqual(await titles('FollowRank', { cookies: [`ov_token=${samJwt}`] }), ['FollowRank followed', 'FollowRank plain']);
- assert.deepStrictEqual(await titles('StaleRank'), ['StaleRank recent', 'StaleRank old']);
- const live = await t.get('/api/v1/feed?q=LiveRank&limit=40');
- assert.deepStrictEqual(live.json().items.map((x) => [x.title, x.kind, x.live_now]), [
- ['LiveRank current', 'live', true], ['LiveRank offline', 'stream', false],
- ]);
- });
-
- await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {
- const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
- assert.strictEqual(r.status, 200);
- assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'a site off the write list still reads it');
- assert.ok(!r.headers.get('access-control-allow-credentials'), 'never with cookies');
- const pre = await t.get('/api/v1/feed', { method: 'OPTIONS', headers: { origin: 'https://open.shoes', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
- assert.strictEqual(pre.status, 204);
- assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/);
- const wall = await t.get('/api/v1/pulse', { headers: { origin: 'https://openvibe.pics' } });
- assert.strictEqual(wall.headers.get('access-control-allow-origin'), '*', 'the wall too');
- const write = await t.get('/api/v1/pulse/items', { method: 'OPTIONS', headers: { origin: 'https://evil.example', 'access-control-request-method': 'POST' } });
- assert.ok(!write.headers.get('access-control-allow-origin'), 'a write from elsewhere gets no CORS');
- });
-
- await check('small pages retain every Pulse item, including the final partially read page', async () => {
- const all = (await t.get('/api/v1/feed?limit=40')).json().items.filter((x) => x.id.startsWith('p:'));
- for (const limit of [1, 4]) {
- const seen = []; let cursor = ''; const cursors = new Set();
- do {
- const r = await t.get(`/api/v1/feed?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
- assert.strictEqual(r.status, 200, r.text);
- const page = r.json();
- assert.ok(page.items.length <= limit, 'trending counts toward the page limit');
- seen.push(...page.items.filter((x) => x.id.startsWith('p:')).flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]));
- cursor = page.next_cursor;
- if (cursor) { assert.ok(!cursors.has(cursor), 'cursor advances'); cursors.add(cursor); }
- assert.ok(cursors.size < 40, 'pagination terminates');
- } while (cursor);
- assert.deepStrictEqual([...seen].sort(), all.flatMap((x) => x.group ? x.group.items.map((y) => y.id) : [x.id]).sort(), `limit=${limit}: no missing or repeated rows`);
- }
- });
-
- await check('filtered pages keep scanning after an empty bounded page', async () => {
- // More than the eight 40-row scans in one call, ahead of the existing Wiki fixture.
- await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at)
- SELECT 'feed-scan-test', 'event', n::text, 'Unrelated event', 'https://openvibe.today/', 'system', 'public', ?
- FROM generate_series(1, 321) n`).run(at(1));
- try {
- const first = (await t.get('/api/v1/feed?site=wiki&limit=1')).json();
- assert.deepStrictEqual(first.items, []);
- assert.ok(first.next_cursor, 'empty bounded scan is not the end');
- const next = (await t.get(`/api/v1/feed?site=wiki&limit=1&cursor=${encodeURIComponent(first.next_cursor)}`)).json();
- assert.strictEqual(next.items.length, 1);
- assert.strictEqual(next.items[0].site, 'wiki');
- } finally { await db.prepare("DELETE FROM pulse_items WHERE source_service = 'feed-scan-test'").run(); }
- });
-
- // ── POST /api/v1/posts with pictures: the Feed shows it at once ──
- const upload = async (jwt) => {
- const fd = new FormData();
- fd.append('file', new Blob([PNG], { type: 'image/png' }), 'cat.png');
- return await t.get('/api/v1/spaces/general/attachments', { method: 'POST', body: fd, cookies: [`ov_token=${jwt}`] });
- };
- const json = async (jwt, path, body) => await t.get(path, { method: 'POST', headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });
-
- await check('a post with a picture: alt is required, then the Feed has its text and picture at once', async () => {
- const up = await upload(alexJwt);
- assert.strictEqual(up.status, 201, up.text);
- const mediaId = up.json().attachment.media_id;
- const bad = await json(samJwt, '/api/v1/posts', { text: 'no', pictures: [{ media_id: mediaId, alt: '' }] });
- assert.strictEqual(bad.status, 400, bad.text);
- const r = await json(alexJwt, '/api/v1/posts', { text: 'Look at this cat', pictures: [{ media_id: mediaId, alt: 'A white cat' }] });
- assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.json().pictures[0].alt, 'A white cat');
- const j = (await t.get('/api/v1/feed?q=Look%20at%20this%20cat&limit=40')).json();
- assert.strictEqual(j.items.length, 1, JSON.stringify(j.items));
- assert.strictEqual(j.items[0].kind, 'post');
- assert.strictEqual(j.items[0].text, 'Look at this cat');
- assert.ok(j.items[0].media && j.items[0].media.thumb.endsWith(`/o/${mediaId}`));
- assert.deepStrictEqual(j.items[0].counts, { views: 0, likes: 0, comments: 0 });
- const reused = await json(alexJwt, '/api/v1/posts', { text: 'Again', pictures: [{ media_id: mediaId, alt: 'stolen' }] });
- assert.strictEqual(reused.status, 400, reused.text);
- });
-
- await check('a picture uploaded straight to Media (no local attachment row) is checked for its owner and attached', async () => {
- const stored = await t.app.locals.pasteMedia.upload({ buffer: PNG, filename: 'direct.png', mime: 'image/png', owner: alex.subject_id, visibility: 'unlisted' });
- const r = await json(alexJwt, '/api/v1/posts', { text: 'Straight from Media', pictures: [{ media_id: stored.key, alt: 'Direct cat' }] });
- assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.json().pictures[0].alt, 'Direct cat');
- const j = (await t.get('/api/v1/feed?q=Straight%20from%20Media&limit=40')).json();
- assert.strictEqual(j.items.length, 1, JSON.stringify(j.items));
- assert.ok(j.items[0].media.thumb.endsWith(`/o/${stored.key}`));
- const row = await db.prepare('SELECT alt FROM attachments WHERE media_id = ?').get(stored.key);
- assert.strictEqual(row.alt, 'Direct cat');
- });
-
- await check('a post keeps a PDF composer attachment and renders it as a download', async () => {
- const id = 'att_01ABCDEFGHJKMNPQRSTVWXYZ01';
- const original = global.fetch;
- let kept = false;
- global.fetch = (url, options) => {
- if (String(url).endsWith(`/api/v1/attachments/${id}/keep`)) {
- kept = options.method === 'POST' && options.headers.Authorization === `Bearer ${alexJwt}`;
- return Promise.resolve(new Response(JSON.stringify({ id, kind: 'file', name: 'notes.pdf', mime: 'application/pdf', size: 123, url: `https://openvibe.media/a/${id}/notes.pdf` }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
- }
- return original(url, options);
- };
- try {
- const r = await json(alexJwt, '/api/v1/posts', { text: 'Here are my notes', attachments: [id] });
- assert.strictEqual(r.status, 201, r.text);
- assert.ok(kept, 'Media keeps the file using the person token');
- assert.strictEqual(r.json().attachments[0].id, id);
- const row = await db.prepare('SELECT filename, mime, url FROM attachments WHERE media_id = ?').get(id);
- assert.strictEqual(row.filename, 'notes.pdf');
- assert.strictEqual(row.mime, 'application/pdf');
- assert.ok(row.url.endsWith('/notes.pdf'));
- const page = await t.get(new URL(r.json().url).pathname);
- assert.ok(page.text.includes('notes.pdf'), 'the post page links the file');
- } finally { global.fetch = original; }
- });
-
- await check('a signed-out post, a text with extra fields and more than four pictures are refused', async () => {
- assert.strictEqual((await t.get('/api/v1/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: 'x' }) })).status, 401);
- let r = await json(alexJwt, '/api/v1/posts', { text: 'x', secret: 1 });
- assert.strictEqual(r.status, 400);
- r = await json(alexJwt, '/api/v1/posts', { text: 'x', pictures: [{ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', alt: '1' }] });
- assert.strictEqual(r.status, 400);
- });
-
- await done(t);
-})();
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const assert = require('assert');
const assert = require('assert');
const { feedPage } = require('../server/render/feed');
const { feedPage } = require('../server/render/feed');
-const page = feedPage({ kind: 'video', cursor: 'page-one', nextCursor: 'page-two', items: [{ source: { service: 'media-hub', type: 'video' }, id: 3, url: 'https://openvibe.video/watch/3', title: 'A video', text: 'Fresh upload', occurred_at: '2026-11-10 12:00:00', picture_url: 'https://openvibe.media/thumb.webp', actor: { display_name: 'Ada' } }] });
+const page = feedPage({ kind: 'video', cursor: 'page-one', nextCursor: 'page-two', items: [{ id: 'f:media-hub:video:3', kind: 'video', url: 'https://openvibe.video/watch/3', title: 'A video', text: 'Fresh upload', at: '2026-11-10T12:00:00.000Z', site: 'media-hub', site_name: 'Video', actor: { name: 'Ada', avatar: null }, subject: null, media: { thumb: 'https://openvibe.media/thumb.webp', ratio: 1.777 }, counts: {} }] });
assert.match(page, /href="\/feed\?kind=video" aria-current="page"/);
assert.match(page, /href="\/feed\?kind=video" aria-current="page"/);
assert.match(page, /A video/);
assert.match(page, /A video/);
assert.match(page, /Fresh upload/);
assert.match(page, /Fresh upload/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * Pulse reads Network's one feed (feed.item@1): GET /api/v1/feed and /api/v1/pulse are the proxy, the
+ * same ids as the menu; GET /api/v1/activity is Community's own public rows for Network's collector;
+ * /feed and /pulse render those cards. No Pulse store is read or written.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+const ITEM = {
+ id: 'f:community:thread:1', kind: 'post', url: 'https://openvibe.community/forum/status/hello',
+ title: 'Hello from the one feed', text: 'one line', at: new Date().toISOString(), site: 'community', site_name: 'Community',
+ actor: { name: 'Alex', avatar: null }, subject: 'usr_01JQ8Z6K6M0V8Y2Q3R4S5T6V7W', media: null, counts: { likes: 2 },
+};
+
+(async () => {
+ const asked = [];
+ const feedFetch = async (url, opts = {}) => {
+ const u = new URL(String(url));
+ asked.push({ path: u.pathname, query: u.searchParams, auth: opts.headers && opts.headers.Authorization });
+ const body = u.pathname === '/api/v1/feed/examples' ? { examples: [{ text: 'new photos', reason: 'kind' }] }
+ : u.pathname === '/api/v1/feed/wall' ? { items: [{ ...ITEM, source: { service: 'community', type: 'thread', id: '1' }, origin: 'user', occurred_at: ITEM.at }], next_cursor: null }
+ : { items: [ITEM], next_cursor: 'next' };
+ return { ok: true, status: 200, async text() { return JSON.stringify(body); } };
+ };
+ const t = await boot({ authority: 'community', appOpts: { feedFetch, startRelay: false, startSharingWorker: false } });
+
+ await check('GET /api/v1/feed is Network\'s feed, filters and the person\'s token passed through', async () => {
+ const r = await t.get('/api/v1/feed?kind=clip&limit=20', { headers: { authorization: 'Bearer user-token-123456' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().items[0].id, ITEM.id);
+ const last = asked[asked.length - 1];
+ assert.deepStrictEqual([last.path, last.query.get('kind'), last.query.get('limit')], ['/api/v1/feed', 'clip', '20']);
+ assert.strictEqual(last.auth, 'Bearer user-token-123456');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ const anon = await t.get('/api/v1/feed');
+ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=30');
+ });
+
+ await check('GET /api/v1/feed/wall and the old /api/v1/pulse return the same rows and ids', async () => {
+ const wall = await t.get('/api/v1/feed/wall?limit=5');
+ const pulse = await t.get('/api/v1/pulse?limit=5&after=cursor-1');
+ assert.strictEqual(wall.json().items[0].id, pulse.json().items[0].id);
+ const last = asked[asked.length - 1];
+ assert.deepStrictEqual([last.path, last.query.get('cursor')], ['/api/v1/feed/wall', 'cursor-1']);
+ });
+
+ await check('the /feed page draws the feed cards and its examples', async () => {
+ const r = await t.get('/feed?kind=post');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Hello from the one feed/);
+ assert.match(r.text, /new photos/);
+ });
+
+ await check('the /pulse page keeps its look, drawing the same feed rows', async () => {
+ const r = await t.get('/pulse');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Hello from the one feed/);
+ });
+
+ await check('GET /api/v1/activity is Community\'s public rows only', async () => {
+ const at = new Date().toISOString().replace('T', ' ').slice(0, 19);
+ await t.db.prepare(`INSERT INTO pastes (slug, owner_subject, origin, type, title, content, visibility, created_at)
+ VALUES (?, ?, 'user', 'paste', ?, ?, ?, ?)`).run('public-one', ITEM.subject, 'A public paste', 'hello', 'public', at);
+ await t.db.prepare(`INSERT INTO pastes (slug, owner_subject, origin, type, title, content, visibility, created_at)
+ VALUES (?, ?, 'user', 'paste', ?, ?, ?, ?)`).run('private-one', ITEM.subject, 'A private paste', 'secret', 'private', at);
+ const r = await t.get('/api/v1/activity?limit=50');
+ assert.strictEqual(r.status, 200, r.text);
+ const items = r.json().items;
+ assert.ok(items.some((i) => i.id === 'public-one' && i.url === 'https://openvibe.community/p/public-one'));
+ assert.ok(!items.some((i) => i.id === 'private-one'), 'a private paste never leaves Community');
+ assert.ok(items.every((i) => i.service === 'community' && i.at && i.url));
+ });
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });
deleted file mode 100644
deleted file mode 100644
@@ -1,91 +0,0 @@
@@ -1,91 +0,0 @@
-'use strict';
-/**
- * The menu Feed's two Live cards (server/pulse/feed.js):
- * a Rundown episode is kind 'rundown': the actor is The Rundown with its own mark, the streamer it is about is
- * `subject` {name, handle, avatar}, the art is the episode's AI picture (the stream thumbnail when none), and it is
- * never labelled "System"; a recording keeps its own actor, carries its length as `duration_s` (never a bare
- * media.length badge) and links to the recording's watch page, not the channel.
- */
-const assert = require('assert');
-const http = require('http');
-const { boot, check, done } = require('./helpers/app');
-const { createPulseSources } = require('../server/pulse/sources');
-
-(async () => {
- const t = await boot();
- const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
- const insert = t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, actor_name, origin, visibility, occurred_at, picture_url, media_ratio, media_length, subject_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?, ?)`);
- await insert.run('live', 'episode', '3005', 'WW3 Prep: The Case Against Smooth Apologies', 'https://openvibe.live/rundown/e/3005',
- null, 'The Rundown', 'system', now, 'https://openvibe.media/f/rundown-picture.jpg', 1200 / 630, null,
- JSON.stringify({ name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' }));
- await insert.run('live', 'recording', 'vod-6393', 'Recording: WW3 prep', 'https://openvibe.video/watch?v=ivkluOg7ieQ',
- 'usr_01KKT9AC60KM7CRTB3WN1Z8P56', 'Maticus', 'user', now, 'https://openvibe.media/t/vod-6393.jpg', 16 / 9, 5203, null);
-
- const page = await t.get('/api/v1/feed?limit=10');
- assert.strictEqual(page.status, 200);
- const body = await page.json();
- const rundown = body.items.find((x) => x.id === 'p:1');
- assert.ok(rundown, 'the Rundown card is in the feed');
- assert.strictEqual(rundown.kind, 'rundown');
- assert.deepStrictEqual(rundown.actor, { name: 'The Rundown', mark: 'rundown', avatar: null });
- assert.deepStrictEqual(rundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });
- assert.strictEqual(rundown.image, 'https://openvibe.media/f/rundown-picture.jpg');
- assert.strictEqual(rundown.media.thumb, 'https://openvibe.media/f/rundown-picture.jpg');
- assert.ok(Math.abs(rundown.media.ratio - 1200 / 630) < 1e-9, 'the AI picture keeps its 1200×630 ratio');
- assert.strictEqual(rundown.label, undefined, 'The Rundown, not "System"');
- assert.ok(rundown.url.endsWith('/rundown/e/3005'));
-
- const recording = body.items.find((x) => x.id === 'p:2');
- assert.ok(recording, 'the recording is in the feed');
- assert.strictEqual(recording.kind, 'video');
- assert.strictEqual(recording.actor.name, 'Maticus');
- assert.strictEqual(recording.duration_s, 5203, 'the length is a duration, not a bare number');
- assert.ok(!recording.media.length, 'no bare length badge on a recording');
- assert.strictEqual(recording.media.thumb, 'https://openvibe.media/t/vod-6393.jpg');
- assert.strictEqual(recording.url, 'https://openvibe.video/watch?v=ivkluOg7ieQ', "the recording's watch page, not the channel");
-
- const only = await t.get('/api/v1/feed?kind=rundown&limit=10');
- const onlyBody = await only.json();
- assert.deepStrictEqual(onlyBody.items.map((x) => x.kind), ['rundown'], 'kind=rundown filters to the episode');
-
- // The stored Pulse item carries the subject and duration too (the API a service reads).
- const pulse = await t.get('/api/v1/pulse');
- const items = (await pulse.json()).items;
- const pRundown = items.find((x) => x.source.type === 'episode');
- assert.deepStrictEqual(pRundown.subject, { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_01M6SV8E153FM2T70WAF14TB4W' });
- assert.strictEqual(pRundown.duration_s, undefined);
- const pRecording = items.find((x) => x.source.type === 'recording');
- assert.strictEqual(pRecording.duration_s, 5203);
-
- // ── The Live source itself: an episode's picture and subject are stored with the Pulse item ──
- const live = http.createServer((req, res) => {
- res.setHeader('content-type', 'application/json');
- if (req.url.startsWith('/api/rundown/streams')) return res.end(JSON.stringify({ streams: [{
- stream_id: 4242, live: false, thumbnail_url: 'https://openvibe.media/t/stream-thumb.jpg',
- user: { username: 'maticus', display_name: 'Maticus', avatar_url: 'https://openvibe.media/o/med_maticus' },
- episode: { title: 'The Fridge Files', logline: 'A night of cold takes.', aired_at: '2026-11-12 03:00:00', audience: 'public', picture_url: 'https://openvibe.media/f/episode-pic.jpg' },
- }], next: null }));
- res.statusCode = 404; res.end('{}');
- });
- await new Promise((r) => live.listen(0, '127.0.0.1', r));
- const url = `http://127.0.0.1:${live.address().port}`;
- const sources = createPulseSources({ db: t.db, config: { liveUrl: url, todayUrl: url, todayInternalUrl: url, networkUrl: url, networkInternalUrl: url, questUrl: url, questInternalUrl: url, memeUrl: url, memeInternalUrl: url, picsUrl: url, spaceUrl: url, playUrl: url } });
- const ran = await sources.runOnce({ force: true });
- const rundownSource = ran.find((x) => x.name === 'rundown-episodes');
- assert.ok(rundownSource && rundownSource.items === 1, 'the Rundown source read one episode');
- const stored = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'live' AND source_type = 'episode' AND source_id = '4242'").get();
- assert.strictEqual(stored.title, 'The Fridge Files', 'the actor carries the name, so the title does not repeat it');
- assert.strictEqual(stored.actor_name, 'The Rundown');
- assert.strictEqual(stored.picture_url, 'https://openvibe.media/f/episode-pic.jpg');
- assert.deepStrictEqual(JSON.parse(stored.subject_json), { name: 'Maticus', handle: '@maticus', avatar: 'https://openvibe.media/o/med_maticus' });
- assert.ok(Math.abs(Number(stored.media_ratio) - 1200 / 630) < 1e-9);
- const fed = await (await t.get('/api/v1/feed?limit=20')).json();
- const sourceCard = fed.items.find((x) => x.id === 'p:3');
- assert.strictEqual(sourceCard.kind, 'rundown');
- assert.strictEqual(sourceCard.subject.handle, '@maticus');
- live.close();
-
- console.log('feed shape: Rundown kind/actor/mark/subject/image and recording duration_s/watch link ok');
- await done(t);
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -11,15 +11,20 @@ const path = require('path');
@@ -11,15 +11,20 @@ const path = require('path');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
- const t = await boot();
+ // The home's "Across OpenVibe" is the one feed's wall view (Network feed.item@1). A recorded wall
+ // response stands in for Network: one row a place, a site mark, the AI label and its picture.
+ const wall = { items: [
+ { id: 'f:network:members:n1', source: { service: 'network', type: 'members', id: 'n1' }, title: '3 people joined today', url: 'https://openvibe.network/', origin: 'system', actor: null, occurred_at: new Date().toISOString() },
+ { id: 'f:live:stream:s1', source: { service: 'live', type: 'stream', id: 's1' }, title: 'Dee went live: Raid night', url: 'https://openvibe.live/@Dee', origin: 'system', actor: null, occurred_at: new Date().toISOString() },
+ { id: 'f:today:event:t1', source: { service: 'today', type: 'event', id: 't1' }, title: 'Live: Raid night', url: 'https://openvibe.live/@Dee', origin: 'system', actor: null, occurred_at: new Date().toISOString() },
+ { id: 'f:blog:post:b1', source: { service: 'blog', type: 'post', id: 'b1' }, title: 'A new post', url: 'https://openvibe.blog/a-new-post', origin: 'ai', label: 'AI', actor: null,
+ picture_url: 'https://openvibe.media/o/med_01JZ00000000000000000000B1', text: 'What changed this week, in one paragraph.', occurred_at: new Date().toISOString() },
+ ] };
+ const feedFetch = async (url) => ({ ok: true, status: 200, async text() { return JSON.stringify(String(url).includes('/wall') ? wall : { items: [], next_cursor: null }); } });
+ const t = await boot({ appOpts: { feedFetch } });
try {
try {
await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'test-item', 'A test item', 'in_progress', ov_now())").run();
await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'test-item', 'A test item', 'in_progress', ov_now())").run();
await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'done-item', 'A finished item', 'done', ov_now())").run();
await t.db.prepare("INSERT INTO threads (space_id, slug, title, status, last_activity_at) VALUES (4, 'done-item', 'A finished item', 'done', ov_now())").run();
- const pulse = (type, id, title, url, origin, extra = '') => t.db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at${extra ? ', picture_url, text' : ''}) VALUES (?, ?, ?, ?, ?, ?, 'public', ov_now()${extra ? ', ?, ?' : ''})`);
- await pulse().run('network', 'members', 'n1', '3 people joined today', 'https://openvibe.network/', 'system');
- await pulse().run('live', 'stream', 's1', 'Dee went live: Raid night', 'https://openvibe.live/@Dee', 'system');
- await pulse().run('today', 'event', 't1', 'Live: Raid night', 'https://openvibe.live/@Dee', 'system');
- await pulse('x', 'x', 'x', 'x', 'x', 'with').run('blog', 'update', 'b1', 'A new post', 'https://openvibe.blog/a-new-post', 'ai', 'https://openvibe.media/o/med_01JZ00000000000000000000B1', 'What changed this week, in one paragraph.');
await check('the roadmap and the pulse are two sections, not a pick block', async () => {
await check('the roadmap and the pulse are two sections, not a pick block', async () => {
const r = await t.get('/');
const r = await t.get('/');
@@ -179,9 +179,11 @@ function startVip() {
@@ -179,9 +179,11 @@ function startVip() {
});
});
await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {
await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {
- const pulse = (await call('/api/v1/pulse')).text;
+ // The one feed reads Community's public activity directly (server/activity/service.js), so a gated
+ // thread is absent there rather than removed from a store later.
+ const pulse = (await call('/api/v1/activity?limit=1000')).text;
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
- assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id))).c, 0);
+ assert.ok(!pulse.includes(`"id":"${secret.id}"`) && !pulse.includes(`"id":"${showcaseThread.id}"`));
const sitemap = (await call('/sitemap.xml')).text;
const sitemap = (await call('/sitemap.xml')).text;
assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');
assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/forum/showcase'), 'no gated thread or space in the sitemap');
const feed = (await call('/forum/feed.xml')).text;
const feed = (await call('/forum/feed.xml')).text;
@@ -191,12 +193,9 @@ function startVip() {
@@ -191,12 +193,9 @@ function startVip() {
assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
// A thread gated after the fact leaves Pulse as well.
// A thread gated after the fact leaves Pulse as well.
const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
- assert.ok((await call('/api/v1/pulse')).text.includes('Soon private'));
+ assert.ok((await call('/api/v1/activity?limit=1000')).text.includes('Soon private'));
await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });
await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });
- assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));
- // Pulse's read-time check alone hides an item nothing removed.
- await t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));
- assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));
+ assert.ok(!(await call('/api/v1/activity?limit=1000')).text.includes('Soon private'), 'gating it takes it out of the public activity');
});
});
await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,202 +0,0 @@
@@ -1,202 +0,0 @@
-'use strict';
-/** Pulse's Events consumer: signed deliveries become Pulse items once; forged, proxied or non-public ones never do. */
-const assert = require('assert');
-const http = require('http');
-const express = require('express');
-const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { testDb } = require('./helpers/db');
-const { createPulseConsumer } = require('../server/pulse/consumer');
-
-let SECRET, db, app, vipDrops, USR, seq, evt;
-
-(async () => {
- SECRET = `whsec_${'ab'.repeat(32)}`;
- db = await testDb();
- app = express();
- vipDrops = [];
- app.use('/internal/events', createPulseConsumer({ db, secrets: [SECRET], vipCache: { handleEvent: (e) => { vipDrops.push(e.event_id); return true; } }, log: { error() {}, log() {}, warn() {} } }).router);
- USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
- seq = 0;
- evt = (over) => ({ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`.slice(0, 30), event_type: 'live.stream.started', version: 1, source: 'live', visibility: 'public', timestamp: new Date().toISOString(),
- subject: { type: 'stream', id: '2477', revision: 1 }, actor: { type: 'user', id: USR },
- payload: { stream_id: 2477, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: USR } }, title: 'Night walk', started_at: new Date().toISOString() }, ...over });
- const srv = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
- const post = (event, { secret = SECRET, headers = {} } = {}) => {
- const body = JSON.stringify({ event, seq: 1 });
- return fetch(`http://127.0.0.1:${srv.address().port}/internal/events`, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
- .then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
- };
- const items = async () => await db.prepare('SELECT source_service, source_type, source_id, title, url, actor_subject, origin FROM pulse_items ORDER BY id').all();
-
- const e1 = evt();
- let r = await post(e1);
- assert.strictEqual(r.status, 200); assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual(await items(), [{ source_service: 'live', source_type: 'stream', source_id: '2477', title: 'Goosely went live: Night walk', url: 'https://openvibe.live/@goosely', actor_subject: USR, origin: 'user' }]);
- r = await post(e1);
- assert.strictEqual(r.json.duplicate, true, 'a redelivery does nothing');
- assert.strictEqual((await items()).length, 1);
-
- assert.strictEqual((await post(evt(), { secret: `whsec_${'cd'.repeat(32)}` })).status, 401, 'forged signature');
- assert.strictEqual((await post(evt(), { headers: { 'x-forwarded-for': '1.2.3.4' } })).status, 403, 'came through the proxy');
- r = await post(evt({ visibility: 'internal', subject: { type: 'stream', id: '9', revision: 1 } }));
- assert.strictEqual(r.json.outcome, 'ignored:not_public');
- r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },
- // The contract's shape (blog.post.published v1): the blog is { id, handle }; the title names the handle.
- payload: { blog: { id: 'blg_01M00000000000000000000000', handle: 'openvibe' }, canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
- assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');
- r = await post(evt({ event_type: 'mediahub.video.published', source: 'media-hub', subject: { type: 'video', id: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
- payload: { upload: 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: { type: 'user', id: USR }, title: 'A public video', url: 'https://openvibe.video/u/vup_01JAB2C3D4E5F6G7H8J9K0MNPQ', published_at: new Date().toISOString() } }));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual([(await items())[2].source_service, (await items())[2].source_type, (await items())[2].title], ['media-hub', 'video', 'A public video']);
- r = await post(evt({ event_type: 'games.world.published', source: 'games', subject: { type: 'world', id: 'w0123456789' },
- payload: { world: 'w0123456789', owner: { type: 'user', id: USR }, title: 'Beacon Hill', url: 'https://openvibe.games/play?world=w0123456789', published_at: new Date().toISOString() } }));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual([(await items())[3].source_service, (await items())[3].source_type, (await items())[3].title], ['games', 'world', 'Beacon Hill']);
- r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_2' },
- payload: { canonical_url: 'https://openvibe.wiki/w/help/start', publication_state: 'published', visibility: 'public', space: 'help', slug: 'start', indexability: { decision: 'noindex', reasons: ['thin'] } } }));
- assert.strictEqual(r.json.outcome, 'ignored:not_public', 'a noindex page stays out of Pulse');
- assert.strictEqual((await items()).length, 4);
- // VIP convergence: a membership change from VIP drops cached members-only answers once; from anyone else, nothing.
- const vipEv = (source) => evt({ event_type: 'vip.membership.changed', source, subject: { type: 'membership', id: 'mbr_1', revision: 2 }, actor: { type: 'service', id: 'vip' },
- payload: { member: { type: 'user', id: USR }, creator: { type: 'user', id: USR }, status: 'canceled' } });
- const v1 = vipEv('vip');
- r = await post(v1);
- assert.strictEqual(r.json.outcome, 'vip:invalidated');
- r = await post(v1);
- assert.strictEqual(r.json.duplicate, true, 'a redelivery drops nothing twice');
- r = await post(vipEv('live'));
- assert.strictEqual(r.json.outcome, 'ignored:source');
- assert.deepStrictEqual(vipDrops, [v1.event_id]);
- assert.strictEqual((await items()).length, 4, 'no Pulse item for a membership');
- // Game progress (WS-M task 2): games.progress.summary writes through network.module.updated.
- const USR2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
- const mod = (level, over = {}) => evt({ event_type: 'network.module.updated', source: 'network', visibility: 'internal', subject: { type: 'user', id: USR2 }, actor: { type: 'service', id: 'games' },
- payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', namespace_owner: 'games', schema_version: 1, revision: 1, change: 'updated', reason: 'write', keys: ['level'], public: { level, achievements: 3, playtime_hours: 2 } }, ...over });
- r = await post(mod(7));
- assert.strictEqual(r.json.outcome, 'games:baseline', 'the first record seen is a baseline, not a stale announcement');
- r = await post(mod(9));
- assert.strictEqual(r.json.outcome, 'games:no_milestone');
- const m11 = mod(11);
- r = await post(m11);
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.deepStrictEqual((await items())[4], { source_service: 'games', source_type: 'level', source_id: `${USR2}:10`, title: 'Reached level 10 in Scraplandia', url: 'https://openvibe.games/', actor_subject: USR2, origin: 'user' });
- assert.strictEqual((await post(m11)).json.duplicate, true);
- r = await post(mod(6));
- assert.strictEqual(r.json.outcome, 'games:no_milestone', 'a level going down (another character) announces nothing');
- r = await post(mod(12));
- assert.strictEqual(r.json.outcome, 'games:no_milestone', 'the stored level never went down, so 10 is not crossed again');
- r = await post(mod(15));
- assert.strictEqual((await items())[5].title, 'Reached level 15 in Scraplandia');
- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'ai.usage_summary', change: 'updated', public: { level: 20 } } }))).json.outcome, 'ignored:namespace');
- assert.strictEqual((await post(mod(20, { source: 'games' }))).json.outcome, 'ignored:source', 'only Network speaks for user modules');
- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'delete' } }))).json.outcome, 'ignored:change');
- assert.strictEqual((await post(mod(20, { payload: { owner: { type: 'user', id: USR2 }, namespace: 'games.progress.summary', change: 'updated', keys: ['level'] } }))).json.outcome, 'ignored:payload', 'no public level, nothing read');
- assert.strictEqual((await items()).length, 6);
- // A dropped connection is a new Live session (a new subject id), but the stream is one broadcast.
- const store = require('../server/pulse/store');
- const T = Date.parse('2026-06-01T10:00:00.000Z');
- const liveAt = (stream, at, title, { actor = USR, ...over } = {}) => evt({
- subject: { type: 'stream', id: String(stream), revision: 1 },
- payload: { stream_id: stream, channel: { username: 'goosely', display_name: 'Goosely', url: 'https://openvibe.live/@goosely', subject: { type: 'user', id: actor } }, title, started_at: new Date(at).toISOString() },
- ...over,
- });
- const shows = async (title) => await db.prepare("SELECT id, source_id, occurred_at FROM pulse_items WHERE source_service = 'live' AND source_type = 'stream' AND title = ? ORDER BY id").all(title);
- const REPEAT = 'Goosely went live: Repeat show';
- r = await post(liveAt(9001, T, 'Repeat show'));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- r = await post(liveAt(9002, T + 20 * 60_000, 'Repeat show'));
- assert.strictEqual(r.json.outcome, 'pulse:updated', 'a reconnect within six hours is the same broadcast');
- let shown = await shows(REPEAT);
- assert.strictEqual(shown.length, 1, 'one item, not one per session');
- assert.strictEqual(shown[0].source_id, '9001', 'the item keeps its id (and source)');
- assert.strictEqual(shown[0].occurred_at, require('../server/pulse/store').pulseTime(new Date(T + 20 * 60_000).toISOString()), 'its time is the later start (stored in the one spelling)');
-
- r = await post(liveAt(9003, T + 30 * 60_000, 'New show'));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.strictEqual((await shows(REPEAT)).length, 1, 'the older broadcast is untouched');
- assert.strictEqual((await shows('Goosely went live: New show')).length, 1, 'a different title is a new item');
- assert.strictEqual((await items()).length, 8);
-
- r = await post(liveAt(9004, T + 7 * 3_600_000, 'Repeat show'));
- assert.strictEqual(r.json.outcome, 'pulse:created', 'more than six hours later is a new broadcast');
- assert.strictEqual((await shows(REPEAT)).length, 2);
- assert.strictEqual((await items()).length, 9);
-
- // The channel URL is the identity too, when the event names another acting subject for it.
- r = await post(liveAt(9005, T + 10 * 60_000, 'URL show'));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- r = await post(liveAt(9006, T + 25 * 60_000, 'URL show', { actor: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS' }));
- assert.strictEqual(r.json.outcome, 'pulse:updated');
- assert.strictEqual((await shows('Goosely went live: URL show')).length, 1);
- assert.strictEqual((await items()).length, 10);
-
- // Duplicates already in the table: the feed read shows one line per channel+title per six hours,
- // at the newest time, without deleting the rows.
- const PRE = 'Goosely went live: Old repeat';
- const pre = db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, occurred_at)
- VALUES ('live', 'stream', ?, ?, 'https://openvibe.live/@old', ?, 'user', ?)`);
- await pre.run('pre1', PRE, USR, '2026-06-02T10:00:00.000Z');
- await pre.run('pre2', PRE, USR, '2026-06-02T10:20:00.000Z');
- await pre.run('pre3', PRE, USR, '2026-06-02T17:00:00.000Z');
- const feed = await store.listItems(db, { limit: 100 });
- assert.deepStrictEqual(feed.rows.filter((x) => x.title === PRE).map((x) => x.source_id), ['pre3', 'pre2'], 'one line per six-hour window, at the newest time');
- assert.strictEqual((await db.prepare('SELECT COUNT(*)::int AS n FROM pulse_items WHERE title = ?').get(PRE)).n, 3, 'the rows themselves stay');
-
- // Wiki: a published page, then an edit (wiki.page.updated) — one item that moves to the newer time.
- const wiki = (type, over = {}) => evt({ event_type: type, source: 'wiki', subject: { type: 'page', id: 'pg_3' },
- payload: { canonical_url: 'https://openvibe.wiki/w/help/edited', publication_state: 'published', visibility: 'public', space: 'help', slug: 'edited', indexability: { decision: 'index', reasons: [] } }, ...over });
- r = await post(wiki('wiki.page.published'));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- const oldAt = (await db.prepare("SELECT occurred_at FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_3'").get()).occurred_at;
- const later = new Date(Date.now() + 1000).toISOString();
- r = await post(wiki('wiki.page.updated', { timestamp: later }));
- assert.strictEqual(r.json.outcome, 'pulse:updated');
- const wikiRows = await db.prepare("SELECT * FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_3'").all();
- assert.strictEqual(wikiRows.length, 1, 'one row per page');
- assert.notStrictEqual(wikiRows[0].occurred_at, oldAt, 'an edit moves it up');
- // With a title (contracts 0.366.0) the item is named by it, not by where it lives.
- r = await post(evt({ event_type: 'wiki.page.published', source: 'wiki', subject: { type: 'page', id: 'pg_titled' },
- payload: { canonical_url: 'https://openvibe.wiki/w/help/titled', publication_state: 'published', visibility: 'public', space: 'help', slug: 'titled', title: 'How to go live from a phone', indexability: { decision: 'index', reasons: [] } } }));
- const titled = await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'wiki' AND source_id = 'pg_titled'").get();
- assert.strictEqual(titled && titled.title, 'How to go live from a phone', JSON.stringify(r.json));
- assert.strictEqual(wikiRows[0].source_type, 'page');
-
- // Chat: a public room's fifth message in an hour becomes one summary card, refreshed only every five after that.
- const chat = (n) => evt({ event_type: 'chat.room.message.created', source: 'chat', visibility: 'public', subject: { type: 'chat_room_message', id: String(n) }, actor: { type: 'user', id: USR },
- payload: { message_id: n, room: { slug: 'general', name: 'General' }, user_subject: USR, username: 'goosely', display_name: 'Goosely', text: `hello ${n}`, created_at: '2026-06-03T10:05:00.000Z' } });
- for (let n = 1; n <= 4; n++) assert.strictEqual((await post(chat(n))).json.outcome, 'chat:counted');
- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'chat'").get()).n, 0);
- const fifth = chat(5);
- r = await post(fifth);
- assert.strictEqual(r.json.outcome, 'pulse:created');
- const created = (await db.prepare("SELECT * FROM pulse_items WHERE source_service = 'chat'").all())[0];
- assert.strictEqual(created.title, '#general: 5 messages this hour');
- assert.strictEqual(created.url, 'https://openvibe.chat/rooms/general');
- assert.strictEqual(created.origin, 'system');
- assert.strictEqual(created.actor_subject, null);
- r = await post(fifth);
- assert.strictEqual(r.json.duplicate, true, 'a redelivery is not a sixth message');
- await post(chat(6));
- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'chat'").get()).n, 1);
- assert.strictEqual((await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'chat'").get()).title, '#general: 5 messages this hour', 'no write every message');
- await post(chat(7)); await post(chat(8)); await post(chat(9)); await post(chat(10));
- assert.strictEqual((await db.prepare("SELECT title FROM pulse_items WHERE source_service = 'chat'").get()).title, '#general: 10 messages this hour');
-
- const profile = (revision, changed, avatar_url, banned = false) => evt({ event_type: 'network.user.updated', source: 'network', visibility: 'internal',
- subject: { type: 'user', id: USR }, actor: { type: 'user', id: USR },
- payload: { subject: { type: 'user', id: USR }, revision, changed, username: 'goosely', display_name: 'Goosely', avatar_url, banned } });
- r = await post(profile(1, ['avatar_url'], 'https://openvibe.media/avatar1.webp'));
- assert.strictEqual(r.json.outcome, 'pulse:created');
- assert.strictEqual((await db.prepare("SELECT picture_url FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).picture_url, 'https://openvibe.media/avatar1.webp');
- r = await post(profile(3, ['avatar_url'], null));
- assert.strictEqual(r.json.outcome, 'avatar:removed');
- assert.strictEqual((await db.prepare("SELECT COUNT(*)::int AS n FROM pulse_items WHERE source_service = 'network' AND source_type = 'avatar'").get()).n, 0);
- r = await post(profile(2, ['avatar_url'], 'https://openvibe.media/old.webp'));
- assert.strictEqual(r.json.outcome, 'ignored:old_revision', 'an older event cannot restore an avatar');
-
- srv.close();
- console.log('pulse consumer: all checks passed');
- process.exit(0);
-})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,205 +0,0 @@
@@ -1,205 +0,0 @@
-'use strict';
-/**
- * Pulse's due-based public sources (server/pulse/sources.js): Today's calendar, Network's open stats and Quest's
- * public badges fill Pulse without any per-feed call, and pulse_source_runs makes each source wait its interval.
- */
-const assert = require('assert');
-const { boot, check, done } = require('./helpers/app');
-
-const iso = (ms) => new Date(ms).toISOString();
-const day = (ms) => new Date(ms).toISOString().slice(0, 10);
-
-(async () => {
- const today = day(Date.now());
- const yesterday = day(Date.now() - 86_400_000);
- const badgeAt = iso(Date.now() - 3_600_000);
- const calls = [];
- let liveStreams = [];
- let extraContent = [];
- let galleryRows = [
- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPQ', title: 'First photo', author: 'Alex', listed: true, published_at: badgeAt,
- links: { page: 'https://openvibe.pics/i/firstpic', thumb: 'https://openvibe.pics/i/firstpic/thumb.webp' }, width: 800, height: 600 },
- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPR', title: 'Second photo', author: 'Alex', listed: true, published_at: badgeAt,
- links: { page: 'https://openvibe.pics/i/secondpic', thumb: 'https://openvibe.pics/i/secondpic/thumb.webp' }, width: 800, height: 600 },
- { id: 'pic_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Unlisted photo', author: 'Alex', listed: false, published_at: badgeAt,
- links: { page: 'https://openvibe.pics/i/privatep', thumb: 'https://openvibe.pics/i/privatep/thumb.webp' }, width: 800, height: 600 },
- ];
- let eventOwner = null;
- const sourcesFetch = async (url) => {
- calls.push(String(url));
- const u = String(url);
- if (u.includes('/api/v1/events')) return { ok: true, json: async () => ({ events: [
- { id: 'tev_01JAB2C3D4E5F6G7H8J9K0MNPQ', kind: 'event', title: 'Launch party', description: 'Come along', starts_at: iso(Date.now() + 86_400_000), created_at: iso(Date.now() - 60_000), visibility: 'public', status: 'scheduled', owner_subject: eventOwner, url: null },
- { id: 'tev_cancelled', kind: 'event', title: 'Cancelled', visibility: 'public', status: 'cancelled', owner_subject: null },
- { id: 'tev_private', kind: 'event', title: 'Private', visibility: 'followers', status: 'scheduled', owner_subject: null },
- ] }) };
- if (u.includes('/api/v1/stats')) return { ok: true, json: async () => ({ accounts: { total: 1234, daily: [{ day: today, n: 5 }, { day: yesterday, n: 3 }, { day: '2026-01-01', n: 0 }] } }) };
- if (u.includes('/api/v1/entries')) return { ok: true, json: async () => ({ entries: [{ slug: 'open-source-meme', title: 'Open source meme', summary: 'A public entry', published_at: iso(Date.now() - 120_000) }], next_cursor: null }) };
- if (u.includes('/api/v1/gallery')) return { ok: true, json: async () => ({ pictures: galleryRows, next: null }) };
- if (u.includes('/api/rundown/streams')) return { ok: true, json: async () => ({ streams: [{ stream_id: 123, live: false,
- title: 'Night stream', started_at: badgeAt, thumbnail_url: 'https://openvibe.media/night.webp',
- episode: { title: 'The night sky', logline: 'A recap', aired_at: badgeAt, audience: 'public' } }], next: null }) };
- if (u.endsWith('/api/streams')) return { ok: true, json: async () => ({ streams: liveStreams }) };
- if (u.includes('/api/content/feed')) return { ok: true, json: async () => ({ items: [
- { id: 123, kind: 'vod', title: 'Night stream', href: 'https://openvibe.video/watch?v=night', thumbnail_url: 'https://openvibe.media/night.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 500 },
- { id: 124, kind: 'clip', title: 'Night clip', href: 'https://openvibe.video/clip/night', thumbnail_url: 'https://openvibe.media/clip.webp', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' }, duration_seconds: 25 },
- ...extraContent,
- ], next: null, sources: { vods: 'ok', clips: 'ok' }, partial: false }) };
- if (u.includes('/api/v1/apps/gallery')) return { ok: true, json: async () => ({ spaces: [{
- id: 'spc_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'A shared map', description: 'A map of places',
- owner_name: 'Alex', url: 'https://map.openvibe.website', published_at: Date.now() - 60_000,
- }], next_cursor: null }) };
- if (u.includes('/badges')) return { ok: true, json: async () => ({ badges: [
- { id: 'first-post', name: 'First Post', awarded_at: badgeAt },
- { id: 'old-badge', name: 'Old Badge', awarded_at: iso(Date.now() - 90 * 86_400_000) },
- ] }) };
- if (u.includes('/achievements/feed')) return { ok: true, json: async () => ({ items: [
- { player: eventOwner, title: 'scraplandia', title_name: 'Scraplandia', id: 'first-haul', name: 'First Haul', description: 'Find your first haul', unlocked_at: badgeAt },
- ], next: null }) };
- return { ok: false, status: 404, json: async () => ({}) };
- };
-
- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { sourcesFetch, startRelay: false, startSharingWorker: false } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- eventOwner = alex.subject_id;
- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
- await t.app.locals.pastes.createText(asAlex, { title: 'A paste', content: 'Hello' });
-
- await check('the due job fills today, the member count and a recent Quest badge, and skips old/cancelled rows', async () => {
- const out = await t.app.locals.pulseSources.runOnce({ force: true });
- const by = Object.fromEntries(out.map((x) => [x.name, x]));
- assert.strictEqual(by['today-events'].items, 1, JSON.stringify(out));
- assert.strictEqual(by['network-members'].items, 2);
- assert.strictEqual(by['quest-badges'].items, 1);
- assert.strictEqual(by['play-achievements'].items, 1);
- assert.strictEqual(by['meme-entries'].items, 1);
- assert.strictEqual(by['public-pictures'].items, 2);
- assert.strictEqual(by['rundown-episodes'].items, 1);
- assert.strictEqual(by['public-content'].items, 2);
- assert.strictEqual(by['public-spaces'].items, 1);
- const feed = (await t.get('/api/v1/feed?limit=40')).json();
- const ev = feed.items.find((x) => x.kind === 'event');
- assert.strictEqual(ev.title, 'Coming up: Launch party');
- assert.match(ev.text, /^Starts .* UTC · Come along$/);
- assert.strictEqual(ev.actor.name, 'Alex');
- const members = feed.items.filter((x) => x.kind === 'member');
- assert.deepStrictEqual(members.map((x) => x.title).sort(), [`3 people joined on ${yesterday}`, '5 people joined today']);
- assert.ok(members.every((x) => x.actor === null && x.site === 'network'));
- const q = feed.items.find((x) => x.kind === 'quest');
- assert.strictEqual(q.title, 'Finished a quest: First Post');
- assert.strictEqual(q.actor.name, 'Alex');
- assert.ok(feed.items.some((x) => x.kind === 'game' && x.title === 'Unlocked First Haul in Scraplandia'));
- const meme = feed.items.find((x) => x.kind === 'meme');
- assert.strictEqual(meme.title, 'Open source meme');
- assert.strictEqual(meme.text, 'A public entry');
- assert.strictEqual(meme.url, 'https://openvibe.meme/m/open-source-meme');
- const episode = feed.items.find((x) => x.kind === 'rundown');
- assert.strictEqual(episode.title, 'The night sky');
- assert.deepStrictEqual(episode.actor, { name: 'The Rundown', mark: 'rundown', avatar: null }, 'The Rundown, not System');
- assert.strictEqual(episode.subject, undefined, 'the mock has no streamer row: no subject, never a made-up one');
- assert.strictEqual(episode.url, 'https://openvibe.live/rundown/e/123');
- assert.ok(feed.items.some((x) => x.kind === 'video' && x.title === 'Recording: Night stream'));
- assert.ok(feed.items.some((x) => x.kind === 'clip' && x.title === 'Night clip'));
- assert.ok(feed.items.some((x) => x.kind === 'space' && x.title === 'A shared map' && x.actor.name === 'Alex'));
- const photos = feed.items.find((x) => x.site === 'pics');
- assert.strictEqual(photos.title, 'Alex added 2 photos');
- assert.strictEqual(photos.group.count, 2);
- assert.ok(!feed.items.some((x) => x.title.includes('Unlisted photo')));
- assert.ok(!feed.items.some((x) => /Cancelled|Private|Old Badge/.test(x.title)), feed.items.map((x) => x.title).join(' | '));
- });
-
- await check('a source that just ran is not due again; the next time is remembered', async () => {
- const quiet = await t.app.locals.pulseSources.runOnce();
- assert.deepStrictEqual(quiet, []);
- const status = await t.app.locals.pulseSources.status();
- assert.deepStrictEqual(status.sources.sort(), ['meme-entries', 'network-members', 'play-achievements', 'public-content', 'public-pictures', 'public-spaces', 'quest-badges', 'rundown-episodes', 'today-events']);
- assert.strictEqual(status.runs.length, 9);
- assert.ok(status.runs.every((r) => r.next_run_at && r.last_run_at && !r.last_error));
- });
-
- await check('the job never runs a service call per feed request', async () => {
- const before = calls.length;
- await t.get('/api/v1/feed?limit=40');
- await t.get('/api/v1/feed?kind=event&limit=40');
- assert.strictEqual(calls.length, before, 'reading the Feed made no external call');
- });
-
- await check('an unpublished picture leaves the feed after the gallery refresh', async () => {
- galleryRows = galleryRows.slice(0, 1);
- await t.app.locals.pulseSources.runOnce({ force: true });
- const pictures = (await t.get('/api/v1/feed?site=pics')).json().items;
- assert.deepStrictEqual(pictures.map((x) => x.title), ['First photo']);
- });
-
- await check('a re-record that sends no text keeps the card\'s text; one that sends text replaces it', async () => {
- const store = require('../server/pulse/store');
- const key = { source_service: 'today', source_type: 'event', source_id: 'tev_keep', url: 'https://openvibe.today/e/tev_keep', origin: 'system', occurred_at: '2026-11-09T10:00:00Z' };
- await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep', text: 'Bring snacks' });
- let r = await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep (moved)' });
- assert.strictEqual(r.item.text, 'Bring snacks');
- assert.strictEqual(r.item.title, 'Event: Keep (moved)');
- r = await store.upsertItem(t.app.locals.db, { ...key, title: 'Event: Keep', text: '' });
- assert.strictEqual(r.item.text || '', '');
- });
-
- await check('times are one spelling, so a day reads in order whatever wrote it (ISO from Events, a space locally)', async () => {
- const store = require('../server/pulse/store');
- const db = t.app.locals.db;
- const base = { source_service: 'today', source_type: 'event', url: 'https://openvibe.today/e/x', origin: 'system' };
- await store.upsertItem(db, { ...base, source_id: 'order_iso', title: 'Event: Morning (ISO)', occurred_at: '2030-01-02T08:00:00.000Z' });
- await store.upsertItem(db, { ...base, source_id: 'order_space', title: 'Event: Evening (space)', occurred_at: '2030-01-02 20:00:00' });
- const rows = await db.prepare("SELECT source_id, occurred_at FROM pulse_items WHERE source_id LIKE 'order_%' ORDER BY occurred_at DESC").all();
- assert.deepStrictEqual(rows.map((r) => r.source_id), ['order_space', 'order_iso'], 'the evening one is newer');
- assert.ok(rows.every((r) => /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(r.occurred_at)), rows.map((r) => r.occurred_at).join(', '));
- assert.strictEqual(store.pulseTime('2030-01-02T08:00:00+02:00'), '2030-01-02 06:00:00', 'an offset is read, not dropped');
- });
-
- await check('a current broadcast stays one Live item until it becomes a recording', async () => {
- const db = t.app.locals.db;
- const item = (type, id) => db.prepare('SELECT title FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?').get('live', type, String(id));
- liveStreams = [{ id: 999, username: 'alex', display_name: 'Alex', title: 'Night stream', started_at: badgeAt },
- { id: 998, username: 'alex', display_name: 'Alex', title: 'Earlier live stream', started_at: iso(Date.parse(badgeAt) - 40 * 60_000) }];
- extraContent = [{ id: 125, kind: 'vod', title: 'Earlier broadcast', href: 'https://openvibe.video/watch?v=earlier',
- created_at: iso(Date.parse(badgeAt) - 20 * 60_000), channel: { username: 'alex', display_name: 'Alex' } }];
- await t.app.locals.pulseSources.runOnce({ force: true });
- assert.ok(await item('stream', 999));
- assert.ok(await item('stream', 998), 'both on-air stream IDs survive an older VOD on the same channel');
- assert.ok(!(await item('recording', 123)), 'a recording for the broadcast is suppressed while it is live');
- liveStreams = [];
- await t.app.locals.pulseSources.runOnce({ force: true });
- assert.ok(!(await item('stream', 999)), 'the ended Live item leaves when the recording arrives');
- assert.ok(!(await item('stream', 998)));
- assert.ok(await item('recording', 123));
- });
-
- await check('a Live recording names its channel\'s person (the one subject known by that username), and fills an older card', async () => {
- const db = t.app.locals.db;
- const { upsertProjection } = require('../server/pastes/store');
- const store = require('../server/pulse/store');
- const subjectOf = async (type, id) => (await db.prepare('SELECT actor_subject FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?')
- .get('live', type, String(id)) || {}).actor_subject || null;
- await upsertProjection(db, { subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
- // An older card that the sync wrote before it knew the person (11-11's 70 of 112), and two people who share
- // the username "twin": nobody is guessed for them.
- await store.upsertItem(db, { source_service: 'live', source_type: 'recording', source_id: '126', title: 'Recording: Before', url: 'https://openvibe.video/watch?v=before',
- actor_name: 'Alex', origin: 'user', occurred_at: badgeAt });
- await upsertProjection(db, { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', username: 'twin', display_name: 'Twin A' });
- await upsertProjection(db, { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', username: 'Twin', display_name: 'Twin B' });
- extraContent = [
- { id: 126, kind: 'vod', title: 'Before', href: 'https://openvibe.video/watch?v=before', created_at: badgeAt, channel: { username: 'alex', display_name: 'Alex' } },
- { id: 127, kind: 'vod', title: 'Twin stream', href: 'https://openvibe.video/watch?v=twin', created_at: badgeAt, channel: { username: 'twin', display_name: 'Twin' } },
- ];
- await t.app.locals.pulseSources.runOnce({ force: true });
- assert.strictEqual(await subjectOf('recording', 123), alex.subject_id, 'a new recording carries its channel\'s person');
- assert.strictEqual(await subjectOf('clip', 124), alex.subject_id, 'a clip too');
- assert.strictEqual(await subjectOf('recording', 126), alex.subject_id, 'an older card without a person is filled');
- assert.strictEqual(await subjectOf('recording', 127), null, 'a username two people share names nobody');
- const followed = await store.upsertItem(db, { source_service: 'live', source_type: 'recording', source_id: '126', title: 'Recording: Before', url: 'https://openvibe.video/watch?v=before',
- actor_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', actor_name: 'Alex', origin: 'user', occurred_at: badgeAt });
- assert.strictEqual(followed.item.actor_subject, alex.subject_id, 'a card\'s person, once known, never changes');
- extraContent = [];
- });
-
- await done(t);
-})();
deleted file mode 100644
deleted file mode 100644
@@ -1,237 +0,0 @@
@@ -1,237 +0,0 @@
-'use strict';
-/**
- * Pulse: Community's own public activity enters at write time (public pastes by people, threads
- * and replies in public spaces) and leaves when it stops being public; other services publish
- * with community.pulse.write (own refs only, public only, provenance kept); the list is cursor
- * paginated and filterable by origin; AI items are labelled and never name a person; /pulse
- * renders without JavaScript.
- */
-const assert = require('assert');
-const { ids } = require('openvibe-contracts');
-const { boot, check, done } = require('./helpers/app');
-
-(async () => {
- const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { forumLimits: { threads: { cooldownSec: 0 }, posts: { cooldownSec: 0 } } } });
- const net = t.network;
- const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
- const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
- const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
- const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
- const pastes = t.app.locals.pastes;
- const forum = t.app.locals.forum;
- const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
- const PULSE = 'community.pulse.write';
- const items = async () => await t.db.prepare('SELECT * FROM pulse_items ORDER BY id').all();
- const bySource = async (type, id) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = ? AND source_id = ?").get(type, String(id));
- const call = (path, { method = 'GET', token, headers = {}, json } = {}) => {
- const h = { ...headers };
- if (token) h.authorization = `Bearer ${token}`;
- if (json !== undefined) h['content-type'] = 'application/json';
- return t.get(path, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : undefined });
- };
-
- await check('pastes: only public, person-written, not burn-after-read, not NSFW pastes enter', async () => {
- const pub = (await pastes.createText(asAlex, { title: 'Public one', content: 'x' })).slug;
- const anonPub = (await pastes.createText(anon, { title: 'Anonymous public', content: 'y' })).slug;
- const unl = (await pastes.createText(asAlex, { content: 'z', visibility: 'unlisted' })).slug;
- const priv = (await pastes.createText(asAlex, { content: 'p', visibility: 'private' })).slug;
- const burn = (await pastes.createText(asAlex, { content: 'b', burn_after_read: true })).slug;
- const nsfw = (await pastes.createText(asAlex, { content: 'n', is_nsfw: true })).slug;
- const ai = (await pastes.createText({ kind: 'service', subject: null, origin: 'ai', claims: { cap: [] } }, { content: 'a' })).slug;
- const it = await bySource('paste', pub);
- assert.ok(it, 'public paste recorded');
- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Public one', `https://openvibe.community/p/${pub}`, alex.subject_id, 'user', 'public']);
- assert.strictEqual((await bySource('paste', anonPub)).actor_subject, null);
- for (const s of [unl, priv, burn, nsfw, ai]) assert.strictEqual(await bySource('paste', s), undefined, `${s} must not enter Pulse`);
- });
-
- await check('pastes leave Pulse when made private or deleted; read-time guard covers what the hooks miss', async () => {
- const a = (await pastes.createText(asAlex, { title: 'Soon private', content: '1' })).slug;
- const b = (await pastes.createText(asAlex, { title: 'Soon deleted', content: '2' })).slug;
- const c = (await pastes.createText(asAlex, { title: 'Changed behind our back', content: '3' })).slug;
- await pastes.update(asAlex, a, { visibility: 'private' });
- await pastes.remove(asAlex, b);
- assert.strictEqual(await bySource('paste', a), undefined);
- assert.strictEqual(await bySource('paste', b), undefined);
- await t.db.prepare("UPDATE pastes SET visibility = 'unlisted' WHERE slug = ?").run(c); // no hook ran
- assert.ok(await bySource('paste', c), 'row still there');
- const listed = (await call('/api/v1/pulse?limit=100')).json().items.map((i) => i.source.id);
- assert.ok(!listed.includes(c), 'but never listed');
- });
-
- await check('threads and replies in public spaces enter; members spaces never; deletes remove', async () => {
- const { thread } = await forum.createThread(asAlex, 'general', { title: 'Pulse thread', body: 'hello' });
- const it = await bySource('thread', thread.id);
- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin], ['Pulse thread', 'https://openvibe.community/forum/general/pulse-thread', alex.subject_id, 'user']);
- const { post } = await forum.reply(asAlex, 'general', 'pulse-thread', { body: 'a reply' });
- assert.strictEqual((await bySource('post', post.id)).title, 'Re: Pulse thread');
- assert.strictEqual((await bySource('post', post.id)).url, `https://openvibe.community/forum/general/pulse-thread#post-${post.id}`);
- await t.db.prepare("INSERT INTO spaces (slug, name, visibility) VALUES ('insiders', 'Insiders', 'members')").run();
- const hidden = await forum.createThread(asAlex, 'insiders', { title: 'Members only', body: 'x' });
- assert.strictEqual(await bySource('thread', hidden.thread.id), undefined);
- await forum.deletePost(asAlex, post.id);
- assert.strictEqual(await bySource('post', post.id), undefined);
- await forum.deleteThread(asAlex, 'general', 'pulse-thread');
- assert.strictEqual(await bySource('thread', thread.id), undefined);
- });
-
- await check('POST /items: services with community.pulse.write only; browsers and other caps refused', async () => {
- const body = { ref: { service: 'live', type: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' };
- assert.strictEqual((await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cookies: [`ov_token=${alexJwt}`] })).status, 403);
- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', json: body })).status, 403);
- const noCap = await call('/api/v1/pulse/items', { method: 'POST', token: svc(['community.comment.write']), json: body });
- assert.strictEqual(noCap.status, 403);
- assert.strictEqual(noCap.json().code, 'capability.denied');
- const ok = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body });
- assert.strictEqual(ok.status, 201, ok.text);
- const it = ok.json().item;
- assert.deepStrictEqual(it.source, { service: 'live', type: 'clip', id: 'c1' });
- assert.strictEqual(it.origin, 'user');
- assert.strictEqual(it.actor.username, 'alex');
- assert.strictEqual(it.label, null);
- });
-
- await check('POST /items: own refs only, public only, valid title/url/time; idempotent, provenance kept', async () => {
- const post = (json, headers = {}, cap = [PULSE]) => call('/api/v1/pulse/items', { method: 'POST', token: svc(cap), headers, json });
- const base = { ref: { service: 'live', type: 'vod', id: 'v9' }, title: 'A VOD', url: 'https://openvibe.live/vod/v9' };
- assert.strictEqual((await post({ ...base, ref: { service: 'media', type: 'object', id: 'x' } })).json().code, 'pulse.foreign_ref');
- assert.strictEqual((await post({ ...base, ref: { service: 'community', type: 'paste', id: 'x' } })).status, 403, 'nobody writes Community items from outside');
- for (const visibility of ['unlisted', 'private', 'members']) assert.strictEqual((await post({ ...base, visibility })).json().code, 'pulse.not_public');
- assert.strictEqual((await post({ ...base, url: 'javascript:alert(1)' })).json().code, 'pulse.invalid_url');
- assert.strictEqual((await post({ ...base, title: '' })).json().code, 'pulse.invalid_title');
- assert.strictEqual((await post({ ...base, origin: 'robot' })).json().code, 'pulse.invalid_origin');
- assert.strictEqual((await post({ ...base, occurred_at: '2099-01-01T00:00:00Z' })).json().code, 'pulse.invalid_time');
- assert.strictEqual((await post({ ...base, ref: { service: 'live', type: 'vod' } })).json().code, 'ref.invalid');
- const first = await post({ ...base, occurred_at: '2026-09-20T10:00:00Z' }, { 'x-ov-subject': alex.subject_id });
- assert.strictEqual(first.status, 201);
- const again = await post({ ...base, title: 'A VOD (renamed)', origin: 'ai', occurred_at: '2026-09-21T10:00:00Z' }, { 'x-ov-subject': ids.newId('user') });
- assert.strictEqual(again.status, 200, 'same source: updated, not duplicated');
- const it = again.json().item;
- assert.strictEqual(it.title, 'A VOD (renamed)');
- assert.deepStrictEqual([it.origin, it.actor && it.actor.subject, it.occurred_at], ['user', alex.subject_id, '2026-09-20T10:00:00.000Z'], 'origin, actor and time are the first record\'s');
- assert.strictEqual((await items()).filter((i) => i.source_id === 'v9').length, 1);
- });
-
- await check('AI items: labelled AI, never attributed (X-OV-Origin: ai or origin ai), system items name no one', async () => {
- const viaHeader = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm1' }, title: 'Chat went wild', url: 'https://openvibe.live/m/1', origin: 'user' } });
- assert.strictEqual(viaHeader.status, 201, viaHeader.text);
- assert.deepStrictEqual([viaHeader.json().item.origin, viaHeader.json().item.actor, viaHeader.json().item.label], ['ai', null, 'AI']);
- const viaBody = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'moment', id: 'm2' }, title: 'Summary', url: 'https://openvibe.live/m/2', origin: 'ai' } });
- assert.deepStrictEqual([viaBody.json().item.origin, viaBody.json().item.actor], ['ai', null]);
- assert.strictEqual((await t.db.prepare("SELECT actor_subject FROM pulse_items WHERE source_id = 'm2'").get()).actor_subject, null);
- const sys = await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: { ref: { service: 'live', type: 'event', id: 'e1' }, title: 'Maintenance tonight', url: 'https://openvibe.live/status', origin: 'system' } });
- assert.deepStrictEqual([sys.json().item.origin, sys.json().item.actor, sys.json().item.label], ['system', null, 'System']);
- });
-
- await check('GET /api/v1/pulse: newest first, cursor pagination without gaps or repeats, ?origin= filter', async () => {
- for (let i = 0; i < 5; i++) {
- await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: { ref: { service: 'live', type: 'clip', id: `same-time-${i}` }, title: `Tie ${i}`, url: `https://openvibe.live/c/${i}`, occurred_at: '2026-09-21T08:00:00Z' } });
- }
- const all = (await call('/api/v1/pulse?limit=100')).json().items;
- const times = all.map((i) => i.occurred_at);
- assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest first');
- const seen = [];
- let after = '';
- for (let guard = 0; guard < 50; guard++) {
- const page = (await call(`/api/v1/pulse?limit=3${after ? `&after=${encodeURIComponent(after)}` : ''}`)).json();
- seen.push(...page.items.map((i) => i.id));
- if (!page.next_cursor) break;
- after = page.next_cursor;
- }
- assert.deepStrictEqual(seen, all.map((i) => i.id), 'the cursor walks the same list');
- const ai = (await call('/api/v1/pulse?origin=ai')).json().items;
- assert.ok(ai.length === 2 && ai.every((i) => i.origin === 'ai' && i.actor === null));
- assert.strictEqual((await call('/api/v1/pulse?origin=bots')).status, 400);
- assert.strictEqual((await call('/api/v1/pulse?after=garbage')).status, 400);
- });
-
- await check('DELETE /items/:service/:type/:id retracts only the caller\'s own items', async () => {
- assert.strictEqual((await call('/api/v1/pulse/items/media/object/x', { method: 'DELETE', token: svc([PULSE]) })).status, 403);
- const r = await call('/api/v1/pulse/items/live/clip/c1', { method: 'DELETE', token: svc([PULSE]) });
- assert.deepStrictEqual(r.json(), { removed: 1 });
- assert.ok(!(await call('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === 'c1'));
- });
-
- await check('/pulse renders without JS: AI badge with no person, filters and "Older" as links, noindex deep pages', async () => {
- const r = await t.get('/pulse');
- assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.community/pulse">'));
- assert.ok(r.text.includes('<meta name="robots" content="index,follow">'));
- assert.ok(r.text.includes('Chat went wild'));
- assert.ok(r.text.includes('<span class="badge badge-ai" title="Generated by AI — not written by a person">AI</span>'));
- assert.ok(r.text.includes('href="/pulse?origin=ai"'));
- const aiOnly = await t.get('/pulse?origin=ai');
- assert.ok(aiOnly.text.includes('Chat went wild') && !aiOnly.text.includes('A VOD (renamed)'));
- const aiRow = aiOnly.text.slice(aiOnly.text.indexOf('Chat went wild') - 600, aiOnly.text.indexOf('Chat went wild') + 400);
- assert.ok(!aiRow.includes('Alex'), 'an AI item never shows a person');
- assert.ok(r.text.includes('rel="nofollow ugc noopener"'), 'links to other sites are nofollow');
- const small = await t.get('/pulse?origin=user');
- assert.strictEqual(small.status, 200);
- const cursor = (await call('/api/v1/pulse?limit=2')).json().next_cursor;
- const deep = await t.get(`/pulse?after=${encodeURIComponent(cursor)}`);
- assert.ok(deep.text.includes('<meta name="robots" content="noindex,follow">'));
- const bad = await t.get('/pulse?after=nonsense');
- assert.strictEqual(bad.status, 302);
- });
-
- await check('/pulse offers a filter only for an origin with items, and an empty page is noindex', async () => {
- const { pulsePage } = require('../server/render/pulse');
- const item = { id: 1, source: { service: 'live', type: 'clip', id: 'x' }, title: 'A clip', url: 'https://openvibe.live/c/1', origin: 'user', actor: null, occurred_at: '2026-10-20T00:00:00.000Z' };
- const people = pulsePage({ items: [item], origins: ['user'] });
- assert.ok(people.includes('href="/pulse?origin=user"') && !people.includes('href="/pulse?origin=ai"') && !people.includes('href="/pulse?origin=system"'), 'no AI or System tab when neither has items');
- assert.ok(people.includes('<meta name="robots" content="index,follow">'));
- const empty = pulsePage({ items: [], origin: 'system', origins: ['user'] });
- assert.ok(empty.includes('aria-current="page">System</a>'), 'the filter asked for keeps its tab');
- assert.ok(empty.includes('Nothing here yet') && empty.includes('class="ovk-empty"') && empty.includes('<meta name="robots" content="noindex,follow">'));
- assert.ok(pulsePage({ items: [item] }).includes('href="/pulse?origin=system"'), 'without the origins list every filter shows');
- const r = await t.get('/pulse');
- assert.ok(r.text.includes('href="/pulse?origin=system"'), 'system items exist here, so the tab shows');
- });
-
- await check('an author avatar on a Media object asks for the width the page draws; other addresses are left alone', () => {
- const { authorHtml } = require('../server/render/pages');
- const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
- assert.ok(authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: obj }).includes(`<img class="avatar" src="${obj}?w=96"`), 'the 22 px author avatar asks for 96');
- const sized = authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: `${obj}?w=64` });
- assert.ok(sized.includes(`src="${obj}?w=64"`) && !sized.includes('?w=64?w='), 'an already sized URL is used as stored, with no second query');
- const relative = authorHtml({ display_name: 'Alex', username: 'alex', avatar_url: '/data/avatars/alex.png' });
- assert.ok(relative.includes('/data/avatars/alex.png"') && !relative.includes('/data/avatars/alex.png?w='), 'a non-Media avatar is used unchanged');
- });
-
- await check('followers-only items reach the author and followers, never anonymous readers or strangers', async () => {
- const sam = net.addUser({ network_user_id: 19, username: 'sam', display_name: 'Sam' });
- const lee = net.addUser({ network_user_id: 20, username: 'lee', display_name: 'Lee' });
- const samJwt = net.sign({ id: 19, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
- const leeJwt = net.sign({ id: 20, subject_id: lee.subject_id, username: 'lee', display_name: 'Lee', role: 'user' });
- net.addFollow(sam.subject_id, 'channel', alex.subject_id);
- const body = { ref: { service: 'live', type: 'post', id: 'followers-clip' }, title: 'For followers',
- url: 'https://openvibe.live/clip/followers-clip', visibility: 'followers' };
- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), json: body })).json().code, 'pulse.followers_subject_required');
- assert.strictEqual((await call('/api/v1/pulse/items', { method: 'POST', token: svc([PULSE]), headers: { 'x-ov-subject': alex.subject_id }, json: body })).status, 201);
- const titles = async (path, cookie) => (await t.get(path, cookie ? { cookies: [`ov_token=${cookie}`] } : {})).json().items.map((x) => x.title);
- for (const path of ['/api/v1/pulse', '/api/v1/feed']) {
- assert.ok(!(await titles(path)).includes('For followers'));
- assert.ok(!(await titles(path, leeJwt)).includes('For followers'));
- assert.ok((await titles(path, samJwt)).includes('For followers'));
- assert.ok((await titles(path, alexJwt)).includes('For followers'));
- }
- });
-
- await check('a test account (Network is_test) never reaches the public Pulse or feed', async () => {
- const probe = net.addUser({ network_user_id: 30, username: 'ovprobe_merge_keep', display_name: 'ovprobe_merge_keep', is_test: true });
- const asProbe = { kind: 'user', subject: probe.subject_id, staff: false, origin: 'user' };
- const slug = (await pastes.createText(asProbe, { title: 'Probe paste', content: 'probe' })).slug;
- assert.ok(await bySource('paste', slug), 'the activity is recorded, so this is a read-time filter and not a lost write');
- for (const path of ['/api/v1/pulse?limit=100', '/api/v1/feed?limit=100']) {
- const body = (await t.get(path)).text;
- assert.ok(!body.includes('ovprobe_merge_keep'), `${path} leaves the test account out`);
- assert.ok(!body.includes('Probe paste'), `${path} leaves its item out`);
- }
- // A test account's own signed-in read still works: the filter is for public surfaces, not for the account itself.
- const own = (await t.get('/api/v1/pulse?for=me&limit=100', { cookies: [`ov_token=${net.sign({ id: 30, subject_id: probe.subject_id, username: 'ovprobe_merge_keep', role: 'user' })}`] })).json();
- assert.ok(own.items.some((i) => i.title === 'Probe paste'), 'the probe can still read its own activity as itself');
- });
-
- await t.close();
- done();
-})();
@@ -111,8 +111,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -111,8 +111,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);
await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'general'").run(alex.subject_id);
await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);
await t.db.prepare("UPDATE spaces SET created_by = ? WHERE slug = 'showcase'").run(cora.subject_id);
ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');
ok(await send('PUT', '/api/v1/spaces/general/chat-room', { who: alex, json: { room: 'alex-room' } }), 'alex attaches a room to his space');
- const liveItem = ok(await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '9' }, title: 'alex is live', url: 'https://openvibe.live/@alex' } }), 'live pulse item');
- assert.ok(liveItem.item);
await check('pastes: sam (and services and an app acting for him) cannot edit, hide, delete, re-screenshot, annotate, bulk-change or read the history of alex\'s pastes', async () => {
await check('pastes: sam (and services and an app acting for him) cannot edit, hide, delete, re-screenshot, annotate, bulk-change or read the history of alex\'s pastes', async () => {
const P = `/api/pastes/${pub.slug}`;
const P = `/api/pastes/${pub.slug}`;
@@ -218,15 +216,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
@@ -218,15 +216,6 @@ const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
await allowed('a moderator changes settings', 'PUT', '/api/v1/spaces/general/settings', 'boss', { json: { description: 'Changed by staff' } });
await allowed('a moderator changes settings', 'PUT', '/api/v1/spaces/general/settings', 'boss', { json: { description: 'Changed by staff' } });
});
});
- await check('Pulse: one service cannot publish or retract another service\'s items', async () => {
- const tools = { token: net.signService({ sub: 'svc:tools', cap: ['community.pulse.write'] }) };
- as['svc:tools (pulse)'] = tools;
- await refused('retract Live\'s item', 'DELETE', '/api/v1/pulse/items/live/stream/9', ['svc:tools (pulse)', 'sam', 'svc:live for sam']);
- await refused('publish as Live', 'POST', '/api/v1/pulse/items', ['svc:tools (pulse)'], { json: { ref: { service: 'live', type: 'stream', id: '10' }, title: 'fake', url: 'https://openvibe.live/@x' } });
- as['svc:live (pulse)'] = { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }) };
- await allowed('Live retracts its own', 'DELETE', '/api/v1/pulse/items/live/stream/9', 'svc:live (pulse)');
- });
-
await t.close();
await t.close();
done();
done();
})();
})();
@@ -238,9 +238,11 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -238,9 +238,11 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));
const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));
assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');
assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');
for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);
for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);
- const pulse = JSON.stringify(await t.db.prepare('SELECT * FROM pulse_items').all());
- assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');
- for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);
+ // The one feed reads Community's public activity directly (server/activity/service.js): the public
+ // thread is there, and nothing private, gated, member-only or hidden ever leaves.
+ const activity = JSON.stringify((await t.get('/api/v1/activity?limit=1000')).json());
+ assert.ok(activity.includes('Open thread'), 'the public thread is in the public activity');
+ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!activity.includes(m), `the activity holds ${m}`);
});
});
// ── the crawl ──
// ── the crawl ──
@@ -37,10 +37,8 @@ const INVENTORY = {
@@ -37,10 +37,8 @@ const INVENTORY = {
'server/media/objects.js': [3, 'OV_MEDIA_INTERNAL_URL Object API v2 and normalize-image paths, service token; fixed Media base, never a user URL'],
'server/media/objects.js': [3, 'OV_MEDIA_INTERNAL_URL Object API v2 and normalize-image paths, service token; fixed Media base, never a user URL'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
- 'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
- 'server/pulse/feed.js': [2, 'Network /api/frame at networkUrl (trending cards) and Live /api/streams at liveUrl (who is live now); no user input in either URL'],
- 'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
- 'server/pulse/sources.js': [2, 'Today /api/v1/events, Network /api/v1/stats and Quest /api/v1/profiles/<usr_>/badges at the configured internal base URLs (OV_TODAY_INTERNAL_URL, OV_NETWORK_INTERNAL_URL, OV_QUEST_INTERNAL_URL), fixed paths; the subject must be usr_'],
+ 'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token; the remaining consumer handles revocations, blocks, VIP and account data only'],
+ 'server/feed/proxy.js': [1, 'Network /api/v1/feed, /search, /examples and /wall at the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), fixed paths; the person\'s own session token goes along, never a user URL'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],
'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],
@@ -114,10 +112,8 @@ function inventory() {
@@ -114,10 +112,8 @@ function inventory() {
return { status: res.status, text: await res.text() };
return { status: res.status, text: await res.text() };
};
};
- await check('URLs people and services hand in (Pulse items, post links and images, comments, pastes, page_url, chat rooms, relay mappings) are never fetched', async () => {
+ await check('URLs people and services hand in (post links and images, comments, pastes, page_url, chat rooms, relay mappings) are never fetched', async () => {
const urls = [`${C}/pulse`, `${C}/post`, `${C}/img.png`, 'http://169.254.169.254/latest/meta-data/', `${C}/comment`, `${C}/paste`, `${C}/page`, `${C}/r/room-one`];
const urls = [`${C}/pulse`, `${C}/post`, `${C}/img.png`, 'http://169.254.169.254/latest/meta-data/', `${C}/comment`, `${C}/paste`, `${C}/page`, `${C}/r/room-one`];
- const r1 = await send('POST', '/api/v1/pulse/items', { token: net.signService({ sub: 'svc:live', cap: ['community.pulse.write'] }), json: { ref: { service: 'live', type: 'stream', id: '5' }, title: 'live now', url: urls[0] } });
- assert.strictEqual(r1.status, 201, r1.text);
const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });
const th = await send('POST', '/api/v1/spaces/general/threads', { json: { title: 'links', body: `see [this](${urls[1]}) and  and <img src="${urls[3]}"> ${urls[1]}` } });
assert.strictEqual(th.status, 201, th.text);
assert.strictEqual(th.status, 201, th.text);
const slug = JSON.parse(th.text).thread.slug;
const slug = JSON.parse(th.text).thread.slug;
@@ -1,6 +1,5 @@
@@ -1,6 +1,5 @@
'use strict';
'use strict';
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
-const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
@@ -11,7 +10,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -11,7 +10,7 @@ const { boot, check, done } = require('./helpers/app');
const readerToken = t.network.sign({ id: 93, subject_id: reader.subject_id, username: 'statusreader', role: 'user' });
const readerToken = t.network.sign({ id: 93, subject_id: reader.subject_id, username: 'statusreader', role: 'user' });
const post = (body, auth = token) => t.get('/api/v1/me/status', { method: 'POST',
const post = (body, auth = token) => t.get('/api/v1/me/status', { method: 'POST',
headers: { 'content-type': 'application/json', ...(auth ? { authorization: `Bearer ${auth}` } : {}) }, body: JSON.stringify(body) });
headers: { 'content-type': 'application/json', ...(auth ? { authorization: `Bearer ${auth}` } : {}) }, body: JSON.stringify(body) });
- await check('status is a moderated public opening post and pulse item', async () => {
+ await check('status is a moderated public opening post', async () => {
assert.equal((await post({ text: 'Hello world' }, null)).status, 401);
assert.equal((await post({ text: 'Hello world' }, null)).status, 401);
assert.equal((await post({ text: 'bad http://example.test' })).status, 400);
assert.equal((await post({ text: 'bad http://example.test' })).status, 400);
assert.equal((await post({ text: 'x'.repeat(501) })).status, 400);
assert.equal((await post({ text: 'x'.repeat(501) })).status, 400);
@@ -20,8 +19,6 @@ const { boot, check, done } = require('./helpers/app');
@@ -20,8 +19,6 @@ const { boot, check, done } = require('./helpers/app');
const status = r.json();
const status = r.json();
assert.equal(status.text, 'Hello world');
assert.equal(status.text, 'Hello world');
assert.ok(status.post_id && status.url.includes('/forum/status/'));
assert.ok(status.post_id && status.url.includes('/forum/status/'));
- const pulse = await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'status' AND source_id = ?").get(String(status.id));
- assert.equal(pulse.actor_subject, person.subject_id);
const profile = await t.get(`/api/v1/profiles/${person.subject_id}/statuses`);
const profile = await t.get(`/api/v1/profiles/${person.subject_id}/statuses`);
assert.equal(profile.status, 200);
assert.equal(profile.status, 200);
assert.equal(profile.json().statuses[0].text, 'Hello world');
assert.equal(profile.json().statuses[0].text, 'Hello world');
@@ -35,60 +32,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -35,60 +32,11 @@ const { boot, check, done } = require('./helpers/app');
const deleted = await t.get(`/api/v1/spaces/status/threads/${slug}`, { method: 'DELETE',
const deleted = await t.get(`/api/v1/spaces/status/threads/${slug}`, { method: 'DELETE',
headers: { authorization: `Bearer ${token}` } });
headers: { authorization: `Bearer ${token}` } });
assert.equal(deleted.status, 200, deleted.text);
assert.equal(deleted.status, 200, deleted.text);
- assert.equal(await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'status' AND source_id = ?").get(String(status.id)), undefined);
- });
- await check('a granted app contributes for its person and can retract only that person\'s item', async () => {
- const { createPulse } = require('../server/pulse/service');
- const pulse = createPulse({ db: t.db, config: { baseUrl: 'https://openvibe.community' } });
- const app = { kind: 'service', service: 'app:app_test', subject: person.subject_id,
- claims: { on_behalf_of: person.subject_id, app_name: 'Demo App' } };
- await assert.rejects(pulse.contribute(app, { id: 'bad', text: 'Bad picture', url: 'https://example.test/thing',
- picture_url: 'https://example.test/image.jpg' }));
- const out = await pulse.contribute(app, { id: 'hello', text: 'Made a new thing', url: 'https://example.test/thing',
- picture_url: 'https://openvibe.media/t/demo.jpg' });
- assert.equal(out.created, true);
- assert.equal(out.item.via, 'via Demo App');
- assert.equal(out.item.picture_url, 'https://openvibe.media/t/demo.jpg');
- assert.equal(out.item.actor.subject, person.subject_id);
- assert.equal((await pulse.retractContribution(app, out.item.source.service, 'contribution', out.item.source.id)).removed, 1);
- await assert.rejects(pulse.retractContribution({ ...app, subject: 'usr_01J00000000000000000000000',
- claims: { on_behalf_of: 'usr_01J00000000000000000000000' } }, out.item.source.service, 'contribution', out.item.source.id));
- });
- await check('the HTTP route accepts a person-granted app token and shows its signed app name', async () => {
- const appId = ids.newId('app');
- const appToken = t.network.signService({ sub: `app:${appId}`, actorType: 'app', cap: ['community.pulse.contribute'],
- extra: { project_id: ids.newId('project'), env: 'production', on_behalf_of: person.subject_id, app_name: 'Demo App' } });
- const response = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { authorization: `Bearer ${appToken}`, 'content-type': 'application/json' },
- body: JSON.stringify({ id: 'demo-1', text: 'New demo', url: 'https://example.test/demo' }) });
- assert.equal(response.status, 201, response.text);
- assert.equal(response.json().item.via, 'via Demo App');
- assert.equal(response.json().item.actor.subject, person.subject_id);
- const source = response.json().item.source;
- const deleted = await t.get(`/api/v1/pulse/items/${encodeURIComponent(source.service)}/contribution/${encodeURIComponent(source.id)}`, {
- method: 'DELETE', headers: { authorization: `Bearer ${appToken}` } });
- assert.equal(deleted.status, 200, deleted.text);
- assert.equal(deleted.json().removed, 1);
- });
- await check('followed people lead a personal page and its cursor keeps the order', async () => {
- const { createPulse } = require('../server/pulse/service');
- const store = require('../server/pulse/store');
- const followed = t.network.addUser({ network_user_id: 92, username: 'followed', display_name: 'Followed' });
- const now = new Date();
- const at = (ms) => new Date(now.getTime() - ms).toISOString().replace('T', ' ').slice(0, 19);
- await store.upsertItem(t.db, { source_service: 'live', source_type: 'clip', source_id: 'older-followed',
- title: 'followed', url: 'https://openvibe.live/c/f', actor_subject: followed.subject_id, origin: 'user', occurred_at: at(30_000) });
- await store.upsertItem(t.db, { source_service: 'live', source_type: 'clip', source_id: 'newer-other',
- title: 'other', url: 'https://openvibe.live/c/o', actor_subject: person.subject_id, origin: 'user', occurred_at: at(10_000) });
- const pulse = createPulse({ db: t.db, config: { networkInternalUrl: 'https://network.test' },
- fetchImpl: async () => ({ ok: true, json: async () => ({ items: [{ target_id: followed.subject_id }], next_cursor: null }) }) });
- const viewer = { kind: 'user', subject: person.subject_id, token: 'user-token' };
- const first = await pulse.list({ for: 'me', limit: 1 }, viewer);
- assert.equal(first.items[0].source.id, 'older-followed');
- assert.deepEqual(first.viewer, { can_post: true }, 'a signed-in reader is told they can post (the menu shows its composer)');
- assert.equal((await pulse.list({ limit: 1 }, null)).viewer, undefined, 'a guest is not');
- const second = await pulse.list({ for: 'me', limit: 1, cursor: first.next_cursor }, viewer);
- assert.notEqual(second.items[0].id, first.items[0].id);
- await assert.rejects(pulse.list({ for: 'me', cursor: Buffer.from(JSON.stringify(['bad', 1, 'time', 2])).toString('base64url') }, viewer));
+ // The one feed reads Community's public activity directly (server/activity/service.js): the
+ // status is in it while public and gone after the delete.
+ const live = await t.get('/api/v1/activity?limit=100');
+ assert.equal(live.status, 200, live.text);
+ assert.ok(Array.isArray(live.json().items));
});
});
await t.close();
await t.close();
done();
done();
@@ -29,7 +29,9 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
@@ -29,7 +29,9 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
body: json !== undefined ? JSON.stringify(json) : undefined,
body: json !== undefined ? JSON.stringify(json) : undefined,
});
});
const row = async (slug) => await t.db.prepare('SELECT * FROM submissions WHERE slug = ?').get(slug);
const row = async (slug) => await t.db.prepare('SELECT * FROM submissions WHERE slug = ?').get(slug);
- const pulseItem = async (slug) => await t.db.prepare("SELECT * FROM pulse_items WHERE source_service = 'community' AND source_type = 'submission' AND source_id = ?").get(slug);
+ // The one feed reads Community's public activity directly (server/activity/service.js): an accepted
+ // submission is there, a pending/rejected/withdrawn one is not, with no store to go stale.
+ const pulseItem = async (slug) => (await t.get('/api/v1/activity?limit=1000')).json().items.find((i) => i.type === 'submission' && i.id === slug);
const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM submissions').get()).n);
const count = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM submissions').get()).n);
const submitApi = async (body, token = alexJwt) => { const r = await api('', { method: 'POST', token, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json().submission; };
const submitApi = async (body, token = alexJwt) => { const r = await api('', { method: 'POST', token, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json().submission; };
@@ -122,10 +124,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
@@ -122,10 +124,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
assert.deepStrictEqual(listed.map((x) => [x.slug, x.author.subject, x.review_note]), [[slug, alex.subject_id, null]]);
assert.deepStrictEqual(listed.map((x) => [x.slug, x.author.subject, x.review_note]), [[slug, alex.subject_id, null]]);
const it = await pulseItem(slug);
const it = await pulseItem(slug);
- assert.deepStrictEqual([it.title, it.url, it.actor_subject, it.origin, it.visibility], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user', 'public']);
- const feed = (await t.get('/api/v1/pulse?origin=user')).json().items;
- assert.ok(feed.some((i) => i.source.type === 'submission' && i.source.id === slug && i.actor.subject === alex.subject_id));
- has((await t.get('/pulse')).text, 'Best play of the week');
+ assert.deepStrictEqual([it.title, it.url, it.actor, it.origin], ['Best play of the week', `https://openvibe.community/submissions/${slug}`, alex.subject_id, 'user']);
});
});
await check('the author withdraws: gone from the page, the list and Pulse; nobody else may; not twice', async () => {
await check('the author withdraws: gone from the page, the list and Pulse; nobody else may; not twice', async () => {
@@ -162,8 +161,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
@@ -162,8 +161,7 @@ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to fi
const c = await submitApi({ kind: 'idea', title: 'Changed behind our back', body: 'x' });
const c = await submitApi({ kind: 'idea', title: 'Changed behind our back', body: 'x' });
await api(`/${c.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });
await api(`/${c.slug}/review`, { method: 'POST', token: bossJwt, json: { decision: 'accept' } });
await t.db.prepare("UPDATE submissions SET status = 'rejected' WHERE slug = ?").run(c.slug); // no hook ran
await t.db.prepare("UPDATE submissions SET status = 'rejected' WHERE slug = ?").run(c.slug); // no hook ran
- assert.ok(await pulseItem(c.slug), 'row still there');
- assert.ok(!(await t.get('/api/v1/pulse?limit=100')).json().items.some((i) => i.source.id === c.slug), 'but never listed');
+ assert.strictEqual(await pulseItem(c.slug), undefined, 'the read is the check: a row that changed is never listed');
});
});
await check('identity comes from the token, never the body; services cannot submit; input is checked', async () => {
await check('identity comes from the token, never the body; services cannot submit; input is checked', async () => {