openvibers/openvibe.community Commits

Widen the Feed: every kind, picture and text, counts and filters (#310)

OP OpenVibers committed 4 days ago · a8fcb0d5ad · 23 files, +880 −70

* The Feed on every site: GET /api/v1/feed, one stream from Pulse with trending pictures woven in

The menu's Feed (openvibe-shared ov-home.js feed()) asks Community for one stream of everything public on the network:
Pulse items as cards by kind (live, clip, video, pic, post, meme, game, update), a kind filter, cursor paging, and the
network's trending pictures and videos from Network's Frame woven into the first page (https and pictures only).
Cross-site CORS, read limits (community.feed.read).

* Widen the Feed: every kind, pictures, counts and filters

* Merge origin/main into the wide Feed branch

* Merge remote-tracking branch 'origin/main' into feed-wide-1109

* Merge origin/main into the wide Feed branch

* Merge remote-tracking branch 'origin/main' into feed-wide-1109

# Conflicts:
#	server/app.js

* Pulse: a re-record that sends no text keeps the card's text

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 43f4dfe..70280f8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,21 @@
## Unreleased
++- **The wide Feed** (owner, 11-09: "one huge feed of everything, all the data, SDK and API enabled"). Pulse now carries
++ every kind the menu's Feed draws — `wiki` (new and updated articles), `quest` (a finished quest's badge), `chat` (a
++ busy public room hour), `event` (Today's calendar) and `member` (a day's new Network accounts, "5 people joined
++ today") — beside live, clip, video, pic, post, meme, game and update. Wiki and Chat arrive over Events
++ (`wiki.page.updated`, `chat.room.message.created`); Today, Network and Quest are read by the due-based
++ `server/pulse/sources.js` (next run per source in `pulse_source_runs`), never while a feed request is answered.
++ - `GET /api/v1/feed` keeps its shape and gains `text` (the first 280 characters), the first `picture_url` of
++ Community's own posts and pastes, Community's own `counts` (views, likes, comments), and the `site=`, `q=` and
++ `following=1` filters (the signed-in person's Network follows).
++ - `POST /api/v1/posts { text, pictures: [{ media_id, alt }] }` posts up to four pictures (alt required), uploaded
++ through Media, and the post is in the Feed at once. Attachments remember the alt text.
++ - `migrations/0024_feed_wide.sql` (expand): `pulse_items.text`, `attachments.alt`, `pulse_source_runs` and
++ `pulse_quest_people`.
++ - Contracts: `community.feed-item@1`; SDK: `ov.community.feed()` and `ov.community.posts.create()` (their own PRs).
++
- **The forum moved from `/s` to `/forum`** (design 2026-11-07, section 13 "URLs people already know"). The board
is `/forum`, a space `/forum/<space>`, and a thread `/forum/<space>/<thread>` (the `/t/` segment is gone);
`/forum/new` and `/forum/discuss` replace `/s/new-space` and `/s/discuss`. Every old `/s` address answers a
ddiff --git a/migrations/0024_feed_wide.sql b/migrations/0024_feed_wide.sql
nnew file mode 100644
iindex 0000000..39cd4a4
---- /dev/null
++++ b/migrations/0024_feed_wide.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- The wide feed (owner, 11-09: "one huge feed of everything, all the data, SDK and API enabled"): Pulse items
++-- carry a short text beside the title and, for Community's own posts and pastes, their first picture; post
++-- attachments remember the alt text the writer gave them. The due-based public sources (server/pulse/sources.js)
++-- keep their next run per source, and the Quest scan keeps when it last checked a person. Additive only.
++ALTER TABLE pulse_items ADD COLUMN IF NOT EXISTS text text COLLATE "C";
++ALTER TABLE attachments ADD COLUMN IF NOT EXISTS alt text COLLATE "C";
++
++CREATE TABLE IF NOT EXISTS pulse_source_runs (
++ name text COLLATE "C" PRIMARY KEY,
++ next_run_at text COLLATE "C",
++ last_run_at text COLLATE "C",
++ last_error text COLLATE "C"
++);
++
++CREATE TABLE IF NOT EXISTS pulse_quest_people (
++ subject text COLLATE "C" PRIMARY KEY,
++ checked_at text COLLATE "C" NOT NULL
++);
ddiff --git a/server/app.js b/server/app.js
iindex 5c4cbba..ba19e67 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -149,6 +149,12 @@ async function createApp(opts = {}) {
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, ...(opts.pulseSources || {}),
++ });
++ app.locals.pulseSources = pulseSources;
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
@@@ -312,7 +318,7 @@ async function createApp(opts = {}) {
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, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
++ 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 }));
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.
@@@ -322,7 +328,7 @@ async function createApp(opts = {}) {
// and these deliveries carry token cutoffs and account deletions.
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
-- app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
++ app.use('/api/v1/posts', createPostsApi({ forum, pulse, viewers, limits, baseUrl: config.baseUrl }));
app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
ddiff --git a/server/config.js b/server/config.js
iindex 8817790..e6e6821 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -46,6 +46,12 @@ module.exports = {
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
++ // The public sites whose activity a due-based job reads into Pulse (server/pulse/sources.js): Today's
++ // calendar, Quest's public badges and Network's open stats. The internal addresses are for the API calls.
++ todayUrl: (process.env.OV_TODAY_URL || 'https://openvibe.today').replace(/\/$/, ''),
++ todayInternalUrl: (process.env.OV_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100').replace(/\/$/, ''),
++ questUrl: (process.env.OV_QUEST_URL || 'https://openvibe.quest').replace(/\/$/, ''),
++ questInternalUrl: (process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980').replace(/\/$/, ''),
// OpenVibe.Vision — the unsafe-image review (VIS-6). Unset or without Community's service principal:
// no review runs and image pastes keep their old behaviour.
vision: {
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 6d32e1d..13d53a6 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -48,7 +48,7 @@
const express = require('express');
const multer = require('multer');
const contracts = require('openvibe-contracts');
--const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
++const { run, serviceCap, serviceAnyCap, jsonBody, fail } = require('../http/v1');
const POST = 'community.post.create';
const MOD = 'community.comment.moderate';
@@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
return router;
}
--function createPostsApi({ forum, viewers, limits }) {
++function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
@@@ -124,6 +124,35 @@ function createPostsApi({ forum, viewers, limits }) {
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
router.use(limits.reads('community.forum.read'));
++ // POST /api/v1/posts — a status post (a thread in the Status space) with up to four pictures, which is in the
++ // Feed at once. Pictures are Media objects the person owns ({ media_id, alt }); the forum service checks that
++ // and remembers the alt text. Same rules as POST /api/v1/me/status: people only, text required.
++ router.post('/', limits('community.post.create', { minute: 6, hour: 30 }), jsonBody, run(async (req) => {
++ const v = req.viewer;
++ if (!v || v.kind !== 'user' || !v.subject) fail(401, 'post.sign_in_required', 'Sign in to post');
++ const body = req.body || {};
++ if (Object.keys(body).some((k) => !['text', 'pictures'].includes(k))) fail(400, 'post.invalid', 'Only text and pictures are accepted');
++ const text = typeof body.text === 'string' ? body.text.trim() : '';
++ if (!text) fail(400, 'post.invalid', 'Write something first');
++ if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
++ const pictures = body.pictures == null ? [] : body.pictures;
++ if (!Array.isArray(pictures) || pictures.length > 4) fail(400, 'post.invalid_pictures', 'pictures is an array of at most four { media_id, alt }');
++ for (const p of pictures) {
++ if (!p || typeof p.media_id !== 'string' || typeof p.alt !== 'string') fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt }');
++ }
++ const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || 'Shared a picture'}`;
++ const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: p.alt })) });
++ if (pulse) {
++ await pulse.forgetLocal('thread', thread.id);
++ const first = (post.attachments || [])[0];
++ await pulse.statusCreated(thread, text, v.subject, first && first.url);
++ }
++ return { id: thread.id, post_id: post.id, text,
++ url: `${String(baseUrl).replace(/\/$/, '')}/forum/status/${thread.slug}`,
++ pictures: (post.attachments || []).map((a) => ({ media_id: a.media_id, alt: a.alt || null, url: a.url })),
++ created_at: thread.created_at };
++ }, 201));
++
router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run(async (req) => await forum.editPost(req.viewer, req.params.id, req.body || {})));
router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run(async (req) => await forum.react(req.viewer, req.params.id, req.body || {})));
router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), jsonBody, run(async (req) => await forum.deletePost(req.viewer, req.params.id, req.body || {})));
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 38e603c..a53eab1 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -269,7 +269,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
}
-- const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });
++ const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, alt: a.alt || null, mime: a.mime, size_bytes: a.size_bytes });
/** post id → [attachment], in order. */
async function attachmentsOf(postIds) {
const out = new Map();
@@@ -289,6 +289,50 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
return rows;
}
++ /**
++ * The pictures a post names: [{ media_id, alt }], alt required, at most four. An upload made through
++ * /api/v1/spaces/:space/attachments is already an attachments row; a direct Media upload (the SDK's
++ * posts.create path) is checked with Media's object API, where the person owns the image, and remembered
++ * here with the alt text. → rows ready for claimable()
++ */
++ async function preparePictures(w, list) {
++ if (list === undefined || list === null) return [];
++ const pictures = Array.isArray(list) ? list : [list];
++ if (pictures.length > ATTACH_MAX) fail(400, 'pictures.too_many', `At most ${ATTACH_MAX} images per post`);
++ if (!w.author && pictures.length) fail(403, 'attachments.person_only', 'Only people attach images');
++ const out = [];
++ for (const p of pictures) {
++ const mediaId = String((p && p.media_id) || '');
++ const alt = String((p && p.alt) == null ? '' : p.alt).replace(/\s+/g, ' ').trim();
++ if (!MED_ID.test(mediaId)) fail(400, 'pictures.invalid', 'Every picture needs a Media media_id');
++ if (!alt) fail(400, 'pictures.alt_required', 'Every picture needs alt text');
++ if (alt.length > 300) fail(400, 'pictures.alt_too_long', 'Alt text is at most 300 characters');
++ let row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);
++ if (row) {
++ if (row.owner_subject !== w.author || row.post_id != null) fail(400, 'pictures.invalid', 'Use pictures you uploaded for this post');
++ } else {
++ if (!media || !media.configured || typeof media.object !== 'function') fail(503, 'attachments.unavailable', 'Images cannot be attached right now');
++ let obj;
++ try { obj = await media.object(mediaId); } catch (err) {
++ console.warn('[Forum] picture lookup failed:', err.message);
++ fail(err.status === 404 ? 400 : 502, 'pictures.invalid', 'That picture could not be read from Media');
++ }
++ if (!obj || !obj.owner || obj.owner.subject !== w.author || obj.lifecycle_status !== 'ready'
++ || !/^image\//i.test(String(obj.mime_type || ''))) {
++ fail(400, 'pictures.invalid', 'Use pictures you uploaded to Media for this post');
++ }
++ const url = /^https:\/\//.test(String(obj.public_url || '')) ? obj.public_url : `${String(config.mediaUrl || '').replace(/\/$/, '')}/o/${mediaId}`;
++ await db.prepare(`INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url, alt)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(media_id) DO NOTHING`)
++ .run(mediaId, w.author, String((obj.metadata && obj.metadata.filename) || 'image').slice(0, 120), String(obj.mime_type), Number(obj.size_bytes) || 0, url, alt);
++ row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);
++ if (!row || row.owner_subject !== w.author || row.post_id != null) fail(400, 'pictures.invalid', 'Use pictures you uploaded for this post');
++ }
++ if ((row.alt || '') !== alt) await db.prepare('UPDATE attachments SET alt = ? WHERE media_id = ?').run(alt, mediaId);
++ out.push({ ...row, alt });
++ }
++ return out;
++ }
async function claimPastes(refs) {
const slugs = parsePasteRefs(refs);
if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);
@@@ -943,7 +987,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);
if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
-- const images = await claimable(w, body.attachments);
++ const pictureRows = await preparePictures(w, body.pictures);
++ const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
++ const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
const pasteRows = await claimPastes(body.pastes);
if (w.key) {
threadLimiter.check(w.key, title);
@@@ -954,7 +1000,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
threadLimiter.record(w.key, title);
await attach(images, post.id);
await attachPastes(pasteRows, post.id);
-- if (pulse) await hook(async () => await pulse.threadCreated(thread, space));
++ if (pulse) await hook(async () => await pulse.threadCreated(thread, space, { text, picture: images[0] && images[0].url }));
if (relay) await hook(async () => await relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
pingThread(space, thread, { withSpace: true });
@@@ -971,14 +1017,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// Platform blocks: nobody replies in a thread whose author blocked them.
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
-- const images = await claimable(w, body.attachments);
++ const pictureRows = await preparePictures(w, body.pictures);
++ const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
++ const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
const pasteRows = await claimPastes(body.pastes);
postLimiter.check(w.key, text);
const post = await store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
postLimiter.record(w.key, text);
await attach(images, post.id);
await attachPastes(pasteRows, post.id);
-- if (pulse) await hook(async () => await pulse.postCreated(post, thread, space));
++ if (pulse) await hook(async () => await pulse.postCreated(post, thread, space, { picture: images[0] && images[0].url }));
if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 88c810c..3f70c0b 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -38,7 +38,7 @@ const EXPORTS = [
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
-- ['space_moderators.json', 'space_moderators', 'subject_id'],
++ ['space_moderators.json', 'space_moderators', 'subject_id'], ['quest_checks.json', 'pulse_quest_people', 'subject'],
];
async function exportPart(db, subject) {
@@@ -47,7 +47,8 @@ async function exportPart(db, subject) {
for (const [name, table, col] of EXPORTS) {
if (!await hasColumn(db, table, col)) continue;
// Newest first: by id where the table has one, else by its timestamp.
-- const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at' : 'updated_at';
++ const order = await hasColumn(db, table, 'id') ? 'id' : await hasColumn(db, table, 'created_at') ? 'created_at'
++ : await hasColumn(db, table, 'checked_at') ? 'checked_at' : 'updated_at';
const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${col} = ? ORDER BY ${order} DESC LIMIT ${ROW_LIMIT + 1}`).all(subject);
if (!rows.length) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
@@@ -172,7 +173,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
add(erased, 'submissions', (await db.prepare(`DELETE FROM submissions WHERE author_subject IN ${S}`).run(...subjects)).changes);
await db.prepare(`UPDATE submissions SET reviewer_subject = NULL WHERE reviewer_subject IN ${S}`).run(...subjects);
}
-- for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
++ for (const [table, where, key] of [['game_progress', `subject_id IN ${S}`, 'game_progress'], ['pulse_quest_people', `subject IN ${S}`, 'quest_checks'], ['pulse_items', `actor_subject IN ${S}`, 'activity'],
['subject_projection', `subject_id IN ${S}`, 'profile'], ['profile_module_pushes', `subject_id IN ${S}`, 'profile'],
['network_blocks', `blocker_subject IN ${S} OR blocked_subject IN ${S}`, 'blocks'], ['space_moderators', `subject_id IN ${S}`, 'space_moderators']]) {
if (!await hasTable(db, table)) continue;
ddiff --git a/server/index.js b/server/index.js
iindex 17b8fb1..da5b646 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -63,6 +63,14 @@ 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
@@@ -80,6 +88,7 @@ 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/media/objects.js b/server/media/objects.js
iindex 4b96cc1..5b9eb27 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -68,6 +68,11 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
}
++ /** One object's metadata (GET /api/v2/community/objects/:id): owner, kind, mime and public URL. */
++ async function object(id) {
++ return await call('GET', `/${encodeURIComponent(String(id))}`);
++ }
++
/**
* A usable URL for one object's bytes: its public address, or a short-lived signed URL for a private
* object. Used by Community's screenshot proxy, which checks canView before asking.
@@@ -108,7 +113,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { retired: true, changed: !!(out && out.changed), ...(out && out.retention_until ? { retention_until: out.retention_until } : {}) };
}
-- return { configured, uploadImage, downloadUrl, deleteObject, retireLegacyPaste };
++ return { configured, uploadImage, object, downloadUrl, deleteObject, retireLegacyPaste };
}
module.exports = { createMediaObjects };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex b0def82..0855ac6 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -355,6 +355,21 @@ route('delete', '/api/v1/pulse/items/{service}/{type}/{id}', {
description: 'community.pulse.write for services, community.pulse.contribute for apps.',
responses: { 200: json('Whether it was there'), ...errors() },
});
++route('get', '/api/v1/feed', {
++ operationId: 'getFeed', summary: 'The menu\'s Feed: one stream from Pulse, newest first', tags: ['feed'], security: ANONYMOUS,
++ description: 'Public. A kind, site or word filters the stream; following=1 is the signed-in person\'s Network follows. Cards carry Community\'s views, likes and comments where it knows them.',
++ parameters: [
++ query('kind', { type: 'string', enum: ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member'] }),
++ query('site', str({ maxLength: 40 }), 'A source service (live, community, wiki, quest, chat, today, network…)'),
++ query('q', str({ maxLength: 80 }), 'Words in the title or text'), query('following', { type: 'string', enum: ['1', 'true'] }),
++ query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40, default: 20 })],
++ responses: { 200: json('Cards and the next cursor', { type: 'object', required: ['items', 'next_cursor'], properties: {
++ items: arrayOf({ type: 'object', required: ['id', 'kind', 'url', 'title', 'text', 'site', 'site_name', 'counts'], properties: {
++ id: str(), kind: str(), url: str(), title: str({ maxLength: 300 }), text: str({ maxLength: 280 }), at: nullable('string', { format: 'date-time' }),
++ site: str(), site_name: str(), media: nullable('object'), counts: { type: 'object' },
++ actor: nullable('object', { properties: { name: nullable('string'), avatar: nullable('string') } }) } }),
++ next_cursor: nullable('string') } }), ...errors([400, 401, 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' }) } }),
@@@ -565,6 +580,15 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
});
// ── /api/v1/posts — one forum post (server/forum/api.js) ────────────────────────────────────
++route('post', '/api/v1/posts', {
++ operationId: 'createPost', summary: 'Post a status with pictures, in the Feed at once', tags: ['posts'], security: PERSON,
++ description: 'People only. Pictures are Media objects the person owns, each with required alt text; the post is a thread in the Status space.',
++ requestBody: body({ type: 'object', required: ['text'], properties: {
++ text: str({ minLength: 1, maxLength: 2000 }),
++ pictures: { type: 'array', maxItems: 4, items: { type: 'object', required: ['media_id', 'alt'], properties: {
++ media_id: str({ pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }), alt: str({ minLength: 1, maxLength: 300 }) } } } } }),
++ responses: { 201: json('The post and its pictures'), ...errors([400, 401, 409, 429]) },
++});
route('put', '/api/v1/posts/{id}', {
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
parameters: [path('id', { type: 'string' })],
ddiff --git a/server/pulse/consumer.js b/server/pulse/consumer.js
iindex 94c0f37..9e33198 100644
---- a/server/pulse/consumer.js
++++ b/server/pulse/consumer.js
@@@ -37,7 +37,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
--const TOPICS = Object.freeze(['live.stream.started', 'blog.post.published', 'wiki.page.published', 'news.story.published', 'vip.membership.changed', 'network.user.token_valid_after', 'network.block.changed', 'network.module.updated', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);
++const TOPICS = Object.freeze(['live.stream.started', '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.block.changed', 'network.module.updated', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted']);
const GAME_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
@@@ -61,7 +61,7 @@ function itemFor(event) {
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 = { 'blog.post.published': ['blog', 'post'], 'wiki.page.published': ['wiki', 'page'], 'news.story.published': ['news', 'story'] };
++ const kinds = { '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';
@@@ -74,7 +74,21 @@ function itemFor(event) {
: k[0] === 'wiki' ? `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;
-- return { source_service: k[0], source_type: k[1], source_id: subjectId, title, url, actor_subject: actor, origin: actor ? 'user' : 'system', occurred_at: at };
++ // 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. */
@@@ -107,6 +121,25 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
// 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);
@@@ -186,6 +219,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
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);
++ 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 {
@@@ -238,4 +284,4 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
}
--module.exports = { createPulseConsumer, startSubscriptions, itemFor, gameProgressOf, TOPICS, GAME_MILESTONE };
++module.exports = { createPulseConsumer, startSubscriptions, itemFor, chatMessageOf, gameProgressOf, TOPICS, GAME_MILESTONE };
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex 252eab1..05d749a 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -4,20 +4,27 @@
* 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, an update…) with the network's trending pictures and videos (Network's Frame cards) woven into the first page.
++ * 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|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)
++ * 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"), url, title, text, at, site, site_name, actor: { name, avatar } | null,
-- * media: { thumb, ratio, length } | null, counts: {}, live_now? }], next_cursor } live_now: on Live's live list now
++ * media: { thumb, ratio, length } | null, 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 } = require('../http/v1');
++const { run, fail, isoTime } = require('../http/v1');
--const KINDS = ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'];
--const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report' };
++const KINDS = ['live', 'stream', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update', 'wiki', 'quest', 'chat', 'event', 'member'];
++const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', 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);
@@@ -31,6 +38,11 @@ function kindOf(i) {
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';
}
@@@ -43,8 +55,8 @@ function card(i) {
kind: kindOf(i),
url: https(i.url) || i.url,
title: clean(i.title, 300),
-- text: '',
-- at: i.occurred_at || null,
++ text: clean(i.text, 280),
++ at: isoTime(i.occurred_at) || i.occurred_at || null,
site,
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
@@@ -54,6 +66,57 @@ function card(i) {
};
}
++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 || '');
++
++/** 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;
@@@ -96,7 +159,7 @@ function createLiveNow({ liveUrl, fetchImpl }) {
}
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 ''; } };
--function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
++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());
@@@ -104,35 +167,53 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
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.
-- // A go-live that is over is a stream that happened (kind "stream"): clients draw LIVE only on kind "live".
const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') { c.live_now = live.has(channelOf(c.url).toLowerCase()); 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);
-- const items = [];
++ 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 followed = following ? new Set(await pulse.followedFor(req.viewer)) : null;
++
++ const picked = [];
let cursor = req.query.cursor ? String(req.query.cursor) : '';
let next = null;
-- // A kind filters Pulse's pages until the page is full (at most four pages read per call).
-- for (let pages = 0; pages < 4 && items.length < want; pages++) {
++ // 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 && picked.length < want; pages++) {
const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
-- for (const i of page.items || []) { const c = card(i); if (!kind || c.kind === kind) items.push(c); if (items.length >= want) break; }
++ for (const i of page.items || []) {
++ const actorSubject = i.actor_subject || (i.actor && i.actor.subject) || null;
++ if (followed && !(actorSubject && followed.has(actorSubject))) continue;
++ const c = card(i);
++ if (kind && c.kind !== kind) continue;
++ if (site && c.site !== site) continue;
++ if (q && !(c.title.toLowerCase().includes(q) || c.text.toLowerCase().includes(q))) continue;
++ picked.push({ row: i, card: c });
++ if (picked.length >= want) break;
++ }
next = page.next_cursor || null;
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)}`) || {};
++
// The first page carries the network's trending pictures and videos, one after every three items.
-- if (!req.query.cursor) {
-- const t = (await trending()).filter((x) => !kind || x.kind === kind);
++ if (!req.query.cursor && !q && !following) {
++ const t = (await trending()).filter((x) => (!kind || x.kind === kind) && (!site || x.site === site));
if (t.length) {
const woven = [];
let k = 0;
-- items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
++ picked.forEach((p, n) => { woven.push(p.card); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
while (woven.length < want && k < t.length) woven.push(t[k++]);
mark(woven, await liveNow());
return { items: woven, next_cursor: next };
}
}
++ const items = picked.map((p) => p.card);
mark(items, await liveNow());
return { items, next_cursor: next };
}));
@@@ -140,4 +221,4 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
return router;
}
--module.exports = { createFeedApi, kindOf, card, channelOf };
++module.exports = { createFeedApi, kindOf, card, countsFor, channelOf };
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex 289cb5f..ccb79b8 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -56,14 +56,19 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
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 }) {
++ 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); }
}
@@@ -83,6 +88,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
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.text ? { text: i.text } : {}),
actor,
occurred_at: isoTime(i.occurred_at),
};
@@@ -91,33 +97,41 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
return {
ORIGINS,
forgetLocal,
-- async statusCreated(thread, text, actor) {
++ /** 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 });
++ actor, origin: 'user', at: thread.created_at, text, pictureUrl });
},
// ── 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 });
++ 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) {
++ 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) {
++ 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) {
ddiff --git a/server/pulse/sources.js b/server/pulse/sources.js
nnew file mode 100644
iindex 0000000..3734060
---- /dev/null
++++ b/server/pulse/sources.js
@@@ -0,0 +1,213 @@
++'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: …"
++ *
++ * 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 { 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 t = Date.parse(String(v || ''));
++ return Number.isFinite(t) ? sqlTime(t) : fallback;
++};
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++
++function createPulseSources({ db, config = {}, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ 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(/\/+$/, '');
++ let timer = null;
++ let running = null;
++
++ 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 - 2 * 60 * MINUTE).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 owner = subject(e.owner_subject);
++ const text = clean(e.description, 280) || (e.starts_at ? `Starts ${String(e.starts_at).replace('T', ' ').slice(0, 16)} UTC` : '');
++ 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: `${e.kind === 'stream' ? 'Live' : 'Event'}: ${clean(e.title, 140)}`,
++ url: https(e.url) || `${todaySite}/e/${encodeURIComponent(e.id)}`,
++ actor_subject: owner, origin: owner ? 'user' : 'system', occurred_at: when, 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);
++ let n = 0;
++ const MAX_CREATED = 25;
++ for (const p of people) {
++ if (n >= MAX_CREATED) break;
++ const who = subject(p.subject);
++ if (!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;
++ }
++
++ 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 },
++ ];
++
++ 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
iindex ff6ff79..0018da8 100644
---- a/server/pulse/store.js
++++ b/server/pulse/store.js
@@@ -58,12 +58,23 @@ async function upsertItem(db, it) {
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)];
-- if (await find.get(...key)) {
-- await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, app_name = ? WHERE source_service = ? AND source_type = ? AND source_id = ?').run(it.title, it.url, it.picture_url || null, it.app_name || null, ...key);
++ 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 };
++ if (it.touch) {
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, app_name = ?, text = ?, occurred_at = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
++ .run(it.title, it.url, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), it.occurred_at, ...key);
++ } else {
++ await db.prepare('UPDATE pulse_items SET title = ?, url = ?, picture_url = ?, app_name = ?, text = ? WHERE source_service = ? AND source_type = ? AND source_id = ?')
++ .run(it.title, it.url, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300), ...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, app_name)
-- VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at, it.picture_url || null, it.app_name || null);
++ await db.prepare(`INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at, picture_url, app_name, text)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?, ?, ?, ?)`).run(...key, it.title, it.url, it.actor_subject || null, it.origin, it.occurred_at, it.picture_url || null, it.app_name || null, it.text == null ? null : String(it.text).slice(0, 300));
return { item: await find.get(...key), created: true };
});
}
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 8b733d4..0a2b6a3 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -221,7 +221,7 @@ function crosspostBlock(c, base) {
/** A post's images. */
function postImages(p) {
-- return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;
++ return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.alt || a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;
}
/**
* A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 3236b66..8230e68 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -25,6 +25,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
taskKeyed: ['spaces', 'comment_threads'],
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Community applied',
++ pulse_quest_people: 'when the Quest badge scan last checked a person: exported as quest_checks.json and erased with them',
share_members: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
share_access_requests: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
},
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex f05f8d6..279daaf 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -1,8 +1,9 @@
'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
-- * thread is a post, a Live clip a clip), a kind filter, the network's trending pictures woven into the first page (from
-- * Network's Frame, https and pictures only), and the shape openvibe-shared's ov-home.js feed() draws.
++ * 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');
@@@ -15,35 +16,95 @@ const FRAME = { cards: [
] },
{ 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: 'ada' }] };
--const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url)), json: async () => (/\/api\/streams/.test(String(url)) ? LIVE : FRAME) });
++const LIVE = { streams: [{ username: 'alex' }, { username: 'ada' }] };
++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 } });
++ 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 } } });
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 pastes = t.app.locals.pastes;
-- const svc = net.signService({ cap: ['community.pulse.write'] });
++ const netSvc = net.signService({ cap: ['community.pulse.write'] });
-- await pastes.createText(asAlex, { title: 'Public one', content: 'x' });
-- const clip = await t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },
++ // 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' }) });
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', 'Event: 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);
++ }
++
++ // 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');
++ const r = await t.get('/api/v1/feed?limit=40');
assert.strictEqual(r.status, 200, r.text);
const j = r.json();
-- const kinds = j.items.map((x) => x.kind);
-- assert.ok(kinds.includes('clip') && kinds.includes('post'), `a clip and a post (${kinds})`);
-- const c = j.items.find((x) => x.kind === 'clip');
-- assert.strictEqual(c.title, 'Great clip');
++ const kinds = new Set(j.items.map((x) => x.kind));
++ for (const k of ['live', 'clip', 'video', 'pic', 'post', '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.title === 'Great clip');
assert.strictEqual(c.site, 'live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.actor.name, 'Alex');
-- assert.ok('next_cursor' in j);
-- for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'site', 'site_name', 'counts']) assert.ok(k in x, `${k} on every card`);
++ });
++
++ 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.kind, 'post');
++ assert.strictEqual(p.text, 'The first line of a public 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 () => {
@@@ -55,21 +116,38 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url
});
await check('LIVE only for a channel that is live now', async () => {
-- const mk = (id, who) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },
++ const mk = (id, who) => 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: a stream`, url: `https://openvibe.live/@${who}` }) });
assert.strictEqual((await mk('s1', 'ada')).status, 201);
assert.strictEqual((await mk('s2', 'bo')).status, 201);
-- const j = (await t.get('/api/v1/feed?kind=live')).json();
++ const j = (await t.get('/api/v1/feed?kind=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');
});
-- await check('a kind filters the stream', async () => {
-- const j = (await t.get('/api/v1/feed?kind=clip')).json();
-- assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));
-- const none = (await t.get('/api/v1/feed?kind=meme')).json();
-- assert.deepStrictEqual(none.items, []);
++ 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('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {
@@@ -86,5 +164,52 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url
assert.ok(!write.headers.get('access-control-allow-origin'), 'a write from elsewhere gets no CORS');
});
++ // ── 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 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/helpers/mock-media.js b/test/helpers/mock-media.js
iindex 8d2f9bc..0706dda 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -46,6 +46,18 @@ function start({ publicPem, issuer }) {
deletes.push(del[1]);
return json(200, { id: del[1], lifecycle_status: 'deleted' });
}
++ // GET /api/v2/community/objects/:id — the metadata Community reads to check a picture the person uploaded.
++ const meta = req.url.match(/^\/api\/v2\/community\/objects\/(med_[0-9A-Z]+)(?:\?.*)?$/);
++ if (meta && req.method === 'GET') {
++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('media.object.read')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ const o = uploads.find((u) => u.key === meta[1]);
++ if (!o) return json(404, { code: 'media.object.not_found' });
++ return json(200, { id: o.key, owner: { subject: o.owner || null, app: null }, lifecycle_status: 'ready',
++ mime_type: o.mime, size_bytes: (o.bytes || Buffer.alloc(0)).length, public_url: `http://127.0.0.1:${server.address().port}/o/${o.key}`,
++ metadata: { filename: o.name } });
++ }
const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete|download))?(?:\?.*)?$/);
if (!v2 && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex 30184fc..eb529dd 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -24,6 +24,7 @@ function start({ clientSecret = 'shh' } = {}) {
const keyCalls = []; // GET /api/v1/people/:subject/keys: who asked, with which token (the Locker's directory)
const lockerKeys = {}; // subject id → { subject, ecdh_public, signing_public, fingerprint }
const issued = []; // every client-credentials access token handed out (security-secrets.test.js looks for them)
++ const follows = new Map(); // follower subject → [{ target_type, target_id }]
let issuer = 'http://network.test';
// Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
@@@ -66,6 +67,19 @@ function start({ clientSecret = 'shh' } = {}) {
return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
}
if (req.url === '/oauth/revoke') return json(200, { ok: true });
++ // GET /api/v1/me/follows?type=&cursor=&limit= — what the caller follows (the Feed's following filter).
++ if (req.url.startsWith('/api/v1/me/follows') && req.method === 'GET') {
++ let sub = null;
++ try { sub = jwt.verify(String(req.headers.authorization || '').slice(7), publicPem, { algorithms: ['RS256'], issuer }).sub; } catch { /* anonymous */ }
++ if (!sub) return json(401, { code: 'auth.required', error: 'sign in' });
++ const q = new URL(req.url, 'http://x').searchParams;
++ const type = q.get('type');
++ // A person token may name its Network id in `sub` (a legacy id): follow the subject it resolves to.
++ const person = directory.users.find((u) => String(u.network_user_id) === String(sub) || u.subject_id === String(sub));
++ const items = (follows.get(person ? person.subject_id : String(sub)) || []).filter((f) => !type || f.target_type === type)
++ .map((f) => ({ ...f, active: true, created_at: new Date().toISOString() }));
++ return json(200, { items, next_cursor: null });
++ }
// The Locker key directory: a PERSON route (Network server/locker/keys.js), never a service token.
const keyPath = /^\/api\/v1\/people\/([^/]+)\/keys$/.exec(req.url);
if (keyPath && req.method === 'GET') {
@@@ -139,6 +153,11 @@ function start({ clientSecret = 'shh' } = {}) {
issuer = url;
resolve({
url, grants, issued, resolveCalls, pushes, keyCalls, directory, addUser, sign, signService, publicPem,
++ addFollow: (follower, targetType, targetId) => {
++ const list = follows.get(follower) || [];
++ if (!list.some((f) => f.target_type === targetType && f.target_id === targetId)) list.push({ target_type: targetType, target_id: targetId });
++ follows.set(follower, list);
++ },
setKeys: (subjectId, keys) => { lockerKeys[subjectId] = keys; },
close: () => new Promise((r) => server.close(r)),
});
ddiff --git a/test/pulse-consumer.test.js b/test/pulse-consumer.test.js
iindex 49620f9..f18ba7a 100644
---- a/test/pulse-consumer.test.js
++++ b/test/pulse-consumer.test.js
@@@ -135,6 +135,42 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
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');
++ 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');
++
srv.close();
console.log('pulse consumer: all checks passed');
process.exit(0);
ddiff --git a/test/pulse-sources.test.js b/test/pulse-sources.test.js
nnew file mode 100644
iindex 0000000..64ebf06
---- /dev/null
++++ b/test/pulse-sources.test.js
@@@ -0,0 +1,89 @@
++'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 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('/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) },
++ ] }) };
++ 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);
++ const feed = (await t.get('/api/v1/feed?limit=40')).json();
++ const ev = feed.items.find((x) => x.kind === 'event');
++ assert.strictEqual(ev.title, 'Event: Launch party');
++ assert.strictEqual(ev.text, '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) => /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(), ['network-members', 'quest-badges', 'today-events']);
++ assert.strictEqual(status.runs.length, 3);
++ 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('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 done(t);
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 6b73ae1..df2115a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -37,6 +37,7 @@ const INVENTORY = {
'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/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'],