openvibers/openvibe.network Commits

User modules (roadmap Wave 1 item 24): every change emits network.module.updated, account removal and merge handle module rows, onOwnerRemoved is enforced, and chat.preferences belongs to Chat. Events: server/identity/module-events.js builds the envelope (source network, visibility internal, subject { type user_module, id <owner>:<namespace>, revision }, actor = the person, the owning service or system:network) and writes it into network_event_outbox in the transaction that makes the change; payload owner, namespace, namespace_owner, schema_version, revision, change (created|updated|deleted), reason (write|delete|owner_delete|subject_removed|subject_merged), keys (changed top-level field names), public (new values of changed public fields only; never a value of a private namespace), merged_into/merged_from. event-relay.js writerFor() enqueues into the running relay's outbox or, when this process relays nothing (OV_EVENTS_INTERNAL_URL unset, or a script), a passive one on the same table, so rows wait there and the relay sends them; the table is created at boot. Revisions only grow per (subject, namespace): user_module_revisions keeps the last one issued, so a delete has a revision and a re-created record continues after it. Owner services get DELETE /internal/modules/:ns/:subject (token only, If-Match optional). Accounts: onSubjectRemoved() deletes a subject's records and revisions with one event each; onSubjectMerged() moves each record to the survivor where it has none and drops it where it has one (updated_by svc:network), events on both sides. Network has no account deletion or merge today, so triggers refuse deleting a users/anon_users row or changing its subject_id while module rows remain; the one merge that exists, POST /api/auth/anon/:token/link, now moves the guest's records to the account. onOwnerRemoved (contract modules.namespace@1): when the owner's service manifest says retired the namespace is read-only (409 modules.namespace_retired, userWritable false), and a daily sweepRetired() empties delete-after-retention namespaces retentionDays after Network first saw the retirement (user_module_retirements). chat.preferences: OWNER_HANDOFFS makes Chat its owner while the installed manifest (0.30.x) still names live ("moves to OpenVibe.Chat in Wave 6"); Chat gets network.modules.read/write on chat.preferences, Live keeps reading it and its default write grant narrows to chat.tts_defaults and live.profile at boot (only a row still as seeded). The registry lists network.module.updated as an observed Network topic. test/module-events.test.js covers all of it plus the relay publishing rows written while it was off; test/developer-defaults.test.js now checks that nothing is queued while the relay is off (the table exists from boot). README: User modules section and tables.

OP OpenVibers committed 23 Sept 2026 · ea29f8e824 · 12 files, +677 −26


Changes

ddiff --git a/README.md b/README.md
iindex 713c1f15..e5a39fd4 100644
---- a/README.md
++++ b/README.md
@@@ -19,6 +19,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; no portal UI yet (that is OpenVibe.Codes). See [docs/developer-projects.md](docs/developer-projects.md).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
++- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
---
@@@ -276,7 +277,7 @@ Anonymous users can browse and interact without creating an account:
- Each anon user gets a unique number (e.g., "Anonymous #42")
- Stats and preferences are tracked via a session token
- Fingerprint matching reconnects returning anonymous visitors
--- Anon identities can be linked to a registered account at any time, merging stats
++- Anon identities can be linked to a registered account at any time, merging stats; the guest's user modules move to the account (the account's own record wins in a namespace where it has one)
- Multi-account switcher includes a "Continue as Anonymous" option
### API
@@@ -288,6 +289,28 @@ Anonymous users can browse and interact without creating an account:
---
++## User modules
++
++One JSON record per (person, namespace), for portable preferences and summaries (never domain truth).
++Namespaces, schemas, writers, public fields and quotas come from openvibe-contracts
++(`manifests/namespaces`); `server/identity/modules.js` enforces them.
++
++- People: `GET /api/modules` (export), `GET|PUT|DELETE /api/modules/:ns` (`If-Match: <revision>` on PUT).
++ Anyone: `GET /api/modules/:ns/public/:subject` (public fields only).
++- Services: `GET|PUT|DELETE /internal/modules/:ns/:subject`, service token only (`network.modules.read` /
++ `network.modules.write` with the namespace in the grant); only the namespace's owner writes.
++ `chat.preferences` is owned by Chat since the Wave 6 cutover (`OWNER_HANDOFFS`, until Contracts says so).
++- Every change emits `network.module.updated` through `network_event_outbox` (relayed to OpenVibe.Events
++ when `OV_EVENTS_INTERNAL_URL` is set; rows wait there otherwise). The payload names the owner subject,
++ namespace, schema version, revision, change, reason and the changed keys; values only for fields the
++ namespace declares public. Revisions only grow for a (person, namespace), deletes included.
++- Accounts: `onSubjectRemoved` / `onSubjectMerged` delete or re-key a subject's records with events; triggers
++ refuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain.
++- Owning service retired (its manifest `status: retired`): the namespace turns read-only;
++ `delete-after-retention` namespaces are emptied `retentionDays` after Network first saw it (daily sweep).
++
++---
++
## Multi-Account Switching
Google-style account management supporting up to 5 accounts:
@@@ -472,6 +495,8 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `dev_grants`, `dev_quotas` | App capability grants; per-project quotas (enforced by the owning service) |
| `dev_audit` | Append-only developer audit; rows that are platform events carry an event envelope |
| `analytics_events`, `analytics_hourly`, `analytics_daily` | Request analytics (raw ≤ 30 days) and rollups, within ADR-021; see [Analytics](#analytics-adr-021) |
++| `user_modules`, `user_module_revisions`, `user_module_retirements` | User-module records, the last revision issued per (subject, namespace), retired namespace owners |
++| `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules) |
| `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup |
---
ddiff --git a/package.json b/package.json
iindex 1b1db8ee..c018edcc 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 test/nginx-generator-live.test.js && node test/admin-refresh.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 test/chrome.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 && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.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 test/chrome.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 && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex ea769d9e..970ffb34 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -627,9 +627,15 @@ router.post('/anon/:token/link', requireAuth, (req, res) => {
const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
-- // Store anon number on the user for reference
-- db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
-- .run(anon.anon_number, req.user.id);
++ const userSubject = subjects.ensureUserSubject(db, req.user);
++ db.transaction(() => {
++ // Store anon number on the user for reference
++ db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
++ .run(anon.anon_number, req.user.id);
++ // The guest's user modules (portable preferences) move to the account; where the account already
++ // has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).
++ if (anon.subject_id && userSubject) require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });
++ })();
console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
res.json({ ok: true, anon_number: anon.anon_number });
ddiff --git a/server/developer/event-relay.js b/server/developer/event-relay.js
iindex f249bc33..a527c2b8 100644
---- a/server/developer/event-relay.js
++++ b/server/developer/event-relay.js
@@@ -23,12 +23,33 @@ const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
const TABLE = 'network_event_outbox';
const TOKEN_TTL_S = 300;
const outboxes = new WeakMap(); // db -> outbox, only while the relay is configured
++const passives = new WeakMap(); // db -> outbox that only writes rows (never started)
/** The outbox registered for this database, or null (relay off). store.audit() enqueues into it. */
function outboxFor(db) {
return outboxes.get(db) || null;
}
++/**
++ * An outbox to enqueue into whether or not this process relays: the running relay's, else a passive
++ * one on the same table that is never started. Rows written while the relay is off (OV_EVENTS_INTERNAL_URL
++ * unset, or a script beside the server) wait in network_event_outbox; whichever process relays sends them,
++ * the server within its next poll. Used by events that have no other durable record to backfill from
++ * (user modules: server/identity/module-events.js); dev_audit events keep their backfill.
++ */
++function writerFor(db) {
++ const live = outboxes.get(db);
++ if (live) return live;
++ let p = passives.get(db);
++ if (!p) {
++ const events = createEventsClient(createClient({ network: 'https://openvibe.network', baseUrls: { events: 'http://127.0.0.1:1' } }), { source: 'network' });
++ p = createOutbox(db, { events, table: TABLE });
++ p.ensureSchema();
++ passives.set(db, p);
++ }
++ return p;
++}
++
/** A self-signed Network service token for publishing to Events, cached until a minute before exp. */
function createSelfTokenSource({ privateKey, issuer }) {
let cached = null;
@@@ -100,4 +121,4 @@ function stopRelay(db) {
return outbox ? outbox.stop() : Promise.resolve();
}
--module.exports = { startRelay, stopRelay, outboxFor, backfill, createSelfTokenSource, TABLE };
++module.exports = { startRelay, stopRelay, outboxFor, writerFor, backfill, createSelfTokenSource, TABLE };
ddiff --git a/server/identity/module-events.js b/server/identity/module-events.js
nnew file mode 100644
iindex 00000000..02abf1ed
---- /dev/null
++++ b/server/identity/module-events.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * network.module.updated: one event per change to a user-module record (server/identity/modules.js),
++ * written into network_event_outbox in the transaction that makes the change and relayed to
++ * OpenVibe.Events by server/developer/event-relay.js (at least once; Events drops a repeated event_id).
++ *
++ * Envelope: source network, visibility internal, subject { type: 'user_module', id: '<owner>:<namespace>',
++ * revision }, actor = who changed it (the person, the owning service, or system:network for account
++ * lifecycle). Payload (proposed contract events/payloads/network.module.updated.v1):
++ *
++ * owner subject ref of the person the record belongs to (usr_ or gst_)
++ * namespace e.g. chat.preferences
++ * namespace_owner the service that owns the namespace (after any handoff in modules.js)
++ * schema_version the record's schema version (the namespace version it was written under)
++ * revision the record's revision after the change; it grows by one on every change to
++ * (owner, namespace), deletes included, and never restarts, so a consumer keeps the
++ * highest revision it has applied and ignores anything lower
++ * change created | updated | deleted
++ * reason write | delete | owner_delete | subject_removed | subject_merged
++ * keys top-level fields that were added, changed or removed (names only, sorted)
++ * public only for fields the namespace declares public and that changed: their new value
++ * (null = removed). Absent for private namespaces: a value never leaves otherwise.
++ * merged_into / merged_from subject refs, on the two sides of an account merge
++ *
++ * Nothing here reads or writes user_modules; the caller passes before/after.
++ */
++const { ids, modules, validate } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
++const EVENT_TYPE = 'network.module.updated';
++const SUBJECT_TYPE = 'user_module';
++const REASONS = ['write', 'delete', 'owner_delete', 'subject_removed', 'subject_merged'];
++
++const subjectRef = (sid) => ({ type: String(sid).startsWith('gst_') ? 'guest' : 'user', id: sid });
++
++/** Top-level keys whose value differs between two data objects (added, changed or removed), sorted. */
++function changedKeys(before, after) {
++ const a = before && typeof before === 'object' ? before : {};
++ const b = after && typeof after === 'object' ? after : {};
++ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
++ return [...keys].filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k])).sort();
++}
++
++/**
++ * The envelope for one change. `actor` is a subject ref ({ type: 'user'|'guest'|'service'|'system', id }).
++ * Throws if the envelope would not match events.event-envelope@1 (a bug, never user input).
++ */
++function buildEnvelope({ subjectId, namespace, namespaceOwner, schemaVersion, revision, change, reason, before, after, actor, mergedInto, mergedFrom, ctx }) {
++ if (!REASONS.includes(reason)) throw new Error(`module-events: unknown reason ${reason}`);
++ const keys = changedKeys(before, change === 'deleted' ? {} : after);
++ const payload = {
++ owner: subjectRef(subjectId), namespace, namespace_owner: namespaceOwner, schema_version: schemaVersion,
++ revision, change, reason, keys,
++ };
++ const ns = modules.get(namespace);
++ const publicKeys = ns && change !== 'deleted' ? keys.filter(k => ns.publicFields.includes(k)) : [];
++ if (publicKeys.length) {
++ payload.public = {};
++ for (const k of publicKeys) payload.public[k] = after && Object.prototype.hasOwnProperty.call(after, k) ? after[k] : null;
++ }
++ if (mergedInto) payload.merged_into = subjectRef(mergedInto);
++ if (mergedFrom) payload.merged_from = subjectRef(mergedFrom);
++ const ms = Date.now();
++ const env = {
++ event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
++ actor, timestamp: new Date(ms).toISOString(), visibility: 'internal',
++ subject: { type: SUBJECT_TYPE, id: `${subjectId}:${namespace}`, revision }, payload,
++ };
++ if (ctx && /^[0-9a-f]{32}$/.test(ctx.traceId || '')) env.trace_id = ctx.traceId;
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`module-events: bad envelope: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ return env;
++}
++
++/** Create network_event_outbox if no relay has yet (boot), so a module write never has to. */
++function ensureSchema(db) {
++ eventRelay.writerFor(db);
++}
++
++/** Build and enqueue; call INSIDE the transaction that makes the change. Returns the envelope. */
++function record(db, change) {
++ const env = buildEnvelope(change);
++ eventRelay.writerFor(db).enqueue(env, { traceparent: change.ctx && change.ctx.traceparent });
++ return env;
++}
++
++/** After the commit: wake the relay if this process runs one. */
++function kick(db) {
++ const live = eventRelay.outboxFor(db);
++ if (live) live.kick();
++}
++
++module.exports = { EVENT_TYPE, SUBJECT_TYPE, REASONS, changedKeys, buildEnvelope, ensureSchema, record, kick };
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex f2a7907b..0beb8707 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -14,10 +14,61 @@
* 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
++ * DELETE /internal/modules/:ns/:subject same; If-Match optional
++ *
++ * Every change emits network.module.updated (./module-events.js) in the same transaction. Revisions
++ * never go backwards for a (subject, namespace): user_module_revisions keeps the last one issued, so
++ * a record deleted and written again continues from there and a delete has a revision of its own.
++ *
++ * Lifecycle:
++ * - accounts: onSubjectRemoved() deletes a subject's records, onSubjectMerged() re-keys them into the
++ * surviving subject; both emit events. Triggers refuse deleting a users/anon_users row, or changing
++ * its subject_id, while module rows remain, so an account deletion or merge cannot skip them.
++ * - owning service retired (namespace onOwnerRemoved, contract modules.namespace@1): when the owner's
++ * service manifest says `retired`, the namespace is read-only (retain-readonly), and with
++ * delete-after-retention sweepRetired() deletes its records retentionDays after Network first saw
++ * the owner retired. People can always read, export and delete their own records.
*/
const express = require('express');
--const { modules, http, assertValid } = require('openvibe-contracts');
++const { modules, services, http, assertValid } = require('openvibe-contracts');
const subjects = require('./subjects');
++const moduleEvents = require('./module-events');
++
++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/**
++ * Namespaces whose owner moved before openvibe-contracts caught up. The manifest of chat.preferences
++ * (0.30.x) still names live and says it "moves to OpenVibe.Chat in Wave 6"; Chat has served Live's chat
++ * since the Wave 6 cutover (2026-09-23). The handoff applies only while the installed manifest still
++ * names `from`: once Contracts publishes owner `to` it is a no-op, and the entry can be deleted.
++ */
++const OWNER_HANDOFFS = Object.freeze({
++ 'chat.preferences': Object.freeze({ from: 'live', to: 'chat' }),
++});
++
++/** The service that owns a namespace now (the manifest's owner unless a handoff moved it). */
++function ownerOf(namespace) {
++ const ns = modules.get(namespace);
++ if (!ns) return null;
++ const h = OWNER_HANDOFFS[namespace];
++ return h && ns.owner === h.from ? h.to : ns.owner;
++}
++
++/** modules.canWrite with the handoff applied. writer = { type: 'user' } | { type: 'service', id } */
++function canWrite(namespace, writer) {
++ const ns = modules.get(namespace);
++ if (!ns || !writer) return false;
++ if (writer.type === 'user') return ns.writers.includes('user');
++ if (writer.type === 'service') return ns.writers.includes('owner') && writer.id === ownerOf(namespace);
++ return false;
++}
++
++/** Is the namespace's owning service retired (its service manifest's status)? */
++function ownerRetired(namespace) {
++ const owner = ownerOf(namespace);
++ const m = owner ? services.get(owner) : null;
++ return !!(m && m.status === 'retired');
++}
function ensureSchema(db) {
db.exec(`
@@@ -31,7 +82,35 @@ function ensureSchema(db) {
updated_by TEXT,
PRIMARY KEY (subject_id, namespace)
);
++ -- The last revision issued per (subject, namespace), kept across deletes.
++ CREATE TABLE IF NOT EXISTS user_module_revisions (
++ subject_id TEXT NOT NULL,
++ namespace TEXT NOT NULL,
++ revision INTEGER NOT NULL,
++ PRIMARY KEY (subject_id, namespace)
++ );
++ -- When Network first saw a namespace's owning service retired (onOwnerRemoved).
++ CREATE TABLE IF NOT EXISTS user_module_retirements (
++ namespace TEXT PRIMARY KEY,
++ owner TEXT NOT NULL,
++ retired_seen_at INTEGER NOT NULL
++ );
`);
++ moduleEvents.ensureSchema(db);
++ // An account cannot disappear, or change subject, while module rows remain: the code that deletes or
++ // merges it must call onSubjectRemoved/onSubjectMerged (same transaction), which also emit the events.
++ for (const table of ['users', 'anon_users']) {
++ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) continue;
++ db.exec(`
++ CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_delete BEFORE DELETE ON ${table}
++ WHEN OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
++ BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'); END;
++ CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_rekey BEFORE UPDATE OF subject_id ON ${table}
++ WHEN OLD.subject_id IS NOT NULL AND NEW.subject_id IS NOT OLD.subject_id
++ AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
++ BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectMerged first'); END;
++ `);
++ }
}
const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
@@@ -46,34 +125,176 @@ function read(db, subjectId, namespace) {
return row ? toRecord(row) : null;
}
++/** The next revision for (subject, namespace): one above both the current row and the last issued. */
++function nextRevision(db, subjectId, namespace, current) {
++ const hw = db.prepare('SELECT revision FROM user_module_revisions WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
++ const next = Math.max(current || 0, hw ? hw.revision : 0) + 1;
++ db.prepare(`INSERT INTO user_module_revisions (subject_id, namespace, revision) VALUES (?, ?, ?)
++ ON CONFLICT(subject_id, namespace) DO UPDATE SET revision = excluded.revision`).run(subjectId, namespace, next);
++ return next;
++}
++
++/** Who a writer is, as an event actor. */
++function actorOf(subjectId, writer) {
++ if (writer.type === 'user') return subjectRef(subjectId);
++ if (writer.type === 'service') return { type: 'service', id: writer.id };
++ return { type: 'system', id: 'network' };
++}
++
/**
* 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 }) {
++function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx } = {}) {
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 (!canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer && writer.type === 'user' ? 'users' : writer && writer.id} may not write ${namespace}` };
++ if (ownerRetired(namespace)) return { status: 409, code: 'modules.namespace_retired', detail: `${ownerOf(namespace)} is retired: ${namespace} is read-only (${ns.onOwnerRemoved})` };
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 out = db.transaction(() => {
++ const cur = db.prepare('SELECT revision, data 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}` };
}
++ const revision = nextRevision(db, subjectId, namespace, have);
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,
++ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
++ ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = excluded.revision,
data = excluded.data, updated_at = CURRENT_TIMESTAMP, updated_by = excluded.updated_by`)
-- .run(subjectId, namespace, ns.version, JSON.stringify(data), by);
++ .run(subjectId, namespace, ns.version, revision, JSON.stringify(data), by);
const record = read(db, subjectId, namespace);
assertValid('modules.module-record@1', record);
++ moduleEvents.record(db, {
++ subjectId, namespace, namespaceOwner: ownerOf(namespace), schemaVersion: ns.version, revision,
++ change: cur ? 'updated' : 'created', reason: 'write', before: cur ? JSON.parse(cur.data) : {}, after: data,
++ actor: actorOf(subjectId, writer), ctx,
++ });
return { status: cur ? 200 : 201, record };
})();
++ if (out.record) moduleEvents.kick(db);
++ return out;
++}
++
++/**
++ * Delete one record inside the caller's transaction (or its own) and emit its event.
++ * Returns the revision of the delete, or 0 when there was no record.
++ */
++function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx }) {
++ const row = db.prepare('SELECT revision, version, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
++ if (!row) return 0;
++ db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjectId, namespace);
++ const revision = nextRevision(db, subjectId, namespace, row.revision);
++ moduleEvents.record(db, {
++ subjectId, namespace, namespaceOwner: ownerOf(namespace) || 'network', schemaVersion: row.version, revision,
++ change: 'deleted', reason, before: JSON.parse(row.data), after: {}, actor, mergedInto, ctx,
++ });
++ return revision;
++}
++
++/**
++ * Delete a record. writer as for write(); a user deletes their own record in any namespace, a service
++ * only in the namespaces it owns. expectedRevision optional (412 if it moved).
++ * Returns { status: 204, revision } | { status, code, detail }.
++ */
++function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}) {
++ if (!modules.get(namespace)) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
++ if (writer.type === 'service' && writer.id !== ownerOf(namespace)) return { status: 403, code: 'modules.write_denied', detail: `${writer.id} may not delete ${namespace}` };
++ const out = db.transaction(() => {
++ const cur = db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
++ if (!cur) return { status: 404, code: 'modules.not_found' };
++ if (expectedRevision !== undefined && Number(expectedRevision) !== cur.revision) {
++ return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${cur.revision}, not ${expectedRevision}` };
++ }
++ const revision = removeRow(db, subjectId, namespace, { actor: actorOf(subjectId, writer), reason: writer.type === 'user' ? 'delete' : 'owner_delete', ctx });
++ return { status: 204, revision };
++ })();
++ if (out.status === 204) moduleEvents.kick(db);
++ return out;
++}
++
++/**
++ * An account is going away: delete every record of the subject, one network.module.updated (deleted,
++ * reason subject_removed) each, and forget its revisions. Call inside the transaction that deletes the
++ * account (the users/anon_users triggers refuse the delete otherwise). Returns how many were deleted.
++ */
++function onSubjectRemoved(db, subjectId, { ctx } = {}) {
++ if (!SUBJECT_RE.test(String(subjectId || ''))) throw new TypeError('onSubjectRemoved: a usr_/gst_ subject id is required');
++ const n = db.transaction(() => {
++ const rows = db.prepare('SELECT namespace FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(subjectId);
++ for (const r of rows) removeRow(db, subjectId, r.namespace, { actor: { type: 'system', id: 'network' }, reason: 'subject_removed', ctx });
++ db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(subjectId);
++ return rows.length;
++ })();
++ if (n) moduleEvents.kick(db);
++ return n;
++}
++
++/**
++ * Two accounts become one (`from` folds into `into`, which survives). Each record of `from` moves to
++ * `into` when `into` has none in that namespace (created there, reason subject_merged, merged_from);
++ * when `into` has one, the survivor's record is kept and `from`'s is dropped. Every record of `from`
++ * ends deleted (reason subject_merged, merged_into). Moved records say updated_by svc:network.
++ * Call inside the merge's transaction, before `from`'s row goes. Returns { moved, dropped }.
++ */
++function onSubjectMerged(db, { from, into, ctx } = {}) {
++ if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');
++ if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');
++ const system = { type: 'system', id: 'network' };
++ const out = db.transaction(() => {
++ const res = { moved: 0, dropped: 0 };
++ for (const row of db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {
++ const has = db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
++ if (!has) {
++ const revision = nextRevision(db, into, row.namespace, 0);
++ db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
++ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'svc:network')`).run(into, row.namespace, row.version, revision, row.data);
++ moduleEvents.record(db, {
++ subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: row.version, revision,
++ change: 'created', reason: 'subject_merged', before: {}, after: JSON.parse(row.data), actor: system, mergedFrom: from, ctx,
++ });
++ res.moved++;
++ } else {
++ res.dropped++;
++ }
++ removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });
++ }
++ db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);
++ return res;
++ })();
++ if (out.moved || out.dropped) moduleEvents.kick(db);
++ return out;
++}
++
++/**
++ * onOwnerRemoved for delete-after-retention namespaces: note when Network first sees the owner retired,
++ * and once retentionDays have passed since, delete the namespace's records (reason owner_delete, actor
++ * system:network). retain-readonly namespaces only become read-only (write()). Returns how many were deleted.
++ */
++function sweepRetired(db, { now = Date.now(), ctx } = {}) {
++ let deleted = 0;
++ for (const ns of modules.namespaces) {
++ const owner = ownerOf(ns.namespace);
++ if (!ownerRetired(ns.namespace)) {
++ db.prepare('DELETE FROM user_module_retirements WHERE namespace = ?').run(ns.namespace); // un-retired
++ continue;
++ }
++ db.prepare('INSERT OR IGNORE INTO user_module_retirements (namespace, owner, retired_seen_at) VALUES (?, ?, ?)').run(ns.namespace, owner, now);
++ if (ns.onOwnerRemoved !== 'delete-after-retention') continue;
++ const seen = db.prepare('SELECT retired_seen_at FROM user_module_retirements WHERE namespace = ?').get(ns.namespace).retired_seen_at;
++ if (now - seen < (ns.retentionDays || 0) * 86400000) continue;
++ deleted += db.transaction(() => {
++ const rows = db.prepare('SELECT subject_id FROM user_modules WHERE namespace = ?').all(ns.namespace);
++ for (const r of rows) removeRow(db, r.subject_id, ns.namespace, { actor: { type: 'system', id: 'network' }, reason: 'owner_delete', ctx });
++ return rows.length;
++ })();
++ }
++ if (deleted) moduleEvents.kick(db);
++ return deleted;
}
const problem = (res, out, req) => http.sendProblem(res, out.status, out.code, { detail: out.detail, errors: out.errors, ctx: req.ov });
@@@ -103,7 +324,7 @@ function userRouter(requireAuth) {
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 })) });
++ namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: ownerOf(n.namespace), userWritable: n.writers.includes('user') && !ownerRetired(n.namespace), description: n.description })) });
});
router.get('/:ns', requireAuth, (req, res) => {
@@@ -118,16 +339,16 @@ function userRouter(requireAuth) {
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 });
++ const out = write(db, subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev, ctx: req.ov });
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();
++ const out = remove(db, subjects.ensureUserSubject(db, req.user), req.params.ns, { writer: { type: 'user' }, ctx: req.ov });
++ if (out.status === 404 && out.code === 'modules.unknown_namespace') return problem(res, out, req);
++ res.status(out.status === 204 ? 204 : 404).end();
});
return router;
}
@@@ -136,6 +357,7 @@ function userRouter(requireAuth) {
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);
++ const serviceOf = (req) => String(req.principal.sub).replace(/^svc:/, '');
router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf, legacy: false }), (req, res) => {
const db = req.app.locals.db;
@@@ -152,11 +374,25 @@ function serviceRoutes(router, principals) {
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 });
++ const out = write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
if (!out.record) return problem(res, out, req);
withEtag(res, out.record).status(out.status).json(out.record);
});
++
++ router.delete('/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 out = remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
++ if (out.status !== 204) return problem(res, out, req);
++ res.set('Cache-Control', 'private, no-store').status(204).end();
++ });
}
--module.exports = { ensureSchema, read, write, userRouter, serviceRoutes };
++module.exports = {
++ OWNER_HANDOFFS, ownerOf, canWrite, ownerRetired,
++ ensureSchema, read, write, remove, onSubjectRemoved, onSubjectMerged, sweepRetired,
++ userRouter, serviceRoutes,
++};
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 6fc8a3a8..9f4a3e1a 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -22,8 +22,11 @@ const DEFAULT_GRANTS = [
['live', 'network.coins.debit', 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).
++ // chat.preferences moved to Chat with the Wave 6 cutover (modules.js OWNER_HANDOFFS); Live keeps reading it.
['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']],
++ ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.tts_defaults', 'live.profile']],
++ ['chat', 'network.modules.read', SELF_AUDIENCE, ['chat.preferences']],
++ ['chat', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences']],
['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']],
@@@ -146,6 +149,13 @@ const DEFAULT_GRANTS = [
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
const REVOKED_GRANTS = [['live', 'network.coins.transfer', SELF_AUDIENCE]];
++// Default grants whose namespaces changed: a row still exactly as the old default seeded it is moved to
++// the new list at boot (a row someone edited is left alone). Live's write grant lost chat.preferences
++// when the namespace moved to Chat (Wave 6).
++const CHANGED_DEFAULT_NAMESPACES = [
++ ['live', 'network.modules.write', SELF_AUDIENCE, ['chat.preferences', 'chat.tts_defaults', 'live.profile'], ['chat.tts_defaults', 'live.profile']],
++];
++
function ensureSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS principal_grants (
@@@ -175,6 +185,10 @@ function ensureSchema(db) {
for (const [client, cap, aud] of REVOKED_GRANTS) {
db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);
}
++ for (const [client, cap, aud, was, now] of CHANGED_DEFAULT_NAMESPACES) {
++ db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = ?")
++ .run(JSON.stringify(now), client, cap, aud, JSON.stringify(was));
++ }
const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
// A default grant that later gained namespaces fills them in on a row still seeded without any
// (INSERT OR IGNORE alone would leave it at []); a row someone edited is left alone.
ddiff --git a/server/index.js b/server/index.js
iindex 9a64c7ad..6512c435 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -728,6 +728,17 @@ app.listen(config.port, config.host, () => {
// bounded batches; hourly/daily rollups stay. First run 5 minutes after boot, then every 24 h.
networkAnalytics.schedulePrune(analytics);
++ // User modules of a retired owning service (onOwnerRemoved, server/identity/modules.js): writes stop at
++ // once; delete-after-retention records go retentionDays after Network first saw the retirement.
++ const sweepModules = () => {
++ try {
++ const n = require('./identity/modules').sweepRetired(db);
++ if (n) console.log(`[Modules] Deleted ${n} record(s) of retired namespace owners`);
++ } catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }
++ };
++ setTimeout(sweepModules, 5 * 60 * 1000);
++ setInterval(sweepModules, 24 * 60 * 60 * 1000);
++
// Clean expired sessions daily
setInterval(() => {
const cleaned = db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run().changes;
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex d6f9a86e..dd0a0b47 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -57,7 +57,7 @@ function requireInternalKey(req, res, next) {
}
const principals = require('../identity/principals');
const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live']);
--const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
++const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/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);
ddiff --git a/server/registry/topics.js b/server/registry/topics.js
iindex 98840548..eb2f852d 100644
---- a/server/registry/topics.js
++++ b/server/registry/topics.js
@@@ -27,6 +27,10 @@ const OBSERVED = {
produced: ['media.vod.ready', 'media.vod.failed', 'media.clip.ready', 'media.clip.failed', 'media.object.uploaded', 'media.storage.alert', 'media.storage.recovered'],
source: 'OpenVibe.Media server/events.js TYPES (event_outbox, visibility internal)',
},
++ network: {
++ produced: ['network.module.updated'],
++ source: 'OpenVibe.Network server/identity/module-events.js (network_event_outbox, visibility internal)',
++ },
};
const SEGMENT = /^[a-z0-9_]+$/;
@@@ -54,7 +58,8 @@ function eventsOf(m, { consumedByNetwork = networkConsumed() } = {}) {
const addP = o ? o.produced.filter(t => !produced.includes(t)) : [];
const addC = m.id === 'network' ? consumedByNetwork.filter(t => !consumed.includes(t)) : [];
const observed = addP.length || addC.length
-- ? { ...(addP.length ? { eventsProduced: addP } : {}), ...(addC.length ? { eventsConsumed: addC } : {}), source: addC.length && !addP.length ? 'OpenVibe.Network server/notifications/events-consumer.js TOPICS' : o.source }
++ ? { ...(addP.length ? { eventsProduced: addP } : {}), ...(addC.length ? { eventsConsumed: addC } : {}),
++ source: [addP.length ? o.source : null, addC.length ? 'OpenVibe.Network server/notifications/events-consumer.js TOPICS' : null].filter(Boolean).join('; ') }
: null;
return { eventsProduced: uniq([...produced, ...addP]), eventsConsumed: uniq([...consumed, ...addC]), observed };
}
ddiff --git a/test/developer-defaults.test.js b/test/developer-defaults.test.js
iindex dc6da790..a75206d5 100644
---- a/test/developer-defaults.test.js
++++ b/test/developer-defaults.test.js
@@@ -202,7 +202,8 @@ async function withoutCatalog(hidden, fn) {
// ── Relay off: nothing is sent ──
assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);
assert.strictEqual(relay.outboxFor(db), null);
-- assert.strictEqual(db.prepare("SELECT name FROM sqlite_master WHERE name = 'network_event_outbox'").get(), undefined);
++ // (The table itself exists from boot: user modules write their events into it whether or not a relay runs.)
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n, 0, 'nothing queued while the relay is off');
// ── Relay on: a fake Events receives every envelope once, in order, with a Network service token ──
const received = [];
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
nnew file mode 100644
iindex 00000000..85cfeaa2
---- /dev/null
++++ b/test/module-events.test.js
@@@ -0,0 +1,239 @@
++'use strict';
++// User modules, Wave 1 item 24 (server/identity/modules.js, module-events.js):
++// - every change emits network.module.updated in the same transaction (network_event_outbox), with the
++// owner, namespace, changed keys and revision, never a private value; revisions survive deletes
++// - chat.preferences is owned by Chat (handoff over the 0.30.x manifest); Live's old write grant narrows
++// - owner-service DELETE; account removal and merge (onSubjectRemoved/onSubjectMerged) with events, and
++// triggers that refuse deleting or re-keying an account while module rows remain; guest -> account link
++// - onOwnerRemoved: a retired owner makes its namespace read-only; delete-after-retention sweeps
++// - the relay publishes rows written while it was off
++// node test/module-events.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 contracts = require('openvibe-contracts');
++const { initDb } = require('../server/db/database');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-module-events-'));
++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']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
++// Chat's principal is provisioned by hand in production (no seeded client); the grants follow at boot.
++db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();
++// A Live write grant as an older boot seeded it (with chat.preferences), to see it narrow.
++db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));
++require('../server/identity/principals').ensureSchema(db);
++
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
++const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
++const GUEST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPG';
++db.prepare(`INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', '${ANN}'), (2, 'bob', 'x', '${BOB}'), (3, 'cat', 'x', '${CAT}')`).run();
++db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id) VALUES (50, 7, 'anon-token-7', '${GUEST}')`).run();
++
++const modulesLib = require('../server/identity/modules');
++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/auth', require('../server/auth/routes'));
++app.use('/api/modules', modulesLib.userRouter(requireAuth));
++app.use('/internal', require('../server/internal/routes'));
++const server = http.createServer(app);
++
++/** Module events in the outbox, oldest first (after `afterId`). */
++const events = (afterId = 0) => db.prepare("SELECT id, envelope FROM network_event_outbox WHERE id > ? ORDER BY id").all(afterId)
++ .map(r => ({ id: r.id, ...JSON.parse(r.envelope) })).filter(e => e.event_type === 'network.module.updated');
++const lastId = () => (db.prepare('SELECT MAX(id) AS id FROM network_event_outbox').get() || {}).id || 0;
++const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e.id ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'id')) : e); assert.ok(v.valid, JSON.stringify(v.errors)); };
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ 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, body: r.status === 204 ? null : await r.json().catch(() => null) }));
++ const tokenOf = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
++ 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()));
++
++ // ── Grants: Chat reads and writes chat.preferences; Live's write grant lost it ──
++ const grant = (c, cap) => JSON.parse(db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap).namespaces);
++ assert.deepStrictEqual(grant('chat', 'network.modules.read'), ['chat.preferences']);
++ assert.deepStrictEqual(grant('chat', 'network.modules.write'), ['chat.preferences']);
++ assert.deepStrictEqual(grant('live', 'network.modules.write'), ['chat.tts_defaults', 'live.profile'], 'the old default narrows at boot');
++ assert.ok(grant('live', 'network.modules.read').includes('chat.preferences'), 'Live keeps reading it');
++ const chatTok = await svc('chat');
++ const claims = JSON.parse(Buffer.from(chatTok.access_token.split('.')[1], 'base64url').toString());
++ assert.deepStrictEqual(claims.ns, ['chat.preferences']);
++ const chat = { authorization: `Bearer ${chatTok.access_token}` };
++ const live = { authorization: `Bearer ${(await svc('live')).access_token}` };
++
++ // ── Handoff: chat.preferences belongs to Chat while the manifest still says live ──
++ assert.strictEqual(contracts.modules.get('chat.preferences').owner, 'live', 'Contracts moved the owner: delete the OWNER_HANDOFFS entry and this line');
++ assert.strictEqual(modulesLib.ownerOf('chat.preferences'), 'chat');
++ assert.strictEqual(modulesLib.ownerOf('chat.tts_defaults'), 'live');
++ let r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: live, body: { data: { timestamps: true } } });
++ assert.strictEqual(r.status, 403, 'Live no longer writes chat.preferences');
++
++ // ── Events on write: created, then updated with the changed keys only, no values ──
++ let mark = lastId();
++ r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '0' }, body: { data: { timestamps: true, compact: false } } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:chat'); assert.strictEqual(r.body.revision, 1);
++ let ev = events(mark);
++ assert.strictEqual(ev.length, 1); valid(ev[0]);
++ assert.deepStrictEqual(ev[0].actor, { type: 'service', id: 'chat' });
++ assert.strictEqual(ev[0].visibility, 'internal'); assert.strictEqual(ev[0].source, 'network');
++ assert.deepStrictEqual(ev[0].subject, { type: 'user_module', id: `${ANN}:chat.preferences`, revision: 1 });
++ assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 1, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });
++ mark = lastId();
++ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });
++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
++ ev = events(mark);
++ assert.strictEqual(ev.length, 1);
++ assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ANN });
++ assert.deepStrictEqual(ev[0].payload.keys, ['compact', 'font_scale'], 'timestamps did not change');
++ assert.strictEqual(ev[0].payload.change, 'updated');
++ assert.ok(!JSON.stringify(ev[0].payload).includes('1.2') && !('public' in ev[0].payload), 'a private namespace never carries a value');
++ r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '1' }, body: { data: { compact: true } } });
++ assert.strictEqual(r.status, 412, 'a stale write emits nothing');
++ assert.strictEqual(events(mark).length, 1);
++
++ // ── Public fields: the changed public values travel, private ones never ──
++ mark = lastId();
++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });
++ assert.strictEqual(r.status, 201);
++ r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 43, is_streamer: true, stream_minutes_30d: 950 } } });
++ ev = events(mark);
++ assert.deepStrictEqual(ev[0].payload.public, { followers: 42, is_streamer: true });
++ assert.deepStrictEqual(ev[1].payload.keys, ['followers', 'stream_minutes_30d']);
++ assert.deepStrictEqual(ev[1].payload.public, { followers: 43 }, 'stream_minutes_30d is not public');
++
++ // ── Deletes: user and owner service; revisions never go back ──
++ mark = lastId();
++ r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });
++ assert.strictEqual(r.status, 204);
++ ev = events(mark);
++ assert.strictEqual(ev.length, 1); valid(ev[0]);
++ assert.deepStrictEqual([ev[0].payload.change, ev[0].payload.reason, ev[0].payload.revision, ev[0].subject.revision], ['deleted', 'delete', 3, 3]);
++ assert.deepStrictEqual(ev[0].payload.keys, ['font_scale', 'timestamps'], 'the keys that went');
++ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '0' }, body: { data: { compact: true } } });
++ assert.strictEqual(r.status, 201); assert.strictEqual(r.body.revision, 4, 'a new record continues after the delete');
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: live });
++ assert.strictEqual(r.status, 403, 'only the owner service deletes');
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '3' } });
++ assert.strictEqual(r.status, 412, JSON.stringify(r.body));
++ mark = lastId();
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '4' } });
++ assert.strictEqual(r.status, 204);
++ ev = events(mark);
++ assert.deepStrictEqual([ev[0].payload.reason, ev[0].payload.revision, ev[0].actor.id], ['owner_delete', 5, 'chat']);
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });
++ assert.strictEqual(r.status, 404);
++ r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' } });
++ assert.strictEqual(r.status, 403, 'never the shared key');
++
++ // ── Account removal: rows and revisions go, one event each; the trigger guards the account row ──
++ modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
++ modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });
++ assert.throws(() => db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');
++ assert.throws(() => db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);
++ mark = lastId();
++ db.transaction(() => {
++ assert.strictEqual(modulesLib.onSubjectRemoved(db, BOB), 2);
++ db.prepare('DELETE FROM users WHERE id = 2').run();
++ })();
++ ev = events(mark);
++ assert.deepStrictEqual(ev.map(e => [e.payload.namespace, e.payload.change, e.payload.reason, e.actor.type]),
++ [['chat.preferences', 'deleted', 'subject_removed', 'system'], ['live.profile', 'deleted', 'subject_removed', 'system']]);
++ ev.forEach(valid);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(BOB).n, 0);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB).n, 0);
++ assert.throws(() => modulesLib.onSubjectRemoved(db, 2), /subject id/);
++
++ // ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──
++ modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });
++ modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });
++ mark = lastId();
++ const merged = modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });
++ assert.deepStrictEqual(merged, { moved: 1, dropped: 1 });
++ const annPrefs = modulesLib.read(db, ANN, 'chat.preferences');
++ assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });
++ assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');
++ assert.strictEqual(annPrefs.updated_by, 'svc:network');
++ assert.strictEqual(modulesLib.read(db, ANN, 'live.profile').data.followers, 43, 'the survivor\'s record wins');
++ ev = events(mark);
++ assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [
++ [ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],
++ ]);
++ ev.forEach(valid);
++ assert.ok(!('public' in ev[2].payload), 'a delete carries no values');
++ db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go
++ assert.throws(() => modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);
++
++ // ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
++ modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
++ modulesLib.write(db, GUEST, 'chat.tts_defaults', { rate: 1.5 }, { writer: { type: 'user' }, expectedRevision: 0 });
++ assert.throws(() => db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
++ r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { rate: 1.5 }, 'moved');
++ assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.preferences').data, { hide_emotes: true }, 'the account kept its own');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST).n, 0);
++
++ // ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──
++ modulesLib.write(db, ANN, 'tools.usage', { recent: [{ tool: 'yt' }] }, { writer: { type: 'service', id: 'tools' } });
++ const realGet = contracts.services.get;
++ contracts.services.get = (id) => (id === 'tools' || id === 'chat' ? { ...realGet(id), status: 'retired' } : realGet(id));
++ try {
++ const w = modulesLib.write(db, ANN, 'chat.preferences', { compact: true }, { writer: { type: 'user' }, expectedRevision: 6 });
++ assert.strictEqual(w.status, 409); assert.strictEqual(w.code, 'modules.namespace_retired');
++ r = await call('GET', '/api/modules', { headers: tokenOf(1) });
++ assert.strictEqual(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable, false);
++ const t0 = Date.now();
++ assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 }), 0, 'retention starts when Network first sees the retirement');
++ assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 89 * 86400000 }), 0);
++ mark = lastId();
++ assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 91 * 86400000 }), 1, 'tools.usage: 90 days');
++ assert.deepStrictEqual(events(mark).map(e => [e.payload.namespace, e.payload.reason]), [['tools.usage', 'owner_delete']]);
++ assert.ok(modulesLib.read(db, ANN, 'chat.preferences'), 'retain-readonly keeps records');
++ r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });
++ assert.strictEqual(r.status, 204, 'people can always delete their own');
++ } finally { contracts.services.get = realGet; }
++ modulesLib.sweepRetired(db);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_retirements').get().n, 0, 'un-retired again');
++
++ // ── Relay: rows written while it was off are published, envelopes valid ──
++ const published = [];
++ const fakeEvents = async (url, init) => {
++ const body = JSON.parse(init.body);
++ const list = body.events || [body];
++ published.push(...list);
++ const results = list.map((e, i) => ({ event_id: e.event_id, seq: published.length - list.length + i + 1, duplicate: false }));
++ return new Response(JSON.stringify(body.events ? { results } : results[0]), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const relay = require('../server/developer/event-relay');
++ const outbox = relay.startRelay(db, { eventsUrl: 'http://events.test', privateKey: keys.privateKey, issuer: config.jwt.issuer, fetch: fakeEvents, autoStart: false, log: { log() {}, warn() {} } });
++ await outbox.flush();
++ const mod = published.filter(e => e.event_type === 'network.module.updated');
++ assert.strictEqual(mod.length, events().length, 'every module event went out');
++ mod.forEach(valid);
++ assert.strictEqual(outbox.pending(), 0);
++ // With the relay on, a write lands in the same outbox and goes on the next flush.
++ modulesLib.write(db, ANN, 'live.profile', { followers: 44 }, { writer: { type: 'service', id: 'live' } });
++ await outbox.flush();
++ assert.strictEqual(published.at(-1).payload.revision, 3); assert.strictEqual(published.at(-1).subject.id, `${ANN}:live.profile`);
++ await relay.stopRelay(db);
++
++ server.close(); db.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('module events and lifecycle: all checks passed');
++})().catch(err => { console.error(err); process.exit(1); });