openvibers/openvibe.sdk Commits

0.16.0: openvibe-sdk/openre, the client for OpenRe.Stream, the platform's streaming engine. It covers streams (byExternalRef, create, get, update, delete, keys, rotateKey, destinations, addDestination), destinations (update, delete, test, start, stop, logs), sessions (list, get, playback cached 10 s, end, outputs), outputLogs, workers and manageUrl. Every call uses a service token for openvibe.openre, and { subject } acts for a person (X-OV-Subject). A missing stream or session is null; refusals are OpenVibeError. It is server-only (browser: null) and exported for CJS, ESM and types. Products stop carrying their own copy, starting with OpenVibe.Live.

OP OpenVibers committed 28 Sept 2026 · fbc8893a56 · 15 files, +246 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b1c52b4..3e3ebb5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,16 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++
++## 0.16.0 (2026-09-28)
++
++- **`openvibe-sdk/openre`** (server): `createOpenReClient(client, { baseUrl?, publicUrl?, playbackTtlMs? })`, the client for OpenRe.Stream, the platform's streaming engine. Products stop carrying their own copy, starting with OpenVibe.Live.
++ - `streams`: `list`, `byExternalRef`, `create`, `get` (null when missing), `update`, `delete`, `keys`, `rotateKey`, `destinations` and `addDestination`.
++ - `destinations`: `update`, `delete`, `test`, `start`, `stop` and `logs`.
++ - `sessions`: `list`, `get` (null when missing), `playback` (cached 10 s), `end` and `outputs`.
++ - Also `outputLogs`, `workers` and `manageUrl`.
++ - Every call uses a service token for `openvibe.openre`. `{ subject }` names the person a call acts for (`X-OV-Subject`).
++
## 0.15.0 (2026-09-28)
**The event outbox and inbox on PostgreSQL** (ADR-004 on ADR-035), in `openvibe-sdk/events`. The better-sqlite3 versions stay for services that have not moved yet.
ddiff --git a/README.md b/README.md
iindex 17d763a..3f3f687 100644
---- a/README.md
++++ b/README.md
@@@ -177,6 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
++| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRe.Stream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
ddiff --git a/STATUS.json b/STATUS.json
iindex e41b767..cb02d17 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.15.0",
++ "version": "0.16.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -15,7 +15,7 @@
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"realtime SSE",
"media files and objects v2",
-- "community, search, chat, ai, jobs, tools, projects, modules, identity and vip clients",
++ "community, search, chat, ai, jobs, tools, projects, modules, identity, openre and vip clients",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"mock platform for tests (openvibe-sdk/testing)",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 0e18fde..d48939e 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.15.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.16.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -40,7 +40,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.15.0';
++const SDK_VERSION = '0.16.0';
module.exports = {
createClient,
ddiff --git a/esm/openre.mjs b/esm/openre.mjs
nnew file mode 100644
iindex 0000000..4982e77
---- /dev/null
++++ b/esm/openre.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/openre.js. Do not edit.
++import cjs from '../src/openre.js';
++
++export const {
++ createOpenReClient,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 7e4ff56..332494b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.15.0",
++ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.15.0",
++ "version": "0.16.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 72bbcf2..e78f315 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.15.0",
++ "version": "0.16.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -136,6 +136,12 @@
"import": "./esm/frame.mjs",
"default": "./src/frame.js"
},
++ "./openre": {
++ "types": "./types/openre.d.ts",
++ "browser": null,
++ "import": "./esm/openre.mjs",
++ "default": "./src/openre.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 0addf20..3230db9 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -35,6 +35,7 @@ const ENTRIES = {
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
++ 'openre.mjs': '../src/openre.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'testing.mjs': '../src/testing/index.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 6a11d16..7714af7 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.15.0';
++const SDK_VERSION = '0.16.0';
module.exports = {
createClient,
ddiff --git a/src/openre.js b/src/openre.js
nnew file mode 100644
iindex 0000000..73f974a
---- /dev/null
++++ b/src/openre.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * openvibe-sdk/openre — OpenRe.Stream, the platform's streaming engine (ingest, sessions, restream outputs), for
++ * every product that puts a stream on air (OpenVibe.Live first). One client instead of a copy in each product.
++ * Server-side: it sends a Network service token for audience openvibe.openre.
++ *
++ * const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret });
++ * const openre = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: 'http://127.0.0.1:4500' } }));
++ * const s = await openre.streams.byExternalRef('live:managed_stream:12'); // or null
++ * const { stream } = await openre.streams.create({ title, external_refs }, { subject: 'usr_…' });
++ * const { key } = await openre.streams.rotateKey(s.id, { subject, graceSeconds: 0 });
++ * const pb = await openre.sessions.playback(sessionId); // cached 10 s
++ *
++ * Grants (on the caller's principal, audience openvibe.openre): openre.stream.read / .write, openre.key.rotate,
++ * openre.output.read / .write, openre.session.read / .end. `subject` names the person a call acts for
++ * (X-OV-Subject; a usr_ id); OpenRe checks that they own the stream. Errors are OpenVibeError (status, code);
++ * a missing stream, destination or session is null from get(), not an error.
++ * Shapes: Contracts openre.stream@1, openre.destination@1, openre.output@1, openre.session-read-result@1.
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++const enc = encodeURIComponent;
++const PLAYBACK_TTL_MS = 10_000;
++
++function createOpenReClient(client, { baseUrl, publicUrl = 'https://openre.stream', playbackTtlMs = PLAYBACK_TTL_MS, now = () => Date.now() } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createOpenReClient: pass an openvibe-sdk/core client');
++ const call = ({ subject, ...opts }) => client.json({
++ service: 'openre', baseUrl, audience: 'openvibe.openre', ...opts,
++ headers: { ...(opts.headers || {}), ...(subject ? { 'X-OV-Subject': String(subject) } : {}) },
++ });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const pick = (key) => (r) => (r ? r[key] : null);
++ const playbackCache = new Map();
++
++ const streams = {
++ /** { streams, next_cursor? } — filter by external_ref ("<service>:<type>:<id>"). */
++ list: ({ externalRef, limit, cursor, subject } = {}) => call({ path: '/api/v1/streams', query: { external_ref: externalRef, limit, cursor }, subject }),
++ /** The stream a product object points at, or null. */
++ byExternalRef: async (externalRef, opts = {}) => ((await streams.list({ ...opts, externalRef })).streams || [])[0] || null,
++ /** → { stream, key? }. The key in a create answer is shown once. */
++ create: (body, { subject, idempotencyKey } = {}) => call({ method: 'POST', path: '/api/v1/streams', json: body, subject, idempotencyKey }),
++ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/streams/${enc(id)}`, subject })).then(pick('stream')),
++ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/streams/${enc(id)}`, json: patch, subject }).then(pick('stream')),
++ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/streams/${enc(id)}`, subject }),
++ /** Key metadata (never the secret). */
++ keys: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/keys`, subject }),
++ /** → openre.key-rotate-result@1 ({ key, ingest, … }); the old key keeps working for graceSeconds. */
++ rotateKey: (id, { subject, graceSeconds = 0 } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/keys/rotate`, json: { grace_seconds: graceSeconds }, subject }),
++ destinations: (id, { subject } = {}) => call({ path: `/api/v1/streams/${enc(id)}/destinations`, subject }).then(pick('destinations')),
++ addDestination: (id, body, { subject } = {}) => call({ method: 'POST', path: `/api/v1/streams/${enc(id)}/destinations`, json: body, subject }).then(pick('destination')),
++ };
++
++ const destinations = {
++ update: (id, patch, { subject } = {}) => call({ method: 'PATCH', path: `/api/v1/destinations/${enc(id)}`, json: patch, subject }).then(pick('destination')),
++ delete: (id, { subject } = {}) => call({ method: 'DELETE', path: `/api/v1/destinations/${enc(id)}`, subject }),
++ /** A short test push to the destination; → { ok, detail }. */
++ test: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/test`, subject, timeoutMs: 30_000 }),
++ start: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/start`, subject }),
++ stop: (id, { subject } = {}) => call({ method: 'POST', path: `/api/v1/destinations/${enc(id)}/stop`, subject }),
++ logs: (id, { subject, limit } = {}) => call({ path: `/api/v1/destinations/${enc(id)}/logs`, query: { limit }, subject }),
++ };
++
++ const sessions = {
++ list: ({ streamId, state, limit, cursor, subject } = {}) => call({ path: '/api/v1/sessions', query: { stream_id: streamId, state, limit, cursor }, subject }),
++ get: (id, { subject } = {}) => orNull(call({ path: `/api/v1/sessions/${enc(id)}`, subject })).then(pick('session')),
++ /** The playback descriptor (cached playbackTtlMs: players ask on every viewer connect). */
++ async playback(id) {
++ const hit = playbackCache.get(id);
++ if (hit && now() - hit.at < playbackTtlMs) return hit.value;
++ const value = await orNull(call({ path: `/api/v1/sessions/${enc(id)}/playback` })).then(pick('playback'));
++ playbackCache.set(id, { at: now(), value });
++ if (playbackCache.size > 500) for (const [k, v] of playbackCache) if (now() - v.at > 6 * playbackTtlMs) playbackCache.delete(k);
++ return value;
++ },
++ end: (id, { subject, reason } = {}) => call({ method: 'POST', path: `/api/v1/sessions/${enc(id)}/end`, json: reason ? { reason } : {}, subject }),
++ outputs: (id, { subject } = {}) => call({ path: `/api/v1/sessions/${enc(id)}/outputs`, subject }).then(pick('outputs')),
++ };
++
++ return {
++ streams,
++ destinations,
++ sessions,
++ outputLogs: (id, { subject, limit } = {}) => call({ path: `/api/v1/outputs/${enc(id)}/logs`, query: { limit }, subject }),
++ workers: () => call({ path: '/api/v1/workers' }),
++ /** The standalone UI's page for a stream (for "manage on OpenRe.Stream" links). */
++ manageUrl: (streamId) => `${String(publicUrl).replace(/\/+$/, '')}/streams/${enc(streamId || '')}`,
++ clearCache: () => playbackCache.clear(),
++ };
++}
++
++module.exports = { createOpenReClient };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 504f2aa..996f01f 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './identity', './limits', './pubsub', './queue', './testing', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './identity', './limits', './openre', './pubsub', './queue', './testing', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/openre.test.js b/test/openre.test.js
nnew file mode 100644
iindex 0000000..3140e6b
---- /dev/null
++++ b/test/openre.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/** OpenRe: service-token calls with the acting subject, external_ref lookup, key rotation, null for a missing
++ * stream or session, playback cached 10 s, destinations and sessions paths, and errors kept as OpenVibeError. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient, isOpenVibeError } = require('../src/core');
++const { createOpenReClient } = require('../src/openre');
++
++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++
++async function openre() {
++ return stubServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ const p = u.pathname;
++ if (p === '/api/v1/streams' && req.method === 'GET') return send(res, 200, { streams: u.searchParams.get('external_ref') === 'live:managed_stream:12' ? [{ id: 'str_1', title: 'Slot' }] : [] });
++ if (p === '/api/v1/streams' && req.method === 'POST') return send(res, 201, { stream: { id: 'str_2', title: 'New' }, key: { secret: 'shown-once' } });
++ if (p === '/api/v1/streams/str_404') return send(res, 404, { type: 'about:blank', code: 'openre.not_found', status: 404 });
++ if (p === '/api/v1/streams/str_1' && req.method === 'GET') return send(res, 200, { stream: { id: 'str_1' } });
++ if (p === '/api/v1/streams/str_1/keys/rotate') return send(res, 200, { key: { secret: 'k2' }, ingest: { rtmp: 'rtmp://ingest/app' } });
++ if (p === '/api/v1/streams/str_1/destinations' && req.method === 'POST') return send(res, 201, { destination: { id: 'dst_1' } });
++ if (p === '/api/v1/destinations/dst_1/start') return send(res, 202, { ok: true });
++ if (p === '/api/v1/sessions/ses_1/playback') return send(res, 200, { playback: { flv: 'http://127.0.0.1:4501/live/x.flv' } });
++ if (p === '/api/v1/sessions/ses_404') return send(res, 404, { code: 'openre.not_found', status: 404 });
++ if (p === '/api/v1/sessions/ses_1/end') return send(res, 409, { type: 'about:blank', code: 'openre.session_ended', status: 409, detail: 'already ended' });
++ return send(res, 404, {});
++ });
++}
++
++run([
++ ['streams: external_ref lookup, create with the acting subject, get null on 404, rotate', async () => {
++ const srv = await openre();
++ const tokens = { getToken: async ({ audience }) => `tok-for-${audience}` };
++ const o = createOpenReClient(createClient({ tokenProvider: tokens, baseUrls: { openre: srv.url }, retries: 0 }));
++ assert.deepEqual(await o.streams.byExternalRef('live:managed_stream:12'), { id: 'str_1', title: 'Slot' });
++ assert.equal(await o.streams.byExternalRef('live:managed_stream:99'), null);
++ const first = srv.requests[0];
++ assert.equal(first.headers.authorization, 'Bearer tok-for-openvibe.openre', 'a service token for openvibe.openre');
++ assert.equal(new URL(first.url, 'http://x').searchParams.get('external_ref'), 'live:managed_stream:12');
++ const made = await o.streams.create({ title: 'New' }, { subject: USR });
++ assert.equal(made.stream.id, 'str_2');
++ const post = srv.requests.at(-1);
++ assert.equal(post.headers['x-ov-subject'], USR);
++ assert.deepEqual(JSON.parse(post.body), { title: 'New' });
++ assert.equal(await o.streams.get('str_404'), null);
++ assert.deepEqual(await o.streams.get('str_1'), { id: 'str_1' });
++ const rot = await o.streams.rotateKey('str_1', { subject: USR, graceSeconds: 30 });
++ assert.equal(rot.ingest.rtmp, 'rtmp://ingest/app');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body), { grace_seconds: 30 });
++ assert.equal((await o.streams.addDestination('str_1', { kind: 'rtmp', url: 'rtmp://a/b' }, { subject: USR })).id, 'dst_1');
++ assert.deepEqual(await o.destinations.start('dst_1', { subject: USR }), { ok: true });
++ assert.equal(o.manageUrl('str_1'), 'https://openre.stream/streams/str_1');
++ await srv.close();
++ }],
++ ['sessions: playback is cached, a missing session is null, a refusal is an OpenVibeError', async () => {
++ const srv = await openre();
++ let t = 1000;
++ const o = createOpenReClient(createClient({ token: 'svc', baseUrls: { openre: srv.url }, retries: 0 }), { now: () => t });
++ assert.deepEqual(await o.sessions.playback('ses_1'), { flv: 'http://127.0.0.1:4501/live/x.flv' });
++ await o.sessions.playback('ses_1');
++ assert.equal(srv.requests.length, 1, 'cached');
++ t += 10_001;
++ await o.sessions.playback('ses_1');
++ assert.equal(srv.requests.length, 2, 'refreshed after 10 s');
++ assert.equal(await o.sessions.get('ses_404'), null);
++ await assert.rejects(o.sessions.end('ses_1', { reason: 'admin' }), (err) => isOpenVibeError(err) && err.status === 409 && err.code === 'openre.session_ended');
++ await srv.close();
++ }],
++ ['needs a core client', () => {
++ assert.throws(() => createOpenReClient(null), /openvibe-sdk\/core client/);
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 09e8c46..9886eee 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 26);
++ assert.equal(subpaths.length, 27);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex cbee55e..668bc1c 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -14,3 +14,4 @@ export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
export * as vip from './vip';
++export * as openre from './openre';
ddiff --git a/types/openre.d.ts b/types/openre.d.ts
nnew file mode 100644
iindex 0000000..ae9a205
---- /dev/null
++++ b/types/openre.d.ts
@@@ -0,0 +1,48 @@
++import type { OpenVibeClient } from './core';
++
++/** Contracts openre.stream@1 (abridged; see openvibe-contracts for the full shape). */
++export interface OpenReStream {
++ id: string; title: string; owner_subject?: string; state?: string;
++ recording_mode?: string; recording_visibility?: string; mirror_to_live?: boolean;
++ external_refs?: Array<{ service: string; type: string; id: string; label?: string | null }>;
++ ingest?: Record<string, unknown>;
++ [field: string]: unknown;
++}
++export interface OpenReSession { id: string; stream_id: string; state: string; started_at?: string; ended_at?: string | null; [field: string]: unknown }
++export interface OpenReDestination { id: string; stream_id: string; kind?: string; enabled?: boolean; [field: string]: unknown }
++export interface Acting { subject?: string }
++
++export interface OpenReClient {
++ streams: {
++ list(opts?: Acting & { externalRef?: string; limit?: number; cursor?: string }): Promise<{ streams: OpenReStream[]; next_cursor?: string | null }>;
++ byExternalRef(externalRef: string, opts?: Acting): Promise<OpenReStream | null>;
++ create(body: Record<string, unknown>, opts?: Acting & { idempotencyKey?: string }): Promise<{ stream: OpenReStream; key?: unknown }>;
++ get(id: string, opts?: Acting): Promise<OpenReStream | null>;
++ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReStream>;
++ delete(id: string, opts?: Acting): Promise<unknown>;
++ keys(id: string, opts?: Acting): Promise<unknown>;
++ rotateKey(id: string, opts?: Acting & { graceSeconds?: number }): Promise<Record<string, unknown>>;
++ destinations(id: string, opts?: Acting): Promise<OpenReDestination[]>;
++ addDestination(id: string, body: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;
++ };
++ destinations: {
++ update(id: string, patch: Record<string, unknown>, opts?: Acting): Promise<OpenReDestination>;
++ delete(id: string, opts?: Acting): Promise<unknown>;
++ test(id: string, opts?: Acting): Promise<{ ok: boolean; detail?: string; [k: string]: unknown }>;
++ start(id: string, opts?: Acting): Promise<unknown>;
++ stop(id: string, opts?: Acting): Promise<unknown>;
++ logs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;
++ };
++ sessions: {
++ list(opts?: Acting & { streamId?: string; state?: string; limit?: number; cursor?: string }): Promise<{ sessions: OpenReSession[]; next_cursor?: string | null }>;
++ get(id: string, opts?: Acting): Promise<OpenReSession | null>;
++ playback(id: string): Promise<Record<string, unknown> | null>;
++ end(id: string, opts?: Acting & { reason?: string }): Promise<unknown>;
++ outputs(id: string, opts?: Acting): Promise<unknown[]>;
++ };
++ outputLogs(id: string, opts?: Acting & { limit?: number }): Promise<unknown>;
++ workers(): Promise<unknown>;
++ manageUrl(streamId: string): string;
++ clearCache(): void;
++}
++export function createOpenReClient(client: OpenVibeClient, opts?: { baseUrl?: string; publicUrl?: string; playbackTtlMs?: number; now?: () => number }): OpenReClient;