openvibers/openvibe.live Commits
Pastes can live in OpenVibe.Community: PASTES_AUTHORITY=community routes every paste read and write there with a service token
Changes
@@@ -25,7 +25,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1","openvibe-shared": "file:./vendor/openvibe-shared","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2813,9 +2813,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.3.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",-- "integrity": "sha512-4clDeetK1RofxApXzSLUehl0x3zsc2AsEedFyCTmocsAp3LzCL/07kiBMbt675KEBArmI0b2jQ8qumgl7rwotw==",++ "version": "0.5.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",++ "integrity": "sha512-czQpWKEo8cY56Crj5l5Mv8r5vlIIj0vn/HMxv0OlIu5YTuSSlpvXyayYEfn3GQ5orDhaMIhOUlzmQMxUtWXaLA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -43,7 +43,7 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1","openvibe-shared": "file:./vendor/openvibe-shared","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -435,7 +435,7 @@ async function tick(opts = {}) {// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/// ai_summary/ai_tags/stream_id) are inherited-shape extensions the// contract leaves open — Media ignores what it doesn't know.-- const paste = await media.createPaste({++ const paste = await require('../pastes-client').createPaste({slug, user_id: v.user_id,title: title.slice(0, 80), content, language: 'text', visibility: 'public',stream_id: v.stream_id, metadata,@@@ -445,7 +445,7 @@ async function tick(opts = {}) {filename: path.basename(screenshotPath),contentType: 'image/jpeg',},-- });++ }, { origin: 'ai' });if (paste) {pasteSlug = paste.slug || slug;// Screenshot pastes must use the image endpoint — /raw serves the@@@ -96,7 +96,7 @@ async function tick() {// a work queue and we post results back.if (ai.pasteAnalysisEnabled && ai.pasteAnalysisEnabled()) {try {-- const out = await media.listPastesNeedingAi(anyLive ? 1 : 3).catch(() => null);++ const out = await require('../pastes-client').listPastesNeedingAi(anyLive ? 1 : 3).catch(() => null);for (const p of (out?.pastes || [])) {if (!p || !p.slug) continue;let r = null;@@@ -110,7 +110,7 @@ async function tick() {r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);}if (!r || !r.description) continue;-- await media.setPasteAi(p.slug, {++ await require('../pastes-client').setPasteAi(p.slug, {ai_summary: r.description,ai_tags: JSON.stringify(r.tags || []),}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));@@@ -197,14 +197,14 @@ async function _maybeLivePaste(stream, image, r, offset) {if (!buffer || buffer.length < 2000) return;const username = stream.username || (db.getUserById(stream.user_id) || {}).username || 'someone';let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }-- const media = require('../media-client');-- const paste = await media.createPaste({++ // AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.++ const paste = await require('../pastes-client').createPaste({slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/${username}`, language: 'text', visibility: 'public',stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },-- });++ }, { origin: 'ai' });if (paste) {registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${r.title}"`);@@@ -216,7 +216,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, rconst makePublic = req.body.public === undefined ? true : !(req.body.public === 'false' || req.body.public === false || req.body.public === '0');const imageBuffer = fs.readFileSync(req.file.path);try { fs.unlinkSync(req.file.path); } catch { /* */ }-- const paste = await media.createPaste({++ const paste = await require('../pastes-client').createPaste({title: 'Avatar upload',content: '',language: 'text',@@@ -1608,7 +1608,7 @@ class ChatServer {const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;const userId = client.userId || client.user?.id || null;// Pastes live in OpenVibe.Media now — post through the media client.-- require('../media-client').createPaste({++ require('../pastes-client').createPaste({title,content: args.trim(),language: 'auto',@@@ -58,7 +58,7 @@ async function _mediaContent() {const [vods, clips, pastes] = await Promise.all([media.listVods({ limit: 12 }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),-- media.listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),++ require('../pastes-client').listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),]);_mediaPool = { vods, clips, pastes, at: Date.now() };return _mediaPool;@@@ -247,4 +247,73 @@ router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath(// Deleting needs an identity: without one Media sees the app key alone and lets it delete anything.router.delete('/:slug/comments/:commentId', requireAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));--module.exports = router;++// ── Community authority (PASTES_AUTHORITY=community, roadmap Wave 5) ──────────────++// OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every++// call there with Live's service token; the person is named by their canonical subject (never by a++// Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.++// Only set-avatar stays here, because it changes Live's own user row.++const pastesClient = require('../pastes-client');++const principal = require('../net/network-principal');++const { Readable } = require('stream');++++function toCommunity(subPath) {++ return async (req, res) => {++ try {++ const p = typeof subPath === 'function' ? subPath(req) : subPath;++ const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';++ const headers = { Accept: 'application/json', ...(await principal.serviceHeaders('openvibe.community')) };++ if (req.user && req.user.id) {++ const sid = req.user.subject_id || await pastesClient.subjectForLiveUser(req.user.id);++ if (!sid) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet. Sign in again and retry.' });++ headers['X-OV-Subject'] = sid;++ }++ if (req.ip) headers['X-Forwarded-For'] = req.ip;++ const opts = { method: req.method, headers, redirect: 'manual', signal: AbortSignal.timeout(req.method === 'GET' ? 20000 : 120000) };++ if (!['GET', 'HEAD'].includes(req.method)) {++ const ct = req.headers['content-type'] || '';++ if (req.rawBody) { headers['Content-Type'] = ct || 'application/json'; opts.body = req.rawBody; }++ else if (ct.startsWith('application/x-www-form-urlencoded')) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(req.body || {}); }++ else if (ct) { headers['Content-Type'] = ct; opts.body = Readable.toWeb(req); opts.duplex = 'half'; }++ }++ const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);++ if (upstream.status === 401) principal.invalidate('openvibe.community');++ res.status(upstream.status);++ for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {++ const v = upstream.headers.get(h);++ if (v) res.set(h, v);++ }++ res.send(Buffer.from(await upstream.arrayBuffer()));++ } catch (err) {++ console.warn('[Pastes → Community]', err.message);++ if (!res.headersSent) res.status(502).json({ error: 'Paste service unavailable' });++ }++ };++}++++const communityRouter = express.Router();++communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {++ try {++ let paste = null;++ try { paste = await pastesClient.getPaste(req.params.slug); } catch { paste = null; }++ if (!paste) return res.status(404).json({ error: 'Paste not found' });++ if (paste.type !== 'screenshot' || !paste.screenshot_url) return res.status(400).json({ error: 'That paste is not an image' });++ const avatarUrl = media.publicUrl(paste.screenshot_url) || paste.screenshot_url;++ db.updateUserAvatar(req.user.id, avatarUrl, null);++ try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* next sign-in */ }++ res.json({ success: true, avatar_url: avatarUrl });++ } catch (err) {++ console.warn('[Pastes → Community] set-avatar:', err.message);++ res.status(500).json({ error: 'Failed to set avatar' });++ }++});++communityRouter.get('/:slug/raw', (req, res) => res.redirect(302, `${String(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '')}/p/${encodeURIComponent(req.params.slug)}/raw`));++for (const pth of ['/admin/stats', '/admin/forks']) communityRouter.get(pth, requireAdmin, toCommunity(pth));++communityRouter.delete('/admin/forks', requireAdmin, toCommunity('/admin/forks'));++communityRouter.post('/bulk', requireAdmin, toCommunity('/bulk'));++communityRouter.post('/:slug/censor', requireAdmin, toCommunity((req) => `/${encodeURIComponent(req.params.slug)}/censor`));++communityRouter.post(['/', '/screenshot', '/:slug/fork', '/:slug/comments'], optionalAuth, anonWriteLimiter, toCommunity((req) => req.path === '/' ? '' : req.path));++communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '' : req.path));++++module.exports = (req, res, next) => (pastesClient.onCommunity() ? communityRouter : router)(req, res, next);++module.exports.mediaRouter = router;++module.exports.communityRouter = communityRouter;@@@ -20,9 +20,24 @@ const PAUSE_MS = 5 * 60 * 1000;// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk']);--const tokens = CLIENT_SECRET ? serviceAuth.createTokenClient({-- tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, audience: 'openvibe.network',--}) : null;++const _clients = new Map();++/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */++function clientFor(audience) {++ if (!CLIENT_SECRET) return null;++ if (!_clients.has(audience)) {++ _clients.set(audience, serviceAuth.createTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, audience }));++ }++ return _clients.get(audience);++}++const tokens = clientFor('openvibe.network');++++/** Bearer headers for another service. Throws when no token can be had (no fallback: new APIs are token-only). */++async function serviceHeaders(audience) {++ const c = clientFor(audience);++ if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ return c.authHeaders();++}++function invalidate(audience) { const c = _clients.get(audience); if (c) c.invalidate(); }let pausedUntil = 0;let lastFailure = null;const stats = { token: 0, key: 0, tokenFailures: 0 };@@@ -53,4 +68,4 @@ function tokenRejected(problemCode) {console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); using the internal key for ${PAUSE_MS / 60000} min`);}--module.exports = { headersFor, tokenRejected, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; if (tokens) tokens.invalidate(); } };++module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; for (const c of _clients.values()) c.invalidate(); } };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * Where Live's pastes live (roadmap Wave 5).++ *++ * PASTES_AUTHORITY=community: OpenVibe.Community owns pastes. Live calls its /api/pastes with a++ * service token (community.paste.create/write/moderate) and names the person it acts for in++ * X-OV-Subject (their canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never++ * filed under a person (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.++ * Anything else (default): the old path through OpenVibe.Media's app API (media-client).++ *++ * Same function shapes as media-client's paste helpers, so call sites only swap the require.++ */++const media = require('./media-client');++const principal = require('./net/network-principal');++++const COMMUNITY_URL = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');++const AUDIENCE = 'openvibe.community';++const onCommunity = () => process.env.PASTES_AUTHORITY === 'community';++++class CommunityApiError extends Error {++ constructor(status, body) {++ super((body && (body.error || body.detail)) || `Community API ${status}`);++ this.name = 'MediaApiError'; // call sites already branch on this name for status passthrough++ this.status = status;++ this.body = body;++ }++}++++const _subjects = new Map(); // live user id -> usr_ id (or null), for this process++/** Canonical subject for a Live user: from the link table (tokens fill it), else Network's identity map. */++async function subjectForLiveUser(liveUserId) {++ if (liveUserId == null) return null;++ const id = Number(liveUserId);++ if (_subjects.has(id)) return _subjects.get(id);++ let sid = null;++ try { sid = require('./auth/identity-sync').subjectOf(id); } catch { /* */ }++ if (!sid) {++ try {++ const res = await fetch(`${process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await principal.serviceHeaders('openvibe.network')) },++ body: JSON.stringify({ system: 'live', type: 'user', ids: [String(id)] }),++ signal: AbortSignal.timeout(5000),++ });++ const out = res.ok ? await res.json() : null;++ const hit = out && out.results && out.results[String(id)];++ sid = hit && hit.subject ? hit.subject.id : null;++ } catch (err) { console.warn(`[Pastes] subject lookup for live user ${id} failed: ${err.message}`); }++ }++ if (sid) _subjects.set(id, sid);++ return sid;++}++++/**++ * One call to Community's paste API. body: plain object (JSON) or FormData. act: { liveUserId } to act++ * as a person, { origin: 'ai', sourceRef } for derived pastes, {} for Live itself (moderation paths).++ */++async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20000, retried = false } = {}) {++ const qs = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)]))}` : '';++ const headers = { Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) };++ if (act.liveUserId != null) {++ const sid = await subjectForLiveUser(act.liveUserId);++ if (!sid) throw new CommunityApiError(409, { error: 'This account is not linked to an OpenVibe account yet — sign in again and retry.' });++ headers['X-OV-Subject'] = sid;++ }++ if (act.origin === 'ai') {++ headers['X-OV-Origin'] = 'ai';++ if (act.sourceRef) headers['X-OV-Source-Ref'] = JSON.stringify(act.sourceRef);++ }++ if (ip) headers['X-Forwarded-For'] = ip;++ let payload;++ if (body instanceof FormData) payload = body;++ else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }++ const res = await fetch(`${COMMUNITY_URL}/api/pastes${path}${qs}`, { method, headers, body: payload, signal: AbortSignal.timeout(timeoutMs) });++ const out = await res.json().catch(() => null);++ if (res.status === 401 && !retried) { // token rotated/expired under us: fetch a fresh one once++ principal.invalidate(AUDIENCE);++ return request(method, path, { query, body, act, ip, timeoutMs, retried: true });++ }++ if (!res.ok) throw new CommunityApiError(res.status, out);++ return out;++}++++/** media-client-compatible createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */++async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {++ if (!onCommunity()) return media.createPaste({ screenshot, user_id, ...fields }, opts);++ const act = opts.origin === 'ai'++ ? { origin: 'ai', sourceRef: fields.stream_id ? { service: 'live', type: 'stream', id: String(fields.stream_id) } : undefined }++ : { liveUserId: user_id };++ if (typeof fields.metadata === 'object' && fields.metadata) fields.metadata = JSON.stringify(fields.metadata);++ if (screenshot) {++ const fd = new FormData();++ for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, String(v));++ fd.append('screenshot', new Blob([screenshot.buffer], { type: screenshot.contentType || 'image/png' }), screenshot.filename || 'screenshot.png');++ return request('POST', '', { body: fd, act, timeoutMs: 60000 });++ }++ return request('POST', '', { body: fields, act });++}++++async function getPaste(slug) {++ if (!onCommunity()) return media.getPaste(slug);++ const out = await request('GET', `/${encodeURIComponent(slug)}`, { query: { no_view: 1 } });++ return (out && out.paste) || out;++}++++function listPastes(query = {}) {++ if (!onCommunity()) return media.listPastes(query);++ return request('GET', '', { query });++}++++function listPastesNeedingAi(limit = 5) {++ if (!onCommunity()) return media.listPastesNeedingAi(limit);++ return request('GET', '', { query: { needs_ai: 1, limit } });++}++++function setPasteAi(slug, { ai_summary, ai_tags } = {}) {++ if (!onCommunity()) return media.setPasteAi(slug, { ai_summary, ai_tags });++ return request('POST', `/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags } });++}++++module.exports = { onCommunity, request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };@@@ -30,10 +30,10 @@ async function _cached(key, fn) {}const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);--const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await media.listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);++const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));--const _pasteGet = (slug) => _cached(`p:${slug}`, () => media.getPaste(slug));++const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so// descriptions/transcripts keep enriching the crawlable snapshot.@@@ -572,7 +572,7 @@ async function buildSitemap() {urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));if (c.username) seenChannels.add(c.username);});-- await page((l, o) => media.listPastes({ limit: l, offset: o, visibility: 'public' }).then(r => r?.pastes || []), 200, SITEMAP_CAP, (x) => {++ await page((l, o) => require('../pastes-client').listPastes({ limit: l, offset: o, visibility: 'public' }).then(r => r?.pastes || []), 200, SITEMAP_CAP, (x) => {if (!Number(x.is_nsfw)) urls.push(_urlTag(`/p/${x.slug}`, isoDate(x.created_at), 'monthly', '0.4'));});for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++++// PASTES_AUTHORITY=community (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with++// a service token. AI pastes are ownerless (origin ai + source stream), a person is named by subject, the++// SPA's /api/pastes forwards anonymously when nobody is signed in, and staff routes keep requireAdmin.++++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 { serviceAuth } = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pastes-community-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.INTERNAL_API_KEY = 'legacy-key';++process.env.OV_OAUTH_CLIENT_ID = 'live';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++process.env.PASTES_AUTHORITY = 'community';++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const SID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';++const SID2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';++const seen = [];++++const net = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') {++ const aud = new URLSearchParams(raw).get('audience');++ const now = Math.floor(Date.now() / 1000);++ const cap = aud === 'openvibe.community' ? ['community.paste.create', 'community.paste.write', 'community.paste.moderate'] : ['identity.subject.resolve'];++ return res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [aud], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), expires_in: 300 }));++ }++ if (req.url === '/internal/identity/resolve-batch') {++ const { ids } = JSON.parse(raw);++ return res.end(JSON.stringify({ results: Object.fromEntries(ids.map((id) => [id, id === '2' ? { subject: { type: 'user', id: SID2 } } : null])) }));++ }++ res.statusCode = 404; res.end('{}');++ });++});++const community = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const auth = req.headers.authorization || '';++ const claims = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.community' });++ seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, origin: req.headers['x-ov-origin'] || null, sourceRef: req.headers['x-ov-source-ref'] || null, xff: req.headers['x-forwarded-for'] || null, type: req.headers['content-type'] || '', body: Buffer.concat(chunks).toString('latin1'), tokenOk: claims.ok });++ res.setHeader('Content-Type', 'application/json');++ if (req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ id: 9, slug: 'new-slug-1', url: '/p/new-slug-1', paste: { slug: 'new-slug-1', origin: req.headers['x-ov-origin'] || 'user' } })); }++ res.end(JSON.stringify({ pastes: [], total: 0 }));++ });++});++++(async () => {++ await new Promise((r) => net.listen(0, '127.0.0.1', r));++ await new Promise((r) => community.listen(0, '127.0.0.1', r));++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${net.address().port}`;++ process.env.OV_COMMUNITY_INTERNAL_URL = `http://127.0.0.1:${community.address().port}`;++++ const db = require('../server/db/database');++ db.initDb();++ const d = db.getDb();++ const u1 = d.prepare("INSERT INTO users (username, password_hash, stream_key) VALUES ('ann', 'x', 'k1')").run().lastInsertRowid;++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '11', ?)").run(u1, SID);++ const client = require('../server/pastes-client');++++ // AI paste: no person, origin ai, stream as source.++ let out = await client.createPaste({ user_id: u1, title: 'moment', content: 'x', stream_id: 77, metadata: { ai_moment: true } }, { origin: 'ai' });++ assert.strictEqual(out.slug, 'new-slug-1');++ let s = seen.pop();++ assert.strictEqual(s.subject, null, 'AI pastes are never filed under a person');++ assert.strictEqual(s.origin, 'ai');++ assert.deepStrictEqual(JSON.parse(s.sourceRef), { service: 'live', type: 'stream', id: '77' });++ assert.ok(s.tokenOk, 'Live calls with a Community-audience service token');++ assert.ok(!('user_id' in JSON.parse(s.body)), 'no Live id reaches Community');++++ // A person: subject from the link table.++ await client.createPaste({ user_id: u1, title: 't', content: 'hello' });++ s = seen.pop();++ assert.strictEqual(s.subject, SID);++ // Not in the link table: resolved through Network's identity map (live user 2 -> SID2).++ d.prepare("INSERT INTO users (id, username, password_hash, stream_key) VALUES (2, 'bob', 'x', 'k2')").run();++ await client.createPaste({ user_id: 2, content: 'hi' });++ assert.strictEqual(seen.pop().subject, SID2);++ // Nobody we can name: refused rather than filed under the wrong person or as anonymous.++ d.prepare("INSERT INTO users (id, username, password_hash, stream_key) VALUES (3, 'cat', 'x', 'k3')").run();++ await assert.rejects(client.createPaste({ user_id: 3, content: 'hi' }), (e) => e.status === 409);++++ // Screenshot: multipart with the file.++ await client.createPaste({ user_id: u1, title: 'shot', screenshot: { buffer: Buffer.from('PNGDATA'), filename: 'a.png', contentType: 'image/png' } });++ s = seen.pop();++ assert.ok(s.type.startsWith('multipart/form-data') && s.body.includes('PNGDATA') && s.body.includes('name="screenshot"'));++++ // The SPA router: anonymous list/create forward anonymously; staff routes need a Live admin.++ const app = express();++ app.set('trust proxy', 1);++ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));++ app.use('/api/pastes', require('../server/media-proxy/pastes'));++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}/api/pastes`;++ let r = await fetch(`${base}?limit=5&username=ann`, { headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.strictEqual(r.status, 200);++ s = seen.pop();++ assert.strictEqual(s.url, '/api/pastes?limit=5&username=ann', 'query forwarded as-is');++ assert.strictEqual(s.subject, null, 'nobody signed in, nobody named');++ assert.strictEqual(s.xff, '203.0.113.9', 'Community sees the visitor address for its limits');++ r = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'anon paste', user_id: 1 }) });++ assert.strictEqual(r.status, 201);++ s = seen.pop();++ assert.strictEqual(s.body, JSON.stringify({ content: 'anon paste', user_id: 1 }), 'body bytes forwarded untouched (Community ignores identity fields)');++ r = await fetch(`${base}/bulk`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });++ assert.strictEqual(r.status, 401, 'staff routes still need a signed-in Live admin');++ r = await fetch(`${base}/some-slug/raw`, { redirect: 'manual' });++ assert.strictEqual(r.status, 302);++ assert.ok(r.headers.get('location').endsWith('/p/some-slug/raw'));++++ // Default mode goes to Media exactly as before.++ process.env.PASTES_AUTHORITY = '';++ assert.strictEqual(client.onCommunity(), false);++++ srv.close(); net.close(); community.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('pastes on community: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
"openvibe-shared": "file:./vendor/openvibe-shared",
"openvibe-shared": "file:./vendor/openvibe-shared",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2813,9 +2813,9 @@
@@ -2813,9 +2813,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.3.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
- "integrity": "sha512-4clDeetK1RofxApXzSLUehl0x3zsc2AsEedFyCTmocsAp3LzCL/07kiBMbt675KEBArmI0b2jQ8qumgl7rwotw==",
+ "version": "0.5.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
+ "integrity": "sha512-czQpWKEo8cY56Crj5l5Mv8r5vlIIj0vn/HMxv0OlIu5YTuSSlpvXyayYEfn3GQ5orDhaMIhOUlzmQMxUtWXaLA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.3.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
"openvibe-shared": "file:./vendor/openvibe-shared",
"openvibe-shared": "file:./vendor/openvibe-shared",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -435,7 +435,7 @@ async function tick(opts = {}) {
@@ -435,7 +435,7 @@ async function tick(opts = {}) {
// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/
// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/
// ai_summary/ai_tags/stream_id) are inherited-shape extensions the
// ai_summary/ai_tags/stream_id) are inherited-shape extensions the
// contract leaves open — Media ignores what it doesn't know.
// contract leaves open — Media ignores what it doesn't know.
- const paste = await media.createPaste({
+ const paste = await require('../pastes-client').createPaste({
slug, user_id: v.user_id,
slug, user_id: v.user_id,
title: title.slice(0, 80), content, language: 'text', visibility: 'public',
title: title.slice(0, 80), content, language: 'text', visibility: 'public',
stream_id: v.stream_id, metadata,
stream_id: v.stream_id, metadata,
@@ -445,7 +445,7 @@ async function tick(opts = {}) {
@@ -445,7 +445,7 @@ async function tick(opts = {}) {
filename: path.basename(screenshotPath),
filename: path.basename(screenshotPath),
contentType: 'image/jpeg',
contentType: 'image/jpeg',
},
},
- });
+ }, { origin: 'ai' });
if (paste) {
if (paste) {
pasteSlug = paste.slug || slug;
pasteSlug = paste.slug || slug;
// Screenshot pastes must use the image endpoint — /raw serves the
// Screenshot pastes must use the image endpoint — /raw serves the
@@ -96,7 +96,7 @@ async function tick() {
@@ -96,7 +96,7 @@ async function tick() {
// a work queue and we post results back.
// a work queue and we post results back.
if (ai.pasteAnalysisEnabled && ai.pasteAnalysisEnabled()) {
if (ai.pasteAnalysisEnabled && ai.pasteAnalysisEnabled()) {
try {
try {
- const out = await media.listPastesNeedingAi(anyLive ? 1 : 3).catch(() => null);
+ const out = await require('../pastes-client').listPastesNeedingAi(anyLive ? 1 : 3).catch(() => null);
for (const p of (out?.pastes || [])) {
for (const p of (out?.pastes || [])) {
if (!p || !p.slug) continue;
if (!p || !p.slug) continue;
let r = null;
let r = null;
@@ -110,7 +110,7 @@ async function tick() {
@@ -110,7 +110,7 @@ async function tick() {
r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);
r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);
}
}
if (!r || !r.description) continue;
if (!r || !r.description) continue;
- await media.setPasteAi(p.slug, {
+ await require('../pastes-client').setPasteAi(p.slug, {
ai_summary: r.description,
ai_summary: r.description,
ai_tags: JSON.stringify(r.tags || []),
ai_tags: JSON.stringify(r.tags || []),
}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));
}).catch((e) => console.warn('[AI backfill] paste ai write:', e.message));
@@ -197,14 +197,14 @@ async function _maybeLivePaste(stream, image, r, offset) {
@@ -197,14 +197,14 @@ async function _maybeLivePaste(stream, image, r, offset) {
if (!buffer || buffer.length < 2000) return;
if (!buffer || buffer.length < 2000) return;
const username = stream.username || (db.getUserById(stream.user_id) || {}).username || 'someone';
const username = stream.username || (db.getUserById(stream.user_id) || {}).username || 'someone';
let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }
let base = 'https://openvibe.live'; try { const c = require('../config'); base = String(c.baseUrl || c.publicUrl || base).replace(/\/$/, ''); } catch { /* */ }
- const media = require('../media-client');
- const paste = await media.createPaste({
+ // AI output is never filed under a person (roadmap 33): origin 'ai', the stream rides along as its source.
+ const paste = await require('../pastes-client').createPaste({
slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,
slug: moments.makeSlug ? moments.makeSlug() : undefined, user_id: stream.user_id,
title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/${username}`, language: 'text', visibility: 'public',
title: r.title.slice(0, 80), content: `${r.description}\n\n🔴 Caught live on @${username}'s stream — ${base}/${username}`, language: 'text', visibility: 'public',
stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),
stream_id: stream.id, metadata: JSON.stringify({ ai_moment: true, live: true, stream_id: stream.id, offset, username, vod_link: null }),
ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),
ai_summary: r.description, ai_tags: JSON.stringify(Array.isArray(r.tags) ? r.tags.slice(0, 8) : []),
screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },
screenshot: { buffer, filename: `live-${stream.id}-${offset}.jpg`, contentType: 'image/jpeg' },
- });
+ }, { origin: 'ai' });
if (paste) {
if (paste) {
registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });
registry.record({ kind: 'paste', stream_id: stream.id, offset, desc: r.description, title: r.title });
console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${r.title}"`);
console.log(`[AI-Moments] live paste for stream ${stream.id} at ${offset}s: "${r.title}"`);
@@ -216,7 +216,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -216,7 +216,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
const makePublic = req.body.public === undefined ? true : !(req.body.public === 'false' || req.body.public === false || req.body.public === '0');
const makePublic = req.body.public === undefined ? true : !(req.body.public === 'false' || req.body.public === false || req.body.public === '0');
const imageBuffer = fs.readFileSync(req.file.path);
const imageBuffer = fs.readFileSync(req.file.path);
try { fs.unlinkSync(req.file.path); } catch { /* */ }
try { fs.unlinkSync(req.file.path); } catch { /* */ }
- const paste = await media.createPaste({
+ const paste = await require('../pastes-client').createPaste({
title: 'Avatar upload',
title: 'Avatar upload',
content: '',
content: '',
language: 'text',
language: 'text',
@@ -1608,7 +1608,7 @@ class ChatServer {
@@ -1608,7 +1608,7 @@ class ChatServer {
const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;
const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;
const userId = client.userId || client.user?.id || null;
const userId = client.userId || client.user?.id || null;
// Pastes live in OpenVibe.Media now — post through the media client.
// Pastes live in OpenVibe.Media now — post through the media client.
- require('../media-client').createPaste({
+ require('../pastes-client').createPaste({
title,
title,
content: args.trim(),
content: args.trim(),
language: 'auto',
language: 'auto',
@@ -58,7 +58,7 @@ async function _mediaContent() {
@@ -58,7 +58,7 @@ async function _mediaContent() {
const [vods, clips, pastes] = await Promise.all([
const [vods, clips, pastes] = await Promise.all([
media.listVods({ limit: 12 }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),
media.listVods({ limit: 12 }).then(r => r?.vods || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.vods),
media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),
media.listClips({ limit: 12 }).then(r => r?.clips || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.clips),
- media.listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),
+ require('../pastes-client').listPastes({ limit: 12, visibility: 'public' }).then(r => r?.pastes || (Array.isArray(r) ? r : [])).catch(() => _mediaPool.pastes),
]);
]);
_mediaPool = { vods, clips, pastes, at: Date.now() };
_mediaPool = { vods, clips, pastes, at: Date.now() };
return _mediaPool;
return _mediaPool;
@@ -247,4 +247,73 @@ router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath(
@@ -247,4 +247,73 @@ router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath(
// Deleting needs an identity: without one Media sees the app key alone and lets it delete anything.
// Deleting needs an identity: without one Media sees the app key alone and lets it delete anything.
router.delete('/:slug/comments/:commentId', requireAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
router.delete('/:slug/comments/:commentId', requireAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
-module.exports = router;
+// ── Community authority (PASTES_AUTHORITY=community, roadmap Wave 5) ──────────────
+// OpenVibe.Community owns pastes. This router keeps the SPA's /api/pastes/* URLs and forwards every
+// call there with Live's service token; the person is named by their canonical subject (never by a
+// Live id), anonymous callers stay anonymous, and staff routes still pass Live's requireAdmin first.
+// Only set-avatar stays here, because it changes Live's own user row.
+const pastesClient = require('../pastes-client');
+const principal = require('../net/network-principal');
+const { Readable } = require('stream');
+
+function toCommunity(subPath) {
+ return async (req, res) => {
+ try {
+ const p = typeof subPath === 'function' ? subPath(req) : subPath;
+ const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
+ const headers = { Accept: 'application/json', ...(await principal.serviceHeaders('openvibe.community')) };
+ if (req.user && req.user.id) {
+ const sid = req.user.subject_id || await pastesClient.subjectForLiveUser(req.user.id);
+ if (!sid) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet. Sign in again and retry.' });
+ headers['X-OV-Subject'] = sid;
+ }
+ if (req.ip) headers['X-Forwarded-For'] = req.ip;
+ const opts = { method: req.method, headers, redirect: 'manual', signal: AbortSignal.timeout(req.method === 'GET' ? 20000 : 120000) };
+ if (!['GET', 'HEAD'].includes(req.method)) {
+ const ct = req.headers['content-type'] || '';
+ if (req.rawBody) { headers['Content-Type'] = ct || 'application/json'; opts.body = req.rawBody; }
+ else if (ct.startsWith('application/x-www-form-urlencoded')) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(req.body || {}); }
+ else if (ct) { headers['Content-Type'] = ct; opts.body = Readable.toWeb(req); opts.duplex = 'half'; }
+ }
+ const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);
+ if (upstream.status === 401) principal.invalidate('openvibe.community');
+ res.status(upstream.status);
+ for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
+ const v = upstream.headers.get(h);
+ if (v) res.set(h, v);
+ }
+ res.send(Buffer.from(await upstream.arrayBuffer()));
+ } catch (err) {
+ console.warn('[Pastes → Community]', err.message);
+ if (!res.headersSent) res.status(502).json({ error: 'Paste service unavailable' });
+ }
+ };
+}
+
+const communityRouter = express.Router();
+communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
+ try {
+ let paste = null;
+ try { paste = await pastesClient.getPaste(req.params.slug); } catch { paste = null; }
+ if (!paste) return res.status(404).json({ error: 'Paste not found' });
+ if (paste.type !== 'screenshot' || !paste.screenshot_url) return res.status(400).json({ error: 'That paste is not an image' });
+ const avatarUrl = media.publicUrl(paste.screenshot_url) || paste.screenshot_url;
+ db.updateUserAvatar(req.user.id, avatarUrl, null);
+ try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* next sign-in */ }
+ res.json({ success: true, avatar_url: avatarUrl });
+ } catch (err) {
+ console.warn('[Pastes → Community] set-avatar:', err.message);
+ res.status(500).json({ error: 'Failed to set avatar' });
+ }
+});
+communityRouter.get('/:slug/raw', (req, res) => res.redirect(302, `${String(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '')}/p/${encodeURIComponent(req.params.slug)}/raw`));
+for (const pth of ['/admin/stats', '/admin/forks']) communityRouter.get(pth, requireAdmin, toCommunity(pth));
+communityRouter.delete('/admin/forks', requireAdmin, toCommunity('/admin/forks'));
+communityRouter.post('/bulk', requireAdmin, toCommunity('/bulk'));
+communityRouter.post('/:slug/censor', requireAdmin, toCommunity((req) => `/${encodeURIComponent(req.params.slug)}/censor`));
+communityRouter.post(['/', '/screenshot', '/:slug/fork', '/:slug/comments'], optionalAuth, anonWriteLimiter, toCommunity((req) => req.path === '/' ? '' : req.path));
+communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '' : req.path));
+
+module.exports = (req, res, next) => (pastesClient.onCommunity() ? communityRouter : router)(req, res, next);
+module.exports.mediaRouter = router;
+module.exports.communityRouter = communityRouter;
@@ -20,9 +20,24 @@ const PAUSE_MS = 5 * 60 * 1000;
@@ -20,9 +20,24 @@ const PAUSE_MS = 5 * 60 * 1000;
// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).
// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).
const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk']);
const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk']);
-const tokens = CLIENT_SECRET ? serviceAuth.createTokenClient({
- tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, audience: 'openvibe.network',
-}) : null;
+const _clients = new Map();
+/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */
+function clientFor(audience) {
+ if (!CLIENT_SECRET) return null;
+ if (!_clients.has(audience)) {
+ _clients.set(audience, serviceAuth.createTokenClient({ tokenUrl: `${NETWORK_INTERNAL_URL}/oauth/token`, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, audience }));
+ }
+ return _clients.get(audience);
+}
+const tokens = clientFor('openvibe.network');
+
+/** Bearer headers for another service. Throws when no token can be had (no fallback: new APIs are token-only). */
+async function serviceHeaders(audience) {
+ const c = clientFor(audience);
+ if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ return c.authHeaders();
+}
+function invalidate(audience) { const c = _clients.get(audience); if (c) c.invalidate(); }
let pausedUntil = 0;
let pausedUntil = 0;
let lastFailure = null;
let lastFailure = null;
const stats = { token: 0, key: 0, tokenFailures: 0 };
const stats = { token: 0, key: 0, tokenFailures: 0 };
@@ -53,4 +68,4 @@ function tokenRejected(problemCode) {
@@ -53,4 +68,4 @@ function tokenRejected(problemCode) {
console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); using the internal key for ${PAUSE_MS / 60000} min`);
console.warn(`[Principal] Network refused the service token (${problemCode || '401'}); using the internal key for ${PAUSE_MS / 60000} min`);
}
}
-module.exports = { headersFor, tokenRejected, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; if (tokens) tokens.invalidate(); } };
+module.exports = { headersFor, tokenRejected, serviceHeaders, invalidate, TOKEN_PATHS, stats, _reset() { pausedUntil = 0; for (const c of _clients.values()) c.invalidate(); } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Where Live's pastes live (roadmap Wave 5).
+ *
+ * PASTES_AUTHORITY=community: OpenVibe.Community owns pastes. Live calls its /api/pastes with a
+ * service token (community.paste.create/write/moderate) and names the person it acts for in
+ * X-OV-Subject (their canonical usr_ id). AI-made pastes are sent as X-OV-Origin: ai and are never
+ * filed under a person (roadmap 33); the stream they came from rides along as X-OV-Source-Ref.
+ * Anything else (default): the old path through OpenVibe.Media's app API (media-client).
+ *
+ * Same function shapes as media-client's paste helpers, so call sites only swap the require.
+ */
+const media = require('./media-client');
+const principal = require('./net/network-principal');
+
+const COMMUNITY_URL = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
+const AUDIENCE = 'openvibe.community';
+const onCommunity = () => process.env.PASTES_AUTHORITY === 'community';
+
+class CommunityApiError extends Error {
+ constructor(status, body) {
+ super((body && (body.error || body.detail)) || `Community API ${status}`);
+ this.name = 'MediaApiError'; // call sites already branch on this name for status passthrough
+ this.status = status;
+ this.body = body;
+ }
+}
+
+const _subjects = new Map(); // live user id -> usr_ id (or null), for this process
+/** Canonical subject for a Live user: from the link table (tokens fill it), else Network's identity map. */
+async function subjectForLiveUser(liveUserId) {
+ if (liveUserId == null) return null;
+ const id = Number(liveUserId);
+ if (_subjects.has(id)) return _subjects.get(id);
+ let sid = null;
+ try { sid = require('./auth/identity-sync').subjectOf(id); } catch { /* */ }
+ if (!sid) {
+ try {
+ const res = await fetch(`${process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await principal.serviceHeaders('openvibe.network')) },
+ body: JSON.stringify({ system: 'live', type: 'user', ids: [String(id)] }),
+ signal: AbortSignal.timeout(5000),
+ });
+ const out = res.ok ? await res.json() : null;
+ const hit = out && out.results && out.results[String(id)];
+ sid = hit && hit.subject ? hit.subject.id : null;
+ } catch (err) { console.warn(`[Pastes] subject lookup for live user ${id} failed: ${err.message}`); }
+ }
+ if (sid) _subjects.set(id, sid);
+ return sid;
+}
+
+/**
+ * One call to Community's paste API. body: plain object (JSON) or FormData. act: { liveUserId } to act
+ * as a person, { origin: 'ai', sourceRef } for derived pastes, {} for Live itself (moderation paths).
+ */
+async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20000, retried = false } = {}) {
+ const qs = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)]))}` : '';
+ const headers = { Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) };
+ if (act.liveUserId != null) {
+ const sid = await subjectForLiveUser(act.liveUserId);
+ if (!sid) throw new CommunityApiError(409, { error: 'This account is not linked to an OpenVibe account yet — sign in again and retry.' });
+ headers['X-OV-Subject'] = sid;
+ }
+ if (act.origin === 'ai') {
+ headers['X-OV-Origin'] = 'ai';
+ if (act.sourceRef) headers['X-OV-Source-Ref'] = JSON.stringify(act.sourceRef);
+ }
+ if (ip) headers['X-Forwarded-For'] = ip;
+ let payload;
+ if (body instanceof FormData) payload = body;
+ else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
+ const res = await fetch(`${COMMUNITY_URL}/api/pastes${path}${qs}`, { method, headers, body: payload, signal: AbortSignal.timeout(timeoutMs) });
+ const out = await res.json().catch(() => null);
+ if (res.status === 401 && !retried) { // token rotated/expired under us: fetch a fresh one once
+ principal.invalidate(AUDIENCE);
+ return request(method, path, { query, body, act, ip, timeoutMs, retried: true });
+ }
+ if (!res.ok) throw new CommunityApiError(res.status, out);
+ return out;
+}
+
+/** media-client-compatible createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */
+async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
+ if (!onCommunity()) return media.createPaste({ screenshot, user_id, ...fields }, opts);
+ const act = opts.origin === 'ai'
+ ? { origin: 'ai', sourceRef: fields.stream_id ? { service: 'live', type: 'stream', id: String(fields.stream_id) } : undefined }
+ : { liveUserId: user_id };
+ if (typeof fields.metadata === 'object' && fields.metadata) fields.metadata = JSON.stringify(fields.metadata);
+ if (screenshot) {
+ const fd = new FormData();
+ for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, String(v));
+ fd.append('screenshot', new Blob([screenshot.buffer], { type: screenshot.contentType || 'image/png' }), screenshot.filename || 'screenshot.png');
+ return request('POST', '', { body: fd, act, timeoutMs: 60000 });
+ }
+ return request('POST', '', { body: fields, act });
+}
+
+async function getPaste(slug) {
+ if (!onCommunity()) return media.getPaste(slug);
+ const out = await request('GET', `/${encodeURIComponent(slug)}`, { query: { no_view: 1 } });
+ return (out && out.paste) || out;
+}
+
+function listPastes(query = {}) {
+ if (!onCommunity()) return media.listPastes(query);
+ return request('GET', '', { query });
+}
+
+function listPastesNeedingAi(limit = 5) {
+ if (!onCommunity()) return media.listPastesNeedingAi(limit);
+ return request('GET', '', { query: { needs_ai: 1, limit } });
+}
+
+function setPasteAi(slug, { ai_summary, ai_tags } = {}) {
+ if (!onCommunity()) return media.setPasteAi(slug, { ai_summary, ai_tags });
+ return request('POST', `/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags } });
+}
+
+module.exports = { onCommunity, request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };
@@ -30,10 +30,10 @@ async function _cached(key, fn) {
@@ -30,10 +30,10 @@ async function _cached(key, fn) {
}
}
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
-const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await media.listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);
+const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
-const _pasteGet = (slug) => _cached(`p:${slug}`, () => media.getPaste(slug));
+const _pasteGet = (slug) => _cached(`p:${slug}`, () => require('../pastes-client').getPaste(slug));
// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
// Overlay the Live-owned AI state (vod_ai_state/clip_ai_state) onto a Media row so
// descriptions/transcripts keep enriching the crawlable snapshot.
// descriptions/transcripts keep enriching the crawlable snapshot.
@@ -572,7 +572,7 @@ async function buildSitemap() {
@@ -572,7 +572,7 @@ async function buildSitemap() {
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
if (c.username) seenChannels.add(c.username);
if (c.username) seenChannels.add(c.username);
});
});
- await page((l, o) => media.listPastes({ limit: l, offset: o, visibility: 'public' }).then(r => r?.pastes || []), 200, SITEMAP_CAP, (x) => {
+ await page((l, o) => require('../pastes-client').listPastes({ limit: l, offset: o, visibility: 'public' }).then(r => r?.pastes || []), 200, SITEMAP_CAP, (x) => {
if (!Number(x.is_nsfw)) urls.push(_urlTag(`/p/${x.slug}`, isoDate(x.created_at), 'monthly', '0.4'));
if (!Number(x.is_nsfw)) urls.push(_urlTag(`/p/${x.slug}`, isoDate(x.created_at), 'monthly', '0.4'));
});
});
for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+
+// PASTES_AUTHORITY=community (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with
+// a service token. AI pastes are ownerless (origin ai + source stream), a person is named by subject, the
+// SPA's /api/pastes forwards anonymously when nobody is signed in, and staff routes keep requireAdmin.
+
+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 { serviceAuth } = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pastes-community-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.INTERNAL_API_KEY = 'legacy-key';
+process.env.OV_OAUTH_CLIENT_ID = 'live';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+process.env.PASTES_AUTHORITY = 'community';
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const SID = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
+const SID2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
+const seen = [];
+
+const net = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') {
+ const aud = new URLSearchParams(raw).get('audience');
+ const now = Math.floor(Date.now() / 1000);
+ const cap = aud === 'openvibe.community' ? ['community.paste.create', 'community.paste.write', 'community.paste.moderate'] : ['identity.subject.resolve'];
+ return res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [aud], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), expires_in: 300 }));
+ }
+ if (req.url === '/internal/identity/resolve-batch') {
+ const { ids } = JSON.parse(raw);
+ return res.end(JSON.stringify({ results: Object.fromEntries(ids.map((id) => [id, id === '2' ? { subject: { type: 'user', id: SID2 } } : null])) }));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+});
+const community = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const auth = req.headers.authorization || '';
+ const claims = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.community' });
+ seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'] || null, origin: req.headers['x-ov-origin'] || null, sourceRef: req.headers['x-ov-source-ref'] || null, xff: req.headers['x-forwarded-for'] || null, type: req.headers['content-type'] || '', body: Buffer.concat(chunks).toString('latin1'), tokenOk: claims.ok });
+ res.setHeader('Content-Type', 'application/json');
+ if (req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ id: 9, slug: 'new-slug-1', url: '/p/new-slug-1', paste: { slug: 'new-slug-1', origin: req.headers['x-ov-origin'] || 'user' } })); }
+ res.end(JSON.stringify({ pastes: [], total: 0 }));
+ });
+});
+
+(async () => {
+ await new Promise((r) => net.listen(0, '127.0.0.1', r));
+ await new Promise((r) => community.listen(0, '127.0.0.1', r));
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${net.address().port}`;
+ process.env.OV_COMMUNITY_INTERNAL_URL = `http://127.0.0.1:${community.address().port}`;
+
+ const db = require('../server/db/database');
+ db.initDb();
+ const d = db.getDb();
+ const u1 = d.prepare("INSERT INTO users (username, password_hash, stream_key) VALUES ('ann', 'x', 'k1')").run().lastInsertRowid;
+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '11', ?)").run(u1, SID);
+ const client = require('../server/pastes-client');
+
+ // AI paste: no person, origin ai, stream as source.
+ let out = await client.createPaste({ user_id: u1, title: 'moment', content: 'x', stream_id: 77, metadata: { ai_moment: true } }, { origin: 'ai' });
+ assert.strictEqual(out.slug, 'new-slug-1');
+ let s = seen.pop();
+ assert.strictEqual(s.subject, null, 'AI pastes are never filed under a person');
+ assert.strictEqual(s.origin, 'ai');
+ assert.deepStrictEqual(JSON.parse(s.sourceRef), { service: 'live', type: 'stream', id: '77' });
+ assert.ok(s.tokenOk, 'Live calls with a Community-audience service token');
+ assert.ok(!('user_id' in JSON.parse(s.body)), 'no Live id reaches Community');
+
+ // A person: subject from the link table.
+ await client.createPaste({ user_id: u1, title: 't', content: 'hello' });
+ s = seen.pop();
+ assert.strictEqual(s.subject, SID);
+ // Not in the link table: resolved through Network's identity map (live user 2 -> SID2).
+ d.prepare("INSERT INTO users (id, username, password_hash, stream_key) VALUES (2, 'bob', 'x', 'k2')").run();
+ await client.createPaste({ user_id: 2, content: 'hi' });
+ assert.strictEqual(seen.pop().subject, SID2);
+ // Nobody we can name: refused rather than filed under the wrong person or as anonymous.
+ d.prepare("INSERT INTO users (id, username, password_hash, stream_key) VALUES (3, 'cat', 'x', 'k3')").run();
+ await assert.rejects(client.createPaste({ user_id: 3, content: 'hi' }), (e) => e.status === 409);
+
+ // Screenshot: multipart with the file.
+ await client.createPaste({ user_id: u1, title: 'shot', screenshot: { buffer: Buffer.from('PNGDATA'), filename: 'a.png', contentType: 'image/png' } });
+ s = seen.pop();
+ assert.ok(s.type.startsWith('multipart/form-data') && s.body.includes('PNGDATA') && s.body.includes('name="screenshot"'));
+
+ // The SPA router: anonymous list/create forward anonymously; staff routes need a Live admin.
+ const app = express();
+ app.set('trust proxy', 1);
+ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
+ app.use('/api/pastes', require('../server/media-proxy/pastes'));
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}/api/pastes`;
+ let r = await fetch(`${base}?limit=5&username=ann`, { headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.strictEqual(r.status, 200);
+ s = seen.pop();
+ assert.strictEqual(s.url, '/api/pastes?limit=5&username=ann', 'query forwarded as-is');
+ assert.strictEqual(s.subject, null, 'nobody signed in, nobody named');
+ assert.strictEqual(s.xff, '203.0.113.9', 'Community sees the visitor address for its limits');
+ r = await fetch(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'anon paste', user_id: 1 }) });
+ assert.strictEqual(r.status, 201);
+ s = seen.pop();
+ assert.strictEqual(s.body, JSON.stringify({ content: 'anon paste', user_id: 1 }), 'body bytes forwarded untouched (Community ignores identity fields)');
+ r = await fetch(`${base}/bulk`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
+ assert.strictEqual(r.status, 401, 'staff routes still need a signed-in Live admin');
+ r = await fetch(`${base}/some-slug/raw`, { redirect: 'manual' });
+ assert.strictEqual(r.status, 302);
+ assert.ok(r.headers.get('location').endsWith('/p/some-slug/raw'));
+
+ // Default mode goes to Media exactly as before.
+ process.env.PASTES_AUTHORITY = '';
+ assert.strictEqual(client.onCommunity(), false);
+
+ srv.close(); net.close(); community.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('pastes on community: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });