openvibers/openvibe.community Commits

community.profile on Network (Contracts 0.41.0 user module, WS-B task 9): threads, replies, comments and public pastes a person wrote (deleted, AI and private not counted), first and last activity; a 5-minute scan of authors whose rows changed writes it as the owning service, only when it changed

OP OpenVibers committed 25 Sept 2026 · 8be21c2461 · 5 files, +192 −5


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 0ccafee..affb0df 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
@@@ -1057,9 +1057,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.39.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
-- "integrity": "sha512-ek3tryU3pPwmFSg621gzr4ss1OE/gpufBnxk9XFDLSfUe2RuZpYVZCJNdQ0F/9GfA83Xop+xJW8+IjRyjFg6Yg==",
++ "version": "0.41.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 167f60b..cf1d9e8 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
ddiff --git a/server/identity/profile-module.js b/server/identity/profile-module.js
nnew file mode 100644
iindex 0000000..7ed7eac
---- /dev/null
++++ b/server/identity/profile-module.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * community.profile on OpenVibe.Network (openvibe-contracts 0.41.0 user module, roadmap WS-B task 9):
++ * a person's Community activity for other sites — threads, posts (replies; a thread's opening post is its
++ * thread), comments and pastes they wrote and did not delete, and when they were first and last active.
++ * Community's rows stay the truth. Written as the owning service (grant community network.modules.write on
++ * community.profile), unconditionally, and only when it changed (profile_module_pushes keeps a hash).
++ *
++ * scan() every 5 minutes: authors with a thread, post, comment or paste created, edited or deleted
++ * since the previous scan
++ *
++ * Off without the OAuth client secret or with COMMUNITY_PROFILE_MODULE=off; only usr_ subjects.
++ */
++const crypto = require('crypto');
++
++const NS = 'community.profile';
++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SCAN_MS = 5 * 60 * 1000;
++
++const toIso = (v) => {
++ if (!v) return null;
++ const d = new Date(String(v).includes('T') ? v : `${String(v).replace(' ', 'T')}Z`);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++};
++const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
++
++function ensureSchema(db) {
++ db.exec(`CREATE TABLE IF NOT EXISTS profile_module_pushes (
++ subject_id TEXT PRIMARY KEY,
++ hash TEXT NOT NULL,
++ pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++}
++
++/** The record for one person, or null when they wrote nothing. */
++function summarize(db, subject) {
++ const one = (sql) => db.prepare(sql).get(subject);
++ const threads = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
++ const posts = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'");
++ const comments = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'");
++ const pastes = one("SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'");
++ const all = [threads, posts, comments, pastes];
++ if (!all.some((r) => r.n)) return null;
++ const firsts = all.map((r) => r.first).filter(Boolean).sort();
++ const lasts = all.map((r) => r.last).filter(Boolean).sort();
++ return {
++ threads: threads.n, posts: posts.n, comments: comments.n, pastes: pastes.n,
++ first_active_at: toIso(firsts[0]), last_active_at: toIso(lasts[lasts.length - 1]),
++ };
++}
++
++function createProfileModule({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const secret = config && config.oauth && config.oauth.clientSecret;
++ const enabled = !!secret && String(process.env.COMMUNITY_PROFILE_MODULE || '').toLowerCase() !== 'off';
++ const stats = { written: 0, unchanged: 0, failed: 0, lastError: null };
++ let modules = null, lastScan = null, timer = null;
++
++ function client() {
++ if (modules) return modules;
++ const { createClient } = require('openvibe-sdk/core');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const { createModulesClient } = require('openvibe-sdk/modules');
++ const base = config.networkInternalUrl;
++ const tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId || 'community', clientSecret: secret, fetch: fetchImpl });
++ const core = createClient({ baseUrls: { network: base }, tokenProvider: tokens, retries: 1, fetch: fetchImpl });
++ modules = createModulesClient(core, { baseUrl: base }).forSubject;
++ return modules;
++ }
++
++ /** Write one person's record when it changed. → true when written */
++ async function push(subject) {
++ if (!enabled || !USR.test(String(subject || ''))) return false;
++ const data = summarize(db, subject);
++ if (!data) return false;
++ const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
++ const last = db.prepare('SELECT hash FROM profile_module_pushes WHERE subject_id = ?').get(subject);
++ if (last && last.hash === hash) { stats.unchanged++; return false; }
++ try { await client().put(NS, subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; return false; }
++ db.prepare(`INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash);
++ stats.written++;
++ return true;
++ }
++
++ /** Authors with something created, edited or deleted since the previous scan. → how many were looked at */
++ async function scan({ now = Date.now() } = {}) {
++ if (!enabled) return 0;
++ const from = lastScan || sqlTime(now - 10 * 60 * 1000);
++ const to = sqlTime(now);
++ const changed = (table, col, cols) => cols.map((c) => `SELECT ${col} AS s FROM ${table} WHERE ${c} >= @from AND ${c} < @to`).join(' UNION ');
++ const sql = [
++ changed('threads', 'author_subject', ['created_at', 'deleted_at']),
++ changed('posts', 'author_subject', ['created_at', 'updated_at', 'deleted_at']),
++ changed('comments', 'author_subject', ['created_at', 'updated_at', 'deleted_at']),
++ changed('pastes', 'owner_subject', ['created_at', 'updated_at', 'deleted_at']),
++ ].join(' UNION ');
++ const subjects = db.prepare(sql).all({ from, to }).map((r) => r.s).filter((s) => USR.test(String(s || '')));
++ for (const s of subjects) await push(s);
++ lastScan = to;
++ return subjects.length;
++ }
++
++ function start() {
++ if (!enabled || timer) return false;
++ ensureSchema(db);
++ timer = setInterval(() => { scan().catch((err) => { stats.lastError = err.message; }); }, SCAN_MS);
++ if (timer.unref) timer.unref();
++ log.log && log.log('[Modules] community.profile: scanning every 5 minutes');
++ return true;
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return { enabled, push, scan, start, stop, stats: () => ({ enabled, ...stats }) };
++}
++
++module.exports = { NS, ensureSchema, summarize, createProfileModule };
ddiff --git a/server/index.js b/server/index.js
iindex 771d20a..490c14c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -22,6 +22,8 @@ try {
const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();
require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });
} catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }
++// community.profile on Network (Contracts 0.41.0): a 5-minute scan of changed authors (off without the client secret).
++try { require('./identity/profile-module').createProfileModule({ db: require('./db').getDb(), config }).start(); } catch (err) { console.warn('[Modules] community.profile not started:', err.message); }
function shutdown(signal) {
console.log(`[Community] ${signal} — closing`);
ddiff --git a/test/profile-module.test.js b/test/profile-module.test.js
nnew file mode 100644
iindex 0000000..305ae15
---- /dev/null
++++ b/test/profile-module.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++// community.profile on Network (server/identity/profile-module.js, Contracts 0.41.0, WS-B task 9): the
++// person's threads, replies, comments and public pastes (deleted, AI and private ones not counted), first
++// and last activity; written with Community's service token through openvibe-sdk/modules only when it
++// changed; the scan finds authors whose rows were created, edited or deleted since the previous one.
++const assert = require('assert');
++const http = require('http');
++const { modules } = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const { summarize, createProfileModule, ensureSchema } = require('../server/identity/profile-module');
++
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
++const db = openDb(':memory:');
++ensureSchema(db);
++const now = Date.parse('2026-09-25T12:00:00Z');
++const at = (msAgo) => new Date(now - msAgo).toISOString().replace('T', ' ').slice(0, 19);
++const DAY = 86400000;
++
++const space = db.prepare("INSERT INTO spaces (slug, name) VALUES ('profile-test', 'Profile test')").run().lastInsertRowid;
++const thread = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, created_at) VALUES (?, ?, ?, ?, ?)').run(space, 'hello', 'Hello', ANN, at(10 * DAY)).lastInsertRowid;
++db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 1, ?, ?)').run(thread, ANN, 'hi', at(10 * DAY));
++db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at) VALUES (?, ?, 0, ?, ?)').run(thread, ANN, 'reply', at(2 * DAY));
++db.prepare('INSERT INTO posts (thread_id, author_subject, is_opening, body_markdown, created_at, deleted_at) VALUES (?, ?, 0, ?, ?, ?)').run(thread, ANN, 'gone', at(DAY), at(DAY));
++const ct = db.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', '1')").run().lastInsertRowid;
++db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, ANN, 'nice', at(3 * DAY));
++db.prepare("INSERT INTO comments (thread_id, author_subject, origin, message, created_at) VALUES (?, ?, 'ai', ?, ?)").run(ct, ANN, 'bot', at(3 * DAY));
++db.prepare("INSERT INTO pastes (slug, owner_subject, content, created_at) VALUES ('p1', ?, 'x', ?)").run(ANN, at(5 * DAY));
++db.prepare("INSERT INTO pastes (slug, owner_subject, content, visibility, created_at) VALUES ('p2', ?, 'x', 'private', ?)").run(ANN, at(5 * DAY));
++
++(async () => {
++ const s = summarize(db, ANN);
++ assert.deepStrictEqual(s, { threads: 1, posts: 1, comments: 1, pastes: 1, first_active_at: new Date(now - 10 * DAY).toISOString(), last_active_at: new Date(now - 2 * DAY).toISOString() });
++ assert.ok(modules.validateData('community.profile', s).valid, 'matches the namespace schema');
++ assert.strictEqual(summarize(db, BOB), null, 'nothing written: no record');
++
++ const puts = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ res.setHeader('content-type', 'application/json');
++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'svc', token_type: 'Bearer', expires_in: 300 }));
++ const m = req.url.match(/^\/internal\/modules\/community\.profile\/(usr_[0-9A-Z]+)$/);
++ assert.ok(m && req.method === 'PUT' && req.headers.authorization === 'Bearer svc', `${req.method} ${req.url}`);
++ puts.push({ subject: m[1], data: JSON.parse(body).data });
++ res.statusCode = 201; res.end(JSON.stringify({ revision: puts.length }));
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const config = { networkInternalUrl: `http://127.0.0.1:${server.address().port}`, oauth: { clientId: 'community', clientSecret: 'x'.repeat(40) } };
++ const mod = createProfileModule({ db, config, log: {} });
++ assert.ok(await mod.push(ANN));
++ assert.deepStrictEqual(puts[0], { subject: ANN, data: s });
++ assert.strictEqual(await mod.push(ANN), false, 'unchanged: not written again');
++ assert.strictEqual(await mod.push('gst_01JAB2C3D4E5F6G7H8J9K0MNPG'), false, 'guests have no record');
++
++ // The scan: Bob comments, Ann deletes her reply.
++ db.prepare('INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, ?, ?)').run(ct, BOB, 'first!', at(60000));
++ db.prepare("UPDATE posts SET deleted_at = ? WHERE body_markdown = 'reply'").run(at(30000));
++ assert.strictEqual(await mod.scan({ now }), 2);
++ assert.deepStrictEqual(puts.slice(1).map((p) => [p.subject, p.data.comments, p.data.posts]).sort(), [[ANN, 1, 0], [BOB, 1, 0]]);
++ assert.strictEqual(await mod.scan({ now: now + 5 * 60000 }), 0, 'the next scan starts where this one ended');
++
++ const off = createProfileModule({ db, config: { oauth: {} }, log: {} });
++ assert.strictEqual(off.enabled, false); assert.strictEqual(off.start(), false);
++ server.close();
++ console.log('community.profile: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });