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
Changes
@@@ -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",@@@ -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"},nnew file mode 100644@@@ -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 };@@@ -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`);nnew file mode 100644@@@ -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); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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 };
@@ -22,6 +22,8 @@ try {
@@ -22,6 +22,8 @@ try {
const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();
const secret = String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',')[0].trim();
require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });
require('./pulse/consumer').startSubscriptions({ config, port: config.port, secret });
} catch (err) { console.warn('[Pulse consumer] not subscribed:', err.message); }
} 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) {
function shutdown(signal) {
console.log(`[Community] ${signal} — closing`);
console.log(`[Community] ${signal} — closing`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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); });