openvibers/openvibe.contracts Commits
v0.4.0: user-module namespaces (modules.namespace@1, modules.module-record@1, 6 seed namespaces, validateData/publicView/canWrite), network.modules.read/write capabilities
Changes
@@@ -2,7 +2,7 @@> Machine-readable contracts for the whole OpenVibe network.--**Status:** alpha, v0.3 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.++**Status:** alpha, v0.4 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -64,6 +64,10 @@ Manifests: `manifests/services/` (the 7 running services plus the 21 charter rep**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.++## User modules (v0.4)++++`manifests/namespaces/*.json` define per-subject module namespaces (`modules.namespace@1`): owner, data schema, writers (`owner` service and/or the `user`), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (`modules.module-record@1`, revision-checked writes). `contracts.modules.validateData / publicView / canWrite` apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.++## Versioning and compatibility- A contract id is permanent. Minor versions only add optional fields.@@@ -88,6 +88,25 @@"compatibility": "backward","status": "active","adr": "ADR-006"++ },++ {++ "id": "modules.namespace",++ "version": "1.0.0",++ "owner": "network",++ "schema": "modules/namespace.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-001"++ },++ {++ "id": "modules.module-record",++ "version": "1.0.0",++ "owner": "network",++ "schema": "modules/module-record.v1.json",++ "visibility": "public",++ "compatibility": "backward",++ "status": "active"}]}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",++ "title": "ModuleRecord",++ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",++ "type": "object",++ "required": ["subject", "namespace", "version", "revision", "data", "updated_at"],++ "additionalProperties": false,++ "properties": {++ "subject": { "$ref": "../identity/subject-ref.v1.json" },++ "namespace": { "type": "string" },++ "version": { "type": "integer", "minimum": 1 },++ "revision": { "type": "integer", "minimum": 0 },++ "data": { "type": "object" },++ "updated_at": { "type": "string" },++ "updated_by": { "type": "string", "description": "user:<subject> or svc:<service>" }++ }++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",++ "title": "ModuleNamespace",++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",++ "type": "object",++ "required": ["namespace", "owner", "version", "schema", "writers", "publicFields", "quotaBytes", "onOwnerRemoved"],++ "additionalProperties": false,++ "properties": {++ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$" },++ "owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "Service that owns the namespace and may write it with network.modules.write." },++ "version": { "type": "integer", "minimum": 1, "description": "Schema version stored with every record; a change needs a migration note." },++ "description": { "type": "string" },++ "schema": { "type": "object", "description": "JSON Schema (2020-12) every stored value must satisfy." },++ "writers": { "type": "array", "minItems": 1, "items": { "enum": ["owner", "user"] }, "description": "owner = the owning service with a token; user = the subject themselves." },++ "publicFields": { "type": "array", "items": { "type": "string" }, "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services." },++ "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." }++ }++}nnew file mode 100644@@@ -0,0 +1,11 @@++{++ "subject": {++ "type": "user",++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++ },++ "namespace": "chat.tts_defaults",++ "version": 1,++ "revision": -1,++ "data": {},++ "updated_at": "x"++}nnew file mode 100644@@@ -0,0 +1,15 @@++{++ "subject": {++ "type": "user",++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++ },++ "namespace": "chat.tts_defaults",++ "version": 1,++ "revision": 3,++ "data": {++ "voice": "en-US-1",++ "rate": 1.2++ },++ "updated_at": "2026-09-22 21:40:00",++ "updated_by": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "namespace": "x.y",++ "owner": "live",++ "version": 1,++ "schema": {},++ "writers": [++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 10000000,++ "onOwnerRemoved": "retain-readonly"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "namespace": "x.y",++ "owner": "live",++ "version": 1,++ "schema": {},++ "writers": [],++ "publicFields": [],++ "quotaBytes": 100,++ "onOwnerRemoved": "retain-readonly"++}nnew file mode 100644@@@ -0,0 +1,44 @@++{++ "namespace": "live.profile",++ "owner": "live",++ "version": 1,++ "description": "Live channel summary shown on other sites. Written by Live only.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "channel_url",++ "followers",++ "is_streamer",++ "last_live_at"++ ],++ "quotaBytes": 4096,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "channel_url": {++ "type": "string",++ "format": "uri"++ },++ "followers": {++ "type": "integer",++ "minimum": 0++ },++ "is_streamer": {++ "type": "boolean"++ },++ "last_live_at": {++ "type": [++ "string",++ "null"++ ]++ },++ "stream_minutes_30d": {++ "type": "integer",++ "minimum": 0++ }++ }++ }++}@@@ -669,6 +669,137 @@}}}++ },++ "modules.namespace": {++ "version": "1.0.0",++ "owner": "network",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",++ "title": "ModuleNamespace",++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",++ "type": "object",++ "required": [++ "namespace",++ "owner",++ "version",++ "schema",++ "writers",++ "publicFields",++ "quotaBytes",++ "onOwnerRemoved"++ ],++ "additionalProperties": false,++ "properties": {++ "namespace": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"++ },++ "owner": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9-]{1,39}$",++ "description": "Service that owns the namespace and may write it with network.modules.write."++ },++ "version": {++ "type": "integer",++ "minimum": 1,++ "description": "Schema version stored with every record; a change needs a migration note."++ },++ "description": {++ "type": "string"++ },++ "schema": {++ "type": "object",++ "description": "JSON Schema (2020-12) every stored value must satisfy."++ },++ "writers": {++ "type": "array",++ "minItems": 1,++ "items": {++ "enum": [++ "owner",++ "user"++ ]++ },++ "description": "owner = the owning service with a token; user = the subject themselves."++ },++ "publicFields": {++ "type": "array",++ "items": {++ "type": "string"++ },++ "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services."++ },++ "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."++ }++ }++ }++ },++ "modules.module-record": {++ "version": "1.0.0",++ "owner": "network",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",++ "title": "ModuleRecord",++ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",++ "type": "object",++ "required": [++ "subject",++ "namespace",++ "version",++ "revision",++ "data",++ "updated_at"++ ],++ "additionalProperties": false,++ "properties": {++ "subject": {++ "$ref": "../identity/subject-ref.v1.json"++ },++ "namespace": {++ "type": "string"++ },++ "version": {++ "type": "integer",++ "minimum": 1++ },++ "revision": {++ "type": "integer",++ "minimum": 0++ },++ "data": {++ "type": "object"++ },++ "updated_at": {++ "type": "string"++ },++ "updated_by": {++ "type": "string",++ "description": "user:<subject> or svc:<service>"++ }++ }++ }}}}@@@ -193,3 +193,61 @@ export interface MediaRef {role?: string;variant?: string;}++++/** [email protected] (owner: network) */++/**++ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).++ */++export interface ModuleNamespace {++ namespace: string;++ /**++ * Service that owns the namespace and may write it with network.modules.write.++ */++ owner: string;++ /**++ * Schema version stored with every record; a change needs a migration note.++ */++ version: number;++ description?: string;++ /**++ * JSON Schema (2020-12) every stored value must satisfy.++ */++ schema: {};++ /**++ * owner = the owning service with a token; user = the subject themselves.++ *++ * @minItems 1++ */++ writers: ["owner" | "user", ...("owner" | "user")[]];++ /**++ * Top-level fields anyone may read. Everything else is readable only by the subject and granted services.++ */++ publicFields: string[];++ quotaBytes: number;++ /**++ * What happens to records when the owning service or mod is retired.++ */++ onOwnerRemoved: "retain-readonly" | "delete-after-retention";++ retentionDays?: number;++ /**++ * How records of the previous version are upgraded.++ */++ migration?: string;++}++++/** [email protected] (owner: network) */++/**++ * One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.++ */++export interface ModuleRecord {++ subject: SubjectRef;++ namespace: string;++ version: number;++ revision: number;++ data: {};++ updated_at: string;++ /**++ * user:<subject> or svc:<service>++ */++ updated_by?: string;++}@@@ -21,4 +21,5 @@ module.exports = {capabilities: require('./lib/capabilities'),services: require('./lib/services'),serviceAuth: require('./lib/service-auth'),++ modules: require('./lib/modules'),};nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++/**++ * User-module namespaces (manifests/namespaces/*.json, contract modules.namespace@1).++ * Network stores the records; every service uses the same rules through these helpers.++ */++const fs = require('fs');++const path = require('path');++const { ajv } = require('./registry');++++const DIR = path.join(__dirname, '..', 'manifests', 'namespaces');++const namespaces = fs.readdirSync(DIR).filter(f => f.endsWith('.json')).sort()++ .map(f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));++const byNs = new Map(namespaces.map(n => [n.namespace, n]));++const validators = new Map(namespaces.map(n => [n.namespace, ajv.compile(n.schema)]));++++const sizeOf = (data) => Buffer.byteLength(JSON.stringify(data));++++/** { valid, errors } for a value in a namespace: its schema plus its quota. */++function validateData(namespace, data) {++ const ns = byNs.get(namespace);++ if (!ns) return { valid: false, errors: [{ path: '/', message: `unknown namespace ${namespace}` }] };++ if (!data || typeof data !== 'object' || Array.isArray(data)) return { valid: false, errors: [{ path: '/', message: 'data must be an object' }] };++ const fn = validators.get(namespace);++ const errors = fn(data) ? [] : fn.errors.map(e => ({ path: e.instancePath || '/', message: e.message }));++ const size = sizeOf(data);++ if (size > ns.quotaBytes) errors.push({ path: '/', message: `${size} bytes exceeds the ${ns.quotaBytes}-byte quota` });++ return { valid: errors.length === 0, errors };++}++++/** Only the fields the namespace declares public. */++function publicView(namespace, data) {++ const ns = byNs.get(namespace);++ const out = {};++ if (!ns || !data) return out;++ for (const f of ns.publicFields) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];++ return out;++}++++/** May this writer write? writer = { type: 'user' } | { type: 'service', id } */++function canWrite(namespace, writer) {++ const ns = byNs.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 === ns.owner;++ return false;++}++++module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "network.modules.read",++ "version": "1.0",++ "owner": "network",++ "status": "active",++ "visibility": "first-party",++ "description": "Read a subject's user-module record in a granted namespace.",++ "outputSchema": "modules.module-record@1",++ "permissions": [++ "modules:read"++ ],++ "resourceConstraints": [++ "namespace"++ ],++ "quotaClass": "modules-read",++ "events": [],++ "implementedBy": [++ "GET /internal/modules/:namespace/:subject"++ ]++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "network.modules.write",++ "version": "1.0",++ "owner": "network",++ "status": "active",++ "visibility": "first-party",++ "description": "Write a subject's record in a namespace the service owns (If-Match revision).",++ "inputSchema": "modules.module-record@1",++ "outputSchema": "modules.module-record@1",++ "permissions": [++ "modules:write"++ ],++ "resourceConstraints": [++ "namespace"++ ],++ "quotaClass": "modules-write",++ "events": [++ "network.module.updated"++ ],++ "implementedBy": [++ "PUT /internal/modules/:namespace/:subject"++ ]++}nnew file mode 100644@@@ -0,0 +1,43 @@++{++ "namespace": "ai.preferences",++ "owner": "network",++ "version": 1,++ "description": "How this person wants AI features to behave (roadmap 28.3 m12: style, length, perspective, personalization opt-out).",++ "writers": [++ "owner",++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 2048,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "style": {++ "enum": [++ "neutral",++ "casual",++ "formal"++ ]++ },++ "length": {++ "enum": [++ "short",++ "medium",++ "long"++ ]++ },++ "perspective": {++ "type": "string",++ "maxLength": 80++ },++ "personalization": {++ "type": "boolean"++ },++ "history": {++ "type": "boolean"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,36 @@++{++ "namespace": "chat.preferences",++ "owner": "live",++ "version": 1,++ "description": "Portable chat display preferences (moves to OpenVibe.Chat in Wave 6).",++ "writers": [++ "owner",++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 4096,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "timestamps": {++ "type": "boolean"++ },++ "compact": {++ "type": "boolean"++ },++ "font_scale": {++ "type": "number",++ "minimum": 0.75,++ "maximum": 2++ },++ "show_badges": {++ "type": "boolean"++ },++ "hide_emotes": {++ "type": "boolean"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++{++ "namespace": "chat.tts_defaults",++ "owner": "live",++ "version": 1,++ "description": "Default text-to-speech voice and rate for this person.",++ "writers": [++ "owner",++ "user"++ ],++ "publicFields": [],++ "quotaBytes": 2048,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "voice": {++ "type": "string",++ "maxLength": 80++ },++ "rate": {++ "type": "number",++ "minimum": 0.5,++ "maximum": 2++ },++ "muted": {++ "type": "boolean"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,38 @@++{++ "namespace": "games.progress.summary",++ "owner": "games",++ "version": 1,++ "description": "Public summary of game progress. The authoritative inventory stays in OpenVibe.Games.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "level",++ "achievements",++ "playtime_hours"++ ],++ "quotaBytes": 4096,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "level": {++ "type": "integer",++ "minimum": 0++ },++ "achievements": {++ "type": "integer",++ "minimum": 0++ },++ "playtime_hours": {++ "type": "number",++ "minimum": 0++ },++ "last_world": {++ "type": "string",++ "maxLength": 120++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,44 @@++{++ "namespace": "live.profile",++ "owner": "live",++ "version": 1,++ "description": "Live channel summary shown on other sites. Written by Live only.",++ "writers": [++ "owner"++ ],++ "publicFields": [++ "channel_url",++ "followers",++ "is_streamer",++ "last_live_at"++ ],++ "quotaBytes": 4096,++ "onOwnerRemoved": "retain-readonly",++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "channel_url": {++ "type": "string",++ "format": "uri"++ },++ "followers": {++ "type": "integer",++ "minimum": 0++ },++ "is_streamer": {++ "type": "boolean"++ },++ "last_live_at": {++ "type": [++ "string",++ "null"++ ]++ },++ "stream_minutes_30d": {++ "type": "integer",++ "minimum": 0++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,39 @@++{++ "namespace": "tools.usage",++ "owner": "tools",++ "version": 1,++ "description": "Recently used tools, for the launcher. Written by Tools only.",++ "writers": [++ "owner"++ ],++ "publicFields": [],++ "quotaBytes": 8192,++ "onOwnerRemoved": "delete-after-retention",++ "retentionDays": 90,++ "schema": {++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "recent": {++ "type": "array",++ "maxItems": 30,++ "items": {++ "type": "object",++ "required": [++ "tool"++ ],++ "additionalProperties": false,++ "properties": {++ "tool": {++ "type": "string",++ "maxLength": 80++ },++ "at": {++ "type": "string"++ }++ }++ }++ }++ }++ }++}@@@ -22,7 +22,9 @@"network.coins.credit","network.coins.debit","network.coins.transfer",-- "network.notifications.push"++ "network.notifications.push",++ "network.modules.read",++ "network.modules.write"],"eventsProduced": [],"eventsConsumed": [],@@@ -1,6 +1,6 @@{"name": "openvibe-contracts",-- "version": "0.3.0",++ "version": "0.4.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",@@@ -156,6 +156,26 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forcesconst failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });await assert.rejects(failing.getToken(), /401: invalid_client/);++// ── User-module namespaces ────────────────────────────────────────────────++{++ const { modules } = contracts;++ ok(modules.namespaces.length >= 6, 'seed namespaces present');++ for (const n of modules.namespaces) {++ ok(contracts.validate('modules.namespace@1', n).valid, `namespace ${n.namespace} matches the contract`);++ ok(serviceIds.has(n.owner), `${n.namespace} owner ${n.owner} is a service`);++ 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');++ 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');++ ok(modules.canWrite('chat.preferences', { type: 'user' }) && !modules.canWrite('live.profile', { type: 'user' }), 'user write follows writers');++ ok(modules.canWrite('live.profile', { type: 'service', id: 'live' }) && !modules.canWrite('live.profile', { type: 'service', id: 'tools' }), 'only the owning service writes');++}++// ── openvibe-contracts-check (the CLI services run in CI) ─────────────────{const os = require('os');
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
> Machine-readable contracts for the whole OpenVibe network.
-**Status:** alpha, v0.3 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
+**Status:** alpha, v0.4 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -64,6 +64,10 @@ Manifests: `manifests/services/` (the 7 running services plus the 21 charter rep
@@ -64,6 +64,10 @@ Manifests: `manifests/services/` (the 7 running services plus the 21 charter rep
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
+## User modules (v0.4)
+
+`manifests/namespaces/*.json` define per-subject module namespaces (`modules.namespace@1`): owner, data schema, writers (`owner` service and/or the `user`), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (`modules.module-record@1`, revision-checked writes). `contracts.modules.validateData / publicView / canWrite` apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.
+
## Versioning and compatibility
## Versioning and compatibility
- A contract id is permanent. Minor versions only add optional fields.
- A contract id is permanent. Minor versions only add optional fields.
@@ -88,6 +88,25 @@
@@ -88,6 +88,25 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "active",
"status": "active",
"adr": "ADR-006"
"adr": "ADR-006"
+ },
+ {
+ "id": "modules.namespace",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "modules/namespace.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-001"
+ },
+ {
+ "id": "modules.module-record",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "modules/module-record.v1.json",
+ "visibility": "public",
+ "compatibility": "backward",
+ "status": "active"
}
}
]
]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",
+ "title": "ModuleRecord",
+ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",
+ "type": "object",
+ "required": ["subject", "namespace", "version", "revision", "data", "updated_at"],
+ "additionalProperties": false,
+ "properties": {
+ "subject": { "$ref": "../identity/subject-ref.v1.json" },
+ "namespace": { "type": "string" },
+ "version": { "type": "integer", "minimum": 1 },
+ "revision": { "type": "integer", "minimum": 0 },
+ "data": { "type": "object" },
+ "updated_at": { "type": "string" },
+ "updated_by": { "type": "string", "description": "user:<subject> or svc:<service>" }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
+ "title": "ModuleNamespace",
+ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
+ "type": "object",
+ "required": ["namespace", "owner", "version", "schema", "writers", "publicFields", "quotaBytes", "onOwnerRemoved"],
+ "additionalProperties": false,
+ "properties": {
+ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$" },
+ "owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "Service that owns the namespace and may write it with network.modules.write." },
+ "version": { "type": "integer", "minimum": 1, "description": "Schema version stored with every record; a change needs a migration note." },
+ "description": { "type": "string" },
+ "schema": { "type": "object", "description": "JSON Schema (2020-12) every stored value must satisfy." },
+ "writers": { "type": "array", "minItems": 1, "items": { "enum": ["owner", "user"] }, "description": "owner = the owning service with a token; user = the subject themselves." },
+ "publicFields": { "type": "array", "items": { "type": "string" }, "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services." },
+ "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." }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+{
+ "subject": {
+ "type": "user",
+ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+ },
+ "namespace": "chat.tts_defaults",
+ "version": 1,
+ "revision": -1,
+ "data": {},
+ "updated_at": "x"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+{
+ "subject": {
+ "type": "user",
+ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+ },
+ "namespace": "chat.tts_defaults",
+ "version": 1,
+ "revision": 3,
+ "data": {
+ "voice": "en-US-1",
+ "rate": 1.2
+ },
+ "updated_at": "2026-09-22 21:40:00",
+ "updated_by": "user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "namespace": "x.y",
+ "owner": "live",
+ "version": 1,
+ "schema": {},
+ "writers": [
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 10000000,
+ "onOwnerRemoved": "retain-readonly"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "namespace": "x.y",
+ "owner": "live",
+ "version": 1,
+ "schema": {},
+ "writers": [],
+ "publicFields": [],
+ "quotaBytes": 100,
+ "onOwnerRemoved": "retain-readonly"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+{
+ "namespace": "live.profile",
+ "owner": "live",
+ "version": 1,
+ "description": "Live channel summary shown on other sites. Written by Live only.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "channel_url",
+ "followers",
+ "is_streamer",
+ "last_live_at"
+ ],
+ "quotaBytes": 4096,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "channel_url": {
+ "type": "string",
+ "format": "uri"
+ },
+ "followers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "is_streamer": {
+ "type": "boolean"
+ },
+ "last_live_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stream_minutes_30d": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+}
@@ -669,6 +669,137 @@
@@ -669,6 +669,137 @@
}
}
}
}
}
}
+ },
+ "modules.namespace": {
+ "version": "1.0.0",
+ "owner": "network",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
+ "title": "ModuleNamespace",
+ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
+ "type": "object",
+ "required": [
+ "namespace",
+ "owner",
+ "version",
+ "schema",
+ "writers",
+ "publicFields",
+ "quotaBytes",
+ "onOwnerRemoved"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$"
+ },
+ "owner": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]{1,39}$",
+ "description": "Service that owns the namespace and may write it with network.modules.write."
+ },
+ "version": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Schema version stored with every record; a change needs a migration note."
+ },
+ "description": {
+ "type": "string"
+ },
+ "schema": {
+ "type": "object",
+ "description": "JSON Schema (2020-12) every stored value must satisfy."
+ },
+ "writers": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "enum": [
+ "owner",
+ "user"
+ ]
+ },
+ "description": "owner = the owning service with a token; user = the subject themselves."
+ },
+ "publicFields": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Top-level fields anyone may read. Everything else is readable only by the subject and granted services."
+ },
+ "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."
+ }
+ }
+ }
+ },
+ "modules.module-record": {
+ "version": "1.0.0",
+ "owner": "network",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/modules/module-record.v1.json",
+ "title": "ModuleRecord",
+ "description": "One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.",
+ "type": "object",
+ "required": [
+ "subject",
+ "namespace",
+ "version",
+ "revision",
+ "data",
+ "updated_at"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "subject": {
+ "$ref": "../identity/subject-ref.v1.json"
+ },
+ "namespace": {
+ "type": "string"
+ },
+ "version": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "data": {
+ "type": "object"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "updated_by": {
+ "type": "string",
+ "description": "user:<subject> or svc:<service>"
+ }
+ }
+ }
}
}
}
}
}
}
@@ -193,3 +193,61 @@ export interface MediaRef {
@@ -193,3 +193,61 @@ export interface MediaRef {
role?: string;
role?: string;
variant?: string;
variant?: string;
}
}
+
+/** [email protected] (owner: network) */
+/**
+ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).
+ */
+export interface ModuleNamespace {
+ namespace: string;
+ /**
+ * Service that owns the namespace and may write it with network.modules.write.
+ */
+ owner: string;
+ /**
+ * Schema version stored with every record; a change needs a migration note.
+ */
+ version: number;
+ description?: string;
+ /**
+ * JSON Schema (2020-12) every stored value must satisfy.
+ */
+ schema: {};
+ /**
+ * owner = the owning service with a token; user = the subject themselves.
+ *
+ * @minItems 1
+ */
+ writers: ["owner" | "user", ...("owner" | "user")[]];
+ /**
+ * Top-level fields anyone may read. Everything else is readable only by the subject and granted services.
+ */
+ publicFields: string[];
+ quotaBytes: number;
+ /**
+ * What happens to records when the owning service or mod is retired.
+ */
+ onOwnerRemoved: "retain-readonly" | "delete-after-retention";
+ retentionDays?: number;
+ /**
+ * How records of the previous version are upgraded.
+ */
+ migration?: string;
+}
+
+/** [email protected] (owner: network) */
+/**
+ * One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.
+ */
+export interface ModuleRecord {
+ subject: SubjectRef;
+ namespace: string;
+ version: number;
+ revision: number;
+ data: {};
+ updated_at: string;
+ /**
+ * user:<subject> or svc:<service>
+ */
+ updated_by?: string;
+}
@@ -21,4 +21,5 @@ module.exports = {
@@ -21,4 +21,5 @@ module.exports = {
capabilities: require('./lib/capabilities'),
capabilities: require('./lib/capabilities'),
services: require('./lib/services'),
services: require('./lib/services'),
serviceAuth: require('./lib/service-auth'),
serviceAuth: require('./lib/service-auth'),
+ modules: require('./lib/modules'),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+/**
+ * User-module namespaces (manifests/namespaces/*.json, contract modules.namespace@1).
+ * Network stores the records; every service uses the same rules through these helpers.
+ */
+const fs = require('fs');
+const path = require('path');
+const { ajv } = require('./registry');
+
+const DIR = path.join(__dirname, '..', 'manifests', 'namespaces');
+const namespaces = fs.readdirSync(DIR).filter(f => f.endsWith('.json')).sort()
+ .map(f => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')));
+const byNs = new Map(namespaces.map(n => [n.namespace, n]));
+const validators = new Map(namespaces.map(n => [n.namespace, ajv.compile(n.schema)]));
+
+const sizeOf = (data) => Buffer.byteLength(JSON.stringify(data));
+
+/** { valid, errors } for a value in a namespace: its schema plus its quota. */
+function validateData(namespace, data) {
+ const ns = byNs.get(namespace);
+ if (!ns) return { valid: false, errors: [{ path: '/', message: `unknown namespace ${namespace}` }] };
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { valid: false, errors: [{ path: '/', message: 'data must be an object' }] };
+ const fn = validators.get(namespace);
+ const errors = fn(data) ? [] : fn.errors.map(e => ({ path: e.instancePath || '/', message: e.message }));
+ const size = sizeOf(data);
+ if (size > ns.quotaBytes) errors.push({ path: '/', message: `${size} bytes exceeds the ${ns.quotaBytes}-byte quota` });
+ return { valid: errors.length === 0, errors };
+}
+
+/** Only the fields the namespace declares public. */
+function publicView(namespace, data) {
+ const ns = byNs.get(namespace);
+ const out = {};
+ if (!ns || !data) return out;
+ for (const f of ns.publicFields) if (Object.prototype.hasOwnProperty.call(data, f)) out[f] = data[f];
+ return out;
+}
+
+/** May this writer write? writer = { type: 'user' } | { type: 'service', id } */
+function canWrite(namespace, writer) {
+ const ns = byNs.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 === ns.owner;
+ return false;
+}
+
+module.exports = { namespaces, get: (n) => byNs.get(n), validateData, publicView, canWrite, sizeOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "network.modules.read",
+ "version": "1.0",
+ "owner": "network",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read a subject's user-module record in a granted namespace.",
+ "outputSchema": "modules.module-record@1",
+ "permissions": [
+ "modules:read"
+ ],
+ "resourceConstraints": [
+ "namespace"
+ ],
+ "quotaClass": "modules-read",
+ "events": [],
+ "implementedBy": [
+ "GET /internal/modules/:namespace/:subject"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "network.modules.write",
+ "version": "1.0",
+ "owner": "network",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Write a subject's record in a namespace the service owns (If-Match revision).",
+ "inputSchema": "modules.module-record@1",
+ "outputSchema": "modules.module-record@1",
+ "permissions": [
+ "modules:write"
+ ],
+ "resourceConstraints": [
+ "namespace"
+ ],
+ "quotaClass": "modules-write",
+ "events": [
+ "network.module.updated"
+ ],
+ "implementedBy": [
+ "PUT /internal/modules/:namespace/:subject"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+{
+ "namespace": "ai.preferences",
+ "owner": "network",
+ "version": 1,
+ "description": "How this person wants AI features to behave (roadmap 28.3 m12: style, length, perspective, personalization opt-out).",
+ "writers": [
+ "owner",
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 2048,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "style": {
+ "enum": [
+ "neutral",
+ "casual",
+ "formal"
+ ]
+ },
+ "length": {
+ "enum": [
+ "short",
+ "medium",
+ "long"
+ ]
+ },
+ "perspective": {
+ "type": "string",
+ "maxLength": 80
+ },
+ "personalization": {
+ "type": "boolean"
+ },
+ "history": {
+ "type": "boolean"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+{
+ "namespace": "chat.preferences",
+ "owner": "live",
+ "version": 1,
+ "description": "Portable chat display preferences (moves to OpenVibe.Chat in Wave 6).",
+ "writers": [
+ "owner",
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 4096,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "timestamps": {
+ "type": "boolean"
+ },
+ "compact": {
+ "type": "boolean"
+ },
+ "font_scale": {
+ "type": "number",
+ "minimum": 0.75,
+ "maximum": 2
+ },
+ "show_badges": {
+ "type": "boolean"
+ },
+ "hide_emotes": {
+ "type": "boolean"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+{
+ "namespace": "chat.tts_defaults",
+ "owner": "live",
+ "version": 1,
+ "description": "Default text-to-speech voice and rate for this person.",
+ "writers": [
+ "owner",
+ "user"
+ ],
+ "publicFields": [],
+ "quotaBytes": 2048,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "voice": {
+ "type": "string",
+ "maxLength": 80
+ },
+ "rate": {
+ "type": "number",
+ "minimum": 0.5,
+ "maximum": 2
+ },
+ "muted": {
+ "type": "boolean"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+{
+ "namespace": "games.progress.summary",
+ "owner": "games",
+ "version": 1,
+ "description": "Public summary of game progress. The authoritative inventory stays in OpenVibe.Games.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "level",
+ "achievements",
+ "playtime_hours"
+ ],
+ "quotaBytes": 4096,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "level": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "achievements": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "playtime_hours": {
+ "type": "number",
+ "minimum": 0
+ },
+ "last_world": {
+ "type": "string",
+ "maxLength": 120
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+{
+ "namespace": "live.profile",
+ "owner": "live",
+ "version": 1,
+ "description": "Live channel summary shown on other sites. Written by Live only.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [
+ "channel_url",
+ "followers",
+ "is_streamer",
+ "last_live_at"
+ ],
+ "quotaBytes": 4096,
+ "onOwnerRemoved": "retain-readonly",
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "channel_url": {
+ "type": "string",
+ "format": "uri"
+ },
+ "followers": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "is_streamer": {
+ "type": "boolean"
+ },
+ "last_live_at": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stream_minutes_30d": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+{
+ "namespace": "tools.usage",
+ "owner": "tools",
+ "version": 1,
+ "description": "Recently used tools, for the launcher. Written by Tools only.",
+ "writers": [
+ "owner"
+ ],
+ "publicFields": [],
+ "quotaBytes": 8192,
+ "onOwnerRemoved": "delete-after-retention",
+ "retentionDays": 90,
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "recent": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "object",
+ "required": [
+ "tool"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "tool": {
+ "type": "string",
+ "maxLength": 80
+ },
+ "at": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+}
@@ -22,7 +22,9 @@
@@ -22,7 +22,9 @@
"network.coins.credit",
"network.coins.credit",
"network.coins.debit",
"network.coins.debit",
"network.coins.transfer",
"network.coins.transfer",
- "network.notifications.push"
+ "network.notifications.push",
+ "network.modules.read",
+ "network.modules.write"
],
],
"eventsProduced": [],
"eventsProduced": [],
"eventsConsumed": [],
"eventsConsumed": [],
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.3.0",
+ "version": "0.4.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",
@@ -156,6 +156,26 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces
@@ -156,6 +156,26 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces
const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });
const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });
await assert.rejects(failing.getToken(), /401: invalid_client/);
await assert.rejects(failing.getToken(), /401: invalid_client/);
+// ── User-module namespaces ────────────────────────────────────────────────
+{
+ const { modules } = contracts;
+ ok(modules.namespaces.length >= 6, 'seed namespaces present');
+ for (const n of modules.namespaces) {
+ ok(contracts.validate('modules.namespace@1', n).valid, `namespace ${n.namespace} matches the contract`);
+ ok(serviceIds.has(n.owner), `${n.namespace} owner ${n.owner} is a service`);
+ 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');
+ 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');
+ ok(modules.canWrite('chat.preferences', { type: 'user' }) && !modules.canWrite('live.profile', { type: 'user' }), 'user write follows writers');
+ ok(modules.canWrite('live.profile', { type: 'service', id: 'live' }) && !modules.canWrite('live.profile', { type: 'service', id: 'tools' }), 'only the owning service writes');
+}
+
// ── openvibe-contracts-check (the CLI services run in CI) ─────────────────
// ── openvibe-contracts-check (the CLI services run in CI) ─────────────────
{
{
const os = require('os');
const os = require('os');