openvibers/openvibe.media Commits

Channel lookups through Live's canonical lineage resolver (D20): the dev-data API's @username selector asks OpenVibe.Live /internal/lineage/resolve with Media's service token (live.lineage.resolve) for the channel and its Live user id; unresolved means not found. The old lookups (live list, public channel API) remain only for other apps or when Live cannot be asked. MEDIA_LINEAGE=off disables it. server/lineage-client.js, test/lineage-client.test.js

OP OpenVibers committed 24 Sept 2026 · 9de5cb9afc · 3 files, +89 −1


Changes

ddiff --git a/server/lineage-client.js b/server/lineage-client.js
nnew file mode 100644
iindex 0000000..4965526
---- /dev/null
++++ b/server/lineage-client.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Channel/owner lineage from OpenVibe.Live's canonical resolver (roadmap D20): GET
++ * /internal/lineage/resolve with Media's service token (audience openvibe.live, capability
++ * live.lineage.resolve). Media asks it instead of keeping a username → user mapping of its own.
++ *
++ * channelBySlug(slug) -> { slug, live_user_id, owner_subject } | null (unresolved)
++ * throws when the resolver cannot be asked (callers fall back)
++ *
++ * Off (channelBySlug throws) without OV_OAUTH_CLIENT_SECRET or with MEDIA_LINEAGE=off.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const SLUG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++
++function createLineageClient({
++ liveUrl = process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',
++ networkUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
++ clientId = process.env.OV_OAUTH_CLIENT_ID || 'media',
++ clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '',
++ enabled = process.env.MEDIA_LINEAGE !== 'off',
++ fetchImpl = globalThis.fetch,
++ timeoutMs = 5000,
++} = {}) {
++ const base = String(liveUrl).replace(/\/+$/, '');
++ const tokens = clientSecret && enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${String(networkUrl).replace(/\/+$/, '')}/oauth/token`, clientId, clientSecret, audience: 'openvibe.live', fetchImpl, timeoutMs,
++ }) : null;
++
++ async function channelBySlug(slug) {
++ if (!tokens) throw new Error('lineage resolver off');
++ if (!SLUG_RE.test(String(slug || ''))) return null;
++ const res = await fetchImpl(`${base}/internal/lineage/resolve?slug=${encodeURIComponent(slug)}`, { headers: { Accept: 'application/json', ...(await tokens.authHeaders()) }, signal: AbortSignal.timeout(timeoutMs) });
++ if (res.status === 401) tokens.invalidate();
++ const body = await res.json().catch(() => null);
++ if (!res.ok || !body) throw new Error(`lineage resolver answered ${res.status}`);
++ if (body.status !== 'resolved' || !body.channel) return null;
++ const ch = body.channel;
++ return { slug: ch.slug, live_user_id: (ch.legacy_ids && ch.legacy_ids.live_user_id) || null, owner_subject: ch.owner_subject || null };
++ }
++
++ return { enabled: Boolean(tokens), channelBySlug };
++}
++
++let _default = null;
++const lineage = () => (_default || (_default = createLineageClient()));
++
++module.exports = { createLineageClient, lineage };
ddiff --git a/server/public/dev-data.js b/server/public/dev-data.js
iindex bd5c1b5..5109c73 100644
---- a/server/public/dev-data.js
++++ b/server/public/dev-data.js
@@@ -49,8 +49,19 @@ async function _appJson(appId, apiPath) {
}
}
--/** Resolve @username → app user id (live list first, channel lookup as fallback). */
++/**
++ * Resolve @username → app user id. For Live, its canonical lineage resolver answers (D20); the old
++ * lookups (live list, then the public channel API) remain only for other apps or when Live cannot
++ * be asked.
++ */
async function _resolveUser(appId, name) {
++ if (appId === 'live') {
++ try {
++ const ch = await require('../lineage-client').lineage().channelBySlug(name);
++ if (!ch) return null;
++ if (ch.live_user_id) return { uid: ch.live_user_id, username: ch.slug };
++ } catch { /* resolver off or down: the lookups below */ }
++ }
const lower = String(name).toLowerCase();
const streams = await frames.appLiveStreams(appId);
const live = streams.find(s => String(s.username || '').toLowerCase() === lower);
ddiff --git a/test/lineage-client.test.js b/test/lineage-client.test.js
nnew file mode 100644
iindex 0000000..bd04149
---- /dev/null
++++ b/test/lineage-client.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++/** Media asks Live's canonical lineage resolver (D20) for a channel, with its service token; no mapping of its own. */
++const assert = require('assert');
++const { createLineageClient } = require('../server/lineage-client');
++
++(async () => {
++ const asked = [];
++ let answer = { status: 'resolved', channel: { id: '17', slug: 'Goosely', owner_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', legacy_ids: { live_user_id: 5 } }, rule: 'slug' };
++ const fetchImpl = async (url, opts = {}) => {
++ const u = new URL(url);
++ const reply = (status, body) => ({ status, ok: status < 300, json: async () => body });
++ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'svc-media', token_type: 'Bearer', expires_in: 300, scope: 'live.lineage.resolve' });
++ asked.push({ path: u.pathname, slug: u.searchParams.get('slug'), auth: opts.headers.Authorization });
++ return typeof answer === 'number' ? reply(answer, {}) : reply(200, answer);
++ };
++ const c = createLineageClient({ liveUrl: 'http://live.test', networkUrl: 'http://net.test', clientSecret: 'media-secret-for-tests', fetchImpl });
++ assert.deepStrictEqual(await c.channelBySlug('goosely'), { slug: 'Goosely', live_user_id: 5, owner_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' });
++ assert.deepStrictEqual(asked, [{ path: '/internal/lineage/resolve', slug: 'goosely', auth: 'Bearer svc-media' }]);
++ answer = { status: 'unresolved', reason: 'no_match' };
++ assert.strictEqual(await c.channelBySlug('nobody'), null, 'unresolved is an answer, not an error');
++ assert.strictEqual(await c.channelBySlug('../etc'), null, 'not a slug: never sent');
++ assert.strictEqual(asked.length, 2);
++ answer = 503;
++ await assert.rejects(c.channelBySlug('goosely'), /answered 503/, 'Live down: the caller falls back');
++ const off = createLineageClient({ clientSecret: '', fetchImpl });
++ assert.strictEqual(off.enabled, false);
++ await assert.rejects(off.channelBySlug('goosely'), /off/);
++ console.log('lineage client: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });