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.

OP OpenVibers committed 23 Sept 2026 · 21528ba080 · 6 files, +864 −6

GET|POST /internal/lineage/resolve (server/lineage/routes.js, mounted next to /internal/followers) serves it to other services with a Network service token for live.lineage.resolve, loopback only; malformed requests are 400 lineage.invalid_request. The capability id is a constant in the guard call because Live still pins openvibe-contracts v0.30.2, where live.lineage.resolve does not exist yet and openvibe-contracts-check would fail on the literal; service-guard checks the grant itself until the pin moves to v0.32.0.

server/media-proxy/clips.js clipChannelOwnerId (who may edit or delete a clip as the channel) now asks the resolver, limited to the two links it used before (the clip's stream, else its VOD), so the answers are the same; it no longer reads Media's VOD user_id directly but the VOD's stream, slot and recorded owner in that order, which Live's recorder always writes alike.

test/lineage-resolver.test.js: every input kind, precedence between inputs and inside records, conflicts, ambiguous subjects, "a display name alone resolves nothing", legacy maps, a deleted stream with a surviving VOD, clip -> VOD -> stream -> channel, Media down, the clips.js call site (same answers and Media reads as before) and the route's token, loopback and 400s. With openvibe-contracts >= 0.32.0 installed it validates every answer against lineage.resolution@1 (65 answers against a local 0.32.0 build) and checks that the request forms Live accepts are the contract's.

Changes

ddiff --git a/server/index.js b/server/index.js
iindex e49d0aa3..51951664 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/lineage/media-source.js b/server/lineage/media-source.js
nnew file mode 100644
iindex 00000000..8f06bf93
---- /dev/null
++++ b/server/lineage/media-source.js
@@@ -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,
++};
ddiff --git a/server/lineage/resolver.js b/server/lineage/resolver.js
nnew file mode 100644
iindex 00000000..cecfddd7
---- /dev/null
++++ b/server/lineage/resolver.js
@@@ -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 };
ddiff --git a/server/lineage/routes.js b/server/lineage/routes.js
nnew file mode 100644
iindex 00000000..ed9ea9ab
---- /dev/null
++++ b/server/lineage/routes.js
@@@ -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;
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 7110855f..61dd92c4 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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 };
ddiff --git a/test/lineage-resolver.test.js b/test/lineage-resolver.test.js
nnew file mode 100644
iindex 00000000..6f70d1d8
---- /dev/null
++++ b/test/lineage-resolver.test.js
@@@ -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); });