openvibers/openvibe.wiki Commits

wiki.projects on Network (Contracts 0.41.0 user module, WS-B task 9): the spaces a person owns or edits (public ones named, others counted); a space or role change marks the people concerned in the same transaction (a removed role too) and a one-minute drain writes their record as the owning service, only when it changed

OP OpenVibers committed 25 Sept 2026 · 1c94ea3df6 · 7 files, +162 −5


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex ffab53e..d80ab5c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
@@@ -999,9 +999,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.38.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
-- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
++ "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 fa00f03..fb79967 100644
---- a/package.json
++++ b/package.json
@@@ -20,7 +20,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
ddiff --git a/server/db.js b/server/db.js
iindex eca680b..4aa7efb 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -106,6 +106,18 @@ CREATE TABLE IF NOT EXISTS wiki_permissions (
);
CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
++-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as
++-- the space or role change, drained by server/integrations/projects-module.js.
++CREATE TABLE IF NOT EXISTS wiki_module_dirty (
++ subject TEXT PRIMARY KEY,
++ marked_at INTEGER NOT NULL
++);
++CREATE TABLE IF NOT EXISTS wiki_module_pushes (
++ subject TEXT PRIMARY KEY,
++ hash TEXT NOT NULL,
++ pushed_at INTEGER NOT NULL
++);
++
CREATE TABLE IF NOT EXISTS wiki_watchers (
page_id TEXT NOT NULL REFERENCES wiki_pages(id),
subject TEXT NOT NULL,
ddiff --git a/server/index.js b/server/index.js
iindex fbed931..01279e7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -44,6 +44,9 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
}
if (platform.eventsConfigured) platform.outbox.start();
++ // wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.
++ const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });
++ if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));
for (const t of timers) if (t.unref) t.unref();
}
ddiff --git a/server/integrations/projects-module.js b/server/integrations/projects-module.js
nnew file mode 100644
iindex 0000000..d46a3e9
---- /dev/null
++++ b/server/integrations/projects-module.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * wiki.projects on OpenVibe.Network (openvibe-contracts 0.41.0 user module, roadmap WS-B task 9): the
++ * spaces a person owns or edits, for other sites. Only public spaces are named; the others are counted
++ * (private_count). Wiki's spaces and roles stay the truth.
++ *
++ * A space or role change marks the people concerned in wiki_module_dirty inside its own transaction
++ * (server/wiki/service.js); drain() writes each marked person's record as the owning service (grant wiki
++ * network.modules.write on wiki.projects) and clears the mark. Written only when it changed
++ * (wiki_module_pushes keeps a hash); someone who never had a space gets no record. Off without the
++ * service principal; a failed write keeps the mark for the next drain.
++ */
++const crypto = require('crypto');
++
++const NS = 'wiki.projects';
++const MAX_SPACES = 50;
++
++/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */
++function summarize(db, subject) {
++ const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,
++ CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role
++ FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject
++ WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))
++ ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });
++ const pub = rows.filter((r) => r.visibility === 'public');
++ return {
++ spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),
++ private_count: rows.length - pub.length,
++ };
++}
++
++function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ const enabled = !!tokens;
++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
++ const stats = { written: 0, unchanged: 0, failed: 0, lastError: null };
++ let draining = false;
++
++ async function put(subject, data) {
++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders({ audience: 'openvibe.network' })) };
++ const res = await fetchImpl(`${base}/internal/modules/${NS}/${encodeURIComponent(subject)}`, { method: 'PUT', headers, body: JSON.stringify({ data }), signal: AbortSignal.timeout(8000) });
++ if (res.status === 401) tokens.invalidate({ audience: 'openvibe.network' });
++ if (!res.ok) throw new Error(`Network answered ${res.status}`);
++ }
++
++ /** Write every marked person's record. → how many were written */
++ async function drain({ limit = 200 } = {}) {
++ if (!enabled || draining) return 0;
++ draining = true;
++ let written = 0;
++ try {
++ for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {
++ const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);
++ const data = summarize(db, subject);
++ const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
++ const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
++ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }
++ try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }
++ db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
++ ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());
++ clear();
++ stats.written++; written++;
++ }
++ } finally { draining = false; }
++ return written;
++ }
++
++ return { enabled, drain, stats: () => ({ enabled, ...stats }) };
++}
++
++module.exports = { NS, summarize, createProjectsModule };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex b1dbddf..eda312d 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -99,6 +99,12 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
putRole: db.prepare(`INSERT INTO wiki_permissions (space_id, subject, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
++ // wiki.projects (Network user module): who needs their record written again.
++ dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),
++ dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)
++ SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
++ UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
++ ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
@@@ -249,6 +255,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
function emit(envelope) { return outbox.enqueue(envelope); }
function spaceEvent(space, actor, change) {
++ q.dirtySpace.run({ id: space.id, t: now() });
emit({
event_type: 'wiki.space.updated',
actor: hooks.subjectRef(actorId(actor)),
@@@ -572,6 +579,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
if (role == null) q.delRole.run(space.id, subject);
else q.putRole.run(space.id, subject, role, actorId(actor), now());
++ q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
spaceEvent(space, actor, 'roles');
return svc.roles(space.id, actor);
});
ddiff --git a/test/projects-module.test.js b/test/projects-module.test.js
nnew file mode 100644
iindex 0000000..c2d415c
---- /dev/null
++++ b/test/projects-module.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * wiki.projects on Network (server/integrations/projects-module.js, Contracts 0.41.0, WS-B task 9):
++ * creating a space, granting or removing a role and changing a space mark the people concerned in the
++ * same transaction; drain() writes their record (public spaces named, others counted) as the owning
++ * service, only when it changed, and clears the mark; viewers and people who never had a space get none.
++ */
++const assert = require('assert');
++const { modules } = require('openvibe-contracts');
++const H = require('./helpers');
++const { summarize, createProjectsModule } = require('../server/integrations/projects-module');
++
++(async () => {
++ const h = await H.boot();
++ const ann = H.subject(), bob = H.subject(), cat = H.subject();
++ const tok = (sub) => H.userToken({ subject: sub });
++ const puts = [];
++ const fetchImpl = async (url, opts) => {
++ const m = String(url).match(/\/internal\/modules\/wiki\.projects\/(usr_[0-9A-Z]+)$/);
++ assert.ok(m && opts.method === 'PUT' && opts.headers.Authorization === 'Bearer stub-token', url);
++ puts.push({ subject: m[1], data: JSON.parse(opts.body).data });
++ return { ok: true, status: 201, json: async () => ({}) };
++ };
++ const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });
++ const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);
++ try {
++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
++ assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
++ assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');
++
++ assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');
++ assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());
++ assert.deepStrictEqual(dirty(), [], 'every mark cleared');
++
++ // Bob loses his role: he is marked although he is no longer in the space's list.
++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(dirty().includes(bob));
++ await projects.drain();
++ assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');
++
++ // Renaming the space rewrites its people; a drain with nothing new writes nothing.
++ r = await H.req(h, 'PATCH', '/api/v1/spaces/garden', { token: tok(ann), body: { name: 'Big Garden' } });
++ assert.ok(r.status === 200, r.text);
++ const before = puts.length;
++ await projects.drain();
++ assert.strictEqual(puts.length, before + 1);
++ assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');
++ h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
++ await projects.drain();
++ assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');
++ assert.strictEqual(projects.stats().unchanged >= 2, true);
++ console.log('wiki.projects: all checks passed');
++ } finally { await h.stop(); }
++})().catch((err) => { console.error(err); process.exit(1); });