openvibers/openvibe.community Commits
Widen the Feed: every kind, picture and text, counts and filters (#310)
Changes
@@@ -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 boardis `/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 annew file mode 100644@@@ -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++);@@@ -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;@@@ -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: {@@@ -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 || {})));@@@ -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, viif (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, viconst 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, vithreadLimiter.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).@@@ -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;@@@ -63,6 +63,14 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkif (reviewRetry.start()) console.log('[Community] paste review retry: on');} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }++ // Pulse's due-based public sources (Today's calendar, Network's open stats, Quest badges): one minute check,++ // each source's next run kept in pulse_source_runs so a restart does not skip it (server/pulse/sources.js).++ let pulseSources = null;++ try {++ pulseSources = app.locals.pulseSources || null;++ if (pulseSources) { pulseSources.start(); console.log('[Pulse sources] on: today-events, network-members, quest-badges'); }++ } catch (err) { console.warn('[Pulse sources] not started:', err.message); }++// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps@@@ -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),@@@ -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 };@@@ -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' })],@@@ -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.EVENreturn { 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 };@@@ -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 };@@@ -56,14 +56,19 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.freturn 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.flabel: 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.freturn {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) {nnew file mode 100644@@@ -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 };@@@ -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 };});}@@@ -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,@@@ -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)',},@@@ -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(urlassert.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);})();@@@ -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' });@@@ -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)),});@@@ -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);nnew file mode 100644@@@ -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);++})();@@@ -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'],
@@ -2,6 +2,21 @@
@@ -2,6 +2,21 @@
## Unreleased
## 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
- **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);
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
`/forum/new` and `/forum/discuss` replace `/s/new-space` and `/s/discuss`. Every old `/s` address answers a
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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
+);
@@ -149,6 +149,12 @@ async function createApp(opts = {}) {
@@ -149,6 +149,12 @@ async function createApp(opts = {}) {
await revocations.load();
await revocations.load();
const viewers = createViewerResolver({ auth, config, network, revocations });
const viewers = createViewerResolver({ auth, config, network, revocations });
const pulse = createPulse({ db, network, config });
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({
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
db, config, enabled: config.discordRelay.enabled,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
@@ -312,7 +318,7 @@ async function createApp(opts = {}) {
@@ -312,7 +318,7 @@ async function createApp(opts = {}) {
const readCors = v1.publicReadCors(config.apiCorsOrigins);
const readCors = v1.publicReadCors(config.apiCorsOrigins);
app.use('/api/v1/pulse', readCors, createPulseApi({ pulse, viewers, limits }));
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).
// 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/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
@@ -322,7 +328,7 @@ async function createApp(opts = {}) {
@@ -322,7 +328,7 @@ async function createApp(opts = {}) {
// and these deliveries carry token cutoffs and account deletions.
// and these deliveries carry token cutoffs and account deletions.
app.use('/internal/events', pulseConsumer.router);
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
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/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, 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;
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
@@ -46,6 +46,12 @@ module.exports = {
@@ -46,6 +46,12 @@ module.exports = {
// OpenVibe.Search's query API (the /search page), asked anonymously: public documents only.
// 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(/\/$/, ''),
searchInternalUrl: (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/$/, ''),
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').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:
// 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.
// no review runs and image pastes keep their old behaviour.
vision: {
vision: {
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
const express = require('express');
const express = require('express');
const multer = require('multer');
const multer = require('multer');
const contracts = require('openvibe-contracts');
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 POST = 'community.post.create';
const MOD = 'community.comment.moderate';
const MOD = 'community.comment.moderate';
@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
return router;
return router;
}
}
-function createPostsApi({ forum, viewers, limits }) {
+function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -124,6 +124,35 @@ function createPostsApi({ forum, viewers, limits }) {
@@ -124,6 +124,35 @@ function createPostsApi({ forum, viewers, limits }) {
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
// Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
router.use(limits.reads('community.forum.read'));
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.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.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 || {})));
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 || {})));
@@ -269,7 +269,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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. */
/** post id → [attachment], in order. */
async function attachmentsOf(postIds) {
async function attachmentsOf(postIds) {
const out = new Map();
const out = new Map();
@@ -289,6 +289,50 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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');
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;
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) {
async function claimPastes(refs) {
const slugs = parsePasteRefs(refs);
const slugs = parsePasteRefs(refs);
if (slugs.length > PASTE_MAX) fail(400, 'pastes.too_many', `At most ${PASTE_MAX} pastes per post`);
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
@@ -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);
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(', ')}`);
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 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);
const pasteRows = await claimPastes(body.pastes);
if (w.key) {
if (w.key) {
threadLimiter.check(w.key, title);
threadLimiter.check(w.key, title);
@@ -954,7 +1000,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -954,7 +1000,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
await attach(images, post.id);
await attach(images, post.id);
await attachPastes(pasteRows, 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));
if (relay) await hook(async () => await relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
pingThread(space, thread, { withSpace: true });
pingThread(space, thread, { withSpace: true });
@@ -971,14 +1017,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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.
// Platform blocks: nobody replies in a thread whose author blocked them.
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
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 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);
const pasteRows = await claimPastes(body.pastes);
postLimiter.check(w.key, text);
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 });
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);
postLimiter.record(w.key, text);
await attach(images, post.id);
await attach(images, post.id);
await attachPastes(pasteRows, 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));
if (relay) await hook(async () => await relay.enqueuePost(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
// Where the new post lands: its page in the thread (posts are numbered in id order).
@@ -38,7 +38,7 @@ const EXPORTS = [
@@ -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'],
['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'],
['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'],
['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) {
async function exportPart(db, subject) {
@@ -47,7 +47,8 @@ async function exportPart(db, subject) {
@@ -47,7 +47,8 @@ async function exportPart(db, subject) {
for (const [name, table, col] of EXPORTS) {
for (const [name, table, col] of EXPORTS) {
if (!await hasColumn(db, table, col)) continue;
if (!await hasColumn(db, table, col)) continue;
// Newest first: by id where the table has one, else by its timestamp.
// 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);
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) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
if (rows.length > ROW_LIMIT) truncated.push(name);
@@ -172,7 +173,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -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);
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);
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'],
['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']]) {
['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;
if (!await hasTable(db, table)) continue;
@@ -63,6 +63,14 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -63,6 +63,14 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
if (reviewRetry.start()) console.log('[Community] paste review retry: on');
if (reviewRetry.start()) console.log('[Community] paste review retry: on');
} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }
} catch (err) { console.warn('[Pastes] review retry not started:', err.message); }
+ // Pulse's due-based public sources (Today's calendar, Network's open stats, Quest badges): one minute check,
+ // each source's next run kept in pulse_source_runs so a restart does not skip it (server/pulse/sources.js).
+ let pulseSources = null;
+ try {
+ pulseSources = app.locals.pulseSources || null;
+ if (pulseSources) { pulseSources.start(); console.log('[Pulse sources] on: today-events, network-members, quest-badges'); }
+ } catch (err) { console.warn('[Pulse sources] not started:', err.message); }
+
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
// stop (nothing new starts); the server stops taking connections, closes idle keep-alive ones (it keeps
@@ -80,6 +88,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -80,6 +88,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (profiles) profilesDone = profiles.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
() => { if (searchDocs) searchDocs.stop(); },
() => { if (reviewRetry) reviewRetry.stop(); },
() => { if (reviewRetry) reviewRetry.stop(); },
+ () => { if (pulseSources) pulseSources.stop(); },
],
],
close: [
close: [
() => within(1000, profilesDone),
() => within(1000, profilesDone),
@@ -68,6 +68,11 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -68,6 +68,11 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
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
* 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.
* object. Used by Community's screenshot proxy, which checks canView before asking.
@@ -108,7 +113,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -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 { 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 };
module.exports = { createMediaObjects };
@@ -355,6 +355,21 @@ route('delete', '/api/v1/pulse/items/{service}/{type}/{id}', {
@@ -355,6 +355,21 @@ route('delete', '/api/v1/pulse/items/{service}/{type}/{id}', {
description: 'community.pulse.write for services, community.pulse.contribute for apps.',
description: 'community.pulse.write for services, community.pulse.contribute for apps.',
responses: { 200: json('Whether it was there'), ...errors() },
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', {
route('post', '/api/v1/me/status', {
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
operationId: 'postStatus', summary: 'Post a public status to your profile and Pulse', tags: ['pulse'], security: PERSON,
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
requestBody: body({ type: 'object', required: ['text'], properties: { text: str({ minLength: 1, maxLength: 500 }), link: str({ format: 'uri' }) } }),
@@ -565,6 +580,15 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
@@ -565,6 +580,15 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
});
});
// ── /api/v1/posts — one forum post (server/forum/api.js) ────────────────────────────────────
// ── /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}', {
route('put', '/api/v1/posts/{id}', {
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
parameters: [path('id', { type: 'string' })],
parameters: [path('id', { type: 'string' })],
@@ -37,7 +37,7 @@ const store = require('./store');
@@ -37,7 +37,7 @@ const store = require('./store');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const CONSUMER = 'community';
const CONSUMER = 'community';
-const TOPICS = Object.freeze(['live.stream.started', '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_NAMESPACE = 'games.progress.summary';
const GAME_MILESTONE = 5;
const GAME_MILESTONE = 5;
const GAME_URL = 'https://openvibe.games/';
const GAME_URL = 'https://openvibe.games/';
@@ -61,7 +61,7 @@ function itemFor(event) {
@@ -61,7 +61,7 @@ function itemFor(event) {
const title = clean(p.title, 120);
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 };
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];
const k = kinds[event.event_type];
if (!k) return 'ignored:type';
if (!k) return 'ignored:type';
if (event.source !== k[0]) return 'ignored:source';
if (event.source !== k[0]) return 'ignored:source';
@@ -74,7 +74,21 @@ function itemFor(event) {
@@ -74,7 +74,21 @@ function itemFor(event) {
: k[0] === 'wiki' ? `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`
: k[0] === 'wiki' ? `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`
: `News: ${clean(p.topic, 60) || 'a new story'}`;
: `News: ${clean(p.topic, 60) || 'a new story'}`;
const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;
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. */
/** 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 =
@@ -107,6 +121,25 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
// Receipts (community_event_inbox) are in migrations/0001_initial.sql.
// Receipts (community_event_inbox) are in migrations/0001_initial.sql.
const inbox = createPgInbox(db, { table: 'community_event_inbox', now });
const inbox = createPgInbox(db, { table: 'community_event_inbox', now });
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
+ // A public room's messages are counted per UTC hour; at five the hour becomes one Chat card, refreshed every
+ // five messages after that. This is in memory on purpose: a restart only starts counting the current hour again.
+ const chatHours = new Map();
+ const CHAT_BUSY_MESSAGES = 5;
+ async function applyChatMessage(c) {
+ const hour = new Date(c.at).toISOString().slice(0, 13);
+ const key = `${c.slug}:${hour}`;
+ const n = (chatHours.get(key) || 0) + 1;
+ chatHours.set(key, n);
+ if (chatHours.size > 5000) chatHours.delete(chatHours.keys().next().value);
+ if (n < CHAT_BUSY_MESSAGES || (n !== CHAT_BUSY_MESSAGES && n % 5 !== 0)) return 'chat:counted';
+ const out = await store.upsertItem(db, {
+ source_service: 'chat', source_type: 'hour', source_id: key,
+ title: `#${c.slug}: ${n} messages this hour`,
+ url: `https://openvibe.chat/rooms/${encodeURIComponent(c.slug)}`,
+ actor_subject: null, origin: 'system', occurred_at: `${hour}:00:00Z`, text: '',
+ });
+ return out.created ? 'pulse:created' : 'pulse:updated';
+ }
const router = express.Router();
const router = express.Router();
router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const ctx = http.requestContext(req.headers);
const ctx = http.requestContext(req.headers);
@@ -186,6 +219,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
@@ -186,6 +219,19 @@ function createPulseConsumer({ db, secrets = [], vipCache = null, revocations =
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
return problem(500, 'community.event_failed', 'processing failed; it will be retried');
}
}
}
}
+ if (event.event_type === '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);
const item = itemFor(event);
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
if (typeof item === 'string') { stats.ignored++; return res.json({ event_id: event.event_id, duplicate: false, outcome: item }); }
try {
try {
@@ -238,4 +284,4 @@ function startSubscriptions({ config, port, secret, eventsUrl = process.env.EVEN
@@ -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; } };
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 };
@@ -4,20 +4,27 @@
@@ -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
* 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
* 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,
* 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,
* → { 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.
* 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 express = require('express');
const contracts = require('openvibe-contracts');
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 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);
const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
@@ -31,6 +38,11 @@ function kindOf(i) {
@@ -31,6 +38,11 @@ function kindOf(i) {
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
if (s === 'meme' || t === 'meme') return 'meme';
if (s === 'meme' || t === 'meme') return 'meme';
if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';
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';
if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';
return 'post';
return 'post';
}
}
@@ -43,8 +55,8 @@ function card(i) {
@@ -43,8 +55,8 @@ function card(i) {
kind: kindOf(i),
kind: kindOf(i),
url: https(i.url) || i.url,
url: https(i.url) || i.url,
title: clean(i.title, 300),
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,
site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
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,
actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
@@ -54,6 +66,57 @@ function card(i) {
@@ -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. */
/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */
function createTrending({ networkUrl, fetchImpl }) {
function createTrending({ networkUrl, fetchImpl }) {
let cache = null; let at = 0;
let cache = null; let at = 0;
@@ -96,7 +159,7 @@ function createLiveNow({ liveUrl, fetchImpl }) {
@@ -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 ''; } };
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();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -104,35 +167,53 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
@@ -104,35 +167,53 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
const trending = createTrending({ networkUrl, fetchImpl });
const trending = createTrending({ networkUrl, fetchImpl });
const liveNow = createLiveNow({ liveUrl, fetchImpl });
const liveNow = createLiveNow({ liveUrl, fetchImpl });
// A "went live" item is live now only while that channel is on Live's live list.
// 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'; } });
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) => {
router.get('/', run(async (req) => {
const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
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 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 cursor = req.query.cursor ? String(req.query.cursor) : '';
let next = null;
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);
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;
next = page.next_cursor || null;
if (!next) break;
if (!next) break;
cursor = next;
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.
// 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) {
if (t.length) {
const woven = [];
const woven = [];
let k = 0;
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++]);
while (woven.length < want && k < t.length) woven.push(t[k++]);
mark(woven, await liveNow());
mark(woven, await liveNow());
return { items: woven, next_cursor: next };
return { items: woven, next_cursor: next };
}
}
}
}
+ const items = picked.map((p) => p.card);
mark(items, await liveNow());
mark(items, await liveNow());
return { items, next_cursor: next };
return { items, next_cursor: next };
}));
}));
@@ -140,4 +221,4 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
@@ -140,4 +221,4 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
return router;
return router;
}
}
-module.exports = { createFeedApi, kindOf, card, channelOf };
+module.exports = { createFeedApi, kindOf, card, countsFor, channelOf };
@@ -56,14 +56,19 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -56,14 +56,19 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
return ids;
return ids;
}
}
+ /** The feed's short text: the first 280 characters, one line, never a whole post. */
+ const feedText = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 280);
+ const picture = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
+
/** Record one of Community's own items; never lets a Pulse problem fail the write that caused it. */
/** 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 {
try {
const o = ORIGINS.includes(origin) ? origin : 'user';
const o = ORIGINS.includes(origin) ? origin : 'user';
await store.upsertItem(db, {
await store.upsertItem(db, {
source_service: 'community', source_type: type, source_id: String(id),
source_service: 'community', source_type: type, source_id: String(id),
title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
title: String(title || 'Untitled').slice(0, 300), url: `${base}${path}`,
actor_subject: o === 'user' ? actor : null, origin: o, occurred_at: at || sqlTime(),
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); }
} catch (err) { console.warn('[Pulse] record failed:', err.message); }
}
}
@@ -83,6 +88,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -83,6 +88,7 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
label: ai ? 'AI' : (i.origin === 'system' ? 'System' : null),
...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
...(i.source_service.startsWith('app:') ? { via: `via ${i.app_name || i.source_service.slice(4)}` } : {}),
...(i.picture_url ? { picture_url: i.picture_url } : {}),
...(i.picture_url ? { picture_url: i.picture_url } : {}),
+ ...(i.text ? { text: i.text } : {}),
actor,
actor,
occurred_at: isoTime(i.occurred_at),
occurred_at: isoTime(i.occurred_at),
};
};
@@ -91,33 +97,41 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
@@ -91,33 +97,41 @@ function createPulse({ db, network = null, config = {}, fetchImpl = globalThis.f
return {
return {
ORIGINS,
ORIGINS,
forgetLocal,
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}`,
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 ─────────────────
// ── hooks for Community's own writes ─────────────────
async pasteCreated(p) {
async pasteCreated(p) {
if (!p || p.visibility !== 'public' || p.origin !== 'user' || Number(p.burn_after_read) || Number(p.is_nsfw)) return;
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) {
async pasteChanged(p) {
if (!p) return;
if (!p) return;
if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);
if (p.deleted_at || p.visibility !== 'public' || Number(p.is_nsfw)) await forgetLocal('paste', p.slug);
},
},
async pasteGone(slug) { await forgetLocal('paste', 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;
if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
await recordLocal('thread', thread.id, {
await recordLocal('thread', thread.id, {
title: thread.title, path: `/forum/${space.slug}/${thread.slug}`,
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,
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;
if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
await recordLocal('post', post.id, {
await recordLocal('post', post.id, {
title: `Re: ${thread.title}`, path: `/forum/${space.slug}/${thread.slug}#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,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
+ text: post.body_markdown, pictureUrl: extra.picture,
});
});
},
},
async submissionAccepted(s) {
async submissionAccepted(s) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -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 };
@@ -58,12 +58,23 @@ async function upsertItem(db, it) {
@@ -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 = ?');
const find = db.prepare('SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?');
return await db.tx(async () => {
return await db.tx(async () => {
const key = [it.source_service, it.source_type, String(it.source_id)];
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 };
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 };
return { item: await find.get(...key), created: true };
});
});
}
}
@@ -221,7 +221,7 @@ function crosspostBlock(c, base) {
@@ -221,7 +221,7 @@ function crosspostBlock(c, base) {
/** A post's images. */
/** A post's images. */
function postImages(p) {
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,
* A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
@@ -25,6 +25,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -25,6 +25,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
taskKeyed: ['spaces', 'comment_threads'],
taskKeyed: ['spaces', 'comment_threads'],
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Community applied',
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_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)',
share_access_requests: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
},
},
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'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 assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -15,35 +16,95 @@ const FRAME = { cards: [
@@ -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' } }] },
{ 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 () => {
(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 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 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 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' }) });
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);
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 () => {
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);
assert.strictEqual(r.status, 200, r.text);
const j = r.json();
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, 'live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.site_name, 'Live');
assert.strictEqual(c.actor.name, 'Alex');
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 () => {
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
@@ -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 () => {
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}` }) });
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('s1', 'ada')).status, 201);
assert.strictEqual((await mk('s2', 'bo')).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]]));
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/@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');
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 () => {
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
@@ -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');
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);
await done(t);
})();
})();
@@ -46,6 +46,18 @@ function start({ publicPem, issuer }) {
@@ -46,6 +46,18 @@ function start({ publicPem, issuer }) {
deletes.push(del[1]);
deletes.push(del[1]);
return json(200, { id: del[1], lifecycle_status: 'deleted' });
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))?(?:\?.*)?$/);
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' });
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' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
@@ -24,6 +24,7 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -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 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 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 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';
let issuer = 'http://network.test';
// Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
// Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
@@ -66,6 +67,19 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -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' } });
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 });
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.
// 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);
const keyPath = /^\/api\/v1\/people\/([^/]+)\/keys$/.exec(req.url);
if (keyPath && req.method === 'GET') {
if (keyPath && req.method === 'GET') {
@@ -139,6 +153,11 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -139,6 +153,11 @@ function start({ clientSecret = 'shh' } = {}) {
issuer = url;
issuer = url;
resolve({
resolve({
url, grants, issued, resolveCalls, pushes, keyCalls, directory, addUser, sign, signService, publicPem,
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; },
setKeys: (subjectId, keys) => { lockerKeys[subjectId] = keys; },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),
});
});
@@ -135,6 +135,42 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -135,6 +135,42 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
const feed = await store.listItems(db, { limit: 100 });
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.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');
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();
srv.close();
console.log('pulse consumer: all checks passed');
console.log('pulse consumer: all checks passed');
process.exit(0);
process.exit(0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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);
+})();
@@ -37,6 +37,7 @@ const INVENTORY = {
@@ -37,6 +37,7 @@ const INVENTORY = {
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
'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/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/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-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],
'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],