openvibers/openvibe.network Commits

Versioned user modules: per-subject records with revisions, field-level public reads, owner-only service writes behind scoped tokens

OP OpenVibers committed 22 Sept 2026 · c022dc3548 · 8 files, +296 −9

Roadmap Wave 1 item 13 (sections 4.3-4.5), openvibe-contracts v0.4.0 namespaces.
- user_modules (subject, namespace) -> JSON record, schema version, revision, updated_by
- /api/modules: list/export, read, PUT with If-Match (412 on a moved revision, 428 without one,
  422 on schema/quota), DELETE (a person may delete any record about them)
- /api/modules/:ns/public/:subject returns only the namespace's public fields
- /internal/modules/:ns/:subject for services: network.modules.read/write tokens, namespace
  constraint from the grant, writes only by the namespace's owner; the shared key is refused
- default grants: live -> chat.preferences, chat.tts_defaults, live.profile; tools -> tools.usage;
  games -> games.progress.summary

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 04c06c8f..0f40e9c8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a98a855d..3179b2d1 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 8110407f..44c8f70f 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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;
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
nnew file mode 100644
iindex 00000000..f2a7907b
---- /dev/null
++++ b/server/identity/modules.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex f64f62da..332184f0 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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); },
});
ddiff --git a/server/index.js b/server/index.js
iindex 1b9aeef8..91e1b6bb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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));
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex ef6624b9..2f8ce474 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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 || {};
ddiff --git a/test/modules.test.js b/test/modules.test.js
nnew file mode 100644
iindex 00000000..fbcc38ec
---- /dev/null
++++ b/test/modules.test.js
@@@ -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); });