openvibers/openvibe.network Commits
Versioned user modules: per-subject records with revisions, field-level public reads, owner-only service writes behind scoped tokens
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0","openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1464,9 +1464,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.3.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",-- "integrity": "sha512-4clDeetK1RofxApXzSLUehl0x3zsc2AsEedFyCTmocsAp3LzCL/07kiBMbt675KEBArmI0b2jQ8qumgl7rwotw==",++ "version": "0.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",++ "integrity": "sha512-nzlY6MF75Xfl22fIr2LLf96FDv4u/3ry7jYRazpFJzrHGD6jwa7k/FsjDVj/wZ0IcwpUUwPqIMQBqyh4O1/URA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0","openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -785,6 +785,8 @@ function initDb(dbPath) {require('../identity/subjects').ensureSchema(db);// Service principals: grants per OAuth client + usage counters (server/identity/principals.js).require('../identity/principals').ensureSchema(db);++ // Versioned user modules (server/identity/modules.js).++ require('../identity/modules').ensureSchema(db);console.log('[DB] Central database initialized');return db;nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++/**++ * Versioned user modules (roadmap Wave 1 item 13, sections 4.3-4.5).++ *++ * One JSON record per (subject, namespace). Namespaces, their schemas, writers, public fields and++ * quotas come from openvibe-contracts (manifests/namespaces). Writes replace the record and must name++ * the revision they read (If-Match); a moved revision is 412, so two writers never silently overwrite++ * each other. Modules hold portable preferences and summaries, never domain truth.++ *++ * user GET /api/modules every record of mine (export)++ * GET /api/modules/:ns mine++ * PUT /api/modules/:ns If-Match: <rev> { data } (namespaces users may write)++ * DELETE /api/modules/:ns mine, any namespace (my data)++ * public GET /api/modules/:ns/public/:subject only the namespace's public fields++ * service GET /internal/modules/:ns/:subject token with network.modules.read for ns++ * PUT /internal/modules/:ns/:subject token with network.modules.write for ns, owner only++ */++const express = require('express');++const { modules, http, assertValid } = require('openvibe-contracts');++const subjects = require('./subjects');++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS user_modules (++ subject_id TEXT NOT NULL,++ namespace TEXT NOT NULL,++ version INTEGER NOT NULL,++ revision INTEGER NOT NULL DEFAULT 0,++ data TEXT NOT NULL,++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ updated_by TEXT,++ PRIMARY KEY (subject_id, namespace)++ );++ `);++}++++const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });++++function toRecord(row) {++ return { subject: subjectRef(row.subject_id), namespace: row.namespace, version: row.version, revision: row.revision,++ data: JSON.parse(row.data), updated_at: row.updated_at, updated_by: row.updated_by || undefined };++}++++function read(db, subjectId, namespace) {++ const row = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++ return row ? toRecord(row) : null;++}++++/**++ * Write a record. writer = { type: 'user' } | { type: 'service', id }. expectedRevision: the revision++ * the writer read (0 = "I expect no record"); undefined = unconditional (allowed for the owner service only).++ * Returns { status, record } or { status, code, detail, errors? }.++ */++function write(db, subjectId, namespace, data, { writer, expectedRevision }) {++ const ns = modules.get(namespace);++ if (!ns) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };++ if (!modules.canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer.type === 'user' ? 'users' : writer.id} may not write ${namespace}` };++ if (expectedRevision === undefined && writer.type === 'user') return { status: 428, code: 'modules.revision_required', detail: 'send If-Match with the revision you read (0 for a new record)' };++ const v = modules.validateData(namespace, data);++ if (!v.valid) return { status: 422, code: 'modules.invalid_data', detail: `does not match ${namespace} v${ns.version}`, errors: v.errors };++ const by = writer.type === 'user' ? `user:${subjectId}` : `svc:${writer.id}`;++ return db.transaction(() => {++ const cur = db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++ const have = cur ? cur.revision : 0;++ if (expectedRevision !== undefined && Number(expectedRevision) !== have) {++ return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${have}, not ${expectedRevision}` };++ }++ db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)++ VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP, ?)++ ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = revision + 1,++ data = excluded.data, updated_at = CURRENT_TIMESTAMP, updated_by = excluded.updated_by`)++ .run(subjectId, namespace, ns.version, JSON.stringify(data), by);++ const record = read(db, subjectId, namespace);++ assertValid('modules.module-record@1', record);++ return { status: cur ? 200 : 201, record };++ })();++}++++const problem = (res, out, req) => http.sendProblem(res, out.status, out.code, { detail: out.detail, errors: out.errors, ctx: req.ov });++const revisionHeader = (req) => {++ const h = req.headers['if-match'];++ if (h === undefined) return undefined;++ const n = Number(String(h).replace(/^W\//, '').replace(/"/g, ''));++ return Number.isInteger(n) && n >= 0 ? n : NaN;++};++const withEtag = (res, record) => res.set('ETag', `"${record.revision}"`).set('Cache-Control', 'private, no-store');++++/** User + public routes, mounted at /api/modules. */++function userRouter(requireAuth) {++ const router = express.Router();++ router.use(http.middleware());++++ router.get('/:ns/public/:subject', (req, res) => {++ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(res, { status: 404, code: 'modules.not_found' }, req);++ const rec = read(req.app.locals.db, req.params.subject, req.params.ns);++ if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);++ res.set('Cache-Control', 'public, max-age=60').json({ subject: rec.subject, namespace: rec.namespace, version: rec.version, data: modules.publicView(rec.namespace, rec.data) });++ });++++ router.get('/', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ const sid = subjects.ensureUserSubject(db, req.user);++ const rows = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);++ res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),++ namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: n.owner, userWritable: n.writers.includes('user'), description: n.description })) });++ });++++ router.get('/:ns', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);++ const rec = read(db, subjects.ensureUserSubject(db, req.user), req.params.ns);++ if (!rec) return problem(res, { status: 404, code: 'modules.not_found', detail: 'no record yet (write with If-Match: 0)' }, req);++ withEtag(res, rec).json(rec);++ });++++ router.put('/:ns', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ const rev = revisionHeader(req);++ if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision', detail: 'If-Match must be a revision number' }, req);++ const out = write(db, subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev });++ if (!out.record) return problem(res, out, req);++ withEtag(res, out.record).status(out.status).json(out.record);++ });++++ router.delete('/:ns', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);++ const n = db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjects.ensureUserSubject(db, req.user), req.params.ns).changes;++ res.status(n ? 204 : 404).end();++ });++ return router;++}++++/** Service routes; mounted under /internal (after requireInternalKey) with principal guards. */++function serviceRoutes(router, principals) {++ const nsOf = (req) => req.params.ns;++ const resolveSubject = (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);++++ router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf, legacy: false }), (req, res) => {++ const db = req.app.locals.db;++ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);++ const sid = resolveSubject(db, req.params.subject);++ const rec = sid && read(db, sid, req.params.ns);++ if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);++ withEtag(res, rec).json(rec);++ });++++ router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf, legacy: false }), (req, res) => {++ const db = req.app.locals.db;++ const sid = resolveSubject(db, req.params.subject);++ if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);++ const rev = revisionHeader(req);++ if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);++ const service = String(req.principal.sub).replace(/^svc:/, '');++ const out = write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: service }, expectedRevision: rev });++ if (!out.record) return problem(res, out, req);++ withEtag(res, out.record).status(out.status).json(out.record);++ });++}++++module.exports = { ensureSchema, read, write, userRouter, serviceRoutes };@@@ -22,6 +22,13 @@ const DEFAULT_GRANTS = [['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],++ // User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).++ ['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],++ ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],++ ['tools', 'network.modules.read', SELF_AUDIENCE, ['tools.usage']],++ ['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],++ ['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],++ ['games', 'network.modules.write', SELF_AUDIENCE, ['games.progress.summary']],];function ensureSchema(db) {@@@ -110,7 +117,7 @@ function recordDecision(db) {* service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are* unchanged.*/--function guard(capability, { ownApp } = {}) {++function guard(capability, { ownApp, namespace, legacy = true } = {}) {if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);let check = null;let record = null;@@@ -121,7 +128,9 @@ function guard(capability, { ownApp } = {}) {getPublicKey: (r) => r.app.locals.publicKey,issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,audience: SELF_AUDIENCE,-- legacy: (r) => r.internalKeyOk === true,++ // New routes can refuse the shared key outright (legacy: false) so its use never grows.++ legacy: legacy ? (r) => r.internalKeyOk === true : undefined,++ namespace,// Denials are final here; an allow is recorded below, after the ownership check.onDecision: (d) => { if (!d.allowed) record(d); },});@@@ -389,6 +389,8 @@ app.use('/api/themes', require('./themes/routes'));// OpenCoins wallet API (user-facing, Bearer JWT)app.use('/api/coins', createCoinsRoutes(db, requireAuth));++// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).++app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));// Notification API (authenticated users)app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));@@@ -26,11 +26,12 @@ function requireInternalKey(req, res, next) {}// Routes guarded by a capability also accept a service-principal token instead of the key;// principals.guard() on the route verifies it (and refuses anything it can't verify).-- if (String(req.headers.authorization || '').startsWith('Bearer ') && TOKEN_ROUTES.has(`${req.method} ${req.path}`)) return next();++ if (String(req.headers.authorization || '').startsWith('Bearer ') && (TOKEN_ROUTES.has(`${req.method} ${req.path}`) || TOKEN_ROUTE_PATTERNS.some(re => re.test(`${req.method} ${req.path}`)))) return next();return res.status(403).json({ error: 'Invalid or missing internal key' });}const principals = require('../identity/principals');const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);++const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);@@@ -257,6 +258,9 @@ router.get('/coins/stats', (req, res) => {// ── POST /internal/coins/credit ──────────────────────────────// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }// → { balance }++// User modules for services (token only; server/identity/modules.js).++require('../identity/modules').serviceRoutes(router, principals);++router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {try {const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++// Versioned user modules (server/identity/modules.js): user CRUD with revisions, public field-level++// reads, owner-only service writes behind scoped tokens, and no legacy-key access. Roadmap Wave 1.++// node test/modules.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { validate } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);++db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++const { signToken } = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/api/modules', require('../server/identity/modules').userRouter(requireAuth));++app.use('/internal', require('../server/internal/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const annTok = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);++ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async r => ({ status: r.status, etag: r.headers.get('etag'), cache: r.headers.get('cache-control'), body: r.status === 204 ? null : await r.json().catch(() => null) }));++ const me = { authorization: `Bearer ${annTok}` };++ const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;++++ // ── The user ──++ let r = await call('GET', '/api/modules/chat.tts_defaults', { headers: me });++ assert.strictEqual(r.status, 404);++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: me, body: { data: { voice: 'en-1', rate: 1.2 } } });++ assert.strictEqual(r.status, 428, 'a user write must say which revision it read');++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-1', rate: 1.2 } } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.strictEqual(r.body.revision, 1); assert.strictEqual(r.etag, '"1"');++ assert.ok(validate('modules.module-record@1', r.body).valid);++ assert.deepStrictEqual(r.body.subject, { type: 'user', id: ANN });++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-2' } } });++ assert.strictEqual(r.status, 412, 'stale revision refused'); assert.strictEqual(r.body.code, 'modules.revision_conflict');++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2', rate: 5 } } });++ assert.strictEqual(r.status, 422, 'schema enforced'); assert.ok(r.body.errors.length);++ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2' } } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);++ r = await call('PUT', '/api/modules/live.profile', { headers: { ...me, 'if-match': '0' }, body: { data: { followers: 1000000 } } });++ assert.strictEqual(r.status, 403, 'users cannot write a service-owned summary'); assert.strictEqual(r.body.code, 'modules.write_denied');++ r = await call('PUT', '/api/modules/nope.nope', { headers: { ...me, 'if-match': '0' }, body: { data: {} } });++ assert.strictEqual(r.status, 404);++ r = await call('GET', '/api/modules/chat.tts_defaults', {});++ assert.strictEqual(r.status, 401, 'reading my record needs me');++++ // ── Services ──++ const live = await svc('live');++ const tools = await svc('tools');++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:live');++ r = await call('PUT', `/internal/modules/tools.usage/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { recent: [] } } });++ assert.strictEqual(r.status, 403, 'live has no grant for tools.usage'); assert.strictEqual(r.body.code, 'capability.namespace_denied');++ r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${tools}` } });++ assert.strictEqual(r.status, 403, 'tools cannot read chat preferences');++ r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${live}` } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.data.voice, 'en-2');++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });++ assert.strictEqual(r.status, 403, 'module routes never accept the shared key');++ r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });++ assert.strictEqual(r.status, 404, 'unknown subject');++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });++ assert.strictEqual(r.status, 412, 'services get revision checks when they ask for them');++++ // ── Public, field-level ──++ r = await call('GET', `/api/modules/live.profile/public/${ANN}`);++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.body.data, { followers: 42, is_streamer: true }, 'only public fields');++ r = await call('GET', `/api/modules/chat.tts_defaults/public/${ANN}`);++ assert.deepStrictEqual(r.body.data, {}, 'a namespace with no public fields shows nothing');++ r = await call('GET', '/api/modules/live.profile/public/1');++ assert.strictEqual(r.status, 404, 'public reads take subject ids only');++++ // ── Export and delete: the user's data ──++ r = await call('GET', '/api/modules', { headers: me });++ assert.deepStrictEqual(r.body.modules.map(m => m.namespace), ['chat.tts_defaults', 'live.profile']);++ assert.ok(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable);++ assert.ok(r.cache.includes('no-store'));++ r = await call('DELETE', '/api/modules/live.profile', { headers: me });++ assert.strictEqual(r.status, 204, 'people can delete even service-written summaries about them');++ r = await call('GET', `/api/modules/live.profile/public/${ANN}`);++ assert.strictEqual(r.status, 404);++++ server.close(); db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('modules: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1464,9 +1464,9 @@
@@ -1464,9 +1464,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.3.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
- "integrity": "sha512-4clDeetK1RofxApXzSLUehl0x3zsc2AsEedFyCTmocsAp3LzCL/07kiBMbt675KEBArmI0b2jQ8qumgl7rwotw==",
+ "version": "0.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
+ "integrity": "sha512-nzlY6MF75Xfl22fIr2LLf96FDv4u/3ry7jYRazpFJzrHGD6jwa7k/FsjDVj/wZ0IcwpUUwPqIMQBqyh4O1/URA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -785,6 +785,8 @@ function initDb(dbPath) {
@@ -785,6 +785,8 @@ function initDb(dbPath) {
require('../identity/subjects').ensureSchema(db);
require('../identity/subjects').ensureSchema(db);
// Service principals: grants per OAuth client + usage counters (server/identity/principals.js).
// Service principals: grants per OAuth client + usage counters (server/identity/principals.js).
require('../identity/principals').ensureSchema(db);
require('../identity/principals').ensureSchema(db);
+ // Versioned user modules (server/identity/modules.js).
+ require('../identity/modules').ensureSchema(db);
console.log('[DB] Central database initialized');
console.log('[DB] Central database initialized');
return db;
return db;
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+/**
+ * Versioned user modules (roadmap Wave 1 item 13, sections 4.3-4.5).
+ *
+ * One JSON record per (subject, namespace). Namespaces, their schemas, writers, public fields and
+ * quotas come from openvibe-contracts (manifests/namespaces). Writes replace the record and must name
+ * the revision they read (If-Match); a moved revision is 412, so two writers never silently overwrite
+ * each other. Modules hold portable preferences and summaries, never domain truth.
+ *
+ * user GET /api/modules every record of mine (export)
+ * GET /api/modules/:ns mine
+ * PUT /api/modules/:ns If-Match: <rev> { data } (namespaces users may write)
+ * DELETE /api/modules/:ns mine, any namespace (my data)
+ * public GET /api/modules/:ns/public/:subject only the namespace's public fields
+ * service GET /internal/modules/:ns/:subject token with network.modules.read for ns
+ * PUT /internal/modules/:ns/:subject token with network.modules.write for ns, owner only
+ */
+const express = require('express');
+const { modules, http, assertValid } = require('openvibe-contracts');
+const subjects = require('./subjects');
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS user_modules (
+ subject_id TEXT NOT NULL,
+ namespace TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 0,
+ data TEXT NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_by TEXT,
+ PRIMARY KEY (subject_id, namespace)
+ );
+ `);
+}
+
+const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
+
+function toRecord(row) {
+ return { subject: subjectRef(row.subject_id), namespace: row.namespace, version: row.version, revision: row.revision,
+ data: JSON.parse(row.data), updated_at: row.updated_at, updated_by: row.updated_by || undefined };
+}
+
+function read(db, subjectId, namespace) {
+ const row = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+ return row ? toRecord(row) : null;
+}
+
+/**
+ * Write a record. writer = { type: 'user' } | { type: 'service', id }. expectedRevision: the revision
+ * the writer read (0 = "I expect no record"); undefined = unconditional (allowed for the owner service only).
+ * Returns { status, record } or { status, code, detail, errors? }.
+ */
+function write(db, subjectId, namespace, data, { writer, expectedRevision }) {
+ const ns = modules.get(namespace);
+ if (!ns) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
+ if (!modules.canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer.type === 'user' ? 'users' : writer.id} may not write ${namespace}` };
+ if (expectedRevision === undefined && writer.type === 'user') return { status: 428, code: 'modules.revision_required', detail: 'send If-Match with the revision you read (0 for a new record)' };
+ const v = modules.validateData(namespace, data);
+ if (!v.valid) return { status: 422, code: 'modules.invalid_data', detail: `does not match ${namespace} v${ns.version}`, errors: v.errors };
+ const by = writer.type === 'user' ? `user:${subjectId}` : `svc:${writer.id}`;
+ return db.transaction(() => {
+ const cur = db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+ const have = cur ? cur.revision : 0;
+ if (expectedRevision !== undefined && Number(expectedRevision) !== have) {
+ return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${have}, not ${expectedRevision}` };
+ }
+ db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
+ VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP, ?)
+ ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = revision + 1,
+ data = excluded.data, updated_at = CURRENT_TIMESTAMP, updated_by = excluded.updated_by`)
+ .run(subjectId, namespace, ns.version, JSON.stringify(data), by);
+ const record = read(db, subjectId, namespace);
+ assertValid('modules.module-record@1', record);
+ return { status: cur ? 200 : 201, record };
+ })();
+}
+
+const problem = (res, out, req) => http.sendProblem(res, out.status, out.code, { detail: out.detail, errors: out.errors, ctx: req.ov });
+const revisionHeader = (req) => {
+ const h = req.headers['if-match'];
+ if (h === undefined) return undefined;
+ const n = Number(String(h).replace(/^W\//, '').replace(/"/g, ''));
+ return Number.isInteger(n) && n >= 0 ? n : NaN;
+};
+const withEtag = (res, record) => res.set('ETag', `"${record.revision}"`).set('Cache-Control', 'private, no-store');
+
+/** User + public routes, mounted at /api/modules. */
+function userRouter(requireAuth) {
+ const router = express.Router();
+ router.use(http.middleware());
+
+ router.get('/:ns/public/:subject', (req, res) => {
+ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(res, { status: 404, code: 'modules.not_found' }, req);
+ const rec = read(req.app.locals.db, req.params.subject, req.params.ns);
+ if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
+ res.set('Cache-Control', 'public, max-age=60').json({ subject: rec.subject, namespace: rec.namespace, version: rec.version, data: modules.publicView(rec.namespace, rec.data) });
+ });
+
+ router.get('/', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ const sid = subjects.ensureUserSubject(db, req.user);
+ const rows = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);
+ res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),
+ namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: n.owner, userWritable: n.writers.includes('user'), description: n.description })) });
+ });
+
+ router.get('/:ns', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
+ const rec = read(db, subjects.ensureUserSubject(db, req.user), req.params.ns);
+ if (!rec) return problem(res, { status: 404, code: 'modules.not_found', detail: 'no record yet (write with If-Match: 0)' }, req);
+ withEtag(res, rec).json(rec);
+ });
+
+ router.put('/:ns', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ const rev = revisionHeader(req);
+ if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision', detail: 'If-Match must be a revision number' }, req);
+ const out = write(db, subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev });
+ if (!out.record) return problem(res, out, req);
+ withEtag(res, out.record).status(out.status).json(out.record);
+ });
+
+ router.delete('/:ns', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
+ const n = db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjects.ensureUserSubject(db, req.user), req.params.ns).changes;
+ res.status(n ? 204 : 404).end();
+ });
+ return router;
+}
+
+/** Service routes; mounted under /internal (after requireInternalKey) with principal guards. */
+function serviceRoutes(router, principals) {
+ const nsOf = (req) => req.params.ns;
+ const resolveSubject = (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);
+
+ router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf, legacy: false }), (req, res) => {
+ const db = req.app.locals.db;
+ if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
+ const sid = resolveSubject(db, req.params.subject);
+ const rec = sid && read(db, sid, req.params.ns);
+ if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
+ withEtag(res, rec).json(rec);
+ });
+
+ router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf, legacy: false }), (req, res) => {
+ const db = req.app.locals.db;
+ const sid = resolveSubject(db, req.params.subject);
+ if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
+ const rev = revisionHeader(req);
+ if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
+ const service = String(req.principal.sub).replace(/^svc:/, '');
+ const out = write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: service }, expectedRevision: rev });
+ if (!out.record) return problem(res, out, req);
+ withEtag(res, out.record).status(out.status).json(out.record);
+ });
+}
+
+module.exports = { ensureSchema, read, write, userRouter, serviceRoutes };
@@ -22,6 +22,13 @@ const DEFAULT_GRANTS = [
@@ -22,6 +22,13 @@ const DEFAULT_GRANTS = [
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],
['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
+ // User modules: each service reads and writes the namespaces it owns (openvibe-contracts manifests/namespaces).
+ ['live', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
+ ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile']],
+ ['tools', 'network.modules.read', SELF_AUDIENCE, ['tools.usage']],
+ ['tools', 'network.modules.write', SELF_AUDIENCE, ['tools.usage']],
+ ['games', 'network.modules.read', SELF_AUDIENCE, ['games.progress.summary']],
+ ['games', 'network.modules.write', SELF_AUDIENCE, ['games.progress.summary']],
];
];
function ensureSchema(db) {
function ensureSchema(db) {
@@ -110,7 +117,7 @@ function recordDecision(db) {
@@ -110,7 +117,7 @@ function recordDecision(db) {
* service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are
* service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are
* unchanged.
* unchanged.
*/
*/
-function guard(capability, { ownApp } = {}) {
+function guard(capability, { ownApp, namespace, legacy = true } = {}) {
if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
let check = null;
let check = null;
let record = null;
let record = null;
@@ -121,7 +128,9 @@ function guard(capability, { ownApp } = {}) {
@@ -121,7 +128,9 @@ function guard(capability, { ownApp } = {}) {
getPublicKey: (r) => r.app.locals.publicKey,
getPublicKey: (r) => r.app.locals.publicKey,
issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
audience: SELF_AUDIENCE,
audience: SELF_AUDIENCE,
- legacy: (r) => r.internalKeyOk === true,
+ // New routes can refuse the shared key outright (legacy: false) so its use never grows.
+ legacy: legacy ? (r) => r.internalKeyOk === true : undefined,
+ namespace,
// Denials are final here; an allow is recorded below, after the ownership check.
// Denials are final here; an allow is recorded below, after the ownership check.
onDecision: (d) => { if (!d.allowed) record(d); },
onDecision: (d) => { if (!d.allowed) record(d); },
});
});
@@ -389,6 +389,8 @@ app.use('/api/themes', require('./themes/routes'));
@@ -389,6 +389,8 @@ app.use('/api/themes', require('./themes/routes'));
// OpenCoins wallet API (user-facing, Bearer JWT)
// OpenCoins wallet API (user-facing, Bearer JWT)
app.use('/api/coins', createCoinsRoutes(db, requireAuth));
app.use('/api/coins', createCoinsRoutes(db, requireAuth));
+// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).
+app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
// Notification API (authenticated users)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
@@ -26,11 +26,12 @@ function requireInternalKey(req, res, next) {
@@ -26,11 +26,12 @@ function requireInternalKey(req, res, next) {
}
}
// Routes guarded by a capability also accept a service-principal token instead of the key;
// Routes guarded by a capability also accept a service-principal token instead of the key;
// principals.guard() on the route verifies it (and refuses anything it can't verify).
// principals.guard() on the route verifies it (and refuses anything it can't verify).
- if (String(req.headers.authorization || '').startsWith('Bearer ') && TOKEN_ROUTES.has(`${req.method} ${req.path}`)) return next();
+ if (String(req.headers.authorization || '').startsWith('Bearer ') && (TOKEN_ROUTES.has(`${req.method} ${req.path}`) || TOKEN_ROUTE_PATTERNS.some(re => re.test(`${req.method} ${req.path}`)))) return next();
return res.status(403).json({ error: 'Invalid or missing internal key' });
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
}
const principals = require('../identity/principals');
const principals = require('../identity/principals');
const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);
const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);
+const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
@@ -257,6 +258,9 @@ router.get('/coins/stats', (req, res) => {
@@ -257,6 +258,9 @@ router.get('/coins/stats', (req, res) => {
// ── POST /internal/coins/credit ──────────────────────────────
// ── POST /internal/coins/credit ──────────────────────────────
// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
// → { balance }
// → { balance }
+// User modules for services (token only; server/identity/modules.js).
+require('../identity/modules').serviceRoutes(router, principals);
+
router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {
router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {
try {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+// Versioned user modules (server/identity/modules.js): user CRUD with revisions, public field-level
+// reads, owner-only service writes behind scoped tokens, and no legacy-key access. Roadmap Wave 1.
+// node test/modules.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { validate } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
+db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();
+const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+const { signToken } = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/api/modules', require('../server/identity/modules').userRouter(requireAuth));
+app.use('/internal', require('../server/internal/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const annTok = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);
+ const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async r => ({ status: r.status, etag: r.headers.get('etag'), cache: r.headers.get('cache-control'), body: r.status === 204 ? null : await r.json().catch(() => null) }));
+ const me = { authorization: `Bearer ${annTok}` };
+ const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;
+
+ // ── The user ──
+ let r = await call('GET', '/api/modules/chat.tts_defaults', { headers: me });
+ assert.strictEqual(r.status, 404);
+ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: me, body: { data: { voice: 'en-1', rate: 1.2 } } });
+ assert.strictEqual(r.status, 428, 'a user write must say which revision it read');
+ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-1', rate: 1.2 } } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.strictEqual(r.body.revision, 1); assert.strictEqual(r.etag, '"1"');
+ assert.ok(validate('modules.module-record@1', r.body).valid);
+ assert.deepStrictEqual(r.body.subject, { type: 'user', id: ANN });
+ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '0' }, body: { data: { voice: 'en-2' } } });
+ assert.strictEqual(r.status, 412, 'stale revision refused'); assert.strictEqual(r.body.code, 'modules.revision_conflict');
+ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2', rate: 5 } } });
+ assert.strictEqual(r.status, 422, 'schema enforced'); assert.ok(r.body.errors.length);
+ r = await call('PUT', '/api/modules/chat.tts_defaults', { headers: { ...me, 'if-match': '"1"' }, body: { data: { voice: 'en-2' } } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
+ r = await call('PUT', '/api/modules/live.profile', { headers: { ...me, 'if-match': '0' }, body: { data: { followers: 1000000 } } });
+ assert.strictEqual(r.status, 403, 'users cannot write a service-owned summary'); assert.strictEqual(r.body.code, 'modules.write_denied');
+ r = await call('PUT', '/api/modules/nope.nope', { headers: { ...me, 'if-match': '0' }, body: { data: {} } });
+ assert.strictEqual(r.status, 404);
+ r = await call('GET', '/api/modules/chat.tts_defaults', {});
+ assert.strictEqual(r.status, 401, 'reading my record needs me');
+
+ // ── Services ──
+ const live = await svc('live');
+ const tools = await svc('tools');
+ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:live');
+ r = await call('PUT', `/internal/modules/tools.usage/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { recent: [] } } });
+ assert.strictEqual(r.status, 403, 'live has no grant for tools.usage'); assert.strictEqual(r.body.code, 'capability.namespace_denied');
+ r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${tools}` } });
+ assert.strictEqual(r.status, 403, 'tools cannot read chat preferences');
+ r = await call('GET', `/internal/modules/chat.tts_defaults/${ANN}`, { headers: { authorization: `Bearer ${live}` } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.data.voice, 'en-2');
+ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });
+ assert.strictEqual(r.status, 403, 'module routes never accept the shared key');
+ r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });
+ assert.strictEqual(r.status, 404, 'unknown subject');
+ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });
+ assert.strictEqual(r.status, 412, 'services get revision checks when they ask for them');
+
+ // ── Public, field-level ──
+ r = await call('GET', `/api/modules/live.profile/public/${ANN}`);
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.body.data, { followers: 42, is_streamer: true }, 'only public fields');
+ r = await call('GET', `/api/modules/chat.tts_defaults/public/${ANN}`);
+ assert.deepStrictEqual(r.body.data, {}, 'a namespace with no public fields shows nothing');
+ r = await call('GET', '/api/modules/live.profile/public/1');
+ assert.strictEqual(r.status, 404, 'public reads take subject ids only');
+
+ // ── Export and delete: the user's data ──
+ r = await call('GET', '/api/modules', { headers: me });
+ assert.deepStrictEqual(r.body.modules.map(m => m.namespace), ['chat.tts_defaults', 'live.profile']);
+ assert.ok(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable);
+ assert.ok(r.cache.includes('no-store'));
+ r = await call('DELETE', '/api/modules/live.profile', { headers: me });
+ assert.strictEqual(r.status, 204, 'people can delete even service-written summaries about them');
+ r = await call('GET', `/api/modules/live.profile/public/${ANN}`);
+ assert.strictEqual(r.status, 404);
+
+ server.close(); db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('modules: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });