openvibers/openvibe.live Commits
Canonical channel/owner resolver (roadmap D20, openvibe-contracts lineage.resolve-request@1 -> lineage.resolution@1): server/lineage/resolver.js answers which channel, and whose, a slug, <channel>/<slot> or parent slug, channel id, stream, slot, VOD, clip, Media object (med_ or legacy:live:<kind>:<id>), owner subject or legacy id (Live user id, Network user id) belongs to, with the stream/VOD/clip lineage it walked, resolved_by, rule, confidence (exact|derived|legacy_map), via and what every input came to. Precedence is the roadmap's order (explicit slug, nested/parent slug, channel id, stream lookup, the record's own lineage, Media lineage, owner subject, legacy maps): the first input that resolves decides, any other that names a different channel makes the answer a conflict, one Media could not answer makes it source_unavailable, one that names nothing does not block. Inside a record the first link that answers wins: a clip goes to its stream, then its VOD's lineage, then the channel Media recorded on it, never to the clipper; a VOD to its stream, then its slot when the stream row is gone, then its recorded owner; a Media object to the record its legacy_ref names, then its owner subject, then its owner user. A display name is never read: alone it is answered display_name_only. Nothing is written: an owner without a channels row is not_found. VODs, clips and objects come from Media (server/lineage/media-source.js, API v1 plus v2 objects), never from the frozen tables.
Changes
@@@ -579,6 +579,8 @@ app.use('/internal/tips', require('./tips/delivery-routes'));// OpenVibe.Network sends go-live notifications from live.stream.started and asks here who follows// the channel (service token, live.follower.read).app.use('/internal/followers', require('./streaming/followers-internal'));++// The canonical channel/owner resolver for other services (roadmap D20; service token, live.lineage.resolve).++app.use('/internal/lineage', require('./lineage/routes'));app.use('/internal', require('./internal/routes'));nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/**++ * What the lineage resolver reads from OpenVibe.Media: VODs and clips (API v1, the ids Live uses)++ * and canonical objects (API v2, `GET /api/v2/<app>/objects/<med_… | legacy:…>`), all as this app++ * with no acting user. Every read answers { value } | { not_found } | { unavailable }, so the++ * resolver can tell "Media says no such record" from "Media could not be asked".++ */++const media = require('../media-client');++++const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';++++function classify(err) {++ const status = err && err.name === 'MediaApiError' ? err.status : 0;++ return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };++}++++async function read(fn) {++ try {++ const value = await fn();++ return value ? { value } : { not_found: true };++ } catch (err) {++ return classify(err);++ }++}++++/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */++async function fetchObject(id, { timeoutMs = 10000 } = {}) {++ const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;++ const headers = { Accept: 'application/json' };++ if (MEDIA_API_KEY) headers.Authorization = `Bearer ${MEDIA_API_KEY}`;++ let res;++ let body = null;++ try {++ res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });++ body = await res.json().catch(() => null);++ } catch (err) {++ throw new media.MediaApiError(`Media unreachable (GET objects/${id}): ${err.message}`, 0, null);++ }++ if (!res.ok) throw new media.MediaApiError((body && (body.detail || body.error)) || `Media API ${res.status} on GET objects/${id}`, res.status, body);++ return body;++}++++module.exports = {++ appId: media.MEDIA_APP_ID,++ getVod: (id) => read(() => media.getVod(id)),++ getClip: (id) => read(() => media.getClip(id)),++ getObject: (id) => read(() => fetchObject(id)),++ _classify: classify,++};nnew file mode 100644@@@ -0,0 +1,447 @@++'use strict';++/**++ * The canonical channel/owner resolver (roadmap §15.10, requirement D20): which channel, and whose,++ * a slug, slot, stream, VOD, clip, Media object, subject or legacy id belongs to.++ *++ * Contract: openvibe-contracts lineage.resolve-request@1 -> lineage.resolution@1 (v0.32.0), served at++ * GET|POST /internal/lineage/resolve (./routes.js, capability live.lineage.resolve).++ *++ * PRECEDENCE between inputs. Every input given is evaluated. The first one in this order that++ * resolves decides (resolved_by). Every other one that resolves must name the same owner, or the++ * answer is `conflict`. One whose source could not be read makes the answer `source_unavailable`,++ * because it might have disagreed. One that names nothing is `not_found` and does not block.++ *++ * # input rule how confidence++ * 1 slug explicit_slug users.username, case-insensitive exact++ * 2 slug "a/b", nested_slug the channel a, then its slot b (slug or id) exact++ * parent_slug(+slug)++ * 3 channel_id channel_id channels.id -> channels.user_id exact++ * 4 stream_id stream_lookup streams.user_id exact++ * slot_id stream_lookup managed_streams.user_id exact++ * 5 clip_id the clip's lineage, read from Media (below) derived++ * vod_id the VOD's lineage, read from Media (below) derived++ * 6 media_object_id media_lineage Media object -> the VOD/clip its legacy_ref names derived++ * legacy_map a legacy:live:<kind>:<id> reference legacy_map++ * 7 owner_subject owner_subject linked_accounts.subject_id (two accounts: ambiguous) exact++ * 8 legacy_ids legacy_map users.id, or the Network id on linked_accounts legacy_map++ *++ * LINEAGE inside a record. The first link that answers wins; a link whose source cannot be read++ * stops the walk (a lower link must not answer for it):++ * clip its stream (stream_lookup) -> its VOD's lineage (vod_parent) -> clip.channel_user_id++ * (legacy_metadata). Never clip.user_id: that is the clipper, not the channel.++ * VOD its stream (stream_lookup) -> its slot when the stream row is gone (stream_lookup) ->++ * vod.user_id (legacy_metadata)++ * object the record its legacy_ref names (media_lineage) -> owner.subject (owner_subject) ->++ * owner.user_id (legacy_metadata)++ * Confidence is the weakest hop: exact < derived (through a Media record) < legacy_map.++ *++ * DISPLAY NAMES are never read. A request holding only display_name is answered display_name_only;++ * next to other inputs it is ignored.++ *++ * resolveOwner() is the engine, in Live user ids, for Live's own call sites (it answers with the++ * owner a record names even when that account is gone, as the code it replaced did). resolve() is++ * the contract answer: the owner must still have an account and a `channels` row. Neither writes++ * anything, so an owner whose channel row Live has not created yet (the page creates it on first++ * view) is not_found for resolve().++ */++const db = require('../db/database');++++const SLUG_PART = '[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}';++const SLUG_RE = new RegExp(`^@?(${SLUG_PART})(?:/(${SLUG_PART}))?$`);++const PARENT_RE = new RegExp(`^@?(${SLUG_PART})$`);++const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;++const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++const ID_INPUTS = ['channel_id', 'stream_id', 'slot_id', 'clip_id', 'vod_id'];++const LEGACY_KEYS = ['live_user_id', 'network_user_id'];++const FIELDS = new Set(['slug', 'parent_slug', ...ID_INPUTS, 'media_object_id', 'owner_subject', 'legacy_ids', 'display_name']);++const RULES = ['explicit_slug', 'nested_slug', 'channel_id', 'stream_lookup', 'vod_parent', 'media_lineage', 'owner_subject', 'legacy_metadata', 'legacy_map'];++const CONFIDENCE = ['exact', 'derived', 'legacy_map'];++++const weaker = (a, b) => (CONFIDENCE.indexOf(a) >= CONFIDENCE.indexOf(b) ? a : b);++const str = (v) => (v == null || v === '' ? null : String(v));++function int(v) {++ const s = String(v == null ? '' : v);++ if (!/^\d{1,15}$/.test(s)) return null;++ const n = Number(s);++ return n > 0 ? n : null;++}++++// ── Request ──────────────────────────────────────────────────++++/**++ * A lineage.resolve-request@1 from a JSON body, or from a query string ({ flat: true }: legacy_ids++ * arrive as live_user_id / network_user_id). Ids may also be positive integers (Live's own callers).++ * Returns { input } or { error }.++ */++function normalizeRequest(raw, { flat = false } = {}) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'the request must be an object' };++ const src = { ...raw };++ if (flat) {++ const legacy = {};++ for (const k of LEGACY_KEYS) if (src[k] !== undefined) { legacy[k] = src[k]; delete src[k]; }++ if (Object.keys(legacy).length) {++ if (src.legacy_ids !== undefined) return { error: 'give legacy_ids or live_user_id/network_user_id, not both' };++ src.legacy_ids = legacy;++ }++ }++ const input = {};++ for (const [k, v] of Object.entries(src)) {++ if (!FIELDS.has(k)) return { error: `unknown input ${k}` };++ if (v === undefined) continue;++ if (k === 'slug') {++ if (typeof v !== 'string' || !SLUG_RE.test(v)) return { error: 'slug must be a channel slug or <channel>/<slot>' };++ input.slug = v;++ } else if (k === 'parent_slug') {++ if (typeof v !== 'string' || !PARENT_RE.test(v)) return { error: 'parent_slug must be a channel slug' };++ input.parent_slug = v;++ } else if (ID_INPUTS.includes(k)) {++ const s = Number.isSafeInteger(v) && v > 0 ? String(v) : v;++ if (typeof s !== 'string' || !ID_RE.test(s)) return { error: `${k} must be an id` };++ input[k] = s;++ } else if (k === 'media_object_id') {++ if (typeof v !== 'string' || !MEDIA_ID_RE.test(v)) return { error: 'media_object_id must be med_<ULID> or legacy:<app>:<kind>:<id>' };++ input.media_object_id = v;++ } else if (k === 'owner_subject') {++ if (typeof v !== 'string' || !SUBJECT_RE.test(v)) return { error: 'owner_subject must be a usr_<ULID> subject id' };++ input.owner_subject = v;++ } else if (k === 'legacy_ids') {++ if (!v || typeof v !== 'object' || Array.isArray(v)) return { error: 'legacy_ids must be an object' };++ const out = {};++ for (const [lk, lv] of Object.entries(v)) {++ if (!LEGACY_KEYS.includes(lk)) return { error: `unknown legacy id ${lk}` };++ const n = int(lv);++ if (!n) return { error: `legacy_ids.${lk} must be a positive integer` };++ out[lk] = n;++ }++ if (!Object.keys(out).length) return { error: 'legacy_ids is empty' };++ input.legacy_ids = out;++ } else if (k === 'display_name') {++ if (typeof v !== 'string' || v.length > 200) return { error: 'display_name must be a string of at most 200 characters' };++ input.display_name = v;++ }++ }++ return { input };++}++++// ── Live rows ────────────────────────────────────────────────++++const userById = (id) => db.get('SELECT id, username FROM users WHERE id = ?', [id]);++const userBySlug = (slug) => db.get('SELECT id, username FROM users WHERE username = ? COLLATE NOCASE', [slug]);++const streamById = (id) => db.get(`SELECT s.id, s.user_id, s.managed_stream_id, ms.slug AS slot_slug++ FROM streams s LEFT JOIN managed_streams ms ON ms.id = s.managed_stream_id WHERE s.id = ?`, [id]);++const slotById = (id) => db.get('SELECT id, user_id, slug FROM managed_streams WHERE id = ?', [id]);++const streamRecord = (s) => ({ id: String(s.id), slot_id: str(s.managed_stream_id), slot_slug: s.slot_slug || null });++const slotRecord = (m) => ({ id: null, slot_id: String(m.id), slot_slug: m.slug || null });++++function subjectOwners(subject) {++ return db.all("SELECT DISTINCT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ?", [subject]).map(r => r.user_id);++}++++// ── Outcomes ─────────────────────────────────────────────────++// A link or input comes to one of:++// { kind: 'owner', userId, rule, confidence, via, records }++// { kind: 'not_found', detail } | { kind: 'unavailable', detail } | { kind: 'ambiguous', userIds, detail }++++function owner(userId, rule, confidence, via, records = {}) {++ return { kind: 'owner', userId: Number(userId), rule, confidence, via: [...via, `user:${userId}`], records };++}++const notFound = (detail) => ({ kind: 'not_found', detail });++const unavailable = (detail) => ({ kind: 'unavailable', detail: detail || 'Media could not be read' });++const ALL = () => true;++++/** One Media read per record per resolution. */++function cached(ctx, key, load) {++ if (!ctx.cache.has(key)) ctx.cache.set(key, Promise.resolve().then(load));++ return ctx.cache.get(key);++}++const readVod = (ctx, id) => (ctx.records.vod && String(ctx.records.vod.id) === String(id) ? Promise.resolve({ value: ctx.records.vod }) : cached(ctx, `vod:${id}`, () => (int(id) ? ctx.media.getVod(id) : { not_found: true })));++const readClip = (ctx, id) => (ctx.records.clip && String(ctx.records.clip.id) === String(id) ? Promise.resolve({ value: ctx.records.clip }) : cached(ctx, `clip:${id}`, () => (int(id) ? ctx.media.getClip(id) : { not_found: true })));++const readObject = (ctx, id) => cached(ctx, `object:${id}`, () => ctx.media.getObject(id));++++function parseLegacyRef(ref) {++ const m = /^legacy:([a-z][a-z0-9-]{1,39}):([a-z]+):(.+)$/.exec(String(ref || ''));++ return m ? { app: m[1], kind: m[2], id: m[3] } : null;++}++++/** A VOD's lineage: its stream, its slot when the stream row is gone, the owner recorded on it. */++function vodLineage(vod, allow = ALL) {++ const via = [`vod:${vod.id}`];++ const records = { vod: { id: String(vod.id), stream_id: str(vod.stream_id), slot_id: str(vod.managed_stream_id) } };++ if (vod.stream_id != null && allow('stream_lookup')) {++ const s = int(vod.stream_id) && streamById(int(vod.stream_id));++ if (s) return owner(s.user_id, 'stream_lookup', 'derived', [...via, `stream:${s.id}`], { ...records, stream: streamRecord(s) });++ records.stream = { id: String(vod.stream_id), slot_id: str(vod.managed_stream_id), slot_slug: null, missing: true };++ }++ if (vod.managed_stream_id != null && allow('stream_lookup')) {++ const m = int(vod.managed_stream_id) && slotById(int(vod.managed_stream_id));++ if (m) {++ records.stream = records.stream ? { ...records.stream, slot_slug: m.slug || null } : slotRecord(m);++ return owner(m.user_id, 'stream_lookup', 'derived', [...via, `slot:${m.id}`], records);++ }++ }++ if (int(vod.user_id) && allow('legacy_metadata')) return owner(int(vod.user_id), 'legacy_metadata', 'derived', via, records);++ return notFound(`VOD ${vod.id} names no stream, slot or owner Live has`);++}++++/** A clip's lineage: its stream, its VOD's lineage, the channel recorded on it. Never the clipper. */++async function clipLineage(ctx, clip, allow = ALL) {++ const via = [`clip:${clip.id}`];++ const records = { clip: { id: String(clip.id), vod_id: str(clip.vod_id), stream_id: str(clip.stream_id) } };++ if (clip.stream_id != null && allow('stream_lookup')) {++ const s = int(clip.stream_id) && streamById(int(clip.stream_id));++ if (s) return owner(s.user_id, 'stream_lookup', 'derived', [...via, `stream:${s.id}`], { ...records, stream: streamRecord(s) });++ records.stream = { id: String(clip.stream_id), slot_id: null, slot_slug: null, missing: true };++ }++ if (clip.vod_id != null && allow('vod_parent')) {++ const v = await readVod(ctx, clip.vod_id);++ if (v.unavailable) return unavailable(v.detail);++ if (v.value) {++ const r = vodLineage(v.value);++ if (r.kind === 'owner') {++ return { ...r, rule: 'vod_parent', via: [...via, ...r.via], records: { ...records, ...r.records, stream: r.records.stream || records.stream } };++ }++ }++ }++ if (int(clip.channel_user_id) && allow('legacy_metadata')) return owner(int(clip.channel_user_id), 'legacy_metadata', 'derived', via, records);++ return notFound(`clip ${clip.id} names no stream, VOD or channel Live has`);++}++++/** A Live-app VOD or clip named by a legacy reference or a Media object's legacy_ref. */++async function legacyRecordLineage(ctx, ref) {++ const read = ref.kind === 'vod' ? await readVod(ctx, ref.id) : await readClip(ctx, ref.id);++ if (read.unavailable) return unavailable(read.detail);++ if (!read.value) return notFound(`no ${ref.kind} ${ref.id}`);++ return ref.kind === 'vod' ? vodLineage(read.value) : clipLineage(ctx, read.value);++}++++function subjectOutcome(subject, confidence, via) {++ const ids = subjectOwners(subject);++ if (!ids.length) return notFound(`no Live account is linked to ${subject}`);++ if (ids.length > 1) return { kind: 'ambiguous', userIds: ids, detail: `${subject} is linked to ${ids.length} Live accounts` };++ return owner(ids[0], 'owner_subject', confidence, via);++}++++/** A Media object's lineage: the record its legacy_ref names, its owner subject, its owner user. */++async function objectLineage(ctx, id, allow = ALL) {++ const legacy = parseLegacyRef(id);++ if (legacy && legacy.app !== ctx.media.appId) return notFound(`${id} belongs to another app`);++ const via = [`media:${id}`];++ if (legacy && (legacy.kind === 'vod' || legacy.kind === 'clip')) {++ // A legacy reference is read as the record it names: the reference is the map.++ if (!allow('legacy_map')) return notFound('legacy references are not accepted here');++ const r = await legacyRecordLineage(ctx, legacy);++ const media_object = { id, kind: legacy.kind, legacy_ref: id };++ return r.kind === 'owner' ? { ...r, rule: 'legacy_map', confidence: 'legacy_map', via: [...via, ...r.via], records: { ...r.records, media_object } } : r;++ }++ const read = await readObject(ctx, id);++ if (read.unavailable) return unavailable(read.detail);++ if (!read.value) return notFound(`Media has no object ${id}`);++ const obj = read.value;++ const records = { media_object: { id: MEDIA_ID_RE.test(String(obj.id || '')) ? obj.id : id, kind: str(obj.kind), legacy_ref: str(obj.legacy_ref) } };++ const base = legacy ? 'legacy_map' : 'derived';++ const ref = parseLegacyRef(obj.legacy_ref);++ if (!legacy && ref && ref.app === ctx.media.appId && (ref.kind === 'vod' || ref.kind === 'clip') && allow('media_lineage')) {++ const r = await legacyRecordLineage(ctx, ref);++ if (r.kind === 'unavailable' || r.kind === 'ambiguous') return r;++ if (r.kind === 'owner') return { ...r, rule: 'media_lineage', confidence: weaker(r.confidence, 'derived'), via: [...via, ...r.via], records: { ...r.records, ...records } };++ }++ const subject = String((obj.owner && obj.owner.subject) || '').replace(/^user:/, '');++ if (SUBJECT_RE.test(subject) && allow('owner_subject')) {++ const r = subjectOutcome(subject, base, [...via, `subject:${subject}`]);++ if (r.kind !== 'not_found') return r.kind === 'owner' ? { ...r, records } : r;++ }++ const recorded = int(obj.owner && obj.owner.user_id);++ if (recorded && allow('legacy_metadata')) return owner(recorded, legacy ? 'legacy_map' : 'legacy_metadata', base, via, records);++ return notFound(`Media object ${id} names no record or owner Live has`);++}++++// ── Inputs ───────────────────────────────────────────────────++++/** The inputs of a request as evaluation units, in precedence order. */++function units(input) {++ const out = [];++ if (input.slug !== undefined) {++ const [, a, b] = SLUG_RE.exec(input.slug);++ if (!b && input.parent_slug !== undefined) out.push({ input: 'slug', rule: 'nested_slug', channel: PARENT_RE.exec(input.parent_slug)[1], slot: a, pairedParent: true });++ else out.push({ input: 'slug', rule: b ? 'nested_slug' : 'explicit_slug', channel: a, slot: b || null });++ }++ if (input.parent_slug !== undefined && !(out[0] && out[0].pairedParent)) out.push({ input: 'parent_slug', rule: 'nested_slug', channel: PARENT_RE.exec(input.parent_slug)[1], slot: null });++ for (const k of ['channel_id', 'stream_id', 'slot_id', 'clip_id', 'vod_id', 'media_object_id', 'owner_subject']) {++ if (input[k] !== undefined) out.push({ input: k, value: input[k] });++ }++ if (input.legacy_ids) for (const k of LEGACY_KEYS) if (input.legacy_ids[k]) out.push({ input: 'legacy_ids', legacy: k, value: input.legacy_ids[k] });++ return out;++}++++async function evaluate(ctx, u, allow) {++ const gate = (rule, fn) => (allow(rule) ? fn() : notFound(`${rule} is not accepted here`));++ switch (u.input) {++ case 'slug':++ case 'parent_slug':++ return gate(u.rule, () => {++ const user = userBySlug(u.channel);++ if (!user) return notFound(`no channel @${u.channel}`);++ if (!u.slot) return owner(user.id, u.rule, 'exact', []);++ const m = db.getManagedStreamByIdOrSlug(user.id, u.slot);++ if (!m) return notFound(`@${u.channel} has no slot ${u.slot}`);++ return owner(user.id, u.rule, 'exact', [`slot:${m.id}`], { stream: slotRecord(m) });++ });++ case 'channel_id':++ return gate('channel_id', () => {++ const ch = int(u.value) && db.get('SELECT id, user_id FROM channels WHERE id = ?', [int(u.value)]);++ return ch ? owner(ch.user_id, 'channel_id', 'exact', [`channel:${ch.id}`]) : notFound(`no channel ${u.value}`);++ });++ case 'stream_id':++ return gate('stream_lookup', () => {++ const s = int(u.value) && streamById(int(u.value));++ return s ? owner(s.user_id, 'stream_lookup', 'exact', [`stream:${s.id}`], { stream: streamRecord(s) }) : notFound(`no stream ${u.value}`);++ });++ case 'slot_id':++ return gate('stream_lookup', () => {++ const m = int(u.value) && slotById(int(u.value));++ return m ? owner(m.user_id, 'stream_lookup', 'exact', [`slot:${m.id}`], { stream: slotRecord(m) }) : notFound(`no slot ${u.value}`);++ });++ case 'clip_id': {++ const read = await readClip(ctx, u.value);++ if (read.unavailable) return unavailable(read.detail);++ return read.value ? clipLineage(ctx, read.value, allow) : notFound(`Media has no clip ${u.value}`);++ }++ case 'vod_id': {++ const read = await readVod(ctx, u.value);++ if (read.unavailable) return unavailable(read.detail);++ return read.value ? vodLineage(read.value, allow) : notFound(`Media has no VOD ${u.value}`);++ }++ case 'media_object_id':++ return objectLineage(ctx, u.value, allow);++ case 'owner_subject':++ return gate('owner_subject', () => subjectOutcome(u.value, 'exact', [`subject:${u.value}`]));++ case 'legacy_ids':++ return gate('legacy_map', () => {++ if (u.legacy === 'live_user_id') {++ return userById(u.value) ? owner(u.value, 'legacy_map', 'legacy_map', []) : notFound(`no Live user ${u.value}`);++ }++ const row = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?", [String(u.value)]);++ return row ? owner(row.user_id, 'legacy_map', 'legacy_map', [`network_user:${u.value}`]) : notFound(`no Live account for Network user ${u.value}`);++ });++ default:++ return notFound(`unknown input ${u.input}`);++ }++}++++// ── Resolution ───────────────────────────────────────────────++++/**++ * The engine, in Live user ids.++ * opts.media { appId, getVod, getClip, getObject } (default ./media-source)++ * opts.records { vod?, clip? } rows the caller already read from Media (saves a read)++ * opts.rules only these lineage rules may answer (default: all), for a call site that must++ * keep an older, narrower rule set++ * -> { status: 'resolved', userId, resolved_by, rule, confidence, via, records, checked }++ * | { status: 'unresolved', reason, detail, checked } checked: [{ input, outcome, userId? }]++ */++async function resolveOwner(raw, opts = {}) {++ const { input, error } = normalizeRequest(raw);++ if (error) throw Object.assign(new Error(error), { code: 'lineage.invalid_request' });++ const allow = Array.isArray(opts.rules) ? (rule) => opts.rules.includes(rule) : ALL;++ const ctx = { media: opts.media || require('./media-source'), records: opts.records || {}, cache: new Map() };++ const list = units(input);++ const checked = [];++ const ignoredName = input.display_name !== undefined ? [{ input: 'display_name', outcome: 'ignored' }] : [];++++ if (!list.length) {++ return ignoredName.length++ ? { status: 'unresolved', reason: 'display_name_only', detail: 'a display name is never sufficient to establish a channel or its owner', checked: ignoredName }++ : { status: 'unresolved', reason: 'no_input', detail: 'the request names nothing', checked: [] };++ }++++ const results = [];++ for (const u of list) results.push({ u, r: await evaluate(ctx, u, allow) });++++ const decider = results.find(x => x.r.kind === 'owner');++ let conflict = false;++ for (const { u, r } of results) {++ const entry = { input: u.input };++ if (r.kind === 'owner') {++ entry.userId = r.userId;++ entry.outcome = r === decider.r ? 'decided' : (r.userId === decider.r.userId ? 'agrees' : 'conflict');++ } else if (r.kind === 'ambiguous') {++ entry.outcome = decider ? (r.userIds.includes(decider.r.userId) ? 'agrees' : 'conflict') : 'ambiguous';++ if (decider && entry.outcome === 'agrees') entry.userId = decider.r.userId;++ } else {++ entry.outcome = r.kind; // not_found | unavailable++ }++ if (entry.outcome === 'conflict') conflict = true;++ checked.push(entry);++ if (u.pairedParent) checked.push({ ...entry, input: 'parent_slug' });++ }++ checked.push(...ignoredName);++++ const failed = (reason, detail) => ({ status: 'unresolved', reason, detail, checked });++ const down = results.find(x => x.r.kind === 'unavailable');++ if (down) return failed('source_unavailable', `${down.u.input}: ${down.r.detail}`);++ if (conflict) {++ const other = checked.find(c => c.outcome === 'conflict');++ return failed('conflict', `${decider.u.input} and ${other.input} name different channels`);++ }++ if (!decider) {++ const amb = results.find(x => x.r.kind === 'ambiguous');++ if (amb) return failed('ambiguous', amb.r.detail);++ return failed('not_found', results.map(x => x.r.detail).filter(Boolean).join('; ').slice(0, 500) || 'nothing matched');++ }++++ // The deciding input's records first, then those of the inputs that agree.++ const records = {};++ for (const { r } of results) {++ if (r.kind !== 'owner' || r.userId !== decider.r.userId) continue;++ for (const [k, v] of Object.entries(r.records || {})) if (!records[k]) records[k] = v;++ }++ return {++ status: 'resolved',++ userId: decider.r.userId,++ resolved_by: decider.u.input,++ rule: decider.r.rule,++ confidence: decider.r.confidence,++ via: decider.r.via.slice(0, 32),++ records,++ checked,++ };++}++++/** The contract answer (lineage.resolution@1). Read-only. */++async function resolve(raw, opts = {}) {++ const r = await resolveOwner(raw, opts);++ const slugs = new Map();++ const slugOf = (id) => { if (!slugs.has(id)) slugs.set(id, (userById(id) || {}).username || null); return slugs.get(id); };++ const checked = r.checked.map(({ input, outcome, userId }) => {++ const c = { input, outcome };++ const slug = userId != null ? slugOf(userId) : null;++ if (slug) c.channel_slug = slug;++ return c;++ });++ const unresolved = (reason, detail) => ({ status: 'unresolved', reason, ...(detail ? { detail: String(detail).slice(0, 500) } : {}), checked });++ if (r.status !== 'resolved') return unresolved(r.reason, r.detail);++++ const user = userById(r.userId);++ if (!user) return unresolved('not_found', `the owner ${r.resolved_by} names no longer has a Live account`);++ const ch = db.get('SELECT id FROM channels WHERE user_id = ?', [user.id]);++ if (!ch) return unresolved('not_found', `@${user.username} has no channel yet`);++ const link = db.get("SELECT subject_id, service_user_id FROM linked_accounts WHERE service = 'network' AND user_id = ?", [user.id]);++ const legacy_ids = { live_user_id: user.id };++ if (link && int(link.service_user_id)) legacy_ids.network_user_id = int(link.service_user_id);++ const out = {++ status: 'resolved',++ channel: {++ id: String(ch.id),++ slug: user.username,++ owner_subject: link && SUBJECT_RE.test(String(link.subject_id || '')) ? link.subject_id : null,++ legacy_ids,++ },++ };++ for (const k of ['stream', 'vod', 'clip', 'media_object']) if (r.records[k]) out[k] = r.records[k];++ Object.assign(out, { resolved_by: r.resolved_by, rule: r.rule, confidence: r.confidence, via: r.via, checked });++ return out;++}++++module.exports = { resolve, resolveOwner, normalizeRequest, RULES };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * GET|POST /internal/lineage/resolve: the canonical channel/owner resolver (./resolver.js) for other++ * services (Pulse, OpenRe, Media, creator UI). Service token only (aud openvibe.live, capability++ * live.lineage.resolve), loopback only.++ *++ * GET ?slug=&parent_slug=&channel_id=&stream_id=&slot_id=&vod_id=&clip_id=&media_object_id=++ * &owner_subject=&live_user_id=&network_user_id=&display_name=++ * POST lineage.resolve-request@1 (JSON)++ *++ * Every well-formed request is answered 200 with a lineage.resolution@1, resolved or unresolved++ * (a conflict, a display name alone, Media down); a malformed one is 400 lineage.invalid_request.++ *++ * The capability id is passed as a constant, not a string literal inside the guard call:++ * live.lineage.resolve is registered in openvibe-contracts 0.32.0, Live still pins 0.30.2, and++ * `openvibe-contracts-check` in CI fails on a literal capability id the pinned registry does not++ * define (it scans comments too). guard() checks the grant itself while the id is unknown to the++ * registry and switches to capabilities.check() once the pin moves. When Live pins 0.32.0+, inline++ * the id so the CI check enforces it.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { guard } = require('../net/service-guard');++const resolver = require('./resolver');++++const CAPABILITY = 'live.lineage.resolve';++++const router = express.Router();++router.use(guard(CAPABILITY));++++async function answer(req, res, raw, flat) {++ const { input, error } = resolver.normalizeRequest(raw, { flat });++ if (error) return http.sendProblem(res, 400, 'lineage.invalid_request', { detail: error });++ try {++ res.set('Cache-Control', 'no-store').json(await resolver.resolve(input));++ } catch (err) {++ console.warn('[Lineage] resolve failed:', err.message);++ http.sendProblem(res, 500, 'lineage.failed', { detail: 'the resolver failed' });++ }++}++++router.get('/resolve', (req, res) => answer(req, res, req.query, true));++router.post('/resolve', express.json({ limit: '16kb' }), (req, res) => answer(req, res, req.body, false));++++module.exports = router;@@@ -13,6 +13,7 @@ const permissions = require('../auth/permissions');const access = require('./access');const commentsClient = require('../comments-client');const { commentCount } = require('./comments');++const lineage = require('../lineage/resolver');const router = express.Router();@@@ -77,13 +78,14 @@ function withUserFields(clip) {return clip;}++// The clipped channel's owner (Live user id), from the canonical resolver (server/lineage): the++// clip's stream, else its VOD's lineage; null when neither answers or Media is down. Kept to those++// two links as before, so the channel Media recorded on the clip (legacy_metadata) does not decide++// here; the clipper (clip.user_id) and display names never do.async function clipChannelOwnerId(clip) {-- if (!clip) return null;-- if (clip.stream_id) { const s = db.getStreamById(clip.stream_id); if (s) return s.user_id; }-- if (clip.vod_id) {-- try { const v = await media.getVod(clip.vod_id); if (v) return v.user_id; } catch { /* */ }-- }-- return null;++ if (!clip || clip.id == null) return null;++ const r = await lineage.resolveOwner({ clip_id: String(clip.id) }, { records: { clip }, rules: ['stream_lookup', 'vod_parent'] });++ return r.status === 'resolved' ? r.userId : null;}async function canActorModerateClip(actor, clip) {@@@ -365,3 +367,4 @@ router.post('/:id/recut', requireAuth, async (req, res) => {});module.exports = router;++module.exports._internals = { clipChannelOwnerId };nnew file mode 100644@@@ -0,0 +1,312 @@++'use strict';++++// The canonical channel/owner resolver (roadmap D20, server/lineage): every input kind, the++// precedence between inputs and inside a record, conflicts, "a display name alone resolves++// nothing", legacy maps, a deleted stream with a surviving VOD, the clip -> VOD -> stream -> channel++// chain, the clips.js call site, and GET|POST /internal/lineage/resolve behind its service token.++// Media is a fake HTTP server; answers are checked against lineage.resolution@1 when the installed++// openvibe-contracts has it (0.32.0+).++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const express = require('express');++const contracts = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-lineage-'));++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_API_KEY = 'media-key';++process.env.MEDIA_APP_ID = 'live';++const log = console.log;++console.log = () => {};++console.warn = () => {};++++const sub = (tag) => `usr_${`01J${tag}`.padEnd(26, '0')}`;++const ALICE = sub('AA'), BOB = sub('BB'), SHARED = sub('CC'), NOBODY = sub('DD');++const med = (tag) => `med_${`01J${tag}`.padEnd(26, '0')}`;++++// ── Fake OpenVibe.Media ─────────────────────────────────────++const VODS = {++ 42: { id: 42, stream_id: 9, managed_stream_id: 3, user_id: 17 },++ 43: { id: 43, stream_id: 999999, managed_stream_id: 3, user_id: 17 }, // stream row never existed here++ 44: { id: 44, stream_id: 999998, managed_stream_id: 999997, user_id: 17 }, // stream and slot gone++ 45: { id: 45, stream_id: null, managed_stream_id: null, user_id: null }, // names nothing++ 46: { id: 46, stream_id: 9, managed_stream_id: null, user_id: 18 }, // stream says alice, recorded owner bob++ 47: { id: 47, stream_id: null, managed_stream_id: null, user_id: 999 }, // owner account deleted++ 48: { id: 48, stream_id: 11, managed_stream_id: 3, user_id: 17 }, // stream 11 is deleted below++};++const CLIPS = {++ 7: { id: 7, vod_id: 42, stream_id: null, user_id: 18, channel_user_id: 17 }, // bob clipped alice's VOD++ 8: { id: 8, vod_id: null, stream_id: 9, user_id: 18, channel_user_id: null },++ 9: { id: 9, vod_id: null, stream_id: null, user_id: 18, channel_user_id: 17 },++ 10: { id: 10, vod_id: null, stream_id: null, user_id: 18, channel_user_id: null },++ 12: { id: 12, vod_id: 404404, stream_id: null, user_id: 18, channel_user_id: null },++ 13: { id: 13, vod_id: 42, stream_id: 10, user_id: 19, channel_user_id: null }, // bob's stream, alice's VOD++ 14: { id: 14, vod_id: 44, stream_id: null, user_id: 18, channel_user_id: null },++ 15: { id: 15, vod_id: 47, stream_id: null, user_id: 18, channel_user_id: null },++};++const OBJECTS = {++ [med('A1')]: { id: med('A1'), kind: 'vod', legacy_ref: 'legacy:live:vod:42', owner: { subject: null, app: 'live', user_id: 17 } },++ [med('B1')]: { id: med('B1'), kind: 'file', legacy_ref: null, owner: { subject: `user:${ALICE}`, app: 'live', user_id: 18 } },++ [med('C1')]: { id: med('C1'), kind: 'file', legacy_ref: null, owner: { subject: null, app: 'live', user_id: 17 } },++ 'legacy:live:thumbnail:x.jpg': { id: med('D1'), kind: 'thumbnail', legacy_ref: 'legacy:live:thumbnail:x.jpg', owner: { subject: null, app: 'live', user_id: 17 } },++};++const mediaState = { down: false, calls: [] };++const mediaServer = http.createServer((req, res) => {++ const url = decodeURIComponent(req.url.split('?')[0]);++ mediaState.calls.push(url);++ assert.strictEqual(req.headers.authorization, 'Bearer media-key', 'Live reads Media as its app');++ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };++ if (mediaState.down) return send(503, { error: 'down' });++ let m;++ if ((m = /^\/api\/v1\/live\/vods\/(\d+)$/.exec(url))) return VODS[m[1]] ? send(200, VODS[m[1]]) : send(404, { error: 'VOD not found' });++ if ((m = /^\/api\/v1\/live\/clips\/(\d+)$/.exec(url))) return CLIPS[m[1]] ? send(200, CLIPS[m[1]]) : send(404, { error: 'Clip not found' });++ if ((m = /^\/api\/v2\/live\/objects\/(.+)$/.exec(url))) return OBJECTS[m[1]] ? send(200, OBJECTS[m[1]]) : send(404, { code: 'media.object.not_found' });++ send(404, { error: 'no route' });++});++++// Every contract answer is checked against lineage.resolution@1 when the installed contracts know it.++const RESOLUTION = 'lineage.resolution@1';++const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();++let validated = 0;++function checkContract(out, label) {++ if (!contractKnown) return out;++ const r = contracts.validate(RESOLUTION, out);++ assert.ok(r.valid, `${label}: not a ${RESOLUTION}: ${JSON.stringify(r.errors)}`);++ validated++;++ return out;++}++++(async () => {++ await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));++ process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;++++ const db = require('../server/db/database');++ db.initDb();++ const d = db.getDb();++ const users = [[17, 'alice', 'Alice Wonder'], [18, 'bob', 'alice'], [19, 'carol', 'Carol'], [20, 'dave', 'streamqueen'], [21, 'mallory', 'Alice Wonder'], [22, 'erin', 'Erin']];++ for (const [id, name, display] of users) d.prepare("INSERT INTO users (id, username, display_name, password_hash) VALUES (?, ?, ?, 'x')").run(id, name, display);++ const channelOf = {};++ for (const id of [17, 18, 19, 21, 22]) channelOf[id] = Number(d.prepare('INSERT INTO channels (user_id) VALUES (?)').run(id).lastInsertRowid);++ const link = d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)");++ link.run(17, '57', ALICE); link.run(18, '58', BOB); link.run(19, '59', SHARED); link.run(20, '60', null); link.run(21, '61', SHARED);++ d.prepare("INSERT INTO managed_streams (id, user_id, slug, stream_key) VALUES (3, 17, 'garage-cam', 'k3'), (4, 18, 'desk', 'k4')").run();++ d.prepare("INSERT INTO streams (id, user_id, managed_stream_id, channel_id, title) VALUES (9, 17, 3, ?, 'a'), (10, 18, 4, ?, 'b'), (11, 17, 3, ?, 'c')").run(channelOf[17], channelOf[18], channelOf[17]);++++ const lineage = require('../server/lineage/resolver');++ const resolve = async (input, label = JSON.stringify(input)) => checkContract(await lineage.resolve(input), label);++ const expectResolved = async (input, want) => {++ const r = await resolve(input);++ assert.strictEqual(r.status, 'resolved', `${JSON.stringify(input)} resolves: ${JSON.stringify(r)}`);++ for (const [k, v] of Object.entries(want)) assert.deepStrictEqual(r[k], v, `${JSON.stringify(input)}: ${k}`);++ return r;++ };++ const expectUnresolved = async (input, reason) => {++ const r = await resolve(input);++ assert.strictEqual(r.status, 'unresolved', `${JSON.stringify(input)} does not resolve: ${JSON.stringify(r)}`);++ assert.strictEqual(r.reason, reason, `${JSON.stringify(input)}: reason`);++ assert.ok(!('channel' in r) && !('resolved_by' in r), 'an unresolved answer names no channel');++ return r;++ };++ const aliceChannel = { id: String(channelOf[17]), slug: 'alice', owner_subject: ALICE, legacy_ids: { live_user_id: 17, network_user_id: 57 } };++ const slot3 = { id: null, slot_id: '3', slot_slug: 'garage-cam' };++ const stream9 = { id: '9', slot_id: '3', slot_slug: 'garage-cam' };++++ // ── The request ──────────────────────────────────────────++ const N = (v, o) => lineage.normalizeRequest(v, o);++ assert.ok(N({ username: 'alice' }).error && N({ channel_name: 'Alice' }).error, 'no name-shaped inputs besides slug');++ assert.ok(N({ slug: 'Alice Wonder' }).error, 'a display name is not a slug');++ assert.ok(N({ slug: 'a/b/c' }).error && N({ parent_slug: 'a/b' }).error, 'one level of nesting');++ assert.ok(N({ owner_subject: '17' }).error && N({ legacy_ids: {} }).error && N({ legacy_ids: { live_user_id: 0 } }).error, 'bad subject and legacy ids');++ assert.ok(N({ media_object_id: 'vod:42' }).error, 'media ids are med_ or legacy: refs');++ assert.deepStrictEqual(N({ vod_id: 42 }).input, { vod_id: '42' }, "Live's own callers may pass integer ids");++ assert.deepStrictEqual(N({ live_user_id: '17', network_user_id: '57' }, { flat: true }).input, { legacy_ids: { live_user_id: 17, network_user_id: 57 } }, 'GET flattens legacy_ids');++ assert.ok(N({ live_user_id: '17' }).error, 'flat legacy ids only on GET');++++ // ── Every input kind ─────────────────────────────────────++ await expectResolved({ slug: 'alice' }, { channel: aliceChannel, resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact', via: ['user:17'] });++ await expectResolved({ slug: '@ALICE' }, { channel: aliceChannel, rule: 'explicit_slug' });++ await expectResolved({ slug: 'alice/garage-cam' }, { channel: aliceChannel, resolved_by: 'slug', rule: 'nested_slug', confidence: 'exact', stream: slot3 });++ await expectResolved({ slug: 'alice/3' }, { rule: 'nested_slug', stream: slot3 });++ await expectUnresolved({ slug: 'alice/desk' }, 'not_found'); // bob's slot is not inside alice++ let r = await expectResolved({ parent_slug: 'alice', slug: 'garage-cam' }, { resolved_by: 'slug', rule: 'nested_slug', stream: slot3 });++ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['slug', 'decided'], ['parent_slug', 'decided']]);++ await expectResolved({ parent_slug: 'alice' }, { resolved_by: 'parent_slug', rule: 'nested_slug', channel: aliceChannel });++ await expectResolved({ channel_id: String(channelOf[17]) }, { resolved_by: 'channel_id', rule: 'channel_id', confidence: 'exact' });++ await expectResolved({ stream_id: '9' }, { resolved_by: 'stream_id', rule: 'stream_lookup', confidence: 'exact', stream: stream9, via: ['stream:9', 'user:17'] });++ await expectResolved({ slot_id: '3' }, { resolved_by: 'slot_id', rule: 'stream_lookup', confidence: 'exact', stream: slot3 });++ await expectResolved({ vod_id: '42' }, { resolved_by: 'vod_id', rule: 'stream_lookup', confidence: 'derived', vod: { id: '42', stream_id: '9', slot_id: '3' }, stream: stream9, via: ['vod:42', 'stream:9', 'user:17'] });++ await expectResolved({ clip_id: '8' }, { resolved_by: 'clip_id', rule: 'stream_lookup', confidence: 'derived', via: ['clip:8', 'stream:9', 'user:17'] });++ await expectResolved({ media_object_id: med('A1') }, { resolved_by: 'media_object_id', rule: 'media_lineage', confidence: 'derived', channel: aliceChannel,++ media_object: { id: med('A1'), kind: 'vod', legacy_ref: 'legacy:live:vod:42' }, vod: { id: '42', stream_id: '9', slot_id: '3' } });++ // The object's owner subject outranks the owner user recorded next to it (18, bob).++ await expectResolved({ media_object_id: med('B1') }, { rule: 'owner_subject', confidence: 'derived', channel: aliceChannel });++ await expectResolved({ media_object_id: med('C1') }, { rule: 'legacy_metadata', confidence: 'derived', channel: aliceChannel });++ await expectResolved({ owner_subject: ALICE }, { resolved_by: 'owner_subject', rule: 'owner_subject', confidence: 'exact', channel: aliceChannel });++ await expectResolved({ legacy_ids: { live_user_id: 17 } }, { resolved_by: 'legacy_ids', rule: 'legacy_map', confidence: 'legacy_map', channel: aliceChannel });++ await expectResolved({ legacy_ids: { network_user_id: 57 } }, { resolved_by: 'legacy_ids', rule: 'legacy_map', confidence: 'legacy_map', via: ['network_user:57', 'user:17'] });++++ // ── Precedence between inputs ────────────────────────────++ r = await expectResolved({ legacy_ids: { live_user_id: 17 }, owner_subject: ALICE, vod_id: '42', slug: 'alice' }, { resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact' });++ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['slug', 'decided'], ['vod_id', 'agrees'], ['owner_subject', 'agrees'], ['legacy_ids', 'agrees']], 'checked follows precedence, not argument order');++ assert.deepStrictEqual(r.vod, { id: '42', stream_id: '9', slot_id: '3' }, 'records of agreeing inputs are kept');++ await expectResolved({ owner_subject: ALICE, stream_id: '9' }, { resolved_by: 'stream_id' });++ await expectResolved({ vod_id: '42', clip_id: '7' }, { resolved_by: 'clip_id' });++ await expectResolved({ owner_subject: ALICE, media_object_id: med('A1') }, { resolved_by: 'media_object_id' });++ await expectResolved({ channel_id: String(channelOf[17]), slug: 'alice/garage-cam' }, { resolved_by: 'slug', rule: 'nested_slug' });++ r = await expectResolved({ vod_id: '404404', owner_subject: ALICE }, { resolved_by: 'owner_subject' });++ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['vod_id', 'not_found'], ['owner_subject', 'decided']], 'a missing record does not block');++ assert.ok(!('vod' in r), 'a record that was not found is not attached');++ r = await expectResolved({ slug: 'renamed-away', stream_id: '9' }, { resolved_by: 'stream_id', channel: aliceChannel });++ assert.strictEqual(r.checked[0].outcome, 'not_found');++ // Inside a record the first link that answers wins.++ await expectResolved({ vod_id: '46' }, { rule: 'stream_lookup', channel: aliceChannel }); // not the recorded owner, bob++ r = await expectResolved({ clip_id: '13' }, { rule: 'stream_lookup', via: ['clip:13', 'stream:10', 'user:18'] }); // the clip's stream, not its VOD++ assert.strictEqual(r.channel.slug, 'bob');++ assert.deepStrictEqual(lineage.RULES, contractKnown ? contracts.schema('lineage.resolution').properties.rule.enum : lineage.RULES, 'the rule set is the contract\'s');++++ // ── Conflicts ────────────────────────────────────────────++ r = await expectUnresolved({ slug: 'mallory', vod_id: '42' }, 'conflict');++ assert.deepStrictEqual(r.checked, [{ input: 'slug', outcome: 'decided', channel_slug: 'mallory' }, { input: 'vod_id', outcome: 'conflict', channel_slug: 'alice' }]);++ await expectUnresolved({ legacy_ids: { live_user_id: 17, network_user_id: 58 } }, 'conflict');++ await expectUnresolved({ owner_subject: BOB, clip_id: '7' }, 'conflict');++++ // ── A display name alone resolves nothing ────────────────++ r = await expectUnresolved({ display_name: 'Alice Wonder' }, 'display_name_only');++ assert.deepStrictEqual(r.checked, [{ input: 'display_name', outcome: 'ignored' }]);++ await expectUnresolved({ display_name: 'alice' }, 'display_name_only'); // even when it is someone's username++ await expectUnresolved({ slug: 'streamqueen' }, 'not_found'); // dave's display name is not a slug++ await expectUnresolved({ slug: 'ghost', display_name: 'Alice Wonder' }, 'not_found'); // and never rescues a miss++ r = await expectResolved({ slug: 'alice', display_name: 'Mallory' }, { resolved_by: 'slug', channel: aliceChannel });++ assert.deepStrictEqual(r.checked.at(-1), { input: 'display_name', outcome: 'ignored' });++ assert.strictEqual((await expectResolved({ slug: 'bob' }, {})).channel.slug, 'bob'); // bob's display name is "alice"; alice stays alice++ assert.strictEqual((await resolve({ slug: 'alice' })).channel.legacy_ids.live_user_id, 17);++++ // ── Legacy maps ──────────────────────────────────────────++ await expectResolved({ legacy_ids: { live_user_id: 17, network_user_id: 57 } }, { confidence: 'legacy_map', channel: aliceChannel });++ await expectResolved({ media_object_id: 'legacy:live:vod:42' }, { rule: 'legacy_map', confidence: 'legacy_map', channel: aliceChannel,++ via: ['media:legacy:live:vod:42', 'vod:42', 'stream:9', 'user:17'], media_object: { id: 'legacy:live:vod:42', kind: 'vod', legacy_ref: 'legacy:live:vod:42' } });++ await expectResolved({ media_object_id: 'legacy:live:clip:7' }, { rule: 'legacy_map', confidence: 'legacy_map', via: ['media:legacy:live:clip:7', 'clip:7', 'vod:42', 'stream:9', 'user:17'] });++ await expectResolved({ media_object_id: 'legacy:live:thumbnail:x.jpg' }, { rule: 'legacy_map', confidence: 'legacy_map', media_object: { id: med('D1'), kind: 'thumbnail', legacy_ref: 'legacy:live:thumbnail:x.jpg' } });++ await expectUnresolved({ media_object_id: 'legacy:games:vod:42' }, 'not_found'); // another app's object++ await expectUnresolved({ legacy_ids: { network_user_id: 999 } }, 'not_found');++ await expectUnresolved({ legacy_ids: { live_user_id: 999 } }, 'not_found');++++ // ── A deleted stream with a surviving VOD ────────────────++ d.prepare('DELETE FROM streams WHERE id = 11').run();++ await expectResolved({ vod_id: '48' }, { channel: aliceChannel, rule: 'stream_lookup', confidence: 'derived', via: ['vod:48', 'slot:3', 'user:17'],++ stream: { id: '11', slot_id: '3', slot_slug: 'garage-cam', missing: true }, vod: { id: '48', stream_id: '11', slot_id: '3' } });++ await expectResolved({ vod_id: '43' }, { rule: 'stream_lookup', stream: { id: '999999', slot_id: '3', slot_slug: 'garage-cam', missing: true } });++ await expectResolved({ vod_id: '44' }, { rule: 'legacy_metadata', confidence: 'derived', via: ['vod:44', 'user:17'], stream: { id: '999998', slot_id: '999997', slot_slug: null, missing: true } });++ await expectUnresolved({ vod_id: '45' }, 'not_found');++ r = await expectUnresolved({ vod_id: '47' }, 'not_found'); // the recorded owner's account is gone++ assert.match(r.detail, /no longer has a Live account/);++++ // ── clip -> VOD -> stream -> channel ─────────────────────++ r = await expectResolved({ clip_id: '7' }, { channel: aliceChannel, resolved_by: 'clip_id', rule: 'vod_parent', confidence: 'derived',++ via: ['clip:7', 'vod:42', 'stream:9', 'user:17'], clip: { id: '7', vod_id: '42', stream_id: null }, vod: { id: '42', stream_id: '9', slot_id: '3' }, stream: stream9 });++ assert.ok(!r.via.includes('user:18'), 'the clipper (bob) is never the channel');++ await expectResolved({ clip_id: '9' }, { rule: 'legacy_metadata', channel: aliceChannel }); // only the recorded channel is left++ await expectUnresolved({ clip_id: '10' }, 'not_found'); // only the clipper: nothing++ await expectResolved({ clip_id: '14' }, { rule: 'vod_parent', via: ['clip:14', 'vod:44', 'user:17'] });++++ // ── Ambiguous subjects, owners without a channel, no input ─++ r = await expectUnresolved({ owner_subject: SHARED }, 'ambiguous');++ assert.deepStrictEqual(r.checked, [{ input: 'owner_subject', outcome: 'ambiguous' }]);++ r = await expectResolved({ slug: 'carol', owner_subject: SHARED }, { resolved_by: 'slug' });++ assert.deepStrictEqual(r.checked[1], { input: 'owner_subject', outcome: 'agrees', channel_slug: 'carol' });++ await expectUnresolved({ slug: 'erin', owner_subject: SHARED }, 'conflict');++ await expectUnresolved({ owner_subject: NOBODY }, 'not_found');++ r = await expectUnresolved({ slug: 'dave' }, 'not_found');++ assert.match(r.detail, /has no channel yet/);++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM channels WHERE user_id = 20').get().n, 0, 'resolving never creates a channel');++ assert.deepStrictEqual((({ status, userId, rule }) => ({ status, userId, rule }))(await lineage.resolveOwner({ slug: 'dave' })), { status: 'resolved', userId: 20, rule: 'explicit_slug' });++ assert.strictEqual((await lineage.resolveOwner({ vod_id: '47' })).userId, 999, 'resolveOwner reports the owner a record names');++ await expectUnresolved({}, 'no_input');++ await assert.rejects(lineage.resolveOwner({ slug: 'Not A Slug' }), /slug/);++++ // ── Media down: nothing that needs it resolves ───────────++ mediaState.down = true;++ await expectUnresolved({ vod_id: '42' }, 'source_unavailable');++ await expectUnresolved({ slug: 'alice', vod_id: '42' }, 'source_unavailable'); // it might have disagreed++ await expectUnresolved({ media_object_id: med('A1') }, 'source_unavailable');++ await expectResolved({ slug: 'alice', stream_id: '9' }, { resolved_by: 'slug' }); // no Media needed++ mediaState.down = false;++++ // ── The clips.js call site: stream, else the VOD's lineage, as before ─++ const { clipChannelOwnerId } = require('../server/media-proxy/clips')._internals;++ mediaState.calls = [];++ assert.strictEqual(await clipChannelOwnerId(CLIPS[8]), 17, 'clip stream');++ assert.deepStrictEqual(mediaState.calls, [], 'no Media read when the clip names a live stream row, and the clip is not re-read');++ assert.strictEqual(await clipChannelOwnerId(CLIPS[7]), 17, 'clip -> VOD -> stream');++ assert.deepStrictEqual(mediaState.calls, ['/api/v1/live/vods/42'], 'one VOD read, as before');++ assert.strictEqual(await clipChannelOwnerId(CLIPS[14]), 17, "a VOD whose stream and slot are gone still answers with its recorded owner");++ assert.strictEqual(await clipChannelOwnerId(CLIPS[15]), 999, 'the recorded owner is returned even when the account is gone, as before');++ assert.strictEqual(await clipChannelOwnerId(CLIPS[13]), 18, "the clip's own stream wins over its VOD");++ assert.strictEqual(await clipChannelOwnerId(CLIPS[9]), null, "the channel recorded on the clip does not decide here (it did not before)");++ assert.strictEqual(await clipChannelOwnerId(CLIPS[10]), null, 'the clipper never owns the channel');++ assert.strictEqual(await clipChannelOwnerId(CLIPS[12]), null, 'a VOD Media does not have');++ assert.strictEqual(await clipChannelOwnerId(null), null);++ mediaState.down = true;++ assert.strictEqual(await clipChannelOwnerId(CLIPS[7]), null, 'Media down: no owner, as before');++ mediaState.down = false;++++ // ── GET|POST /internal/lineage/resolve ───────────────────++ const { serviceAuth } = contracts;++ const now = () => Math.floor(Date.now() / 1000);++ let jti = 0;++ const token = (cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.live'], cap, iat: now(), exp: now() + 300, jti: `tok_lin_${++jti}` }, keys.privateKey);++ const app = express();++ app.use('/internal/lineage', require('../server/lineage/routes'));++ const server = await new Promise((ok) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => ok(s)); });++ const base = `http://127.0.0.1:${server.address().port}/internal/lineage/resolve`;++ const call = async (method, q, body, { cap = ['live.lineage.resolve'], headers = {} } = {}) => {++ const res = await fetch(`${base}${q ? `?${q}` : ''}`, {++ method, headers: { Authorization: `Bearer ${token(cap)}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },++ body: body ? JSON.stringify(body) : undefined,++ });++ return { status: res.status, json: await res.json().catch(() => null), cache: res.headers.get('cache-control') };++ };++ assert.strictEqual((await fetch(`${base}?slug=alice`)).status, 401, 'no token');++ assert.strictEqual((await call('GET', 'slug=alice', null, { cap: ['live.follower.read'] })).status, 403, 'needs live.lineage.resolve');++ assert.strictEqual((await call('GET', 'slug=alice', null, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');++ let res = await call('GET', 'slug=alice&vod_id=42&live_user_id=17');++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.cache, 'no-store');++ checkContract(res.json, 'GET');++ assert.deepStrictEqual([res.json.status, res.json.channel.slug, res.json.resolved_by, res.json.checked.length], ['resolved', 'alice', 'slug', 3]);++ res = await call('POST', '', { clip_id: '7', legacy_ids: { network_user_id: 57 } });++ checkContract(res.json, 'POST');++ assert.deepStrictEqual([res.status, res.json.rule, res.json.clip.id], [200, 'vod_parent', '7']);++ res = await call('GET', `display_name=${encodeURIComponent('Alice Wonder')}`);++ assert.deepStrictEqual([res.status, res.json.status, res.json.reason], [200, 'unresolved', 'display_name_only']);++ res = await call('POST', '', { slug: 'mallory', vod_id: '42' });++ assert.deepStrictEqual([res.status, res.json.reason], [200, 'conflict']);++ for (const [q, body] of [['username=alice', null], ['slug=Alice%20Wonder', null], ['', { owner_subject: '17' }], ['', { vod_id: '42', extra: 1 }], ['slug=a&slug=b', null]]) {++ res = await call(body ? 'POST' : 'GET', q, body);++ assert.deepStrictEqual([res.status, res.json.code], [400, 'lineage.invalid_request'], `${q || JSON.stringify(body)} is refused`);++ }++ server.close();++ mediaServer.close();++++ // The request forms this route accepts are the contract's (when the installed contracts know it).++ if (contractKnown) {++ for (const req of [{ slug: '@alice' }, { slug: 'alice/cam' }, { display_name: 'x' }, { media_object_id: 'legacy:live:vod:1' }, { slug: 'Alice Wonder' }, { owner_subject: '17' }, { vod_id: 42 }, { legacy_ids: {} }, { username: 'a' }]) {++ const strict = contracts.validate('lineage.resolve-request@1', req).valid;++ const ours = !lineage.normalizeRequest(req).error;++ // Live also takes integer ids from its own callers; everything else matches the contract.++ if (!(typeof req.vod_id === 'number')) assert.strictEqual(ours, strict, `${JSON.stringify(req)}: Live ${ours}, contract ${strict}`);++ }++ }++++ console.log = log;++ console.log(`lineage resolver: all checks passed (${contractKnown ? `${validated} answers validated against ${RESOLUTION}` : `installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION}; contract validation skipped`})`);++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -579,6 +579,8 @@ app.use('/internal/tips', require('./tips/delivery-routes'));
@@ -579,6 +579,8 @@ app.use('/internal/tips', require('./tips/delivery-routes'));
// OpenVibe.Network sends go-live notifications from live.stream.started and asks here who follows
// OpenVibe.Network sends go-live notifications from live.stream.started and asks here who follows
// the channel (service token, live.follower.read).
// the channel (service token, live.follower.read).
app.use('/internal/followers', require('./streaming/followers-internal'));
app.use('/internal/followers', require('./streaming/followers-internal'));
+// The canonical channel/owner resolver for other services (roadmap D20; service token, live.lineage.resolve).
+app.use('/internal/lineage', require('./lineage/routes'));
app.use('/internal', require('./internal/routes'));
app.use('/internal', require('./internal/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/**
+ * What the lineage resolver reads from OpenVibe.Media: VODs and clips (API v1, the ids Live uses)
+ * and canonical objects (API v2, `GET /api/v2/<app>/objects/<med_… | legacy:…>`), all as this app
+ * with no acting user. Every read answers { value } | { not_found } | { unavailable }, so the
+ * resolver can tell "Media says no such record" from "Media could not be asked".
+ */
+const media = require('../media-client');
+
+const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
+
+function classify(err) {
+ const status = err && err.name === 'MediaApiError' ? err.status : 0;
+ return [400, 404, 410, 422].includes(status) ? { not_found: true } : { unavailable: true, detail: (err && err.message) || 'Media unavailable' };
+}
+
+async function read(fn) {
+ try {
+ const value = await fn();
+ return value ? { value } : { not_found: true };
+ } catch (err) {
+ return classify(err);
+ }
+}
+
+/** GET /api/v2/<app>/objects/<id>: the canonical object (legacy_ref, kind, owner { subject, user_id }). */
+async function fetchObject(id, { timeoutMs = 10000 } = {}) {
+ const url = `${media.MEDIA_URL}/api/v2/${encodeURIComponent(media.MEDIA_APP_ID)}/objects/${encodeURIComponent(id)}`;
+ const headers = { Accept: 'application/json' };
+ if (MEDIA_API_KEY) headers.Authorization = `Bearer ${MEDIA_API_KEY}`;
+ let res;
+ let body = null;
+ try {
+ res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
+ body = await res.json().catch(() => null);
+ } catch (err) {
+ throw new media.MediaApiError(`Media unreachable (GET objects/${id}): ${err.message}`, 0, null);
+ }
+ if (!res.ok) throw new media.MediaApiError((body && (body.detail || body.error)) || `Media API ${res.status} on GET objects/${id}`, res.status, body);
+ return body;
+}
+
+module.exports = {
+ appId: media.MEDIA_APP_ID,
+ getVod: (id) => read(() => media.getVod(id)),
+ getClip: (id) => read(() => media.getClip(id)),
+ getObject: (id) => read(() => fetchObject(id)),
+ _classify: classify,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,447 @@
@@ -0,0 +1,447 @@
+'use strict';
+/**
+ * The canonical channel/owner resolver (roadmap §15.10, requirement D20): which channel, and whose,
+ * a slug, slot, stream, VOD, clip, Media object, subject or legacy id belongs to.
+ *
+ * Contract: openvibe-contracts lineage.resolve-request@1 -> lineage.resolution@1 (v0.32.0), served at
+ * GET|POST /internal/lineage/resolve (./routes.js, capability live.lineage.resolve).
+ *
+ * PRECEDENCE between inputs. Every input given is evaluated. The first one in this order that
+ * resolves decides (resolved_by). Every other one that resolves must name the same owner, or the
+ * answer is `conflict`. One whose source could not be read makes the answer `source_unavailable`,
+ * because it might have disagreed. One that names nothing is `not_found` and does not block.
+ *
+ * # input rule how confidence
+ * 1 slug explicit_slug users.username, case-insensitive exact
+ * 2 slug "a/b", nested_slug the channel a, then its slot b (slug or id) exact
+ * parent_slug(+slug)
+ * 3 channel_id channel_id channels.id -> channels.user_id exact
+ * 4 stream_id stream_lookup streams.user_id exact
+ * slot_id stream_lookup managed_streams.user_id exact
+ * 5 clip_id the clip's lineage, read from Media (below) derived
+ * vod_id the VOD's lineage, read from Media (below) derived
+ * 6 media_object_id media_lineage Media object -> the VOD/clip its legacy_ref names derived
+ * legacy_map a legacy:live:<kind>:<id> reference legacy_map
+ * 7 owner_subject owner_subject linked_accounts.subject_id (two accounts: ambiguous) exact
+ * 8 legacy_ids legacy_map users.id, or the Network id on linked_accounts legacy_map
+ *
+ * LINEAGE inside a record. The first link that answers wins; a link whose source cannot be read
+ * stops the walk (a lower link must not answer for it):
+ * clip its stream (stream_lookup) -> its VOD's lineage (vod_parent) -> clip.channel_user_id
+ * (legacy_metadata). Never clip.user_id: that is the clipper, not the channel.
+ * VOD its stream (stream_lookup) -> its slot when the stream row is gone (stream_lookup) ->
+ * vod.user_id (legacy_metadata)
+ * object the record its legacy_ref names (media_lineage) -> owner.subject (owner_subject) ->
+ * owner.user_id (legacy_metadata)
+ * Confidence is the weakest hop: exact < derived (through a Media record) < legacy_map.
+ *
+ * DISPLAY NAMES are never read. A request holding only display_name is answered display_name_only;
+ * next to other inputs it is ignored.
+ *
+ * resolveOwner() is the engine, in Live user ids, for Live's own call sites (it answers with the
+ * owner a record names even when that account is gone, as the code it replaced did). resolve() is
+ * the contract answer: the owner must still have an account and a `channels` row. Neither writes
+ * anything, so an owner whose channel row Live has not created yet (the page creates it on first
+ * view) is not_found for resolve().
+ */
+const db = require('../db/database');
+
+const SLUG_PART = '[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}';
+const SLUG_RE = new RegExp(`^@?(${SLUG_PART})(?:/(${SLUG_PART}))?$`);
+const PARENT_RE = new RegExp(`^@?(${SLUG_PART})$`);
+const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
+const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const ID_INPUTS = ['channel_id', 'stream_id', 'slot_id', 'clip_id', 'vod_id'];
+const LEGACY_KEYS = ['live_user_id', 'network_user_id'];
+const FIELDS = new Set(['slug', 'parent_slug', ...ID_INPUTS, 'media_object_id', 'owner_subject', 'legacy_ids', 'display_name']);
+const RULES = ['explicit_slug', 'nested_slug', 'channel_id', 'stream_lookup', 'vod_parent', 'media_lineage', 'owner_subject', 'legacy_metadata', 'legacy_map'];
+const CONFIDENCE = ['exact', 'derived', 'legacy_map'];
+
+const weaker = (a, b) => (CONFIDENCE.indexOf(a) >= CONFIDENCE.indexOf(b) ? a : b);
+const str = (v) => (v == null || v === '' ? null : String(v));
+function int(v) {
+ const s = String(v == null ? '' : v);
+ if (!/^\d{1,15}$/.test(s)) return null;
+ const n = Number(s);
+ return n > 0 ? n : null;
+}
+
+// ── Request ──────────────────────────────────────────────────
+
+/**
+ * A lineage.resolve-request@1 from a JSON body, or from a query string ({ flat: true }: legacy_ids
+ * arrive as live_user_id / network_user_id). Ids may also be positive integers (Live's own callers).
+ * Returns { input } or { error }.
+ */
+function normalizeRequest(raw, { flat = false } = {}) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'the request must be an object' };
+ const src = { ...raw };
+ if (flat) {
+ const legacy = {};
+ for (const k of LEGACY_KEYS) if (src[k] !== undefined) { legacy[k] = src[k]; delete src[k]; }
+ if (Object.keys(legacy).length) {
+ if (src.legacy_ids !== undefined) return { error: 'give legacy_ids or live_user_id/network_user_id, not both' };
+ src.legacy_ids = legacy;
+ }
+ }
+ const input = {};
+ for (const [k, v] of Object.entries(src)) {
+ if (!FIELDS.has(k)) return { error: `unknown input ${k}` };
+ if (v === undefined) continue;
+ if (k === 'slug') {
+ if (typeof v !== 'string' || !SLUG_RE.test(v)) return { error: 'slug must be a channel slug or <channel>/<slot>' };
+ input.slug = v;
+ } else if (k === 'parent_slug') {
+ if (typeof v !== 'string' || !PARENT_RE.test(v)) return { error: 'parent_slug must be a channel slug' };
+ input.parent_slug = v;
+ } else if (ID_INPUTS.includes(k)) {
+ const s = Number.isSafeInteger(v) && v > 0 ? String(v) : v;
+ if (typeof s !== 'string' || !ID_RE.test(s)) return { error: `${k} must be an id` };
+ input[k] = s;
+ } else if (k === 'media_object_id') {
+ if (typeof v !== 'string' || !MEDIA_ID_RE.test(v)) return { error: 'media_object_id must be med_<ULID> or legacy:<app>:<kind>:<id>' };
+ input.media_object_id = v;
+ } else if (k === 'owner_subject') {
+ if (typeof v !== 'string' || !SUBJECT_RE.test(v)) return { error: 'owner_subject must be a usr_<ULID> subject id' };
+ input.owner_subject = v;
+ } else if (k === 'legacy_ids') {
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return { error: 'legacy_ids must be an object' };
+ const out = {};
+ for (const [lk, lv] of Object.entries(v)) {
+ if (!LEGACY_KEYS.includes(lk)) return { error: `unknown legacy id ${lk}` };
+ const n = int(lv);
+ if (!n) return { error: `legacy_ids.${lk} must be a positive integer` };
+ out[lk] = n;
+ }
+ if (!Object.keys(out).length) return { error: 'legacy_ids is empty' };
+ input.legacy_ids = out;
+ } else if (k === 'display_name') {
+ if (typeof v !== 'string' || v.length > 200) return { error: 'display_name must be a string of at most 200 characters' };
+ input.display_name = v;
+ }
+ }
+ return { input };
+}
+
+// ── Live rows ────────────────────────────────────────────────
+
+const userById = (id) => db.get('SELECT id, username FROM users WHERE id = ?', [id]);
+const userBySlug = (slug) => db.get('SELECT id, username FROM users WHERE username = ? COLLATE NOCASE', [slug]);
+const streamById = (id) => db.get(`SELECT s.id, s.user_id, s.managed_stream_id, ms.slug AS slot_slug
+ FROM streams s LEFT JOIN managed_streams ms ON ms.id = s.managed_stream_id WHERE s.id = ?`, [id]);
+const slotById = (id) => db.get('SELECT id, user_id, slug FROM managed_streams WHERE id = ?', [id]);
+const streamRecord = (s) => ({ id: String(s.id), slot_id: str(s.managed_stream_id), slot_slug: s.slot_slug || null });
+const slotRecord = (m) => ({ id: null, slot_id: String(m.id), slot_slug: m.slug || null });
+
+function subjectOwners(subject) {
+ return db.all("SELECT DISTINCT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ?", [subject]).map(r => r.user_id);
+}
+
+// ── Outcomes ─────────────────────────────────────────────────
+// A link or input comes to one of:
+// { kind: 'owner', userId, rule, confidence, via, records }
+// { kind: 'not_found', detail } | { kind: 'unavailable', detail } | { kind: 'ambiguous', userIds, detail }
+
+function owner(userId, rule, confidence, via, records = {}) {
+ return { kind: 'owner', userId: Number(userId), rule, confidence, via: [...via, `user:${userId}`], records };
+}
+const notFound = (detail) => ({ kind: 'not_found', detail });
+const unavailable = (detail) => ({ kind: 'unavailable', detail: detail || 'Media could not be read' });
+const ALL = () => true;
+
+/** One Media read per record per resolution. */
+function cached(ctx, key, load) {
+ if (!ctx.cache.has(key)) ctx.cache.set(key, Promise.resolve().then(load));
+ return ctx.cache.get(key);
+}
+const readVod = (ctx, id) => (ctx.records.vod && String(ctx.records.vod.id) === String(id) ? Promise.resolve({ value: ctx.records.vod }) : cached(ctx, `vod:${id}`, () => (int(id) ? ctx.media.getVod(id) : { not_found: true })));
+const readClip = (ctx, id) => (ctx.records.clip && String(ctx.records.clip.id) === String(id) ? Promise.resolve({ value: ctx.records.clip }) : cached(ctx, `clip:${id}`, () => (int(id) ? ctx.media.getClip(id) : { not_found: true })));
+const readObject = (ctx, id) => cached(ctx, `object:${id}`, () => ctx.media.getObject(id));
+
+function parseLegacyRef(ref) {
+ const m = /^legacy:([a-z][a-z0-9-]{1,39}):([a-z]+):(.+)$/.exec(String(ref || ''));
+ return m ? { app: m[1], kind: m[2], id: m[3] } : null;
+}
+
+/** A VOD's lineage: its stream, its slot when the stream row is gone, the owner recorded on it. */
+function vodLineage(vod, allow = ALL) {
+ const via = [`vod:${vod.id}`];
+ const records = { vod: { id: String(vod.id), stream_id: str(vod.stream_id), slot_id: str(vod.managed_stream_id) } };
+ if (vod.stream_id != null && allow('stream_lookup')) {
+ const s = int(vod.stream_id) && streamById(int(vod.stream_id));
+ if (s) return owner(s.user_id, 'stream_lookup', 'derived', [...via, `stream:${s.id}`], { ...records, stream: streamRecord(s) });
+ records.stream = { id: String(vod.stream_id), slot_id: str(vod.managed_stream_id), slot_slug: null, missing: true };
+ }
+ if (vod.managed_stream_id != null && allow('stream_lookup')) {
+ const m = int(vod.managed_stream_id) && slotById(int(vod.managed_stream_id));
+ if (m) {
+ records.stream = records.stream ? { ...records.stream, slot_slug: m.slug || null } : slotRecord(m);
+ return owner(m.user_id, 'stream_lookup', 'derived', [...via, `slot:${m.id}`], records);
+ }
+ }
+ if (int(vod.user_id) && allow('legacy_metadata')) return owner(int(vod.user_id), 'legacy_metadata', 'derived', via, records);
+ return notFound(`VOD ${vod.id} names no stream, slot or owner Live has`);
+}
+
+/** A clip's lineage: its stream, its VOD's lineage, the channel recorded on it. Never the clipper. */
+async function clipLineage(ctx, clip, allow = ALL) {
+ const via = [`clip:${clip.id}`];
+ const records = { clip: { id: String(clip.id), vod_id: str(clip.vod_id), stream_id: str(clip.stream_id) } };
+ if (clip.stream_id != null && allow('stream_lookup')) {
+ const s = int(clip.stream_id) && streamById(int(clip.stream_id));
+ if (s) return owner(s.user_id, 'stream_lookup', 'derived', [...via, `stream:${s.id}`], { ...records, stream: streamRecord(s) });
+ records.stream = { id: String(clip.stream_id), slot_id: null, slot_slug: null, missing: true };
+ }
+ if (clip.vod_id != null && allow('vod_parent')) {
+ const v = await readVod(ctx, clip.vod_id);
+ if (v.unavailable) return unavailable(v.detail);
+ if (v.value) {
+ const r = vodLineage(v.value);
+ if (r.kind === 'owner') {
+ return { ...r, rule: 'vod_parent', via: [...via, ...r.via], records: { ...records, ...r.records, stream: r.records.stream || records.stream } };
+ }
+ }
+ }
+ if (int(clip.channel_user_id) && allow('legacy_metadata')) return owner(int(clip.channel_user_id), 'legacy_metadata', 'derived', via, records);
+ return notFound(`clip ${clip.id} names no stream, VOD or channel Live has`);
+}
+
+/** A Live-app VOD or clip named by a legacy reference or a Media object's legacy_ref. */
+async function legacyRecordLineage(ctx, ref) {
+ const read = ref.kind === 'vod' ? await readVod(ctx, ref.id) : await readClip(ctx, ref.id);
+ if (read.unavailable) return unavailable(read.detail);
+ if (!read.value) return notFound(`no ${ref.kind} ${ref.id}`);
+ return ref.kind === 'vod' ? vodLineage(read.value) : clipLineage(ctx, read.value);
+}
+
+function subjectOutcome(subject, confidence, via) {
+ const ids = subjectOwners(subject);
+ if (!ids.length) return notFound(`no Live account is linked to ${subject}`);
+ if (ids.length > 1) return { kind: 'ambiguous', userIds: ids, detail: `${subject} is linked to ${ids.length} Live accounts` };
+ return owner(ids[0], 'owner_subject', confidence, via);
+}
+
+/** A Media object's lineage: the record its legacy_ref names, its owner subject, its owner user. */
+async function objectLineage(ctx, id, allow = ALL) {
+ const legacy = parseLegacyRef(id);
+ if (legacy && legacy.app !== ctx.media.appId) return notFound(`${id} belongs to another app`);
+ const via = [`media:${id}`];
+ if (legacy && (legacy.kind === 'vod' || legacy.kind === 'clip')) {
+ // A legacy reference is read as the record it names: the reference is the map.
+ if (!allow('legacy_map')) return notFound('legacy references are not accepted here');
+ const r = await legacyRecordLineage(ctx, legacy);
+ const media_object = { id, kind: legacy.kind, legacy_ref: id };
+ return r.kind === 'owner' ? { ...r, rule: 'legacy_map', confidence: 'legacy_map', via: [...via, ...r.via], records: { ...r.records, media_object } } : r;
+ }
+ const read = await readObject(ctx, id);
+ if (read.unavailable) return unavailable(read.detail);
+ if (!read.value) return notFound(`Media has no object ${id}`);
+ const obj = read.value;
+ const records = { media_object: { id: MEDIA_ID_RE.test(String(obj.id || '')) ? obj.id : id, kind: str(obj.kind), legacy_ref: str(obj.legacy_ref) } };
+ const base = legacy ? 'legacy_map' : 'derived';
+ const ref = parseLegacyRef(obj.legacy_ref);
+ if (!legacy && ref && ref.app === ctx.media.appId && (ref.kind === 'vod' || ref.kind === 'clip') && allow('media_lineage')) {
+ const r = await legacyRecordLineage(ctx, ref);
+ if (r.kind === 'unavailable' || r.kind === 'ambiguous') return r;
+ if (r.kind === 'owner') return { ...r, rule: 'media_lineage', confidence: weaker(r.confidence, 'derived'), via: [...via, ...r.via], records: { ...r.records, ...records } };
+ }
+ const subject = String((obj.owner && obj.owner.subject) || '').replace(/^user:/, '');
+ if (SUBJECT_RE.test(subject) && allow('owner_subject')) {
+ const r = subjectOutcome(subject, base, [...via, `subject:${subject}`]);
+ if (r.kind !== 'not_found') return r.kind === 'owner' ? { ...r, records } : r;
+ }
+ const recorded = int(obj.owner && obj.owner.user_id);
+ if (recorded && allow('legacy_metadata')) return owner(recorded, legacy ? 'legacy_map' : 'legacy_metadata', base, via, records);
+ return notFound(`Media object ${id} names no record or owner Live has`);
+}
+
+// ── Inputs ───────────────────────────────────────────────────
+
+/** The inputs of a request as evaluation units, in precedence order. */
+function units(input) {
+ const out = [];
+ if (input.slug !== undefined) {
+ const [, a, b] = SLUG_RE.exec(input.slug);
+ if (!b && input.parent_slug !== undefined) out.push({ input: 'slug', rule: 'nested_slug', channel: PARENT_RE.exec(input.parent_slug)[1], slot: a, pairedParent: true });
+ else out.push({ input: 'slug', rule: b ? 'nested_slug' : 'explicit_slug', channel: a, slot: b || null });
+ }
+ if (input.parent_slug !== undefined && !(out[0] && out[0].pairedParent)) out.push({ input: 'parent_slug', rule: 'nested_slug', channel: PARENT_RE.exec(input.parent_slug)[1], slot: null });
+ for (const k of ['channel_id', 'stream_id', 'slot_id', 'clip_id', 'vod_id', 'media_object_id', 'owner_subject']) {
+ if (input[k] !== undefined) out.push({ input: k, value: input[k] });
+ }
+ if (input.legacy_ids) for (const k of LEGACY_KEYS) if (input.legacy_ids[k]) out.push({ input: 'legacy_ids', legacy: k, value: input.legacy_ids[k] });
+ return out;
+}
+
+async function evaluate(ctx, u, allow) {
+ const gate = (rule, fn) => (allow(rule) ? fn() : notFound(`${rule} is not accepted here`));
+ switch (u.input) {
+ case 'slug':
+ case 'parent_slug':
+ return gate(u.rule, () => {
+ const user = userBySlug(u.channel);
+ if (!user) return notFound(`no channel @${u.channel}`);
+ if (!u.slot) return owner(user.id, u.rule, 'exact', []);
+ const m = db.getManagedStreamByIdOrSlug(user.id, u.slot);
+ if (!m) return notFound(`@${u.channel} has no slot ${u.slot}`);
+ return owner(user.id, u.rule, 'exact', [`slot:${m.id}`], { stream: slotRecord(m) });
+ });
+ case 'channel_id':
+ return gate('channel_id', () => {
+ const ch = int(u.value) && db.get('SELECT id, user_id FROM channels WHERE id = ?', [int(u.value)]);
+ return ch ? owner(ch.user_id, 'channel_id', 'exact', [`channel:${ch.id}`]) : notFound(`no channel ${u.value}`);
+ });
+ case 'stream_id':
+ return gate('stream_lookup', () => {
+ const s = int(u.value) && streamById(int(u.value));
+ return s ? owner(s.user_id, 'stream_lookup', 'exact', [`stream:${s.id}`], { stream: streamRecord(s) }) : notFound(`no stream ${u.value}`);
+ });
+ case 'slot_id':
+ return gate('stream_lookup', () => {
+ const m = int(u.value) && slotById(int(u.value));
+ return m ? owner(m.user_id, 'stream_lookup', 'exact', [`slot:${m.id}`], { stream: slotRecord(m) }) : notFound(`no slot ${u.value}`);
+ });
+ case 'clip_id': {
+ const read = await readClip(ctx, u.value);
+ if (read.unavailable) return unavailable(read.detail);
+ return read.value ? clipLineage(ctx, read.value, allow) : notFound(`Media has no clip ${u.value}`);
+ }
+ case 'vod_id': {
+ const read = await readVod(ctx, u.value);
+ if (read.unavailable) return unavailable(read.detail);
+ return read.value ? vodLineage(read.value, allow) : notFound(`Media has no VOD ${u.value}`);
+ }
+ case 'media_object_id':
+ return objectLineage(ctx, u.value, allow);
+ case 'owner_subject':
+ return gate('owner_subject', () => subjectOutcome(u.value, 'exact', [`subject:${u.value}`]));
+ case 'legacy_ids':
+ return gate('legacy_map', () => {
+ if (u.legacy === 'live_user_id') {
+ return userById(u.value) ? owner(u.value, 'legacy_map', 'legacy_map', []) : notFound(`no Live user ${u.value}`);
+ }
+ const row = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ?", [String(u.value)]);
+ return row ? owner(row.user_id, 'legacy_map', 'legacy_map', [`network_user:${u.value}`]) : notFound(`no Live account for Network user ${u.value}`);
+ });
+ default:
+ return notFound(`unknown input ${u.input}`);
+ }
+}
+
+// ── Resolution ───────────────────────────────────────────────
+
+/**
+ * The engine, in Live user ids.
+ * opts.media { appId, getVod, getClip, getObject } (default ./media-source)
+ * opts.records { vod?, clip? } rows the caller already read from Media (saves a read)
+ * opts.rules only these lineage rules may answer (default: all), for a call site that must
+ * keep an older, narrower rule set
+ * -> { status: 'resolved', userId, resolved_by, rule, confidence, via, records, checked }
+ * | { status: 'unresolved', reason, detail, checked } checked: [{ input, outcome, userId? }]
+ */
+async function resolveOwner(raw, opts = {}) {
+ const { input, error } = normalizeRequest(raw);
+ if (error) throw Object.assign(new Error(error), { code: 'lineage.invalid_request' });
+ const allow = Array.isArray(opts.rules) ? (rule) => opts.rules.includes(rule) : ALL;
+ const ctx = { media: opts.media || require('./media-source'), records: opts.records || {}, cache: new Map() };
+ const list = units(input);
+ const checked = [];
+ const ignoredName = input.display_name !== undefined ? [{ input: 'display_name', outcome: 'ignored' }] : [];
+
+ if (!list.length) {
+ return ignoredName.length
+ ? { status: 'unresolved', reason: 'display_name_only', detail: 'a display name is never sufficient to establish a channel or its owner', checked: ignoredName }
+ : { status: 'unresolved', reason: 'no_input', detail: 'the request names nothing', checked: [] };
+ }
+
+ const results = [];
+ for (const u of list) results.push({ u, r: await evaluate(ctx, u, allow) });
+
+ const decider = results.find(x => x.r.kind === 'owner');
+ let conflict = false;
+ for (const { u, r } of results) {
+ const entry = { input: u.input };
+ if (r.kind === 'owner') {
+ entry.userId = r.userId;
+ entry.outcome = r === decider.r ? 'decided' : (r.userId === decider.r.userId ? 'agrees' : 'conflict');
+ } else if (r.kind === 'ambiguous') {
+ entry.outcome = decider ? (r.userIds.includes(decider.r.userId) ? 'agrees' : 'conflict') : 'ambiguous';
+ if (decider && entry.outcome === 'agrees') entry.userId = decider.r.userId;
+ } else {
+ entry.outcome = r.kind; // not_found | unavailable
+ }
+ if (entry.outcome === 'conflict') conflict = true;
+ checked.push(entry);
+ if (u.pairedParent) checked.push({ ...entry, input: 'parent_slug' });
+ }
+ checked.push(...ignoredName);
+
+ const failed = (reason, detail) => ({ status: 'unresolved', reason, detail, checked });
+ const down = results.find(x => x.r.kind === 'unavailable');
+ if (down) return failed('source_unavailable', `${down.u.input}: ${down.r.detail}`);
+ if (conflict) {
+ const other = checked.find(c => c.outcome === 'conflict');
+ return failed('conflict', `${decider.u.input} and ${other.input} name different channels`);
+ }
+ if (!decider) {
+ const amb = results.find(x => x.r.kind === 'ambiguous');
+ if (amb) return failed('ambiguous', amb.r.detail);
+ return failed('not_found', results.map(x => x.r.detail).filter(Boolean).join('; ').slice(0, 500) || 'nothing matched');
+ }
+
+ // The deciding input's records first, then those of the inputs that agree.
+ const records = {};
+ for (const { r } of results) {
+ if (r.kind !== 'owner' || r.userId !== decider.r.userId) continue;
+ for (const [k, v] of Object.entries(r.records || {})) if (!records[k]) records[k] = v;
+ }
+ return {
+ status: 'resolved',
+ userId: decider.r.userId,
+ resolved_by: decider.u.input,
+ rule: decider.r.rule,
+ confidence: decider.r.confidence,
+ via: decider.r.via.slice(0, 32),
+ records,
+ checked,
+ };
+}
+
+/** The contract answer (lineage.resolution@1). Read-only. */
+async function resolve(raw, opts = {}) {
+ const r = await resolveOwner(raw, opts);
+ const slugs = new Map();
+ const slugOf = (id) => { if (!slugs.has(id)) slugs.set(id, (userById(id) || {}).username || null); return slugs.get(id); };
+ const checked = r.checked.map(({ input, outcome, userId }) => {
+ const c = { input, outcome };
+ const slug = userId != null ? slugOf(userId) : null;
+ if (slug) c.channel_slug = slug;
+ return c;
+ });
+ const unresolved = (reason, detail) => ({ status: 'unresolved', reason, ...(detail ? { detail: String(detail).slice(0, 500) } : {}), checked });
+ if (r.status !== 'resolved') return unresolved(r.reason, r.detail);
+
+ const user = userById(r.userId);
+ if (!user) return unresolved('not_found', `the owner ${r.resolved_by} names no longer has a Live account`);
+ const ch = db.get('SELECT id FROM channels WHERE user_id = ?', [user.id]);
+ if (!ch) return unresolved('not_found', `@${user.username} has no channel yet`);
+ const link = db.get("SELECT subject_id, service_user_id FROM linked_accounts WHERE service = 'network' AND user_id = ?", [user.id]);
+ const legacy_ids = { live_user_id: user.id };
+ if (link && int(link.service_user_id)) legacy_ids.network_user_id = int(link.service_user_id);
+ const out = {
+ status: 'resolved',
+ channel: {
+ id: String(ch.id),
+ slug: user.username,
+ owner_subject: link && SUBJECT_RE.test(String(link.subject_id || '')) ? link.subject_id : null,
+ legacy_ids,
+ },
+ };
+ for (const k of ['stream', 'vod', 'clip', 'media_object']) if (r.records[k]) out[k] = r.records[k];
+ Object.assign(out, { resolved_by: r.resolved_by, rule: r.rule, confidence: r.confidence, via: r.via, checked });
+ return out;
+}
+
+module.exports = { resolve, resolveOwner, normalizeRequest, RULES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * GET|POST /internal/lineage/resolve: the canonical channel/owner resolver (./resolver.js) for other
+ * services (Pulse, OpenRe, Media, creator UI). Service token only (aud openvibe.live, capability
+ * live.lineage.resolve), loopback only.
+ *
+ * GET ?slug=&parent_slug=&channel_id=&stream_id=&slot_id=&vod_id=&clip_id=&media_object_id=
+ * &owner_subject=&live_user_id=&network_user_id=&display_name=
+ * POST lineage.resolve-request@1 (JSON)
+ *
+ * Every well-formed request is answered 200 with a lineage.resolution@1, resolved or unresolved
+ * (a conflict, a display name alone, Media down); a malformed one is 400 lineage.invalid_request.
+ *
+ * The capability id is passed as a constant, not a string literal inside the guard call:
+ * live.lineage.resolve is registered in openvibe-contracts 0.32.0, Live still pins 0.30.2, and
+ * `openvibe-contracts-check` in CI fails on a literal capability id the pinned registry does not
+ * define (it scans comments too). guard() checks the grant itself while the id is unknown to the
+ * registry and switches to capabilities.check() once the pin moves. When Live pins 0.32.0+, inline
+ * the id so the CI check enforces it.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { guard } = require('../net/service-guard');
+const resolver = require('./resolver');
+
+const CAPABILITY = 'live.lineage.resolve';
+
+const router = express.Router();
+router.use(guard(CAPABILITY));
+
+async function answer(req, res, raw, flat) {
+ const { input, error } = resolver.normalizeRequest(raw, { flat });
+ if (error) return http.sendProblem(res, 400, 'lineage.invalid_request', { detail: error });
+ try {
+ res.set('Cache-Control', 'no-store').json(await resolver.resolve(input));
+ } catch (err) {
+ console.warn('[Lineage] resolve failed:', err.message);
+ http.sendProblem(res, 500, 'lineage.failed', { detail: 'the resolver failed' });
+ }
+}
+
+router.get('/resolve', (req, res) => answer(req, res, req.query, true));
+router.post('/resolve', express.json({ limit: '16kb' }), (req, res) => answer(req, res, req.body, false));
+
+module.exports = router;
@@ -13,6 +13,7 @@ const permissions = require('../auth/permissions');
@@ -13,6 +13,7 @@ const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
const commentsClient = require('../comments-client');
const commentsClient = require('../comments-client');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
+const lineage = require('../lineage/resolver');
const router = express.Router();
const router = express.Router();
@@ -77,13 +78,14 @@ function withUserFields(clip) {
@@ -77,13 +78,14 @@ function withUserFields(clip) {
return clip;
return clip;
}
}
+// The clipped channel's owner (Live user id), from the canonical resolver (server/lineage): the
+// clip's stream, else its VOD's lineage; null when neither answers or Media is down. Kept to those
+// two links as before, so the channel Media recorded on the clip (legacy_metadata) does not decide
+// here; the clipper (clip.user_id) and display names never do.
async function clipChannelOwnerId(clip) {
async function clipChannelOwnerId(clip) {
- if (!clip) return null;
- if (clip.stream_id) { const s = db.getStreamById(clip.stream_id); if (s) return s.user_id; }
- if (clip.vod_id) {
- try { const v = await media.getVod(clip.vod_id); if (v) return v.user_id; } catch { /* */ }
- }
- return null;
+ if (!clip || clip.id == null) return null;
+ const r = await lineage.resolveOwner({ clip_id: String(clip.id) }, { records: { clip }, rules: ['stream_lookup', 'vod_parent'] });
+ return r.status === 'resolved' ? r.userId : null;
}
}
async function canActorModerateClip(actor, clip) {
async function canActorModerateClip(actor, clip) {
@@ -365,3 +367,4 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
@@ -365,3 +367,4 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
});
});
module.exports = router;
module.exports = router;
+module.exports._internals = { clipChannelOwnerId };
new file mode 100644
new file mode 100644
@@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
+'use strict';
+
+// The canonical channel/owner resolver (roadmap D20, server/lineage): every input kind, the
+// precedence between inputs and inside a record, conflicts, "a display name alone resolves
+// nothing", legacy maps, a deleted stream with a surviving VOD, the clip -> VOD -> stream -> channel
+// chain, the clips.js call site, and GET|POST /internal/lineage/resolve behind its service token.
+// Media is a fake HTTP server; answers are checked against lineage.resolution@1 when the installed
+// openvibe-contracts has it (0.32.0+).
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-lineage-'));
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_API_KEY = 'media-key';
+process.env.MEDIA_APP_ID = 'live';
+const log = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+const sub = (tag) => `usr_${`01J${tag}`.padEnd(26, '0')}`;
+const ALICE = sub('AA'), BOB = sub('BB'), SHARED = sub('CC'), NOBODY = sub('DD');
+const med = (tag) => `med_${`01J${tag}`.padEnd(26, '0')}`;
+
+// ── Fake OpenVibe.Media ─────────────────────────────────────
+const VODS = {
+ 42: { id: 42, stream_id: 9, managed_stream_id: 3, user_id: 17 },
+ 43: { id: 43, stream_id: 999999, managed_stream_id: 3, user_id: 17 }, // stream row never existed here
+ 44: { id: 44, stream_id: 999998, managed_stream_id: 999997, user_id: 17 }, // stream and slot gone
+ 45: { id: 45, stream_id: null, managed_stream_id: null, user_id: null }, // names nothing
+ 46: { id: 46, stream_id: 9, managed_stream_id: null, user_id: 18 }, // stream says alice, recorded owner bob
+ 47: { id: 47, stream_id: null, managed_stream_id: null, user_id: 999 }, // owner account deleted
+ 48: { id: 48, stream_id: 11, managed_stream_id: 3, user_id: 17 }, // stream 11 is deleted below
+};
+const CLIPS = {
+ 7: { id: 7, vod_id: 42, stream_id: null, user_id: 18, channel_user_id: 17 }, // bob clipped alice's VOD
+ 8: { id: 8, vod_id: null, stream_id: 9, user_id: 18, channel_user_id: null },
+ 9: { id: 9, vod_id: null, stream_id: null, user_id: 18, channel_user_id: 17 },
+ 10: { id: 10, vod_id: null, stream_id: null, user_id: 18, channel_user_id: null },
+ 12: { id: 12, vod_id: 404404, stream_id: null, user_id: 18, channel_user_id: null },
+ 13: { id: 13, vod_id: 42, stream_id: 10, user_id: 19, channel_user_id: null }, // bob's stream, alice's VOD
+ 14: { id: 14, vod_id: 44, stream_id: null, user_id: 18, channel_user_id: null },
+ 15: { id: 15, vod_id: 47, stream_id: null, user_id: 18, channel_user_id: null },
+};
+const OBJECTS = {
+ [med('A1')]: { id: med('A1'), kind: 'vod', legacy_ref: 'legacy:live:vod:42', owner: { subject: null, app: 'live', user_id: 17 } },
+ [med('B1')]: { id: med('B1'), kind: 'file', legacy_ref: null, owner: { subject: `user:${ALICE}`, app: 'live', user_id: 18 } },
+ [med('C1')]: { id: med('C1'), kind: 'file', legacy_ref: null, owner: { subject: null, app: 'live', user_id: 17 } },
+ 'legacy:live:thumbnail:x.jpg': { id: med('D1'), kind: 'thumbnail', legacy_ref: 'legacy:live:thumbnail:x.jpg', owner: { subject: null, app: 'live', user_id: 17 } },
+};
+const mediaState = { down: false, calls: [] };
+const mediaServer = http.createServer((req, res) => {
+ const url = decodeURIComponent(req.url.split('?')[0]);
+ mediaState.calls.push(url);
+ assert.strictEqual(req.headers.authorization, 'Bearer media-key', 'Live reads Media as its app');
+ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
+ if (mediaState.down) return send(503, { error: 'down' });
+ let m;
+ if ((m = /^\/api\/v1\/live\/vods\/(\d+)$/.exec(url))) return VODS[m[1]] ? send(200, VODS[m[1]]) : send(404, { error: 'VOD not found' });
+ if ((m = /^\/api\/v1\/live\/clips\/(\d+)$/.exec(url))) return CLIPS[m[1]] ? send(200, CLIPS[m[1]]) : send(404, { error: 'Clip not found' });
+ if ((m = /^\/api\/v2\/live\/objects\/(.+)$/.exec(url))) return OBJECTS[m[1]] ? send(200, OBJECTS[m[1]]) : send(404, { code: 'media.object.not_found' });
+ send(404, { error: 'no route' });
+});
+
+// Every contract answer is checked against lineage.resolution@1 when the installed contracts know it.
+const RESOLUTION = 'lineage.resolution@1';
+const contractKnown = (() => { try { contracts.resolve(RESOLUTION); return true; } catch { return false; } })();
+let validated = 0;
+function checkContract(out, label) {
+ if (!contractKnown) return out;
+ const r = contracts.validate(RESOLUTION, out);
+ assert.ok(r.valid, `${label}: not a ${RESOLUTION}: ${JSON.stringify(r.errors)}`);
+ validated++;
+ return out;
+}
+
+(async () => {
+ await new Promise((r) => mediaServer.listen(0, '127.0.0.1', r));
+ process.env.MEDIA_URL = `http://127.0.0.1:${mediaServer.address().port}`;
+
+ const db = require('../server/db/database');
+ db.initDb();
+ const d = db.getDb();
+ const users = [[17, 'alice', 'Alice Wonder'], [18, 'bob', 'alice'], [19, 'carol', 'Carol'], [20, 'dave', 'streamqueen'], [21, 'mallory', 'Alice Wonder'], [22, 'erin', 'Erin']];
+ for (const [id, name, display] of users) d.prepare("INSERT INTO users (id, username, display_name, password_hash) VALUES (?, ?, ?, 'x')").run(id, name, display);
+ const channelOf = {};
+ for (const id of [17, 18, 19, 21, 22]) channelOf[id] = Number(d.prepare('INSERT INTO channels (user_id) VALUES (?)').run(id).lastInsertRowid);
+ const link = d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)");
+ link.run(17, '57', ALICE); link.run(18, '58', BOB); link.run(19, '59', SHARED); link.run(20, '60', null); link.run(21, '61', SHARED);
+ d.prepare("INSERT INTO managed_streams (id, user_id, slug, stream_key) VALUES (3, 17, 'garage-cam', 'k3'), (4, 18, 'desk', 'k4')").run();
+ d.prepare("INSERT INTO streams (id, user_id, managed_stream_id, channel_id, title) VALUES (9, 17, 3, ?, 'a'), (10, 18, 4, ?, 'b'), (11, 17, 3, ?, 'c')").run(channelOf[17], channelOf[18], channelOf[17]);
+
+ const lineage = require('../server/lineage/resolver');
+ const resolve = async (input, label = JSON.stringify(input)) => checkContract(await lineage.resolve(input), label);
+ const expectResolved = async (input, want) => {
+ const r = await resolve(input);
+ assert.strictEqual(r.status, 'resolved', `${JSON.stringify(input)} resolves: ${JSON.stringify(r)}`);
+ for (const [k, v] of Object.entries(want)) assert.deepStrictEqual(r[k], v, `${JSON.stringify(input)}: ${k}`);
+ return r;
+ };
+ const expectUnresolved = async (input, reason) => {
+ const r = await resolve(input);
+ assert.strictEqual(r.status, 'unresolved', `${JSON.stringify(input)} does not resolve: ${JSON.stringify(r)}`);
+ assert.strictEqual(r.reason, reason, `${JSON.stringify(input)}: reason`);
+ assert.ok(!('channel' in r) && !('resolved_by' in r), 'an unresolved answer names no channel');
+ return r;
+ };
+ const aliceChannel = { id: String(channelOf[17]), slug: 'alice', owner_subject: ALICE, legacy_ids: { live_user_id: 17, network_user_id: 57 } };
+ const slot3 = { id: null, slot_id: '3', slot_slug: 'garage-cam' };
+ const stream9 = { id: '9', slot_id: '3', slot_slug: 'garage-cam' };
+
+ // ── The request ──────────────────────────────────────────
+ const N = (v, o) => lineage.normalizeRequest(v, o);
+ assert.ok(N({ username: 'alice' }).error && N({ channel_name: 'Alice' }).error, 'no name-shaped inputs besides slug');
+ assert.ok(N({ slug: 'Alice Wonder' }).error, 'a display name is not a slug');
+ assert.ok(N({ slug: 'a/b/c' }).error && N({ parent_slug: 'a/b' }).error, 'one level of nesting');
+ assert.ok(N({ owner_subject: '17' }).error && N({ legacy_ids: {} }).error && N({ legacy_ids: { live_user_id: 0 } }).error, 'bad subject and legacy ids');
+ assert.ok(N({ media_object_id: 'vod:42' }).error, 'media ids are med_ or legacy: refs');
+ assert.deepStrictEqual(N({ vod_id: 42 }).input, { vod_id: '42' }, "Live's own callers may pass integer ids");
+ assert.deepStrictEqual(N({ live_user_id: '17', network_user_id: '57' }, { flat: true }).input, { legacy_ids: { live_user_id: 17, network_user_id: 57 } }, 'GET flattens legacy_ids');
+ assert.ok(N({ live_user_id: '17' }).error, 'flat legacy ids only on GET');
+
+ // ── Every input kind ─────────────────────────────────────
+ await expectResolved({ slug: 'alice' }, { channel: aliceChannel, resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact', via: ['user:17'] });
+ await expectResolved({ slug: '@ALICE' }, { channel: aliceChannel, rule: 'explicit_slug' });
+ await expectResolved({ slug: 'alice/garage-cam' }, { channel: aliceChannel, resolved_by: 'slug', rule: 'nested_slug', confidence: 'exact', stream: slot3 });
+ await expectResolved({ slug: 'alice/3' }, { rule: 'nested_slug', stream: slot3 });
+ await expectUnresolved({ slug: 'alice/desk' }, 'not_found'); // bob's slot is not inside alice
+ let r = await expectResolved({ parent_slug: 'alice', slug: 'garage-cam' }, { resolved_by: 'slug', rule: 'nested_slug', stream: slot3 });
+ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['slug', 'decided'], ['parent_slug', 'decided']]);
+ await expectResolved({ parent_slug: 'alice' }, { resolved_by: 'parent_slug', rule: 'nested_slug', channel: aliceChannel });
+ await expectResolved({ channel_id: String(channelOf[17]) }, { resolved_by: 'channel_id', rule: 'channel_id', confidence: 'exact' });
+ await expectResolved({ stream_id: '9' }, { resolved_by: 'stream_id', rule: 'stream_lookup', confidence: 'exact', stream: stream9, via: ['stream:9', 'user:17'] });
+ await expectResolved({ slot_id: '3' }, { resolved_by: 'slot_id', rule: 'stream_lookup', confidence: 'exact', stream: slot3 });
+ await expectResolved({ vod_id: '42' }, { resolved_by: 'vod_id', rule: 'stream_lookup', confidence: 'derived', vod: { id: '42', stream_id: '9', slot_id: '3' }, stream: stream9, via: ['vod:42', 'stream:9', 'user:17'] });
+ await expectResolved({ clip_id: '8' }, { resolved_by: 'clip_id', rule: 'stream_lookup', confidence: 'derived', via: ['clip:8', 'stream:9', 'user:17'] });
+ await expectResolved({ media_object_id: med('A1') }, { resolved_by: 'media_object_id', rule: 'media_lineage', confidence: 'derived', channel: aliceChannel,
+ media_object: { id: med('A1'), kind: 'vod', legacy_ref: 'legacy:live:vod:42' }, vod: { id: '42', stream_id: '9', slot_id: '3' } });
+ // The object's owner subject outranks the owner user recorded next to it (18, bob).
+ await expectResolved({ media_object_id: med('B1') }, { rule: 'owner_subject', confidence: 'derived', channel: aliceChannel });
+ await expectResolved({ media_object_id: med('C1') }, { rule: 'legacy_metadata', confidence: 'derived', channel: aliceChannel });
+ await expectResolved({ owner_subject: ALICE }, { resolved_by: 'owner_subject', rule: 'owner_subject', confidence: 'exact', channel: aliceChannel });
+ await expectResolved({ legacy_ids: { live_user_id: 17 } }, { resolved_by: 'legacy_ids', rule: 'legacy_map', confidence: 'legacy_map', channel: aliceChannel });
+ await expectResolved({ legacy_ids: { network_user_id: 57 } }, { resolved_by: 'legacy_ids', rule: 'legacy_map', confidence: 'legacy_map', via: ['network_user:57', 'user:17'] });
+
+ // ── Precedence between inputs ────────────────────────────
+ r = await expectResolved({ legacy_ids: { live_user_id: 17 }, owner_subject: ALICE, vod_id: '42', slug: 'alice' }, { resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact' });
+ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['slug', 'decided'], ['vod_id', 'agrees'], ['owner_subject', 'agrees'], ['legacy_ids', 'agrees']], 'checked follows precedence, not argument order');
+ assert.deepStrictEqual(r.vod, { id: '42', stream_id: '9', slot_id: '3' }, 'records of agreeing inputs are kept');
+ await expectResolved({ owner_subject: ALICE, stream_id: '9' }, { resolved_by: 'stream_id' });
+ await expectResolved({ vod_id: '42', clip_id: '7' }, { resolved_by: 'clip_id' });
+ await expectResolved({ owner_subject: ALICE, media_object_id: med('A1') }, { resolved_by: 'media_object_id' });
+ await expectResolved({ channel_id: String(channelOf[17]), slug: 'alice/garage-cam' }, { resolved_by: 'slug', rule: 'nested_slug' });
+ r = await expectResolved({ vod_id: '404404', owner_subject: ALICE }, { resolved_by: 'owner_subject' });
+ assert.deepStrictEqual(r.checked.map(c => [c.input, c.outcome]), [['vod_id', 'not_found'], ['owner_subject', 'decided']], 'a missing record does not block');
+ assert.ok(!('vod' in r), 'a record that was not found is not attached');
+ r = await expectResolved({ slug: 'renamed-away', stream_id: '9' }, { resolved_by: 'stream_id', channel: aliceChannel });
+ assert.strictEqual(r.checked[0].outcome, 'not_found');
+ // Inside a record the first link that answers wins.
+ await expectResolved({ vod_id: '46' }, { rule: 'stream_lookup', channel: aliceChannel }); // not the recorded owner, bob
+ r = await expectResolved({ clip_id: '13' }, { rule: 'stream_lookup', via: ['clip:13', 'stream:10', 'user:18'] }); // the clip's stream, not its VOD
+ assert.strictEqual(r.channel.slug, 'bob');
+ assert.deepStrictEqual(lineage.RULES, contractKnown ? contracts.schema('lineage.resolution').properties.rule.enum : lineage.RULES, 'the rule set is the contract\'s');
+
+ // ── Conflicts ────────────────────────────────────────────
+ r = await expectUnresolved({ slug: 'mallory', vod_id: '42' }, 'conflict');
+ assert.deepStrictEqual(r.checked, [{ input: 'slug', outcome: 'decided', channel_slug: 'mallory' }, { input: 'vod_id', outcome: 'conflict', channel_slug: 'alice' }]);
+ await expectUnresolved({ legacy_ids: { live_user_id: 17, network_user_id: 58 } }, 'conflict');
+ await expectUnresolved({ owner_subject: BOB, clip_id: '7' }, 'conflict');
+
+ // ── A display name alone resolves nothing ────────────────
+ r = await expectUnresolved({ display_name: 'Alice Wonder' }, 'display_name_only');
+ assert.deepStrictEqual(r.checked, [{ input: 'display_name', outcome: 'ignored' }]);
+ await expectUnresolved({ display_name: 'alice' }, 'display_name_only'); // even when it is someone's username
+ await expectUnresolved({ slug: 'streamqueen' }, 'not_found'); // dave's display name is not a slug
+ await expectUnresolved({ slug: 'ghost', display_name: 'Alice Wonder' }, 'not_found'); // and never rescues a miss
+ r = await expectResolved({ slug: 'alice', display_name: 'Mallory' }, { resolved_by: 'slug', channel: aliceChannel });
+ assert.deepStrictEqual(r.checked.at(-1), { input: 'display_name', outcome: 'ignored' });
+ assert.strictEqual((await expectResolved({ slug: 'bob' }, {})).channel.slug, 'bob'); // bob's display name is "alice"; alice stays alice
+ assert.strictEqual((await resolve({ slug: 'alice' })).channel.legacy_ids.live_user_id, 17);
+
+ // ── Legacy maps ──────────────────────────────────────────
+ await expectResolved({ legacy_ids: { live_user_id: 17, network_user_id: 57 } }, { confidence: 'legacy_map', channel: aliceChannel });
+ await expectResolved({ media_object_id: 'legacy:live:vod:42' }, { rule: 'legacy_map', confidence: 'legacy_map', channel: aliceChannel,
+ via: ['media:legacy:live:vod:42', 'vod:42', 'stream:9', 'user:17'], media_object: { id: 'legacy:live:vod:42', kind: 'vod', legacy_ref: 'legacy:live:vod:42' } });
+ await expectResolved({ media_object_id: 'legacy:live:clip:7' }, { rule: 'legacy_map', confidence: 'legacy_map', via: ['media:legacy:live:clip:7', 'clip:7', 'vod:42', 'stream:9', 'user:17'] });
+ await expectResolved({ media_object_id: 'legacy:live:thumbnail:x.jpg' }, { rule: 'legacy_map', confidence: 'legacy_map', media_object: { id: med('D1'), kind: 'thumbnail', legacy_ref: 'legacy:live:thumbnail:x.jpg' } });
+ await expectUnresolved({ media_object_id: 'legacy:games:vod:42' }, 'not_found'); // another app's object
+ await expectUnresolved({ legacy_ids: { network_user_id: 999 } }, 'not_found');
+ await expectUnresolved({ legacy_ids: { live_user_id: 999 } }, 'not_found');
+
+ // ── A deleted stream with a surviving VOD ────────────────
+ d.prepare('DELETE FROM streams WHERE id = 11').run();
+ await expectResolved({ vod_id: '48' }, { channel: aliceChannel, rule: 'stream_lookup', confidence: 'derived', via: ['vod:48', 'slot:3', 'user:17'],
+ stream: { id: '11', slot_id: '3', slot_slug: 'garage-cam', missing: true }, vod: { id: '48', stream_id: '11', slot_id: '3' } });
+ await expectResolved({ vod_id: '43' }, { rule: 'stream_lookup', stream: { id: '999999', slot_id: '3', slot_slug: 'garage-cam', missing: true } });
+ await expectResolved({ vod_id: '44' }, { rule: 'legacy_metadata', confidence: 'derived', via: ['vod:44', 'user:17'], stream: { id: '999998', slot_id: '999997', slot_slug: null, missing: true } });
+ await expectUnresolved({ vod_id: '45' }, 'not_found');
+ r = await expectUnresolved({ vod_id: '47' }, 'not_found'); // the recorded owner's account is gone
+ assert.match(r.detail, /no longer has a Live account/);
+
+ // ── clip -> VOD -> stream -> channel ─────────────────────
+ r = await expectResolved({ clip_id: '7' }, { channel: aliceChannel, resolved_by: 'clip_id', rule: 'vod_parent', confidence: 'derived',
+ via: ['clip:7', 'vod:42', 'stream:9', 'user:17'], clip: { id: '7', vod_id: '42', stream_id: null }, vod: { id: '42', stream_id: '9', slot_id: '3' }, stream: stream9 });
+ assert.ok(!r.via.includes('user:18'), 'the clipper (bob) is never the channel');
+ await expectResolved({ clip_id: '9' }, { rule: 'legacy_metadata', channel: aliceChannel }); // only the recorded channel is left
+ await expectUnresolved({ clip_id: '10' }, 'not_found'); // only the clipper: nothing
+ await expectResolved({ clip_id: '14' }, { rule: 'vod_parent', via: ['clip:14', 'vod:44', 'user:17'] });
+
+ // ── Ambiguous subjects, owners without a channel, no input ─
+ r = await expectUnresolved({ owner_subject: SHARED }, 'ambiguous');
+ assert.deepStrictEqual(r.checked, [{ input: 'owner_subject', outcome: 'ambiguous' }]);
+ r = await expectResolved({ slug: 'carol', owner_subject: SHARED }, { resolved_by: 'slug' });
+ assert.deepStrictEqual(r.checked[1], { input: 'owner_subject', outcome: 'agrees', channel_slug: 'carol' });
+ await expectUnresolved({ slug: 'erin', owner_subject: SHARED }, 'conflict');
+ await expectUnresolved({ owner_subject: NOBODY }, 'not_found');
+ r = await expectUnresolved({ slug: 'dave' }, 'not_found');
+ assert.match(r.detail, /has no channel yet/);
+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM channels WHERE user_id = 20').get().n, 0, 'resolving never creates a channel');
+ assert.deepStrictEqual((({ status, userId, rule }) => ({ status, userId, rule }))(await lineage.resolveOwner({ slug: 'dave' })), { status: 'resolved', userId: 20, rule: 'explicit_slug' });
+ assert.strictEqual((await lineage.resolveOwner({ vod_id: '47' })).userId, 999, 'resolveOwner reports the owner a record names');
+ await expectUnresolved({}, 'no_input');
+ await assert.rejects(lineage.resolveOwner({ slug: 'Not A Slug' }), /slug/);
+
+ // ── Media down: nothing that needs it resolves ───────────
+ mediaState.down = true;
+ await expectUnresolved({ vod_id: '42' }, 'source_unavailable');
+ await expectUnresolved({ slug: 'alice', vod_id: '42' }, 'source_unavailable'); // it might have disagreed
+ await expectUnresolved({ media_object_id: med('A1') }, 'source_unavailable');
+ await expectResolved({ slug: 'alice', stream_id: '9' }, { resolved_by: 'slug' }); // no Media needed
+ mediaState.down = false;
+
+ // ── The clips.js call site: stream, else the VOD's lineage, as before ─
+ const { clipChannelOwnerId } = require('../server/media-proxy/clips')._internals;
+ mediaState.calls = [];
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[8]), 17, 'clip stream');
+ assert.deepStrictEqual(mediaState.calls, [], 'no Media read when the clip names a live stream row, and the clip is not re-read');
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[7]), 17, 'clip -> VOD -> stream');
+ assert.deepStrictEqual(mediaState.calls, ['/api/v1/live/vods/42'], 'one VOD read, as before');
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[14]), 17, "a VOD whose stream and slot are gone still answers with its recorded owner");
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[15]), 999, 'the recorded owner is returned even when the account is gone, as before');
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[13]), 18, "the clip's own stream wins over its VOD");
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[9]), null, "the channel recorded on the clip does not decide here (it did not before)");
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[10]), null, 'the clipper never owns the channel');
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[12]), null, 'a VOD Media does not have');
+ assert.strictEqual(await clipChannelOwnerId(null), null);
+ mediaState.down = true;
+ assert.strictEqual(await clipChannelOwnerId(CLIPS[7]), null, 'Media down: no owner, as before');
+ mediaState.down = false;
+
+ // ── GET|POST /internal/lineage/resolve ───────────────────
+ const { serviceAuth } = contracts;
+ const now = () => Math.floor(Date.now() / 1000);
+ let jti = 0;
+ const token = (cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.live'], cap, iat: now(), exp: now() + 300, jti: `tok_lin_${++jti}` }, keys.privateKey);
+ const app = express();
+ app.use('/internal/lineage', require('../server/lineage/routes'));
+ const server = await new Promise((ok) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => ok(s)); });
+ const base = `http://127.0.0.1:${server.address().port}/internal/lineage/resolve`;
+ const call = async (method, q, body, { cap = ['live.lineage.resolve'], headers = {} } = {}) => {
+ const res = await fetch(`${base}${q ? `?${q}` : ''}`, {
+ method, headers: { Authorization: `Bearer ${token(cap)}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ return { status: res.status, json: await res.json().catch(() => null), cache: res.headers.get('cache-control') };
+ };
+ assert.strictEqual((await fetch(`${base}?slug=alice`)).status, 401, 'no token');
+ assert.strictEqual((await call('GET', 'slug=alice', null, { cap: ['live.follower.read'] })).status, 403, 'needs live.lineage.resolve');
+ assert.strictEqual((await call('GET', 'slug=alice', null, { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
+ let res = await call('GET', 'slug=alice&vod_id=42&live_user_id=17');
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.cache, 'no-store');
+ checkContract(res.json, 'GET');
+ assert.deepStrictEqual([res.json.status, res.json.channel.slug, res.json.resolved_by, res.json.checked.length], ['resolved', 'alice', 'slug', 3]);
+ res = await call('POST', '', { clip_id: '7', legacy_ids: { network_user_id: 57 } });
+ checkContract(res.json, 'POST');
+ assert.deepStrictEqual([res.status, res.json.rule, res.json.clip.id], [200, 'vod_parent', '7']);
+ res = await call('GET', `display_name=${encodeURIComponent('Alice Wonder')}`);
+ assert.deepStrictEqual([res.status, res.json.status, res.json.reason], [200, 'unresolved', 'display_name_only']);
+ res = await call('POST', '', { slug: 'mallory', vod_id: '42' });
+ assert.deepStrictEqual([res.status, res.json.reason], [200, 'conflict']);
+ for (const [q, body] of [['username=alice', null], ['slug=Alice%20Wonder', null], ['', { owner_subject: '17' }], ['', { vod_id: '42', extra: 1 }], ['slug=a&slug=b', null]]) {
+ res = await call(body ? 'POST' : 'GET', q, body);
+ assert.deepStrictEqual([res.status, res.json.code], [400, 'lineage.invalid_request'], `${q || JSON.stringify(body)} is refused`);
+ }
+ server.close();
+ mediaServer.close();
+
+ // The request forms this route accepts are the contract's (when the installed contracts know it).
+ if (contractKnown) {
+ for (const req of [{ slug: '@alice' }, { slug: 'alice/cam' }, { display_name: 'x' }, { media_object_id: 'legacy:live:vod:1' }, { slug: 'Alice Wonder' }, { owner_subject: '17' }, { vod_id: 42 }, { legacy_ids: {} }, { username: 'a' }]) {
+ const strict = contracts.validate('lineage.resolve-request@1', req).valid;
+ const ours = !lineage.normalizeRequest(req).error;
+ // Live also takes integer ids from its own callers; everything else matches the contract.
+ if (!(typeof req.vod_id === 'number')) assert.strictEqual(ours, strict, `${JSON.stringify(req)}: Live ${ours}, contract ${strict}`);
+ }
+ }
+
+ console.log = log;
+ console.log(`lineage resolver: all checks passed (${contractKnown ? `${validated} answers validated against ${RESOLUTION}` : `installed openvibe-contracts ${require('openvibe-contracts/package.json').version} predates ${RESOLUTION}; contract validation skipped`})`);
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });