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
Changes
nnew file mode 100644@@@ -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 };@@@ -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);nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
@@ -49,8 +49,19 @@ async function _appJson(appId, apiPath) {
@@ -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) {
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 lower = String(name).toLowerCase();
const streams = await frames.appLiveStreams(appId);
const streams = await frames.appLiveStreams(appId);
const live = streams.find(s => String(s.username || '').toLowerCase() === lower);
const live = streams.find(s => String(s.username || '').toLowerCase() === lower);
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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); });