openvibers/openvibe.community Commits

Pulse reads Network's one feed; the local collector retires (#402)

OP OpenVibers committed 1 days ago · c639cf3f1c · 28 files, +458 −2913

Pulse and the menu read OpenVibe.Network's feed.item@1; Community's /api/v1/activity is the public row read Network's collector polls. The Pulse collector and its tests are deleted.

Changes

ddiff --git a/server/activity/api.js b/server/activity/api.js
nnew file mode 100644
iindex 0000000..90bd6f1
---- /dev/null
++++ b/server/activity/api.js
@@@ -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 };
ddiff --git a/server/activity/service.js b/server/activity/service.js
nnew file mode 100644
iindex 0000000..1f19999
---- /dev/null
++++ b/server/activity/service.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 7392ecf..60a7f80 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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_id
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.
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) => {
ddiff --git a/server/feed/proxy.js b/server/feed/proxy.js
nnew file mode 100644
iindex 0000000..546cfdb
---- /dev/null
++++ b/server/feed/proxy.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex da5b646..17b8fb1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -63,14 +63,6 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
if (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),
ddiff --git a/server/openapi.js b/server/openapi.js
iindex d6e5bb2..46fbacc 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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' }) } }),
ddiff --git a/server/pulse/api.js b/server/pulse/api.js
ddeleted file mode 100644
iindex affb5ee..0000000
---- a/server/pulse/api.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex f165a11..efca61b 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -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.EVEN
if (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.EVEN
let 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 };
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
ddeleted file mode 100644
iindex 10fbf70..0000000
---- a/server/pulse/feed.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
ddeleted file mode 100644
iindex 1694a95..0000000
---- a/server/pulse/service.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pulse/sources.js b/server/pulse/sources.js
ddeleted file mode 100644
iindex 8cf29ff..0000000
---- a/server/pulse/sources.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
ddeleted file mode 100644
iindex 3f0d586..0000000
---- a/server/pulse/store.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/render/feed.js b/server/render/feed.js
iindex 992a3b9..0a27cbb 100644
---- a/server/render/feed.js
++++ b/server/render/feed.js
@@@ -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>
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 615be5d..d522b9a 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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));
});
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
ddeleted file mode 100644
iindex 0169ea9..0000000
---- a/test/feed-api.test.js
++++ /dev/null
@@@ -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);
--})();
ddiff --git a/test/feed-page.test.js b/test/feed-page.test.js
iindex f64ef8a..afbad35 100644
---- a/test/feed-page.test.js
++++ b/test/feed-page.test.js
@@@ -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/);
ddiff --git a/test/feed-proxy.test.js b/test/feed-proxy.test.js
nnew file mode 100644
iindex 0000000..ee8bd23
---- /dev/null
++++ b/test/feed-proxy.test.js
@@@ -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; });
ddiff --git a/test/feed-shape.test.js b/test/feed-shape.test.js
ddeleted file mode 100644
iindex 212bc22..0000000
---- a/test/feed-shape.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 2fec487..bd22bb9 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -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('/');
ddiff --git a/test/members-only.test.js b/test/members-only.test.js
iindex 72398fa..6edfb91 100644
---- a/test/members-only.test.js
++++ b/test/members-only.test.js
@@@ -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 () => {
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
ddeleted file mode 100644
iindex f2d08e9..0000000
---- a/test/pulse-consumer.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/pulse-sources.test.js b/test/pulse-sources.test.js
ddeleted file mode 100644
iindex 8bf526c..0000000
---- a/test/pulse-sources.test.js
++++ /dev/null
@@@ -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);
--})();
ddiff --git a/test/pulse.test.js b/test/pulse.test.js
ddeleted file mode 100644
iindex 801580b..0000000
---- a/test/pulse.test.js
++++ /dev/null
@@@ -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();
--})();
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 87ccaff..ea30af2 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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();
})();
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 0b59dbc..e0515e2 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -238,9 +238,11 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
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');
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 ──
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b31be8b..b3eddd4 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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 ![img](${urls[2]}) and <img src="${urls[3]}"> ${urls[1]}` } });
assert.strictEqual(th.status, 201, th.text);
const slug = JSON.parse(th.text).thread.slug;
ddiff --git a/test/status.test.js b/test/status.test.js
iindex c8658b2..6eaa303 100644
---- a/test/status.test.js
++++ b/test/status.test.js
@@@ -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();
ddiff --git a/test/submissions.test.js b/test/submissions.test.js
iindex c8eef4f..9c6db50 100644
---- a/test/submissions.test.js
++++ b/test/submissions.test.js
@@@ -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,
});
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 fi
assert.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 fi
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 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 () => {