openvibers/openvibe.sdk Commits

0.52.0: openvibe-sdk/live, OpenVibe.Live in the SDK (who is live, channels, slots, going live, sessions, the content feeds, WHIP publishing) (#61)

OP OpenVibers committed 24 days ago · 5a9dd6f7e4 · 17 files, +308 −11

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7661a09..eab3a90 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.52.0 (2026-10-20)
++
++- **`openvibe-sdk/live`**: OpenVibe.Live in the SDK, its flagship product had no module. `createLiveClient(client)`
++ covers the routes documented at openvibe.live/documentation: who is live (`streams.list`, `recentlyOnline`), a
++ channel (`channels.get`, `url`, `toggleFollow`), slots (`slots.create` … `regenerateKey`), going live
++ (`goLive(slotId, opts)` → `{ stream, endpoint }`), the session (`update`, `heartbeat`, `endpoint`, `end`), the content
++ feeds (`content.feed`, `content.moments`), and WHIP publishing (`whip.url`, `whip.publish` → `{ answer, location }`,
++ `whip.end`). Signed calls carry the person's Network token; `/whip` gets only the stream key, never the client's
++ token. Writes carry no idempotency key and are never retried (a second go-live is a second session).
++
## 0.51.1 (2026-10-17)
- `openvibe-sdk/sso`: `GET /auth/me` for a signed-out visitor (or an expired, foreign or non-session token) answers
ddiff --git a/README.md b/README.md
iindex 831758b..65337be 100644
---- a/README.md
++++ b/README.md
@@@ -187,6 +187,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
++| `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried |
| `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/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
ddiff --git a/STATUS.json b/STATUS.json
iindex 8506aac..ce54aef 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.51.1",
++ "version": "0.52.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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex a46369f..a720d4a 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.51.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.52.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, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,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.51.1';
++const SDK_VERSION = '0.52.0';
module.exports = {
createClient,
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 3584fa0..1e5afeb 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,7 +7,8 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
-- tools, projects, resources, vip, quest, play,
-- download, openre, geo, usage, commerce, telemetry,
++ tools, projects, resources, vip, quest, live,
++ play, download, openre, geo, usage, commerce,
++ telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/live.mjs b/esm/live.mjs
nnew file mode 100644
iindex 0000000..c02434c
---- /dev/null
++++ b/esm/live.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/live.js. Do not edit.
++import cjs from '../src/live.js';
++
++export const {
++ createLiveClient,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex eb369ca..9e421bd 100644
---- a/index.js
++++ b/index.js
@@@ -26,6 +26,7 @@ module.exports = {
resources: require('./src/resources'),
vip: require('./src/vip'),
quest: require('./src/quest'),
++ live: require('./src/live'),
play: require('./src/play'),
download: require('./src/download'),
openre: require('./src/openre'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 2801faa..912caf8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.51.1",
++ "version": "0.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.51.1",
++ "version": "0.52.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 7ce02cc..ceb3be2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.51.1",
++ "version": "0.52.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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",
@@@ -254,6 +254,12 @@
"import": "./esm/quest.mjs",
"default": "./src/quest.js"
},
++ "./live": {
++ "types": "./types/live.d.ts",
++ "browser": null,
++ "import": "./esm/live.mjs",
++ "default": "./src/live.js"
++ },
"./vip": {
"types": "./types/vip.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 677f1ce..f70cc83 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -55,6 +55,7 @@ const ENTRIES = {
'play.mjs': '../src/play.js',
'download.mjs': '../src/download.js',
'quest.mjs': '../src/quest.js',
++ 'live.mjs': '../src/live.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'service.mjs': '../src/service/index.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 151fe0a..cee4e62 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.51.1';
++const SDK_VERSION = '0.52.0';
module.exports = {
createClient,
ddiff --git a/src/live.js b/src/live.js
nnew file mode 100644
iindex 0000000..2daa1d8
---- /dev/null
++++ b/src/live.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * openvibe-sdk/live — OpenVibe.Live for apps and tools: who is live, channels, slots (managed streams), going live and
++ * ending a session, ingest endpoints, WHIP publishing, and the content feeds. These are the routes the site's own pages
++ * use, documented at https://openvibe.live/documentation and https://openvibe.live/docs/whip. Signed calls carry the
++ * person's Network token (Live checks the issuer); the discovery reads need none.
++ *
++ * const live = createLiveClient(createClient({ tokenProvider }));
++ * const now = await live.streams.list(); // everyone live right now
++ * const channel = await live.channels.get('goosely'); // the channel page's data
++ * const { managed_stream: slot } = await live.slots.create({ title: 'Robot cam', streaming_method: 'whip' });
++ * const { stream, endpoint } = await live.goLive(slot.id, { title: 'Live now' });
++ * const session = await live.whip.publish(live.whip.url(slot.stream_key), offerSdp); // → { answer, location }
++ * await live.whip.end(session.location); // the last session ends the stream
++ * const page = await live.content.feed({ type: 'vods', sort: 'new' }); // recordings link to openvibe.video
++ *
++ * A slot is a saved setup with its own stream key; a stream is one live session started from it (POST /api/streams).
++ * WHIP (docs/whip): POST an SDP offer to /whip/<stream key> (or /whip/<slot id> with the key as a bearer); the answer
++ * comes back with a Location to DELETE when done. Errors are OpenVibeError (status, code); WHIP errors carry Live's
++ * code (invalid_stream_key, slot_not_found, …).
++ */
++const enc = encodeURIComponent;
++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
++const FEEDS = { content: '/api/content/feed', moments: '/api/content/moments' };
++
++function createLiveClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createLiveClient: pass an openvibe-sdk/core client');
++ const at = { service: 'live', baseUrl, audience: 'openvibe.live' };
++ // Writes are never retried on their own: Live keys no request by Idempotency-Key, and a second go-live or slot is
++ // a second one.
++ const call = ({ method = 'GET', ...opts }) => client.json({ ...at, method, ...(method === 'GET' ? {} : { idempotencyKey: false }), ...opts });
++ const user = (username) => {
++ const u = String(username || '').replace(/^@/, '');
++ if (!USERNAME_RE.test(u)) throw new TypeError('live: a username is 1–32 letters, digits or _');
++ return u;
++ };
++ const id = (v, what) => {
++ if (!/^\d{1,15}$/.test(String(v))) throw new TypeError(`live: ${what} is a number`);
++ return String(v);
++ };
++ const origin = async () => (baseUrl ? String(baseUrl).replace(/\/+$/, '') : await client.origin('live'));
++
++ const streams = {
++ /** Everyone live now, with external viewer totals. */
++ list: ({ signal } = {}) => call({ path: '/api/streams', signal }),
++ /** One stream (+ its endpoint while live, controls and cameras; keys stripped). */
++ get: (streamId, { signal } = {}) => call({ path: `/api/streams/${id(streamId, 'a stream id')}`, signal }),
++ /** Streamers grouped with their slots, most recently online first. */
++ recentlyOnline: ({ signal } = {}) => call({ path: '/api/streams/recently-online', signal }),
++ /** The signed-in person's streams. */
++ mine: ({ signal } = {}) => call({ path: '/api/streams/mine', signal }),
++ /** Title, description, category or tags of a live session (owner). */
++ update: (streamId, patch, { signal } = {}) => call({ method: 'PUT', path: `/api/streams/${id(streamId, 'a stream id')}`, json: patch, signal }),
++ /** End the session (owner): the recording finalises, restreams stop. */
++ end: (streamId, { signal } = {}) => call({ method: 'DELETE', path: `/api/streams/${id(streamId, 'a stream id')}`, signal }),
++ /** Keepalive while broadcasting (the active ingest is what keeps a stream live). */
++ heartbeat: (streamId, { signal } = {}) => call({ method: 'POST', path: `/api/streams/${id(streamId, 'a stream id')}/heartbeat`, signal }),
++ /** The session's ingest endpoint and stream key, for its protocol (owner). */
++ endpoint: (streamId, { signal } = {}) => call({ path: `/api/streams/${id(streamId, 'a stream id')}/endpoint`, signal }),
++ };
++
++ const slots = {
++ /** The signed-in person's slots and their slot limit. */
++ list: ({ signal } = {}) => call({ path: '/api/streams/managed', signal }),
++ /** A new slot with its own stream key: { title, description, category, streaming_method (browser|whip|cli|rtmp), … }. */
++ create: (slot, { signal } = {}) => call({ method: 'POST', path: '/api/streams/managed', json: slot, signal }),
++ update: (slotId, patch, { signal } = {}) => call({ method: 'PUT', path: `/api/streams/managed/${id(slotId, 'a slot id')}`, json: patch, signal }),
++ /** Delete a slot (409 while it is live). */
++ remove: (slotId, { signal } = {}) => call({ method: 'DELETE', path: `/api/streams/managed/${id(slotId, 'a slot id')}`, signal }),
++ /** A new stream key for the slot (the old one stops working). */
++ regenerateKey: (slotId, { signal } = {}) => call({ method: 'POST', path: `/api/streams/managed/${id(slotId, 'a slot id')}/regenerate-key`, signal }),
++ };
++
++ /** Go live from a slot → { stream, endpoint }. opts: title, category, tags, is_nsfw. */
++ const goLive = (slotId, opts = {}, { signal } = {}) => call({ method: 'POST', path: '/api/streams', json: { ...opts, managed_stream_id: Number(id(slotId, 'a slot id')) }, signal });
++
++ const channels = {
++ /** A channel page's data: streams, VODs, clips, panels and slots. */
++ get: (username, { signal } = {}) => call({ path: `/api/streams/channel/${enc(user(username))}`, signal }),
++ /** The signed-in person's own channel. */
++ mine: ({ signal } = {}) => call({ path: '/api/streams/channel', signal }),
++ update: (patch, { signal } = {}) => call({ method: 'PUT', path: '/api/streams/channel', json: patch, signal }),
++ /** Follow, or unfollow when already following (it toggles). */
++ toggleFollow: (username, { signal } = {}) => call({ method: 'POST', path: `/api/streams/channel/${enc(user(username))}/follow`, signal }),
++ /** https://openvibe.live/@<username>: a channel's one address. */
++ url: async (username) => `${await origin()}/@${user(username)}`,
++ };
++
++ const feed = (which) => ({ type, sort, window, cursor, limit, signal } = {}) => call({ path: FEEDS[which], query: { type, sort, window, cursor, limit }, signal });
++ const content = {
++ /** What people made: recordings, clips and pastes → { items, next, … } (recordings and clips link to openvibe.video). */
++ feed: feed('content'),
++ /** What the AI made (auto-clips, AI moments, recaps), each labelled AI-generated. */
++ moments: feed('moments'),
++ };
++
++ const whip = {
++ /** The WHIP URL for a stream key (or a slot id, with the key sent as the bearer). */
++ url: async (keyOrSlot) => {
++ const k = String(keyOrSlot || '');
++ if (!/^[A-Za-z0-9_-]{1,128}$/.test(k)) throw new TypeError('live.whip.url: pass a stream key or a slot id');
++ return `${await origin()}/whip/${enc(k)}`;
++ },
++ /**
++ * POST an SDP offer → { answer, location }: the SDP answer, and the session's absolute resource URL (DELETE it
++ * to stop). `key` is sent as the bearer (needed with a slot-id URL); the client's own token is never sent here.
++ */
++ publish: async (url, offer, { key, signal } = {}) => {
++ const target = await url;
++ const res = await client.request({
++ // The answer is application/sdp (read as text); an error is Live's JSON, so its code reaches OpenVibeError.
++ url: target, method: 'POST', body: String(offer), auth: false, idempotencyKey: false, retries: 0, signal,
++ headers: { 'Content-Type': 'application/sdp', Accept: 'application/sdp', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
++ });
++ const loc = res.headers.get('location');
++ return { answer: res.data, location: loc ? new URL(loc, target).href : null };
++ },
++ /** End a WHIP session (its Location). The last session on a stream ends the stream. */
++ end: async (location, { signal } = {}) => {
++ await client.request({ url: String(location), method: 'DELETE', auth: false, idempotencyKey: false, signal });
++ },
++ };
++
++ return { streams, slots, goLive, channels, content, whip };
++}
++
++module.exports = { createLiveClient };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 53b4ca3..c6e709e 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(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './limits', './live', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './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/bot.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/live.test.js b/test/live.test.js
nnew file mode 100644
iindex 0000000..b3772c0
---- /dev/null
++++ b/test/live.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++// openvibe-sdk/live: OpenVibe.Live's documented routes (discovery, channels, slots, going live, sessions, the content
++// feeds) through a real core client against a local stub, and the WHIP exchange: an SDP offer in, the answer and an
++// absolute Location out, Live's JSON error code on a refusal, and the client's own token never sent to /whip.
++const assert = require('assert');
++const http = require('http');
++const { createClient } = require('../src/core');
++const { createLiveClient } = require('../src/live');
++
++(async () => {
++ const seen = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, type: req.headers['content-type'] || null, idem: req.headers['idempotency-key'] || null, body });
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url.startsWith('/whip/')) {
++ if (req.method === 'DELETE') { res.writeHead(200); return res.end(); }
++ if (req.url === '/whip/bad-key') { res.writeHead(401, { 'Content-Type': 'application/json', 'X-WHIP-ERROR': 'invalid_stream_key' }); return res.end(JSON.stringify({ error: 'Stream key not recognised', code: 'invalid_stream_key' })); }
++ res.writeHead(201, { 'Content-Type': 'application/sdp', Location: '/whip/session/abc' });
++ return res.end('v=0\r\no=answer\r\n');
++ }
++ if (req.method === 'POST' && req.url === '/api/streams') return json(201, { stream: { id: 77 }, endpoint: { whipUrlBase: 'x' } });
++ return json(200, { ok: true });
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const client = createClient({ baseUrls: { live: base }, token: 'person-token', retries: 2 });
++ const live = createLiveClient(client);
++ const last = () => seen[seen.length - 1];
++
++ try {
++ // Discovery and channels.
++ await live.streams.list();
++ await live.streams.get(12);
++ await live.streams.recentlyOnline();
++ await live.channels.get('@goosely');
++ await live.content.feed({ type: 'vods', sort: 'new', cursor: 'abc', limit: 30 });
++ await live.content.moments({ window: 'week' });
++ assert.deepStrictEqual(seen.map((s) => `${s.method} ${s.url}`), [
++ 'GET /api/streams', 'GET /api/streams/12', 'GET /api/streams/recently-online', 'GET /api/streams/channel/goosely',
++ 'GET /api/content/feed?type=vods&sort=new&cursor=abc&limit=30', 'GET /api/content/moments?window=week',
++ ]);
++ assert.ok(seen.every((s) => s.auth === 'Bearer person-token'), 'the person\'s token rides along (Live checks the issuer)');
++ assert.strictEqual(await live.channels.url('goosely'), `${base}/@goosely`);
++ assert.throws(() => live.channels.get('no spaces'), /username/);
++ assert.throws(() => live.streams.get('12; DROP'), /number/);
++
++ // Slots, going live, the session.
++ seen.length = 0;
++ await live.slots.list();
++ await live.slots.create({ title: 'Robot cam', streaming_method: 'whip' });
++ await live.slots.update(5, { browser_mode: 'screen' });
++ await live.slots.regenerateKey(5);
++ const went = await live.goLive(5, { title: 'Live now' });
++ await live.streams.endpoint(77);
++ await live.streams.update(77, { title: 'Still live' });
++ await live.streams.heartbeat(77);
++ await live.streams.end(77);
++ await live.slots.remove(5);
++ assert.deepStrictEqual(went, { stream: { id: 77 }, endpoint: { whipUrlBase: 'x' } });
++ assert.deepStrictEqual(seen.map((s) => `${s.method} ${s.url}`), [
++ 'GET /api/streams/managed', 'POST /api/streams/managed', 'PUT /api/streams/managed/5', 'POST /api/streams/managed/5/regenerate-key',
++ 'POST /api/streams', 'GET /api/streams/77/endpoint', 'PUT /api/streams/77', 'POST /api/streams/77/heartbeat', 'DELETE /api/streams/77',
++ 'DELETE /api/streams/managed/5',
++ ]);
++ assert.deepStrictEqual(JSON.parse(seen[4].body), { title: 'Live now', managed_stream_id: 5 });
++ assert.ok(seen.filter((s) => s.method !== 'GET').every((s) => !s.idem), 'writes carry no idempotency key and are never retried');
++
++ // WHIP: the offer, the answer, the Location; the key as bearer only when given; never the client's token.
++ seen.length = 0;
++ const url = await live.whip.url('sk_live_123');
++ assert.strictEqual(url, `${base}/whip/sk_live_123`);
++ const session = await live.whip.publish(live.whip.url('sk_live_123'), 'v=0\r\no=offer\r\n');
++ assert.deepStrictEqual(session, { answer: 'v=0\r\no=answer\r\n', location: `${base}/whip/session/abc` });
++ assert.deepStrictEqual([last().method, last().type, last().auth, last().body], ['POST', 'application/sdp', null, 'v=0\r\no=offer\r\n']);
++ await live.whip.publish(`${base}/whip/5`, 'v=0', { key: 'sk_live_123' });
++ assert.strictEqual(last().auth, 'Bearer sk_live_123', 'a slot-id URL sends the key as the bearer');
++ await live.whip.end(session.location);
++ assert.deepStrictEqual([last().method, last().url, last().auth], ['DELETE', '/whip/session/abc', null]);
++ await assert.rejects(live.whip.publish(live.whip.url('bad-key'), 'v=0'), (err) => err.status === 401 && err.code === 'invalid_stream_key');
++ assert.strictEqual(seen.filter((s) => s.url === '/whip/bad-key').length, 1, 'a refused offer is not retried');
++ await assert.rejects(live.whip.url('../x'), /stream key/);
++
++ assert.throws(() => createLiveClient({ json() {} }), /core client/);
++ console.log('live: all checks passed');
++ } finally {
++ server.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 564477d..5bb7855 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, 47);
++ assert.equal(subpaths.length, 48);
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 30a4325..7ea7f61 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -17,6 +17,7 @@ export * as projects from './projects';
export * as resources from './resources';
export * as vip from './vip';
export * as quest from './quest';
++export * as live from './live';
export * as play from './play';
export * as download from './download';
export * as openre from './openre';
ddiff --git a/types/live.d.ts b/types/live.d.ts
nnew file mode 100644
iindex 0000000..be133e2
---- /dev/null
++++ b/types/live.d.ts
@@@ -0,0 +1,50 @@
++/** openvibe-sdk/live: OpenVibe.Live — who is live, channels, slots, going live, sessions, WHIP and the content feeds. */
++type Opts = { signal?: AbortSignal };
++/** Live's JSON as documented at https://openvibe.live/documentation (shapes vary by route). */
++type LiveJson = Record<string, any>;
++export interface SlotRequest {
++ title?: string; description?: string; category?: string; protocol?: string; is_nsfw?: boolean; slug?: string;
++ streaming_method?: 'browser' | 'whip' | 'cli' | 'rtmp'; control_config_id?: number;
++ [setting: string]: unknown;
++}
++export interface GoLiveOptions { title?: string; category?: string; tags?: string[] | string; is_nsfw?: boolean; control_config_id?: number }
++export interface FeedOptions extends Opts { type?: string; sort?: 'new' | 'top'; window?: string; cursor?: string; limit?: number }
++export interface WhipSession { answer: string; location: string | null }
++export interface LiveClient {
++ streams: {
++ list(opts?: Opts): Promise<LiveJson>;
++ get(streamId: number | string, opts?: Opts): Promise<LiveJson>;
++ recentlyOnline(opts?: Opts): Promise<LiveJson>;
++ mine(opts?: Opts): Promise<LiveJson>;
++ update(streamId: number | string, patch: { title?: string; description?: string; category?: string; tags?: string[] | string }, opts?: Opts): Promise<LiveJson>;
++ end(streamId: number | string, opts?: Opts): Promise<LiveJson>;
++ heartbeat(streamId: number | string, opts?: Opts): Promise<LiveJson>;
++ endpoint(streamId: number | string, opts?: Opts): Promise<LiveJson>;
++ };
++ slots: {
++ list(opts?: Opts): Promise<LiveJson>;
++ create(slot: SlotRequest, opts?: Opts): Promise<LiveJson>;
++ update(slotId: number | string, patch: SlotRequest, opts?: Opts): Promise<LiveJson>;
++ remove(slotId: number | string, opts?: Opts): Promise<LiveJson>;
++ regenerateKey(slotId: number | string, opts?: Opts): Promise<LiveJson>;
++ };
++ /** Go live from a slot → { stream, endpoint }. */
++ goLive(slotId: number | string, opts?: GoLiveOptions, extra?: Opts): Promise<{ stream: LiveJson; endpoint: LiveJson }>;
++ channels: {
++ get(username: string, opts?: Opts): Promise<LiveJson>;
++ mine(opts?: Opts): Promise<LiveJson>;
++ update(patch: Record<string, unknown>, opts?: Opts): Promise<LiveJson>;
++ toggleFollow(username: string, opts?: Opts): Promise<LiveJson>;
++ url(username: string): Promise<string>;
++ };
++ content: {
++ feed(opts?: FeedOptions): Promise<{ items: LiveJson[]; next: string | null; [k: string]: unknown }>;
++ moments(opts?: FeedOptions): Promise<{ items: LiveJson[]; next: string | null; [k: string]: unknown }>;
++ };
++ whip: {
++ url(keyOrSlot: string | number): Promise<string>;
++ publish(url: string | Promise<string>, offer: string, opts?: Opts & { key?: string }): Promise<WhipSession>;
++ end(location: string, opts?: Opts): Promise<void>;
++ };
++}
++export function createLiveClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any>; origin(service: string): Promise<string> }, options?: { baseUrl?: string }): LiveClient;