openvibers/openvibe.live Commits

Pastes can live in OpenVibe.Community: PASTES_AUTHORITY=community routes every paste read and write there with a service token

OP OpenVibers committed 22 Sept 2026 · 6be1fd53f1 · 13 files, +359 −23

Roadmap Wave 5. Off by default (Media path unchanged until the flag flips at cutover).
- server/pastes-client.js: media-client-compatible createPaste/getPaste/listPastes/needs-AI/setPasteAi.
  In community mode Live calls Community's /api/pastes with a community-audience service token and
  names the person by canonical subject (X-OV-Subject; link table, else Network's identity map). A
  Live user who can't be named is refused (409), never filed as someone else or as anonymous
- AI moment pastes (ai-moments, stream-memory) are sent with X-OV-Origin: ai and the stream as
  X-OV-Source-Ref, so they are no longer filed under the streamer (roadmap 33)
- /api/pastes in community mode forwards to Community byte-for-byte with the visitor address;
  anonymous stays anonymous, staff routes keep requireAdmin, set-avatar stays local, /raw redirects
- avatar uploads, chat /paste, AI backfill, SEO lists and the home rail use the client
- network-principal: cached tokens per audience; contracts v0.5.1

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 38f6e415..7ab2e538 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex edd218cb..75a723ba 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex b7f503da..d16b2237 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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
ddiff --git a/server/ai/backfill-job.js b/server/ai/backfill-job.js
iindex b00ac8e5..98ab840c 100644
---- a/server/ai/backfill-job.js
++++ b/server/ai/backfill-job.js
@@@ -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));
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex c038a19c..f537c965 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -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}"`);
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 73a1038b..6df73f5f 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 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',
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 32c43d6f..cfce5896 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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',
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 5e117fb2..bc51fd44 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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;
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex 8fb88a04..dd4bab95 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -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;
ddiff --git a/server/net/network-principal.js b/server/net/network-principal.js
iindex 40ca2b9a..7bdc11f0 100644
---- a/server/net/network-principal.js
++++ b/server/net/network-principal.js
@@@ -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(); } };
ddiff --git a/server/pastes-client.js b/server/pastes-client.js
nnew file mode 100644
iindex 00000000..c2b2f6a0
---- /dev/null
++++ b/server/pastes-client.js
@@@ -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 };
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 592c51c5..10a13981 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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'));
ddiff --git a/test/pastes-community.test.js b/test/pastes-community.test.js
nnew file mode 100644
iindex 00000000..4df532ea
---- /dev/null
++++ b/test/pastes-community.test.js
@@@ -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); });