openvibers/openvibe.examples Commits
examples/inventory-wear: show what people wear on OpenVibe next to names in your app (Inventory's public reads, no token) (#32)
Changes
@@@ -3,7 +3,7 @@> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials> and public APIs, nothing else.--**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Thirteen examples, each with a smoke test that CI runs on++**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Fourteen examples, each with a smoke test that CI runs onNode 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs threeof them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; itis not part of CI or `npm test`, and it was not run against production as part of this release.@@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) || [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` || [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |++| [inventory-wear](examples/inventory-wear) | Show what people wear on OpenVibe (hats, chat tags, badges, name effects) next to names in your own app: the public catalog and who wears what, 100 people per call, with no token; text and escaped HTML | `openvibe-sdk/inventory` (`kinds`, `definitions`, `equipped`) || [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` || [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` || [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |@@@ -293,7 +294,7 @@ test/public-surface.test.js, test/structure.test.js## Owns- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event-- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3) and the space-deploy example (Space deploys from CI)++ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3), the space-deploy example (Space deploys from CI) and the inventory-wear example (what people wear, rendered in your app)- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)@@@ -9,7 +9,7 @@"deployed": false,"deployment": "none: a repository with CI, not a runtime and not a published package","features": [-- "thirteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow)",++ "fourteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow), what people wear (Inventory's public catalog and equipped reads, no token)","smoke tests against openvibe-sdk/testing's mock platform, offline","create-openvibe-app scaffolder","npm run e2e against the real platform (on demand)",nnew file mode 100644@@@ -0,0 +1,3 @@++# Nothing is required: Inventory's catalog and who wears what are public reads, with no token.++# Optional:++# OV_INVENTORY_URL=https://inventory.openvibe.networknnew file mode 100644@@@ -0,0 +1,68 @@++# What people wear, in your app++++On OpenVibe, a person's hat, chat tag, badge and name effect follow them from site to site through++[OpenVibe.Inventory](https://inventory.openvibe.network), the network-wide inventory. This example++shows them next to names in **your** app, as OpenVibe.Live's chat does. Both reads are public, so++you need no token and no app.++++```bash++node wear.js usr_01J… usr_01J…++# 🤠 [VIP] alex ⭐ (Cowboy Hat · VIP tag · Divine Name · Early Supporter)++# sam++```++++## What it proves++++- **The catalog is public and changes rarely.** It's two reads:++ - `kinds.list()`: each kind's slots and how surfaces render it;++ - `definitions.list({ kind })`: names, art, attributes.++++ Read it once and keep it. Production had 6 kinds and 70 published items on 2026-10-22.++- **Who wears what** is one public read for up to 100 people (`inventory.equipped(subjects)`), the++ same call chat renders use. `equippedOf()` splits larger lists into calls of 100.++- **A look:**++ - the hat's emoji goes before the name;++ - a chat tag goes in brackets;++ - a badge's emoji goes after;++ - a name effect is the CSS class OpenVibe's pages use (`name-fx-divine`, …).++++ An item whose definition is retired or unpublished shows nothing rather than a guess.++- **`renderHtml()` escapes the name** and adds a class only when it is a plain token, so a person's++ name can't inject markup.++- **Reading is for everyone; issuing is not.** Defining and granting items is for each kind's++ issuer (OpenVibe's own services today, `inventory.definition.manage` and `inventory.item.grant`++ are first-party), and nothing is sold or traded.++++## Files++++| File | What |++|---|---|++| `wear.js` | `loadConfig()`, `createInventory()`, `loadCatalog()`, `equippedOf()`, `lookOf()`, `renderLine()`, `renderHtml()`, CLI |++| `test/smoke.test.js` | the catalog, batched equipped reads and both renderings against a stand-in for Inventory |++++## Run the smoke test++++```bash++npm test++```++++The test hands the SDK a `fetch` that answers like Inventory's public routes (`server/http/api.js`++on OpenVibe.Inventory's main, shapes sampled from production on 2026-10-22). It checks:++- that no token is sent;++- one definitions read per kind;++- 152 people fetched in calls of 100 and 52;++- the look, including a retired item that shows nothing;++- the text line;++- the escaped HTML, with an unsafe class refused.++++Nothing reaches the network.++++## Run it against the real platform++++```bash++npm install++node wear.js usr_01J… # anyone's subject id (usr_…), as your app learns it from sign-in++```++++An app that signs people in with OpenVibe (see [oauth-app](../oauth-app)) learns each person's++`usr_…` id from their token, and that id is all these reads need.nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-inventory-wear",++ "version": "0.1.0",++ "private": true,++ "description": "Show what people wear on OpenVibe (hats, chat tags, badges, name effects) next to their names in your own app, from Inventory's public reads.",++ "license": "MIT",++ "main": "wear.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env wear.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ }++}nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * Smoke test: wear.js against a stand-in for OpenVibe.Inventory's public reads (the shapes server/http/api.js answers,++ * sampled from production on 2026-10-22): no token sent, the catalog read once per kind, equipped sets fetched 100++ * people per call, a look built from the slots (a retired definition shows nothing), and the line and HTML rendered++ * with names escaped. No network.++ */++const assert = require('node:assert/strict');++const { loadConfig, createInventory, loadCatalog, equippedOf, lookOf, renderLine, renderHtml } = require('../wear');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const A = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';++const B = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1';++const KINDS = [++ { id: 'live.chat_tag', slots: ['chat_tag'] }, { id: 'live.hat', slots: ['hat'] },++ { id: 'live.name_effect', slots: ['name_effect'] }, { id: 'network.badge', slots: ['badge'] },++];++const DEFS = {++ 'live.hat': [{ id: 'itd_01M4GGQ9Y4DWMP43ERNQ3BQW1V', kind: 'live.hat', name: 'Cowboy Hat', art: { emoji: '🤠' }, attributes: { tier: 2 } }],++ 'live.chat_tag': [{ id: 'itd_01M4GGQ9Z0000000000000000A', kind: 'live.chat_tag', name: 'VIP tag', art: {}, attributes: { text: 'VIP' } }],++ 'live.name_effect': [{ id: 'itd_01M4GGQ9VT32FM9CFD8J0FVX3H', kind: 'live.name_effect', name: 'Divine Name', art: { emoji: '✝️', token: 'name-fx-divine' }, attributes: { css_class: 'name-fx-divine' } }],++ 'network.badge': [{ id: 'itd_01M4GGQ9Z0000000000000000B', kind: 'network.badge', name: 'Early Supporter', art: { emoji: '⭐' }, attributes: {} }],++};++const calls = [];++const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/json' } });++async function fakeFetch(url, init = {}) {++ const u = new URL(String(url));++ calls.push({ path: u.pathname, query: Object.fromEntries(u.searchParams), auth: new Headers(init.headers).get('authorization') });++ assert.equal(u.origin, 'https://inventory.openvibe.network');++ if (u.pathname === '/api/v1/kinds') return json({ kinds: KINDS });++ if (u.pathname === '/api/v1/definitions') return json({ definitions: DEFS[u.searchParams.get('kind')] || [] });++ if (u.pathname === '/api/v1/equipped') {++ const subjects = u.searchParams.get('subjects').split(',');++ return json({ equipped: subjects.map((s) => ({++ subject: s, updated_at: '2026-10-22T19:00:00.000Z',++ slots: s === A ? {++ 'live.hat:hat': { instance_id: 'inv_1', definition_id: 'itd_01M4GGQ9Y4DWMP43ERNQ3BQW1V' },++ 'live.chat_tag:chat_tag': { instance_id: 'inv_2', definition_id: 'itd_01M4GGQ9Z0000000000000000A' },++ 'live.name_effect:name_effect': { instance_id: 'inv_3', definition_id: 'itd_01M4GGQ9VT32FM9CFD8J0FVX3H', token: 'name-fx-divine' },++ 'network.badge:badge': { instance_id: 'inv_4', definition_id: 'itd_01M4GGQ9Z0000000000000000B' },++ 'live.particle:particle': { instance_id: 'inv_5', definition_id: 'itd_RETIRED' },++ } : {},++ })) });++ }++ throw new Error(`unexpected ${u.pathname}`);++}++++(async () => {++ assert.equal(loadConfig({}).inventoryUrl, 'https://inventory.openvibe.network');++ const inv = createInventory(loadConfig({}), { fetch: fakeFetch });++++ // The catalog: kinds once, then each kind's published definitions.++ const catalog = await loadCatalog(inv);++ assert.equal(catalog.kinds.size, 4);++ assert.equal(catalog.definitions.size, 4);++ assert.deepEqual(calls.filter((c) => c.path === '/api/v1/definitions').map((c) => c.query.kind).sort(), ['live.chat_tag', 'live.hat', 'live.name_effect', 'network.badge']);++ assert.ok(calls.every((c) => c.auth === null), 'public reads: no token is sent');++++ // Who wears what: 100 people per call.++ calls.length = 0;++ const many = [A, B, ...Array.from({ length: 150 }, (_, i) => `usr_01JABCDEFGHJKMNPQRST${String(i).padStart(6, '0').replace(/[ILOU]/g, '0')}`)];++ const eq = await equippedOf(inv, many);++ assert.equal(eq.size, 152);++ assert.deepEqual(calls.map((c) => c.query.subjects.split(',').length), [100, 52]);++ await assert.rejects(equippedOf(inv, ['alex']), /not a person's id/);++++ // A's look: hat before, tag in brackets, badge after, the name effect as a class; the retired item shows nothing.++ const look = lookOf(eq.get(A), catalog);++ assert.deepEqual([look.before, look.tag, look.after, look.nameClass], [['🤠'], 'VIP', ['⭐'], 'name-fx-divine']);++ assert.equal(renderLine('alex', look), '🤠 [VIP] alex ⭐ (Cowboy Hat · VIP tag · Divine Name · Early Supporter)');++ assert.equal(renderHtml('<b>alex</b>', look), '🤠 <span class="ov-tag">VIP</span> <span class="name-fx-divine"><b>alex</b></span> ⭐', 'names are escaped');++ // B wears nothing: just the name.++ assert.equal(renderLine('sam', lookOf(eq.get(B), catalog)), 'sam');++ assert.equal(renderHtml('sam', lookOf(eq.get(B), catalog)), '<span>sam</span>');++ // A class that is not a plain token is never put into the page.++ assert.equal(renderHtml('x', { ...look, nameClass: 'a" onmouseover="x' }).includes('onmouseover'), false);++++ console.log('inventory-wear smoke: public catalog and equipped reads without a token, 100 per call, the look rendered as text and escaped HTML');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++/**++ * Show what people wear on OpenVibe next to their names in your own app: the network-wide inventory++ * (OpenVibe.Inventory) is public to read, with no token. A person's hat, badge, name effect and chat tag follow them++ * from site to site, and any app can render them the way OpenVibe.Live's chat does.++ *++ * node wear.js usr_01J… usr_01J…++ * # 🤠 [VIP] alex (Cowboy Hat · name effect: name-fx-divine)++ *++ * Two reads, both public and cacheable:++ * - the catalog: every kind (its slots and how surfaces render it) and the published definitions (names, art,++ * attributes), which change rarely, so read them once and keep them;++ * - who wears what: GET /api/v1/equipped for up to 100 people per call, as chat renders do.++ * Defining and granting items is for each kind's issuer (OpenVibe's own services today); an app reads.++ */++const { createClient } = require('openvibe-sdk/core');++const { createInventoryClient } = require('openvibe-sdk/inventory');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const BATCH = 100; // Inventory's limit per /equipped call++++function loadConfig(env = process.env) {++ return { inventoryUrl: String(env.OV_INVENTORY_URL || 'https://inventory.openvibe.network').replace(/\/+$/, '') };++}++++/** Inventory's client with no token: these reads are public. */++function createInventory(config, { fetch } = {}) {++ return createInventoryClient(createClient({ baseUrls: { inventory: config.inventoryUrl }, ...(fetch ? { fetch } : {}) }));++}++++/** The catalog: kinds by id and published definitions by id (page through a kind's definitions as needed). */++async function loadCatalog(inv) {++ const { kinds = [] } = await inv.kinds.list();++ const definitions = new Map();++ for (const k of kinds) {++ const { definitions: list = [] } = await inv.definitions.list({ kind: k.id, limit: 200 });++ for (const d of list) definitions.set(d.id, d);++ }++ return { kinds: new Map(kinds.map((k) => [k.id, k])), definitions };++}++++/** Everyone's equipped slots, 100 people per call → Map(subject → slots). */++async function equippedOf(inv, subjects) {++ const want = [...new Set(subjects)];++ for (const s of want) if (!SUBJECT_RE.test(s)) throw new TypeError(`${s} is not a person's id (usr_…)`);++ const out = new Map();++ for (let i = 0; i < want.length; i += BATCH) {++ const { equipped = [] } = await inv.equipped(want.slice(i, i + BATCH));++ for (const e of equipped) out.set(e.subject, e.slots || {});++ }++ return out;++}++++/**++ * One person's look as plain parts: the hat's emoji before the name, a chat tag in brackets, a badge's emoji after,++ * and a name effect as the CSS class OpenVibe's pages use (with its emoji when there is one).++ */++function lookOf(slots, catalog) {++ const look = { before: [], after: [], tag: null, nameClass: null, items: [] };++ for (const [slotKey, slot] of Object.entries(slots || {})) {++ const kindId = slotKey.slice(0, slotKey.lastIndexOf(':'));++ const def = catalog.definitions.get(slot.definition_id);++ if (!def) continue; // retired or not published: show nothing rather than a guess++ const emoji = def.art && def.art.emoji;++ look.items.push({ kind: kindId, name: def.name });++ if (kindId === 'live.hat' && emoji) look.before.push(emoji);++ else if (kindId === 'live.chat_tag') look.tag = (def.attributes && def.attributes.text) || def.name;++ else if (kindId === 'network.badge' && emoji) look.after.push(emoji);++ else if (kindId === 'live.name_effect') look.nameClass = (def.art && def.art.token) || (def.attributes && def.attributes.css_class) || null;++ }++ return look;++}++++/** The text line a terminal or a plain list shows. */++function renderLine(name, look) {++ return [...look.before, look.tag ? `[${look.tag}]` : null, name, ...look.after].filter(Boolean).join(' ')++ + (look.items.length ? ` (${look.items.map((i) => i.name).join(' · ')})` : '');++}++++/** HTML for a web page, escaped; style the name-fx-* classes as you like. */++function renderHtml(name, look) {++ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++ const tag = look.tag ? `<span class="ov-tag">${esc(look.tag)}</span> ` : '';++ const cls = look.nameClass && /^[a-z0-9-]{1,40}$/.test(look.nameClass) ? ` class="${look.nameClass}"` : '';++ return `${look.before.map(esc).join('')}${look.before.length ? ' ' : ''}${tag}<span${cls}>${esc(name)}</span>${look.after.length ? ' ' : ''}${look.after.map(esc).join('')}`;++}++++module.exports = { loadConfig, createInventory, loadCatalog, equippedOf, lookOf, renderLine, renderHtml };++++if (require.main === module) {++ (async () => {++ const subjects = process.argv.slice(2);++ if (!subjects.length) throw new Error('usage: wear.js <usr_…> [usr_…]');++ const inv = createInventory(loadConfig());++ const [catalog, equipped] = await Promise.all([loadCatalog(inv), equippedOf(inv, subjects)]);++ for (const s of subjects) console.log(renderLine(s, lookOf(equipped.get(s), catalog)));++ })().catch((err) => { console.error(err.code ? `${err.code}: ${err.message}` : err.message); process.exit(1); });++}@@@ -77,6 +77,17 @@"node": ">=22"}},++ "examples/inventory-wear": {++ "name": "openvibe-example-inventory-wear",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ },++ "engines": {++ "node": ">=22"++ }++ },"examples/media-uploader": {"name": "openvibe-example-media-uploader","version": "0.2.0",@@@ -725,6 +736,10 @@"resolved": "examples/go-live","link": true},++ "node_modules/openvibe-example-inventory-wear": {++ "resolved": "examples/inventory-wear",++ "link": true++ },"node_modules/openvibe-example-media-uploader": {"resolved": "examples/media-uploader","link": true@@@ -11,13 +11,13 @@ const fs = require('node:fs');const path = require('node:path');const ROOT = path.join(__dirname, '..');--const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];++const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'inventory-wear', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];// The pin every example must share: the repository's own (the pin bot moves them all at once).const ROOT_PKG = require(path.join(ROOT, 'package.json'));const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();--assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, space-deploy and zone-s3');++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, inventory-wear, space-deploy and zone-s3');function sources(dir) {const out = [];
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
> and public APIs, nothing else.
-**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Thirteen examples, each with a smoke test that CI runs on
+**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Fourteen examples, each with a smoke test that CI runs on
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
is not part of CI or `npm test`, and it was not run against production as part of this release.
@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
+| [inventory-wear](examples/inventory-wear) | Show what people wear on OpenVibe (hats, chat tags, badges, name effects) next to names in your own app: the public catalog and who wears what, 100 people per call, with no token; text and escaped HTML | `openvibe-sdk/inventory` (`kinds`, `definitions`, `equipped`) |
| [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` |
| [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` |
| [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
| [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
@@ -293,7 +294,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -293,7 +294,7 @@ test/public-surface.test.js, test/structure.test.js
## Owns
## Owns
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3) and the space-deploy example (Space deploys from CI)
+ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3), the space-deploy example (Space deploys from CI) and the inventory-wear example (what people wear, rendered in your app)
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"deployed": false,
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"deployment": "none: a repository with CI, not a runtime and not a published package",
"features": [
"features": [
- "thirteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow)",
+ "fourteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow), what people wear (Inventory's public catalog and equipped reads, no token)",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
"npm run e2e against the real platform (on demand)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+# Nothing is required: Inventory's catalog and who wears what are public reads, with no token.
+# Optional:
+# OV_INVENTORY_URL=https://inventory.openvibe.network
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+# What people wear, in your app
+
+On OpenVibe, a person's hat, chat tag, badge and name effect follow them from site to site through
+[OpenVibe.Inventory](https://inventory.openvibe.network), the network-wide inventory. This example
+shows them next to names in **your** app, as OpenVibe.Live's chat does. Both reads are public, so
+you need no token and no app.
+
+```bash
+node wear.js usr_01J… usr_01J…
+# 🤠 [VIP] alex ⭐ (Cowboy Hat · VIP tag · Divine Name · Early Supporter)
+# sam
+```
+
+## What it proves
+
+- **The catalog is public and changes rarely.** It's two reads:
+ - `kinds.list()`: each kind's slots and how surfaces render it;
+ - `definitions.list({ kind })`: names, art, attributes.
+
+ Read it once and keep it. Production had 6 kinds and 70 published items on 2026-10-22.
+- **Who wears what** is one public read for up to 100 people (`inventory.equipped(subjects)`), the
+ same call chat renders use. `equippedOf()` splits larger lists into calls of 100.
+- **A look:**
+ - the hat's emoji goes before the name;
+ - a chat tag goes in brackets;
+ - a badge's emoji goes after;
+ - a name effect is the CSS class OpenVibe's pages use (`name-fx-divine`, …).
+
+ An item whose definition is retired or unpublished shows nothing rather than a guess.
+- **`renderHtml()` escapes the name** and adds a class only when it is a plain token, so a person's
+ name can't inject markup.
+- **Reading is for everyone; issuing is not.** Defining and granting items is for each kind's
+ issuer (OpenVibe's own services today, `inventory.definition.manage` and `inventory.item.grant`
+ are first-party), and nothing is sold or traded.
+
+## Files
+
+| File | What |
+|---|---|
+| `wear.js` | `loadConfig()`, `createInventory()`, `loadCatalog()`, `equippedOf()`, `lookOf()`, `renderLine()`, `renderHtml()`, CLI |
+| `test/smoke.test.js` | the catalog, batched equipped reads and both renderings against a stand-in for Inventory |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The test hands the SDK a `fetch` that answers like Inventory's public routes (`server/http/api.js`
+on OpenVibe.Inventory's main, shapes sampled from production on 2026-10-22). It checks:
+- that no token is sent;
+- one definitions read per kind;
+- 152 people fetched in calls of 100 and 52;
+- the look, including a retired item that shows nothing;
+- the text line;
+- the escaped HTML, with an unsafe class refused.
+
+Nothing reaches the network.
+
+## Run it against the real platform
+
+```bash
+npm install
+node wear.js usr_01J… # anyone's subject id (usr_…), as your app learns it from sign-in
+```
+
+An app that signs people in with OpenVibe (see [oauth-app](../oauth-app)) learns each person's
+`usr_…` id from their token, and that id is all these reads need.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-inventory-wear",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Show what people wear on OpenVibe (hats, chat tags, badges, name effects) next to their names in your own app, from Inventory's public reads.",
+ "license": "MIT",
+ "main": "wear.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env wear.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * Smoke test: wear.js against a stand-in for OpenVibe.Inventory's public reads (the shapes server/http/api.js answers,
+ * sampled from production on 2026-10-22): no token sent, the catalog read once per kind, equipped sets fetched 100
+ * people per call, a look built from the slots (a retired definition shows nothing), and the line and HTML rendered
+ * with names escaped. No network.
+ */
+const assert = require('node:assert/strict');
+const { loadConfig, createInventory, loadCatalog, equippedOf, lookOf, renderLine, renderHtml } = require('../wear');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const A = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const B = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1';
+const KINDS = [
+ { id: 'live.chat_tag', slots: ['chat_tag'] }, { id: 'live.hat', slots: ['hat'] },
+ { id: 'live.name_effect', slots: ['name_effect'] }, { id: 'network.badge', slots: ['badge'] },
+];
+const DEFS = {
+ 'live.hat': [{ id: 'itd_01M4GGQ9Y4DWMP43ERNQ3BQW1V', kind: 'live.hat', name: 'Cowboy Hat', art: { emoji: '🤠' }, attributes: { tier: 2 } }],
+ 'live.chat_tag': [{ id: 'itd_01M4GGQ9Z0000000000000000A', kind: 'live.chat_tag', name: 'VIP tag', art: {}, attributes: { text: 'VIP' } }],
+ 'live.name_effect': [{ id: 'itd_01M4GGQ9VT32FM9CFD8J0FVX3H', kind: 'live.name_effect', name: 'Divine Name', art: { emoji: '✝️', token: 'name-fx-divine' }, attributes: { css_class: 'name-fx-divine' } }],
+ 'network.badge': [{ id: 'itd_01M4GGQ9Z0000000000000000B', kind: 'network.badge', name: 'Early Supporter', art: { emoji: '⭐' }, attributes: {} }],
+};
+const calls = [];
+const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/json' } });
+async function fakeFetch(url, init = {}) {
+ const u = new URL(String(url));
+ calls.push({ path: u.pathname, query: Object.fromEntries(u.searchParams), auth: new Headers(init.headers).get('authorization') });
+ assert.equal(u.origin, 'https://inventory.openvibe.network');
+ if (u.pathname === '/api/v1/kinds') return json({ kinds: KINDS });
+ if (u.pathname === '/api/v1/definitions') return json({ definitions: DEFS[u.searchParams.get('kind')] || [] });
+ if (u.pathname === '/api/v1/equipped') {
+ const subjects = u.searchParams.get('subjects').split(',');
+ return json({ equipped: subjects.map((s) => ({
+ subject: s, updated_at: '2026-10-22T19:00:00.000Z',
+ slots: s === A ? {
+ 'live.hat:hat': { instance_id: 'inv_1', definition_id: 'itd_01M4GGQ9Y4DWMP43ERNQ3BQW1V' },
+ 'live.chat_tag:chat_tag': { instance_id: 'inv_2', definition_id: 'itd_01M4GGQ9Z0000000000000000A' },
+ 'live.name_effect:name_effect': { instance_id: 'inv_3', definition_id: 'itd_01M4GGQ9VT32FM9CFD8J0FVX3H', token: 'name-fx-divine' },
+ 'network.badge:badge': { instance_id: 'inv_4', definition_id: 'itd_01M4GGQ9Z0000000000000000B' },
+ 'live.particle:particle': { instance_id: 'inv_5', definition_id: 'itd_RETIRED' },
+ } : {},
+ })) });
+ }
+ throw new Error(`unexpected ${u.pathname}`);
+}
+
+(async () => {
+ assert.equal(loadConfig({}).inventoryUrl, 'https://inventory.openvibe.network');
+ const inv = createInventory(loadConfig({}), { fetch: fakeFetch });
+
+ // The catalog: kinds once, then each kind's published definitions.
+ const catalog = await loadCatalog(inv);
+ assert.equal(catalog.kinds.size, 4);
+ assert.equal(catalog.definitions.size, 4);
+ assert.deepEqual(calls.filter((c) => c.path === '/api/v1/definitions').map((c) => c.query.kind).sort(), ['live.chat_tag', 'live.hat', 'live.name_effect', 'network.badge']);
+ assert.ok(calls.every((c) => c.auth === null), 'public reads: no token is sent');
+
+ // Who wears what: 100 people per call.
+ calls.length = 0;
+ const many = [A, B, ...Array.from({ length: 150 }, (_, i) => `usr_01JABCDEFGHJKMNPQRST${String(i).padStart(6, '0').replace(/[ILOU]/g, '0')}`)];
+ const eq = await equippedOf(inv, many);
+ assert.equal(eq.size, 152);
+ assert.deepEqual(calls.map((c) => c.query.subjects.split(',').length), [100, 52]);
+ await assert.rejects(equippedOf(inv, ['alex']), /not a person's id/);
+
+ // A's look: hat before, tag in brackets, badge after, the name effect as a class; the retired item shows nothing.
+ const look = lookOf(eq.get(A), catalog);
+ assert.deepEqual([look.before, look.tag, look.after, look.nameClass], [['🤠'], 'VIP', ['⭐'], 'name-fx-divine']);
+ assert.equal(renderLine('alex', look), '🤠 [VIP] alex ⭐ (Cowboy Hat · VIP tag · Divine Name · Early Supporter)');
+ assert.equal(renderHtml('<b>alex</b>', look), '🤠 <span class="ov-tag">VIP</span> <span class="name-fx-divine"><b>alex</b></span> ⭐', 'names are escaped');
+ // B wears nothing: just the name.
+ assert.equal(renderLine('sam', lookOf(eq.get(B), catalog)), 'sam');
+ assert.equal(renderHtml('sam', lookOf(eq.get(B), catalog)), '<span>sam</span>');
+ // A class that is not a plain token is never put into the page.
+ assert.equal(renderHtml('x', { ...look, nameClass: 'a" onmouseover="x' }).includes('onmouseover'), false);
+
+ console.log('inventory-wear smoke: public catalog and equipped reads without a token, 100 per call, the look rendered as text and escaped HTML');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+/**
+ * Show what people wear on OpenVibe next to their names in your own app: the network-wide inventory
+ * (OpenVibe.Inventory) is public to read, with no token. A person's hat, badge, name effect and chat tag follow them
+ * from site to site, and any app can render them the way OpenVibe.Live's chat does.
+ *
+ * node wear.js usr_01J… usr_01J…
+ * # 🤠 [VIP] alex (Cowboy Hat · name effect: name-fx-divine)
+ *
+ * Two reads, both public and cacheable:
+ * - the catalog: every kind (its slots and how surfaces render it) and the published definitions (names, art,
+ * attributes), which change rarely, so read them once and keep them;
+ * - who wears what: GET /api/v1/equipped for up to 100 people per call, as chat renders do.
+ * Defining and granting items is for each kind's issuer (OpenVibe's own services today); an app reads.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createInventoryClient } = require('openvibe-sdk/inventory');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const BATCH = 100; // Inventory's limit per /equipped call
+
+function loadConfig(env = process.env) {
+ return { inventoryUrl: String(env.OV_INVENTORY_URL || 'https://inventory.openvibe.network').replace(/\/+$/, '') };
+}
+
+/** Inventory's client with no token: these reads are public. */
+function createInventory(config, { fetch } = {}) {
+ return createInventoryClient(createClient({ baseUrls: { inventory: config.inventoryUrl }, ...(fetch ? { fetch } : {}) }));
+}
+
+/** The catalog: kinds by id and published definitions by id (page through a kind's definitions as needed). */
+async function loadCatalog(inv) {
+ const { kinds = [] } = await inv.kinds.list();
+ const definitions = new Map();
+ for (const k of kinds) {
+ const { definitions: list = [] } = await inv.definitions.list({ kind: k.id, limit: 200 });
+ for (const d of list) definitions.set(d.id, d);
+ }
+ return { kinds: new Map(kinds.map((k) => [k.id, k])), definitions };
+}
+
+/** Everyone's equipped slots, 100 people per call → Map(subject → slots). */
+async function equippedOf(inv, subjects) {
+ const want = [...new Set(subjects)];
+ for (const s of want) if (!SUBJECT_RE.test(s)) throw new TypeError(`${s} is not a person's id (usr_…)`);
+ const out = new Map();
+ for (let i = 0; i < want.length; i += BATCH) {
+ const { equipped = [] } = await inv.equipped(want.slice(i, i + BATCH));
+ for (const e of equipped) out.set(e.subject, e.slots || {});
+ }
+ return out;
+}
+
+/**
+ * One person's look as plain parts: the hat's emoji before the name, a chat tag in brackets, a badge's emoji after,
+ * and a name effect as the CSS class OpenVibe's pages use (with its emoji when there is one).
+ */
+function lookOf(slots, catalog) {
+ const look = { before: [], after: [], tag: null, nameClass: null, items: [] };
+ for (const [slotKey, slot] of Object.entries(slots || {})) {
+ const kindId = slotKey.slice(0, slotKey.lastIndexOf(':'));
+ const def = catalog.definitions.get(slot.definition_id);
+ if (!def) continue; // retired or not published: show nothing rather than a guess
+ const emoji = def.art && def.art.emoji;
+ look.items.push({ kind: kindId, name: def.name });
+ if (kindId === 'live.hat' && emoji) look.before.push(emoji);
+ else if (kindId === 'live.chat_tag') look.tag = (def.attributes && def.attributes.text) || def.name;
+ else if (kindId === 'network.badge' && emoji) look.after.push(emoji);
+ else if (kindId === 'live.name_effect') look.nameClass = (def.art && def.art.token) || (def.attributes && def.attributes.css_class) || null;
+ }
+ return look;
+}
+
+/** The text line a terminal or a plain list shows. */
+function renderLine(name, look) {
+ return [...look.before, look.tag ? `[${look.tag}]` : null, name, ...look.after].filter(Boolean).join(' ')
+ + (look.items.length ? ` (${look.items.map((i) => i.name).join(' · ')})` : '');
+}
+
+/** HTML for a web page, escaped; style the name-fx-* classes as you like. */
+function renderHtml(name, look) {
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+ const tag = look.tag ? `<span class="ov-tag">${esc(look.tag)}</span> ` : '';
+ const cls = look.nameClass && /^[a-z0-9-]{1,40}$/.test(look.nameClass) ? ` class="${look.nameClass}"` : '';
+ return `${look.before.map(esc).join('')}${look.before.length ? ' ' : ''}${tag}<span${cls}>${esc(name)}</span>${look.after.length ? ' ' : ''}${look.after.map(esc).join('')}`;
+}
+
+module.exports = { loadConfig, createInventory, loadCatalog, equippedOf, lookOf, renderLine, renderHtml };
+
+if (require.main === module) {
+ (async () => {
+ const subjects = process.argv.slice(2);
+ if (!subjects.length) throw new Error('usage: wear.js <usr_…> [usr_…]');
+ const inv = createInventory(loadConfig());
+ const [catalog, equipped] = await Promise.all([loadCatalog(inv), equippedOf(inv, subjects)]);
+ for (const s of subjects) console.log(renderLine(s, lookOf(equipped.get(s), catalog)));
+ })().catch((err) => { console.error(err.code ? `${err.code}: ${err.message}` : err.message); process.exit(1); });
+}
@@ -77,6 +77,17 @@
@@ -77,6 +77,17 @@
"node": ">=22"
"node": ">=22"
}
}
},
},
+ "examples/inventory-wear": {
+ "name": "openvibe-example-inventory-wear",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"examples/media-uploader": {
"examples/media-uploader": {
"name": "openvibe-example-media-uploader",
"name": "openvibe-example-media-uploader",
"version": "0.2.0",
"version": "0.2.0",
@@ -725,6 +736,10 @@
@@ -725,6 +736,10 @@
"resolved": "examples/go-live",
"resolved": "examples/go-live",
"link": true
"link": true
},
},
+ "node_modules/openvibe-example-inventory-wear": {
+ "resolved": "examples/inventory-wear",
+ "link": true
+ },
"node_modules/openvibe-example-media-uploader": {
"node_modules/openvibe-example-media-uploader": {
"resolved": "examples/media-uploader",
"resolved": "examples/media-uploader",
"link": true
"link": true
@@ -11,13 +11,13 @@ const fs = require('node:fs');
@@ -11,13 +11,13 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];
+const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'inventory-wear', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];
// The pin every example must share: the repository's own (the pin bot moves them all at once).
// The pin every example must share: the repository's own (the pin bot moves them all at once).
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
-assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, space-deploy and zone-s3');
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, inventory-wear, space-deploy and zone-s3');
function sources(dir) {
function sources(dir) {
const out = [];
const out = [];