openvibers/openvibe.contracts Commits
v0.41.0: user modules for the rest of D06 — field-level read rules (readers, modules.serviceView), declarative version migrations (migrations, modules.upgrade), new namespaces chat.dm_settings, chat.presence_prefs, live.stats, ai.usage_summary, community.profile, wiki.projects; chat.tts_defaults v2 owned by Chat; tools.usage v2 favorites; ai.preferences readable by ai
Changes
@@@ -4,6 +4,24 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) thfrom `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,the notes were in the tag and commit messages (`git tag -n1`).++## 0.41.0 — 2026-09-25++++User modules, the rest of D06 (roadmap WS-B task 9):++++- **Field-level read rules.** A namespace may list `readers`: `{ service: [fields] }`. A service other than the owner that reads a record (with `network.modules.read`) sees only the public fields and the fields listed for it. `modules.serviceView(namespace, service, data)` applies the rule; Network's service read applies it.++- **Version migration.** A namespace may list declarative `migrations` (`{ from, to, rename, drop, defaults }`). `modules.upgrade(namespace, data, version)` brings a stored record to the current version. Network upgrades a record when it is read and stores it at the current version on the next write. The contract test requires a path from every earlier version.++- **New namespaces:**++ - `chat.dm_settings`: new conversations from everyone or nobody, group invites, previews;++ - `chat.presence_prefs`: whether the person is named in chat user lists;++ - `live.stats`: 30-day streaming summary;++ - `ai.usage_summary`: 30-day AI runs on the person's behalf;++ - `community.profile`: threads, posts, comments, pastes, first and last activity;++ - `wiki.projects`: spaces the person owns or edits, where only public ones are named.++- **`chat.tts_defaults` v2**, now owned by Chat. Its fields are the settings Live's chat panel actually has: `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources`. v1 (voice, rate, muted) was never written, and a v1 record upgrades to an empty v2 record.++- **`tools.usage` v2** adds `favorites` (unique tool ids, at most 24). The person may write it; every v1 record is valid v2.++- **`ai.preferences`** is readable by `ai` (all five fields).++- The chat and ai service manifests list their new namespaces.++## 0.40.0 — 2026-09-24The chat manifest names its own domain: **openvibe.chat** (`domains`, `publicOrigin`). OpenVibe.Chat serves the site itself (global chat, messages, settings, sign-in with the Network), and Network's first-party CORS list, which comes from these manifests, now includes it. Additive.@@@ -17,6 +17,28 @@"quotaBytes": { "type": "integer", "minimum": 64, "maximum": 65536 },"onOwnerRemoved": { "enum": ["retain-readonly", "delete-after-retention"], "description": "What happens to records when the owning service or mod is retired." },"retentionDays": { "type": "integer", "minimum": 0 },-- "migration": { "type": "string", "description": "How records of the previous version are upgraded." }++ "migration": { "type": "string", "description": "How records of the previous version are upgraded." },++ "readers": {++ "type": "object",++ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" },++ "additionalProperties": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }++ },++ "migrations": {++ "type": "array",++ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",++ "items": {++ "type": "object",++ "required": ["from", "to"],++ "additionalProperties": false,++ "properties": {++ "from": { "type": "integer", "minimum": 1 },++ "to": { "type": "integer", "minimum": 2 },++ "rename": { "type": "object", "additionalProperties": { "type": "string" } },++ "drop": { "type": "array", "items": { "type": "string" } },++ "defaults": { "type": "object" }++ }++ }++ }}}@@@ -1197,6 +1197,57 @@"migration": {"type": "string","description": "How records of the previous version are upgraded."++ },++ "readers": {++ "type": "object",++ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",++ "propertyNames": {++ "pattern": "^[a-z][a-z0-9-]{1,39}$"++ },++ "additionalProperties": {++ "type": "array",++ "items": {++ "type": "string"++ },++ "uniqueItems": true++ }++ },++ "migrations": {++ "type": "array",++ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",++ "items": {++ "type": "object",++ "required": [++ "from",++ "to"++ ],++ "additionalProperties": false,++ "properties": {++ "from": {++ "type": "integer",++ "minimum": 1++ },++ "to": {++ "type": "integer",++ "minimum": 2++ },++ "rename": {++ "type": "object",++ "additionalProperties": {++ "type": "string"++ }++ },++ "drop": {++ "type": "array",++ "items": {++ "type": "string"++ }++ },++ "defaults": {++ "type": "object"++ }++ }++ }}}}@@@ -426,6 +426,24 @@ export interface ModuleNamespace {* How records of the previous version are upgraded.*/migration?: string;++ /**++ * Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.++ */++ readers?: {++ [k: string]: string[] | undefined;++ };++ /**++ * Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.++ */++ migrations?: {++ from: number;++ to: number;++ rename?: {++ [k: string]: string | undefined;++ };++ drop?: string[];++ defaults?: {};++ }[];}/** [email protected] (owner: network) */@@@ -45,4 +45,42 @@ function canWrite(namespace, writer) {return false;}--module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };++/**++ * What a service may read of a record (field-level read rules): the owner everything; another service++ * the public fields plus the fields `readers` lists for it.++ */++function serviceView(namespace, service, data) {++ const ns = byNs.get(namespace);++ if (!ns || !data) return {};++ if (service === ns.owner) return { ...data };++ const allowed = new Set([...ns.publicFields, ...((ns.readers && ns.readers[service]) || [])]);++ const out = {};++ for (const f of allowed) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];++ return out;++}++++/**++ * Upgrade a stored record to the namespace's version through its declarative `migrations`++ * (rename, then drop, then defaults, per step). Returns { data, version, upgraded } or null when no++ * chain of migrations leads from `version` to the current one.++ */++function upgrade(namespace, data, version) {++ const ns = byNs.get(namespace);++ if (!ns) return null;++ let v = Number(version) || 1;++ let out = { ...(data || {}) };++ if (v >= ns.version) return { data: out, version: v, upgraded: false };++ while (v < ns.version) {++ const step = (ns.migrations || []).find(m => m.from === v);++ if (!step || step.to <= v) return null;++ for (const [from, to] of Object.entries(step.rename || {})) {++ if (Object.prototype.hasOwnProperty.call(out, from)) { out[to] = out[from]; delete out[from]; }++ }++ for (const f of step.drop || []) delete out[f];++ for (const [f, val] of Object.entries(step.defaults || {})) if (!Object.prototype.hasOwnProperty.call(out, f)) out[f] = val;++ v = step.to;++ }++ return { data: out, version: v, upgraded: true };++}++++module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, serviceView, upgrade, canWrite, sizeOf };@@@ -39,5 +39,14 @@"type": "boolean"}}++ },++ "readers": {++ "ai": [++ "style",++ "length",++ "perspective",++ "personalization",++ "history"++ ]}}nnew file mode 100644@@@ -0,0 +1,48 @@++{++ "namespace": "ai.usage_summary",++ "owner": "ai",++ "version": 1,++ "description": "How much OpenVibe AI did on this person's behalf over the last 30 days (runs, tokens, which services asked). Private; OpenVibe.AI's run log stays the truth.",++ "writers": [++ "owner"++ ],++ "publicFields": [],++ "quotaBytes": 2048,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "runs_30d": {++ "type": "integer",++ "minimum": 0++ },++ "tokens_30d": {++ "type": "integer",++ "minimum": 0++ },++ "by_service": {++ "type": "object",++ "maxProperties": 30,++ "propertyNames": {++ "pattern": "^[a-z][a-z0-9-]{1,39}$"++ },++ "additionalProperties": {++ "type": "integer",++ "minimum": 0++ }++ },++ "last_run_at": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 40++ },++ "computed_at": {++ "type": "string",++ "maxLength": 40++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++{++ "namespace": "chat.dm_settings",++ "owner": "chat",++ "version": 1,++ "description": "Who may start direct messages with this person, whether they may be added to group conversations, and whether notifications show message text. Chat enforces it; blocks stay in Chat.",++ "writers": [++ "owner",++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 512,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "new_conversations": {++ "enum": [++ "everyone",++ "nobody"++ ]++ },++ "group_invites": {++ "type": "boolean"++ },++ "previews": {++ "type": "boolean"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "namespace": "chat.presence_prefs",++ "owner": "chat",++ "version": 1,++ "description": "Whether this person appears by name in chat user lists. Hidden people are counted, never named. Chat enforces it.",++ "writers": [++ "owner",++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 256,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "show_in_user_list": {++ "type": "boolean"++ }++ }++ }++}@@@ -1,30 +1,70 @@{"namespace": "chat.tts_defaults",-- "owner": "live",-- "version": 1,-- "description": "Default text-to-speech voice and rate for this person.",++ "owner": "chat",++ "version": 2,++ "description": "A person's chat text-to-speech and sound settings, the same on every site with OpenVibe chat: whether their own messages are read aloud (also while they broadcast), how loud other people's messages and chat sounds play, and which relayed platforms are read. Chat owns it; the person or Chat writes it.","writers": ["owner","user"],"publicFields": [],-- "quotaBytes": 2048,++ "quotaBytes": 1024,"onOwnerRemoved": "retain-readonly",++ "migration": "v1 (voice, rate, muted) was owned by Live and never written; v2 moves it to Chat with the settings Live's chat panel actually has. A v1 record upgrades to an empty v2 record (Chat's defaults).",++ "migrations": [++ {++ "from": 1,++ "to": 2,++ "drop": [++ "voice",++ "rate",++ "muted"++ ]++ }++ ],"schema": {"type": "object","additionalProperties": false,"properties": {-- "voice": {-- "type": "string",-- "maxLength": 80++ "send": {++ "type": "boolean"},-- "rate": {-- "type": "number",-- "minimum": 0.5,-- "maximum": 2++ "send_while_live": {++ "type": "boolean"},-- "muted": {++ "volume": {++ "type": "integer",++ "minimum": 0,++ "maximum": 100++ },++ "sounds": {"type": "boolean"++ },++ "sound_volume": {++ "type": "integer",++ "minimum": 0,++ "maximum": 100++ },++ "sources": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "native": {++ "type": "boolean"++ },++ "robotstreamer": {++ "type": "boolean"++ },++ "kick": {++ "type": "boolean"++ },++ "youtube": {++ "type": "boolean"++ },++ "twitch": {++ "type": "boolean"++ }++ }}}}nnew file mode 100644@@@ -0,0 +1,54 @@++{++ "namespace": "community.profile",++ "owner": "community",++ "version": 1,++ "description": "This person's OpenVibe Community activity: threads, posts, comments and pastes they wrote, and when they were first and last active. Community recomputes it on each contribution.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "threads",++ "posts",++ "comments",++ "pastes",++ "first_active_at"++ ],++ "quotaBytes": 1024,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "threads": {++ "type": "integer",++ "minimum": 0++ },++ "posts": {++ "type": "integer",++ "minimum": 0++ },++ "comments": {++ "type": "integer",++ "minimum": 0++ },++ "pastes": {++ "type": "integer",++ "minimum": 0++ },++ "first_active_at": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 40++ },++ "last_active_at": {++ "type": [++ "string",++ "null"++ ],++ "maxLength": 40++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,46 @@++{++ "namespace": "live.stats",++ "owner": "live",++ "version": 1,++ "description": "This person's streaming over the last 30 days, recomputed by Live after each stream and daily. A summary; Live's streams and follows stay the truth.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "streams_30d",++ "stream_minutes_30d",++ "peak_viewers_30d"++ ],++ "quotaBytes": 1024,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "streams_30d": {++ "type": "integer",++ "minimum": 0++ },++ "stream_minutes_30d": {++ "type": "integer",++ "minimum": 0++ },++ "peak_viewers_30d": {++ "type": "integer",++ "minimum": 0++ },++ "avg_viewers_30d": {++ "type": "number",++ "minimum": 0++ },++ "new_followers_30d": {++ "type": "integer",++ "minimum": 0++ },++ "computed_at": {++ "type": "string",++ "maxLength": 40++ }++ }++ }++}@@@ -1,10 +1,11 @@{"namespace": "tools.usage","owner": "tools",-- "version": 1,-- "description": "Recently used tools, for the launcher. Written by Tools only.",++ "version": 2,++ "description": "Recently used tools and favourite tools, for the Tools launcher and my.openvibe.network. Tools writes recent; the person or Tools writes favorites.","writers": [-- "owner"++ "owner",++ "user"],"publicFields": [],"quotaBytes": 8192,@@@ -33,7 +34,23 @@}}}++ },++ "favorites": {++ "type": "array",++ "maxItems": 24,++ "uniqueItems": true,++ "items": {++ "type": "string",++ "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$"++ }}}-- }++ },++ "migration": "v2 adds favorites; every v1 record is a valid v2 record.",++ "migrations": [++ {++ "from": 1,++ "to": 2++ }++ ]}nnew file mode 100644@@@ -0,0 +1,53 @@++{++ "namespace": "wiki.projects",++ "owner": "wiki",++ "version": 1,++ "description": "The wiki spaces this person owns or edits. Only public spaces are named; the others are counted. Wiki recomputes it when a permission changes.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "spaces"++ ],++ "quotaBytes": 8192,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "spaces": {++ "type": "array",++ "maxItems": 50,++ "items": {++ "type": "object",++ "required": [++ "slug",++ "name",++ "role"++ ],++ "additionalProperties": false,++ "properties": {++ "slug": {++ "type": "string",++ "maxLength": 80++ },++ "name": {++ "type": "string",++ "maxLength": 120++ },++ "role": {++ "enum": [++ "owner",++ "editor"++ ]++ }++ }++ }++ },++ "private_count": {++ "type": "integer",++ "minimum": 0++ }++ }++ }++}@@@ -27,6 +27,6 @@"ai.run.cached"],"eventsConsumed": [],-- "namespacesOwned": [],++ "namespacesOwned": ["ai.usage_summary"],"notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached have payload contracts (v0.30.0, status planned): AI publishes them once its Events client is wired in; it publishes none today. ai.run.create takes ai.run-request@1 and returns ai.run@1."}@@@ -23,9 +23,7 @@"eventsConsumed": ["network.user.token_valid_after"],-- "namespacesOwned": [-- "chat.preferences"-- ],++ "namespacesOwned": ["chat.preferences", "chat.tts_defaults", "chat.dm_settings", "chat.presence_prefs"],"notes": "Wave 6: Live's chat (WebSocket, DMs, history, TTS, sounds, moderation) behind a service boundary on 127.0.0.1:4400, serving Live's chat paths since 2026-09-23. Since 2026-09-24 also its own site, openvibe.chat (global chat, messages, settings, sign-in with the Network), which works while Live is down.","health": "/health","ready": "/ready"@@@ -1,12 +1,12 @@{"name": "openvibe-contracts",-- "version": "0.40.0",++ "version": "0.41.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-contracts",-- "version": "0.40.0",++ "version": "0.41.0","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1,6 +1,6 @@{"name": "openvibe-contracts",-- "version": "0.40.0",++ "version": "0.41.0","description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.","main": "index.js","types": "generated/typescript/index.d.ts",@@@ -556,9 +556,26 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);for (const f of n.publicFields) ok(n.schema.properties && n.schema.properties[f], `${n.namespace} public field ${f} is in its schema`);ok(n.onOwnerRemoved !== 'delete-after-retention' || Number.isInteger(n.retentionDays), `${n.namespace} deletion has a retention period`);}-- ok(modules.validateData('chat.tts_defaults', { voice: 'a', rate: 1 }).valid, 'valid module data passes');-- ok(!modules.validateData('chat.tts_defaults', { rate: 9 }).valid, 'out-of-range value fails');-- ok(!modules.validateData('chat.tts_defaults', { voice: 'a', password: 'x' }).valid, 'unknown field fails');++ for (const n of modules.namespaces) {++ for (const [svc, fields] of Object.entries(n.readers || {})) {++ ok(serviceIds.has(svc) && svc !== n.owner, `${n.namespace} reader ${svc} is another service`);++ for (const f of fields) ok(n.schema.properties[f], `${n.namespace} field ${f} readable by ${svc} is in its schema`);++ }++ // Every earlier version has a migration path, and each step's result is valid at the next version.++ for (let v = 1; v < n.version; v++) ok(modules.upgrade(n.namespace, {}, v) && modules.upgrade(n.namespace, {}, v).version === n.version, `${n.namespace} v${v} upgrades to v${n.version}`);++ ok(n.version === 1 || typeof n.migration === 'string', `${n.namespace} v${n.version} says how older records upgrade`);++ }++ ok(modules.validateData('chat.tts_defaults', { send: true, volume: 40, sources: { kick: false } }).valid, 'valid module data passes');++ ok(!modules.validateData('chat.tts_defaults', { volume: 101 }).valid, 'out-of-range value fails');++ ok(!modules.validateData('chat.tts_defaults', { send: true, password: 'x' }).valid, 'unknown field fails');++ const up = modules.upgrade('chat.tts_defaults', { voice: 'a', rate: 1, muted: true }, 1);++ ok(up.version === 2 && up.upgraded && JSON.stringify(up.data) === '{}' && modules.validateData('chat.tts_defaults', up.data).valid, 'a v1 tts record upgrades to a valid v2 record');++ ok(modules.upgrade('tools.usage', { recent: [{ tool: 'img' }] }, 1).data.recent.length === 1 && !modules.upgrade('tools.usage', {}, 2).upgraded, 'tools.usage v1 → v2 keeps recent; current records are left alone');++ ok(modules.upgrade('live.profile', {}, 1).upgraded === false, 'a current-version record is not upgraded');++ ok(JSON.stringify(modules.serviceView('ai.preferences', 'ai', { style: 'casual', history: false })) === '{"style":"casual","history":false}', 'a listed reader sees its fields');++ ok(JSON.stringify(modules.serviceView('live.stats', 'ai', { streams_30d: 2, new_followers_30d: 5 })) === '{"streams_30d":2}', 'an unlisted service sees public fields only');++ ok(modules.serviceView('live.stats', 'live', { new_followers_30d: 5 }).new_followers_30d === 5, 'the owner sees everything');++ ok(modules.validateData('tools.usage', { favorites: ['img', 'yt'] }).valid && !modules.validateData('tools.usage', { favorites: ['img', 'img'] }).valid, 'favorites are unique tool ids');ok(!modules.validateData('tools.usage', { recent: Array.from({ length: 30 }, () => ({ tool: 'x'.repeat(80), at: 'y'.repeat(300) })) }).valid, 'over quota fails');ok(!modules.validateData('nope.nope', {}).valid && !modules.validateData('chat.preferences', []).valid, 'unknown namespace / non-object fails');ok(JSON.stringify(modules.publicView('live.profile', { followers: 3, stream_minutes_30d: 99 })) === '{"followers":3}', 'public view keeps only public fields');
@@ -4,6 +4,24 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
@@ -4,6 +4,24 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
the notes were in the tag and commit messages (`git tag -n1`).
+## 0.41.0 — 2026-09-25
+
+User modules, the rest of D06 (roadmap WS-B task 9):
+
+- **Field-level read rules.** A namespace may list `readers`: `{ service: [fields] }`. A service other than the owner that reads a record (with `network.modules.read`) sees only the public fields and the fields listed for it. `modules.serviceView(namespace, service, data)` applies the rule; Network's service read applies it.
+- **Version migration.** A namespace may list declarative `migrations` (`{ from, to, rename, drop, defaults }`). `modules.upgrade(namespace, data, version)` brings a stored record to the current version. Network upgrades a record when it is read and stores it at the current version on the next write. The contract test requires a path from every earlier version.
+- **New namespaces:**
+ - `chat.dm_settings`: new conversations from everyone or nobody, group invites, previews;
+ - `chat.presence_prefs`: whether the person is named in chat user lists;
+ - `live.stats`: 30-day streaming summary;
+ - `ai.usage_summary`: 30-day AI runs on the person's behalf;
+ - `community.profile`: threads, posts, comments, pastes, first and last activity;
+ - `wiki.projects`: spaces the person owns or edits, where only public ones are named.
+- **`chat.tts_defaults` v2**, now owned by Chat. Its fields are the settings Live's chat panel actually has: `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources`. v1 (voice, rate, muted) was never written, and a v1 record upgrades to an empty v2 record.
+- **`tools.usage` v2** adds `favorites` (unique tool ids, at most 24). The person may write it; every v1 record is valid v2.
+- **`ai.preferences`** is readable by `ai` (all five fields).
+- The chat and ai service manifests list their new namespaces.
+
## 0.40.0 — 2026-09-24
## 0.40.0 — 2026-09-24
The chat manifest names its own domain: **openvibe.chat** (`domains`, `publicOrigin`). OpenVibe.Chat serves the site itself (global chat, messages, settings, sign-in with the Network), and Network's first-party CORS list, which comes from these manifests, now includes it. Additive.
The chat manifest names its own domain: **openvibe.chat** (`domains`, `publicOrigin`). OpenVibe.Chat serves the site itself (global chat, messages, settings, sign-in with the Network), and Network's first-party CORS list, which comes from these manifests, now includes it. Additive.
@@ -17,6 +17,28 @@
@@ -17,6 +17,28 @@
"quotaBytes": { "type": "integer", "minimum": 64, "maximum": 65536 },
"quotaBytes": { "type": "integer", "minimum": 64, "maximum": 65536 },
"onOwnerRemoved": { "enum": ["retain-readonly", "delete-after-retention"], "description": "What happens to records when the owning service or mod is retired." },
"onOwnerRemoved": { "enum": ["retain-readonly", "delete-after-retention"], "description": "What happens to records when the owning service or mod is retired." },
"retentionDays": { "type": "integer", "minimum": 0 },
"retentionDays": { "type": "integer", "minimum": 0 },
- "migration": { "type": "string", "description": "How records of the previous version are upgraded." }
+ "migration": { "type": "string", "description": "How records of the previous version are upgraded." },
+ "readers": {
+ "type": "object",
+ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" },
+ "additionalProperties": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
+ },
+ "migrations": {
+ "type": "array",
+ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",
+ "items": {
+ "type": "object",
+ "required": ["from", "to"],
+ "additionalProperties": false,
+ "properties": {
+ "from": { "type": "integer", "minimum": 1 },
+ "to": { "type": "integer", "minimum": 2 },
+ "rename": { "type": "object", "additionalProperties": { "type": "string" } },
+ "drop": { "type": "array", "items": { "type": "string" } },
+ "defaults": { "type": "object" }
+ }
+ }
+ }
}
}
}
}
@@ -1197,6 +1197,57 @@
@@ -1197,6 +1197,57 @@
"migration": {
"migration": {
"type": "string",
"type": "string",
"description": "How records of the previous version are upgraded."
"description": "How records of the previous version are upgraded."
+ },
+ "readers": {
+ "type": "object",
+ "description": "Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.",
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9-]{1,39}$"
+ },
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true
+ }
+ },
+ "migrations": {
+ "type": "array",
+ "description": "Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.",
+ "items": {
+ "type": "object",
+ "required": [
+ "from",
+ "to"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "from": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "to": {
+ "type": "integer",
+ "minimum": 2
+ },
+ "rename": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "drop": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "defaults": {
+ "type": "object"
+ }
+ }
+ }
}
}
}
}
}
}
@@ -426,6 +426,24 @@ export interface ModuleNamespace {
@@ -426,6 +426,24 @@ export interface ModuleNamespace {
* How records of the previous version are upgraded.
* How records of the previous version are upgraded.
*/
*/
migration?: string;
migration?: string;
+ /**
+ * Field-level read rules: a service other than the owner, holding network.modules.read for the namespace, sees publicFields plus the fields listed for it here, and nothing else. A service not listed sees publicFields only. The owner and the person always see the whole record.
+ */
+ readers?: {
+ [k: string]: string[] | undefined;
+ };
+ /**
+ * Declarative upgrades from each earlier version, applied in order when a stored record's version is below the namespace's: rename moves a field, drop removes fields, defaults adds fields the record lacks. Network upgrades a record when it is read and stores the upgraded record on its next write.
+ */
+ migrations?: {
+ from: number;
+ to: number;
+ rename?: {
+ [k: string]: string | undefined;
+ };
+ drop?: string[];
+ defaults?: {};
+ }[];
}
}
/** [email protected] (owner: network) */
/** [email protected] (owner: network) */
@@ -45,4 +45,42 @@ function canWrite(namespace, writer) {
@@ -45,4 +45,42 @@ function canWrite(namespace, writer) {
return false;
return false;
}
}
-module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };
+/**
+ * What a service may read of a record (field-level read rules): the owner everything; another service
+ * the public fields plus the fields `readers` lists for it.
+ */
+function serviceView(namespace, service, data) {
+ const ns = byNs.get(namespace);
+ if (!ns || !data) return {};
+ if (service === ns.owner) return { ...data };
+ const allowed = new Set([...ns.publicFields, ...((ns.readers && ns.readers[service]) || [])]);
+ const out = {};
+ for (const f of allowed) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];
+ return out;
+}
+
+/**
+ * Upgrade a stored record to the namespace's version through its declarative `migrations`
+ * (rename, then drop, then defaults, per step). Returns { data, version, upgraded } or null when no
+ * chain of migrations leads from `version` to the current one.
+ */
+function upgrade(namespace, data, version) {
+ const ns = byNs.get(namespace);
+ if (!ns) return null;
+ let v = Number(version) || 1;
+ let out = { ...(data || {}) };
+ if (v >= ns.version) return { data: out, version: v, upgraded: false };
+ while (v < ns.version) {
+ const step = (ns.migrations || []).find(m => m.from === v);
+ if (!step || step.to <= v) return null;
+ for (const [from, to] of Object.entries(step.rename || {})) {
+ if (Object.prototype.hasOwnProperty.call(out, from)) { out[to] = out[from]; delete out[from]; }
+ }
+ for (const f of step.drop || []) delete out[f];
+ for (const [f, val] of Object.entries(step.defaults || {})) if (!Object.prototype.hasOwnProperty.call(out, f)) out[f] = val;
+ v = step.to;
+ }
+ return { data: out, version: v, upgraded: true };
+}
+
+module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, serviceView, upgrade, canWrite, sizeOf };
@@ -39,5 +39,14 @@
@@ -39,5 +39,14 @@
"type": "boolean"
"type": "boolean"
}
}
}
}
+ },
+ "readers": {
+ "ai": [
+ "style",
+ "length",
+ "perspective",
+ "personalization",
+ "history"
+ ]
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+{
+ "namespace": "ai.usage_summary",
+ "owner": "ai",
+ "version": 1,
+ "description": "How much OpenVibe AI did on this person's behalf over the last 30 days (runs, tokens, which services asked). Private; OpenVibe.AI's run log stays the truth.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [],
+ "quotaBytes": 2048,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "runs_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "tokens_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "by_service": {
+ "type": "object",
+ "maxProperties": 30,
+ "propertyNames": {
+ "pattern": "^[a-z][a-z0-9-]{1,39}$"
+ },
+ "additionalProperties": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "last_run_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 40
+ },
+ "computed_at": {
+ "type": "string",
+ "maxLength": 40
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+{
+ "namespace": "chat.dm_settings",
+ "owner": "chat",
+ "version": 1,
+ "description": "Who may start direct messages with this person, whether they may be added to group conversations, and whether notifications show message text. Chat enforces it; blocks stay in Chat.",
+ "writers": [
+ "owner",
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 512,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "new_conversations": {
+ "enum": [
+ "everyone",
+ "nobody"
+ ]
+ },
+ "group_invites": {
+ "type": "boolean"
+ },
+ "previews": {
+ "type": "boolean"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "namespace": "chat.presence_prefs",
+ "owner": "chat",
+ "version": 1,
+ "description": "Whether this person appears by name in chat user lists. Hidden people are counted, never named. Chat enforces it.",
+ "writers": [
+ "owner",
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 256,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "show_in_user_list": {
+ "type": "boolean"
+ }
+ }
+ }
+}
@@ -1,30 +1,70 @@
@@ -1,30 +1,70 @@
{
{
"namespace": "chat.tts_defaults",
"namespace": "chat.tts_defaults",
- "owner": "live",
- "version": 1,
- "description": "Default text-to-speech voice and rate for this person.",
+ "owner": "chat",
+ "version": 2,
+ "description": "A person's chat text-to-speech and sound settings, the same on every site with OpenVibe chat: whether their own messages are read aloud (also while they broadcast), how loud other people's messages and chat sounds play, and which relayed platforms are read. Chat owns it; the person or Chat writes it.",
"writers": [
"writers": [
"owner",
"owner",
"user"
"user"
],
],
"publicFields": [],
"publicFields": [],
- "quotaBytes": 2048,
+ "quotaBytes": 1024,
"onOwnerRemoved": "retain-readonly",
"onOwnerRemoved": "retain-readonly",
+ "migration": "v1 (voice, rate, muted) was owned by Live and never written; v2 moves it to Chat with the settings Live's chat panel actually has. A v1 record upgrades to an empty v2 record (Chat's defaults).",
+ "migrations": [
+ {
+ "from": 1,
+ "to": 2,
+ "drop": [
+ "voice",
+ "rate",
+ "muted"
+ ]
+ }
+ ],
"schema": {
"schema": {
"type": "object",
"type": "object",
"additionalProperties": false,
"additionalProperties": false,
"properties": {
"properties": {
- "voice": {
- "type": "string",
- "maxLength": 80
+ "send": {
+ "type": "boolean"
},
},
- "rate": {
- "type": "number",
- "minimum": 0.5,
- "maximum": 2
+ "send_while_live": {
+ "type": "boolean"
},
},
- "muted": {
+ "volume": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "sounds": {
"type": "boolean"
"type": "boolean"
+ },
+ "sound_volume": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "sources": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "native": {
+ "type": "boolean"
+ },
+ "robotstreamer": {
+ "type": "boolean"
+ },
+ "kick": {
+ "type": "boolean"
+ },
+ "youtube": {
+ "type": "boolean"
+ },
+ "twitch": {
+ "type": "boolean"
+ }
+ }
}
}
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+{
+ "namespace": "community.profile",
+ "owner": "community",
+ "version": 1,
+ "description": "This person's OpenVibe Community activity: threads, posts, comments and pastes they wrote, and when they were first and last active. Community recomputes it on each contribution.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "threads",
+ "posts",
+ "comments",
+ "pastes",
+ "first_active_at"
+ ],
+ "quotaBytes": 1024,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "threads": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "posts": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "comments": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "pastes": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "first_active_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 40
+ },
+ "last_active_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 40
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+{
+ "namespace": "live.stats",
+ "owner": "live",
+ "version": 1,
+ "description": "This person's streaming over the last 30 days, recomputed by Live after each stream and daily. A summary; Live's streams and follows stay the truth.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "streams_30d",
+ "stream_minutes_30d",
+ "peak_viewers_30d"
+ ],
+ "quotaBytes": 1024,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "streams_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "stream_minutes_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "peak_viewers_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "avg_viewers_30d": {
+ "type": "number",
+ "minimum": 0
+ },
+ "new_followers_30d": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "computed_at": {
+ "type": "string",
+ "maxLength": 40
+ }
+ }
+ }
+}
@@ -1,10 +1,11 @@
@@ -1,10 +1,11 @@
{
{
"namespace": "tools.usage",
"namespace": "tools.usage",
"owner": "tools",
"owner": "tools",
- "version": 1,
- "description": "Recently used tools, for the launcher. Written by Tools only.",
+ "version": 2,
+ "description": "Recently used tools and favourite tools, for the Tools launcher and my.openvibe.network. Tools writes recent; the person or Tools writes favorites.",
"writers": [
"writers": [
- "owner"
+ "owner",
+ "user"
],
],
"publicFields": [],
"publicFields": [],
"quotaBytes": 8192,
"quotaBytes": 8192,
@@ -33,7 +34,23 @@
@@ -33,7 +34,23 @@
}
}
}
}
}
}
+ },
+ "favorites": {
+ "type": "array",
+ "maxItems": 24,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$"
+ }
}
}
}
}
- }
+ },
+ "migration": "v2 adds favorites; every v1 record is a valid v2 record.",
+ "migrations": [
+ {
+ "from": 1,
+ "to": 2
+ }
+ ]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+{
+ "namespace": "wiki.projects",
+ "owner": "wiki",
+ "version": 1,
+ "description": "The wiki spaces this person owns or edits. Only public spaces are named; the others are counted. Wiki recomputes it when a permission changes.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "spaces"
+ ],
+ "quotaBytes": 8192,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "spaces": {
+ "type": "array",
+ "maxItems": 50,
+ "items": {
+ "type": "object",
+ "required": [
+ "slug",
+ "name",
+ "role"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "slug": {
+ "type": "string",
+ "maxLength": 80
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "role": {
+ "enum": [
+ "owner",
+ "editor"
+ ]
+ }
+ }
+ }
+ },
+ "private_count": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+}
@@ -27,6 +27,6 @@
@@ -27,6 +27,6 @@
"ai.run.cached"
"ai.run.cached"
],
],
"eventsConsumed": [],
"eventsConsumed": [],
- "namespacesOwned": [],
+ "namespacesOwned": ["ai.usage_summary"],
"notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached have payload contracts (v0.30.0, status planned): AI publishes them once its Events client is wired in; it publishes none today. ai.run.create takes ai.run-request@1 and returns ai.run@1."
"notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached have payload contracts (v0.30.0, status planned): AI publishes them once its Events client is wired in; it publishes none today. ai.run.create takes ai.run-request@1 and returns ai.run@1."
}
}
@@ -23,9 +23,7 @@
@@ -23,9 +23,7 @@
"eventsConsumed": [
"eventsConsumed": [
"network.user.token_valid_after"
"network.user.token_valid_after"
],
],
- "namespacesOwned": [
- "chat.preferences"
- ],
+ "namespacesOwned": ["chat.preferences", "chat.tts_defaults", "chat.dm_settings", "chat.presence_prefs"],
"notes": "Wave 6: Live's chat (WebSocket, DMs, history, TTS, sounds, moderation) behind a service boundary on 127.0.0.1:4400, serving Live's chat paths since 2026-09-23. Since 2026-09-24 also its own site, openvibe.chat (global chat, messages, settings, sign-in with the Network), which works while Live is down.",
"notes": "Wave 6: Live's chat (WebSocket, DMs, history, TTS, sounds, moderation) behind a service boundary on 127.0.0.1:4400, serving Live's chat paths since 2026-09-23. Since 2026-09-24 also its own site, openvibe.chat (global chat, messages, settings, sign-in with the Network), which works while Live is down.",
"health": "/health",
"health": "/health",
"ready": "/ready"
"ready": "/ready"
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.40.0",
+ "version": "0.41.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.40.0",
+ "version": "0.41.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.40.0",
+ "version": "0.41.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
@@ -556,9 +556,26 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
@@ -556,9 +556,26 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
for (const f of n.publicFields) ok(n.schema.properties && n.schema.properties[f], `${n.namespace} public field ${f} is in its schema`);
for (const f of n.publicFields) ok(n.schema.properties && n.schema.properties[f], `${n.namespace} public field ${f} is in its schema`);
ok(n.onOwnerRemoved !== 'delete-after-retention' || Number.isInteger(n.retentionDays), `${n.namespace} deletion has a retention period`);
ok(n.onOwnerRemoved !== 'delete-after-retention' || Number.isInteger(n.retentionDays), `${n.namespace} deletion has a retention period`);
}
}
- ok(modules.validateData('chat.tts_defaults', { voice: 'a', rate: 1 }).valid, 'valid module data passes');
- ok(!modules.validateData('chat.tts_defaults', { rate: 9 }).valid, 'out-of-range value fails');
- ok(!modules.validateData('chat.tts_defaults', { voice: 'a', password: 'x' }).valid, 'unknown field fails');
+ for (const n of modules.namespaces) {
+ for (const [svc, fields] of Object.entries(n.readers || {})) {
+ ok(serviceIds.has(svc) && svc !== n.owner, `${n.namespace} reader ${svc} is another service`);
+ for (const f of fields) ok(n.schema.properties[f], `${n.namespace} field ${f} readable by ${svc} is in its schema`);
+ }
+ // Every earlier version has a migration path, and each step's result is valid at the next version.
+ for (let v = 1; v < n.version; v++) ok(modules.upgrade(n.namespace, {}, v) && modules.upgrade(n.namespace, {}, v).version === n.version, `${n.namespace} v${v} upgrades to v${n.version}`);
+ ok(n.version === 1 || typeof n.migration === 'string', `${n.namespace} v${n.version} says how older records upgrade`);
+ }
+ ok(modules.validateData('chat.tts_defaults', { send: true, volume: 40, sources: { kick: false } }).valid, 'valid module data passes');
+ ok(!modules.validateData('chat.tts_defaults', { volume: 101 }).valid, 'out-of-range value fails');
+ ok(!modules.validateData('chat.tts_defaults', { send: true, password: 'x' }).valid, 'unknown field fails');
+ const up = modules.upgrade('chat.tts_defaults', { voice: 'a', rate: 1, muted: true }, 1);
+ ok(up.version === 2 && up.upgraded && JSON.stringify(up.data) === '{}' && modules.validateData('chat.tts_defaults', up.data).valid, 'a v1 tts record upgrades to a valid v2 record');
+ ok(modules.upgrade('tools.usage', { recent: [{ tool: 'img' }] }, 1).data.recent.length === 1 && !modules.upgrade('tools.usage', {}, 2).upgraded, 'tools.usage v1 → v2 keeps recent; current records are left alone');
+ ok(modules.upgrade('live.profile', {}, 1).upgraded === false, 'a current-version record is not upgraded');
+ ok(JSON.stringify(modules.serviceView('ai.preferences', 'ai', { style: 'casual', history: false })) === '{"style":"casual","history":false}', 'a listed reader sees its fields');
+ ok(JSON.stringify(modules.serviceView('live.stats', 'ai', { streams_30d: 2, new_followers_30d: 5 })) === '{"streams_30d":2}', 'an unlisted service sees public fields only');
+ ok(modules.serviceView('live.stats', 'live', { new_followers_30d: 5 }).new_followers_30d === 5, 'the owner sees everything');
+ ok(modules.validateData('tools.usage', { favorites: ['img', 'yt'] }).valid && !modules.validateData('tools.usage', { favorites: ['img', 'img'] }).valid, 'favorites are unique tool ids');
ok(!modules.validateData('tools.usage', { recent: Array.from({ length: 30 }, () => ({ tool: 'x'.repeat(80), at: 'y'.repeat(300) })) }).valid, 'over quota fails');
ok(!modules.validateData('tools.usage', { recent: Array.from({ length: 30 }, () => ({ tool: 'x'.repeat(80), at: 'y'.repeat(300) })) }).valid, 'over quota fails');
ok(!modules.validateData('nope.nope', {}).valid && !modules.validateData('chat.preferences', []).valid, 'unknown namespace / non-object fails');
ok(!modules.validateData('nope.nope', {}).valid && !modules.validateData('chat.preferences', []).valid, 'unknown namespace / non-object fails');
ok(JSON.stringify(modules.publicView('live.profile', { followers: 3, stream_minutes_30d: 99 })) === '{"followers":3}', 'public view keeps only public fields');
ok(JSON.stringify(modules.publicView('live.profile', { followers: 3, stream_minutes_30d: 99 })) === '{"followers":3}', 'public view keeps only public fields');