openvibers/openvibe.live Commits

Attempts: 1. author Claude opus done; 2. review deepseek-v4.1-flash done

OP OpenVibers committed 2 Oct 2026 · 2b1fefbca9 · 25 files, +185 −474

Done by the harness job live-t10-lane-e-jobs-2-3-005046.

Changes

ddiff --git a/.env.example b/.env.example
iindex 16874fdf..ffe65c51 100644
---- a/.env.example
++++ b/.env.example
@@@ -157,10 +157,9 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
# admin settings as `ops_alert_webhook_url`.
OPS_ALERT_WEBHOOK_URL=
--# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
--PASTES_ON_COMMUNITY=0
++# Pastes are served by openvibe.community (Live 301s /p/:slug there and links straight to it)
OV_COMMUNITY_URL=https://openvibe.community
--# Community's loopback API: pastes (PASTES_AUTHORITY=community) and VOD/clip comments, which are
++# Community's loopback API: pastes and VOD/clip comments, which are
# Community threads (server/comments-client.js; Live's service token needs community.comment.write
# and community.comment.moderate on openvibe.community)
OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 3e02bca4..535fdca7 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -100,7 +100,7 @@ Everything reads the effective value: listing queries return `COALESCE(ai_catego
Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media
split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,
--same ids) and OpenVibe.Community (pastes, by `legacy_media_id` or slug) hold the old rows too. Since
++same ids) and OpenVibe.Community (pastes, by slug) hold the old rows too. Since
register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read
of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its
allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this
@@@ -119,7 +119,7 @@ What used to read them, and what answers now (most of it in `server/media-proxy/
| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own |
| AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never |
| Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |
--| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` when it owns them | counts only |
++| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` | counts only |
| Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only |
| Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff |
| Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |
@@@ -221,7 +221,7 @@ SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v
SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k
WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');
SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p
-- WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.legacy_media_id = p.id OR x.slug = p.slug);
++ WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.slug = p.slug);
SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l
WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);
SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex e146677e..7452130c 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -476,7 +476,7 @@ function _channelPasteCardHTML(p, canManage) {
const thumb = (isShot && p.screenshot_url)
? `<div class="ch-paste-thumb"><img src="${esc(p.screenshot_url)}" alt="" loading="lazy"></div>`
: `<div class="ch-paste-thumb ch-paste-thumb-icon"><i class="fa-solid ${isShot ? 'fa-image' : 'fa-code'}"></i></div>`;
-- return `<a class="ch-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">
++ return `<a class="ch-paste-card" href="${esc(pasteHref(p.slug))}">
${thumb}
<div class="ch-paste-info">
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 684fd6d5..155fe9c9 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1533,7 +1533,7 @@ async function loadHomePastes(opts) {
? `<div class="home-paste-media"><img src="${esc(p.screenshot_url)}" alt="${esc(p.title || 'Screenshot paste')}" loading="lazy"><span class="home-paste-type">Image</span></div>`
: `<div class="home-paste-media"><div class="home-paste-snippet">${preview || esc(p.title || 'Untitled paste')}</div><div class="home-paste-icon"><i class="fa-solid ${icon}"></i></div><span class="home-paste-type">${p.language && p.language !== 'plaintext' ? esc(p.language) : 'Text'}</span></div>`;
return `
-- <a class="home-paste-card" href="/p/${esc(p.slug)}" onclick="return handleLinkClick(event, '/p/${esc(p.slug)}')">
++ <a class="home-paste-card" href="${esc(pasteHref(p.slug))}">
${media}
<div class="home-paste-body">
<div class="home-paste-info">
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 13026226..8e8abb01 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1025,6 +1025,12 @@ function whenRouteReady(pageId, render) {
});
}
++/** A paste's page on openvibe.community (the shell's ov-pastes-base meta names it). */
++function pasteHref(slug) {
++ const meta = document.querySelector('meta[name="ov-pastes-base"]');
++ return `${meta ? meta.content : ''}/p/${encodeURIComponent(slug)}`;
++}
++
/** Pastes live on openvibe.community. A signed-in user goes through its silent sign-in so they arrive
* signed in; guests go straight to the page. */
function _pasteHandOver(target) {
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex b2a89016..908c523b 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -72,7 +72,7 @@ function renderPasteCard(p) {
const nsfwBlur = p.is_nsfw ? ' paste-card-nsfw-blur' : '';
const _cardInner = `
-- <a class="paste-card${nsfwBlur}" href="/p/${p.slug}" onclick="return handleLinkClick(event, '/p/${p.slug}')">
++ <a class="paste-card${nsfwBlur}" href="${escapeHtml(pasteHref(p.slug))}">
${thumb}
<div class="paste-card-info">
<div class="paste-card-title">${pinBadge}${nsfwBadge}${escapeHtml(p.title)}${burnBadge}</div>
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex b80fc100..8cb133f5 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -425,9 +425,8 @@ async function tick(opts = {}) {
// never knew Community pastes (every moment since the move had a broken image).
let shot = paste.screenshot_url || null;
if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }
-- img = media.publicUrl(shot) || (pastesClient.onCommunity()
-- ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`
-- : `${media.pasteUrl(pasteSlug)}/screenshot`);
++ img = media.publicUrl(shot)
++ || `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`;
}
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 191130dc..66a3a521 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -233,8 +233,8 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
}
});
--// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community — or
--// OpenVibe.Media while PASTES_AUTHORITY is unset). Pastes have no metadata filter upstream, so
++// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community).
++// Pastes have no metadata filter upstream, so
// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.
router.get('/avatar/history', requireAuth, async (req, res) => {
try {
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex 271cfed5..2b80ae67 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -536,7 +536,7 @@ effectsRouter.post('/hardware', (req, res) => {
res.json({ ok: true });
});
--// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).
++// /paste from chat, through Live's pastes client (OpenVibe.Community).
effectsRouter.post('/paste', async (req, res) => {
const b = req.body || {};
try {
ddiff --git a/server/content/feed.js b/server/content/feed.js
iindex e8e47164..b5e86b12 100644
---- a/server/content/feed.js
++++ b/server/content/feed.js
@@@ -269,9 +269,8 @@ const SOURCES = {
},
};
--/** People's or AI pastes from OpenVibe.Community. Pastes are only listed once Community owns them. */
++/** People's or AI pastes from OpenVibe.Community. */
async function communityPastes(origin, offset, n, { sort, since }) {
-- if (!pastesClient.onCommunity()) { const e = new Error('pastes are listed from OpenVibe.Community only'); e.unavailable = true; throw e; }
const r = await pastesClient.request('GET', '', {
query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },
timeoutMs: SOURCE_TIMEOUT_MS,
ddiff --git a/server/index.js b/server/index.js
iindex 8da11a74..a3d0179a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -541,7 +541,7 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
--// Pastes on openvibe.community (PASTES_ON_COMMUNITY=1): /p/<slug> redirects there, and it is mounted
++// Pastes live on openvibe.community: /p/<slug> 301s there, and it is mounted
// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
require('./web/paste-handover').register(app);
try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
@@@ -643,15 +643,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
--// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
--// against THIS origin — bounce them to the Media public host, where the
--// canonical paste page lives.
++// Old relative paste URLs on THIS origin: screenshot bytes still live on the Media public host;
++// raw text is served by OpenVibe.Community, the paste's home.
app.get('/p/:slug/screenshot', (req, res) => {
res.set('Cache-Control', 'public, max-age=300');
res.redirect(302, `${mediaClient.MEDIA_PUBLIC_URL}/p/${encodeURIComponent(req.params.slug)}/screenshot`);
});
app.get('/p/:slug/raw', (req, res) => {
-- res.redirect(302, mediaClient.pasteRawUrl(req.params.slug));
++ res.redirect(302, `${require('./web/paste-handover').communityUrl()}/p/${encodeURIComponent(req.params.slug)}/raw`);
});
// Offline-screen assets (channel offline background: webp images + transcoded webm)
ddiff --git a/server/media-client.js b/server/media-client.js
iindex b648f1cc..02160509 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -271,43 +271,6 @@ function recutClip(clipId, opts = {}) {
return request('POST', `/clips/${clipId}/recut`, opts);
}
--// ── Pastes ───────────────────────────────────────────────────────────────────
--
--/**
-- * POST /pastes { title?, content?, language?, user_id?, visibility?, screenshot (multipart)? }
-- * → { id, slug, url }. Pass `screenshot` as Buffer or {buffer, filename, contentType}.
-- */
--function createPaste({ screenshot, ...fields } = {}, opts = {}) {
-- if (screenshot) {
-- const fd = _formData(fields, screenshot, 'screenshot');
-- return request('POST', '/pastes', { body: fd, timeoutMs: 60000, ...opts });
-- }
-- return request('POST', '/pastes', { body: fields, ...opts });
--}
--
--async function getPaste(slug, opts = {}) {
-- const out = await request('GET', `/pastes/${encodeURIComponent(slug)}`, opts);
-- return (out && out.paste) || out; // Media wraps single pastes as { paste }
--}
--
--function listPastes(query = {}, opts = {}) {
-- return request('GET', '/pastes', { query, ...opts });
--}
--
--/** Pastes awaiting AI analysis (app-key only on Media). */
--function listPastesNeedingAi(limit = 5, opts = {}) {
-- return request('GET', '/pastes', { query: { needs_ai: 1, limit }, ...opts });
--}
--
--/** Write AI results back to a paste. */
--function setPasteAi(slug, { ai_summary, ai_tags } = {}, opts = {}) {
-- return request('POST', `/pastes/${encodeURIComponent(slug)}/ai`, { body: { ai_summary, ai_tags }, ...opts });
--}
--
--function deletePaste(slug, opts = {}) {
-- return request('DELETE', `/pastes/${encodeURIComponent(slug)}`, opts);
--}
--
// ── Files ────────────────────────────────────────────────────────────────────
/** POST /files (multipart) → { key, url, size, mime } */
@@@ -451,8 +414,6 @@ module.exports = {
getVod, listVods, updateVod, deleteVod,
// clips
createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,
-- // pastes
-- createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,
// files + thumbnails
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
ddiff --git a/server/media-proxy/lookups.js b/server/media-proxy/lookups.js
iindex 039fb55e..9e8aaea5 100644
---- a/server/media-proxy/lookups.js
++++ b/server/media-proxy/lookups.js
@@@ -265,19 +265,14 @@ async function topContentRanges(user) {
};
}
--// ── Pastes (OpenVibe.Community; OpenVibe.Media while PASTES_AUTHORITY is unset) ──────────────
++// ── Pastes (OpenVibe.Community) ─────────────────────────────────────────────────────────────
/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */
async function _listUserPastes(user, { limit = 30, type, hidden = false }) {
-- let out;
-- if (pastesClient.onCommunity()) {
-- // Community lists one person's pastes by username. Hidden ones only for the owner (act as
-- // them) or staff (X-OV-Staff): Live says which, after its own check of the caller.
-- const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
-- out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
-- } else {
-- out = await media.listPastes({ user_id: user.id, include_unlisted: hidden ? 1 : undefined, type, limit }, { timeoutMs: TIMEOUT_MS });
-- }
++ // Community lists one person's pastes by username. Hidden ones only for the owner (act as them)
++ // or staff (X-OV-Staff): Live says which, after its own check of the caller.
++ const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
++ const out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
let pastes = rowsOf(out, 'pastes');
if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');
return { pastes, total: totalOf(out, pastes) };
@@@ -341,11 +336,10 @@ async function siteMediaStats() {
let _pasteStats = { at: 0, data: null };
/**
-- * Paste totals from the service that owns pastes: Community's counts under PASTES_AUTHORITY=community
-- * ({ pastes, pasteText, pasteImages }), else null (Media's /stats already carries them).
++ * Paste totals from OpenVibe.Community ({ pastes, pasteText, pasteImages }), or null while it is
++ * unreachable. Cached five minutes; retried 30 s after a failure.
*/
async function sitePasteStats() {
-- if (!pastesClient.onCommunity()) return null;
if (Date.now() - _pasteStats.at < SITE_STATS_TTL_MS) return _pasteStats.data;
try {
const out = await pastesClient.request('GET', '/admin/stats', { act: { staff: true }, timeoutMs: TIMEOUT_MS });
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex d115f4e6..5bdc36a5 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -1,44 +1,21 @@
/**
-- * OpenVibe.Live — /api/pastes proxy (OpenVibe.Media backed)
++ * OpenVibe.Live — /api/pastes proxy (OpenVibe.Community backed)
*
-- * The paste system (incl. like/comment/fork endpoints, preserved from the
-- * inherited code) lives in OpenVibe.Media under /api/v1/live/pastes/... .
-- * This router forwards the SPA's existing /api/pastes/* calls 1:1. Identity is resolved
-- * HERE — every forwarding route runs optionalAuth/requireAuth first, and media-client
-- * sends the resulting Live-local user id to Media. Forwarding the caller's Network JWT
-- * and letting Media read the identity out of it (what this used to do) filed writes
-- * under the Network's id for the account, which is a different number from the local
-- * one and belongs to an unrelated local user.
++ * 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.
*
-- * Live-local exceptions:
++ * Live-local exception:
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
-- * - screenshot upload → re-wrapped as multipart POST /pastes
*/
'use strict';
const express = require('express');
--const multer = require('multer');
const db = require('../db/database');
const { can } = require('../auth/permissions');
const media = require('../media-client');
const { requireAuth, optionalAuth, requireAdmin } = require('../auth/auth');
--// Admin/moderation endpoints on Media are app-key-only: authorize the caller
--// as a Live admin here, then forward WITHOUT the user token so the app key applies.
--function forwardAsApp(subPath) {
-- return (req, res) => {
-- const p = typeof subPath === 'function' ? subPath(req) : subPath;
-- media.proxy(req, res, `/pastes${p}`)
-- .catch((err) => {
-- console.warn('[Pastes proxy]', err.message);
-- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
-- });
-- };
--}
--
--const router = express.Router();
--const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 15 * 1024 * 1024 } });
--// Anonymous pastes and comments are allowed, but Media does not rate-limit app-key calls, so the
--// limit for anonymous writes lives here.
++// Anonymous pastes and comments are allowed; the limit for anonymous writes lives here.
// (A restore drill refuses every write, so it has no limiter and no limiter timer.)
const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next() : require('express-rate-limit')({
windowMs: 10 * 60 * 1000,
@@@ -49,216 +26,12 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
message: { error: 'Too many anonymous posts — sign in or try again later' },
});
--/**
-- * Every request reaches Media with Live's app key. Media treats an app-key call that names no acting
-- * user as the app itself — full authority: it trusts `user_id` in the body and query, lists private
-- * pastes, and moderates comments freely. A signed-in caller is pinned by X-OV-User-Id and gets
-- * normal ownership checks; an anonymous one would get app authority. So identity fields a browser
-- * sends are dropped here, always, and owner-view switches are dropped for anonymous callers.
-- */
--const IDENTITY_FIELDS = ['user_id', 'userId', 'author_id', 'owner_id'];
--const OWNER_VIEW_FIELDS = ['include_unlisted', 'include_private', 'mine'];
--function scrubIdentity(req) {
-- const anon = media.actingUserFrom(req) == null;
-- if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
-- for (const k of IDENTITY_FIELDS) delete req.body[k];
-- }
-- if (anon && req.query) for (const k of OWNER_VIEW_FIELDS) delete req.query[k];
-- return anon;
--}
--
--function forward(subPath) {
-- return (req, res) => {
-- scrubIdentity(req);
-- const p = typeof subPath === 'function' ? subPath(req) : subPath;
-- media.proxy(req, res, `/pastes${p}`, { actingUser: media.actingUserFrom(req) })
-- .catch((err) => {
-- console.warn('[Pastes proxy]', err.message);
-- if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
-- });
-- };
--}
--
--const slugPath = (suffix = '') => (req) => `/${encodeURIComponent(req.params.slug)}${suffix}`;
--
--// ── Screenshot upload (multipart re-wrap) ────────────────────
--router.post('/screenshot', optionalAuth, anonWriteLimiter, shotUpload.single('screenshot'), async (req, res) => {
-- try {
-- if (!req.file) return res.status(400).json({ error: 'No screenshot uploaded' });
-- const out = await media.createPaste({
-- title: req.body.title || 'Screenshot',
-- content: req.body.description || '',
-- language: 'text',
-- visibility: req.body.visibility || 'public',
-- user_id: req.user?.id || undefined,
-- stream_id: req.body.stream_id || undefined,
-- metadata: req.body.metadata || undefined,
-- screenshot: {
-- buffer: req.file.buffer,
-- filename: req.file.originalname || 'screenshot.png',
-- contentType: req.file.mimetype || 'image/png',
-- },
-- }, { actingUser: media.actingUserFrom(req) });
-- const paste = out?.paste || out; // Media returns { id, slug, url, paste }
-- res.status(201).json({ paste, url: out?.url || (paste?.slug ? `/p/${paste.slug}` : null) });
-- } catch (err) {
-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
-- console.warn('[Pastes proxy] screenshot:', err.message);
-- res.status(502).json({ error: 'Media service unavailable' });
-- }
--});
--
--// ── Set an image paste as my avatar (Live-local user record) ─
--router.post('/:slug/set-avatar', requireAuth, async (req, res) => {
-- try {
-- let paste;
-- try { paste = await media.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 && !paste.screenshot_path) {
-- return res.status(400).json({ error: 'That paste is not an image' });
-- }
-- const avatarUrl = media.publicUrl(paste.screenshot_url)
-- || (paste.screenshot_path ? media.screenshotUrl(require('path').basename(paste.screenshot_path)) : media.pasteRawUrl(paste.slug));
-- db.updateUserAvatar(req.user.id, avatarUrl, paste.id || null);
-- // The avatar belongs to the network account: every other OpenVibe site shows it too.
-- try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* picked up at the next sign-in */ }
-- res.json({ success: true, avatar_url: avatarUrl });
-- } catch (err) {
-- console.warn('[Pastes proxy] set-avatar:', err.message);
-- res.status(500).json({ error: 'Failed to set avatar' });
-- }
--});
--
--// ── Everything else: transparent passthrough ─────────────────
--// Media only stores our numeric user ids — resolve author names locally so
--// cards don't all read "Anonymous", and translate ?username= into user_id.
--function _nameUsers(rows) {
-- for (const p of rows || []) {
-- if (p && p.user_id != null && !p.username) {
-- const u = db.getUserById(p.user_id);
-- if (u) { p.username = u.username; p.display_name = u.display_name; p.profile_color = u.profile_color; p.avatar_url = u.avatar_url; }
-- }
-- }
-- return rows;
--}
--function forwardEnriched(subPath, pick) {
-- return async (req, res) => {
-- try {
-- const anon = scrubIdentity(req);
-- const query = { ...req.query };
-- if (query.username && query.username !== 'all') {
-- const u = db.getUserByUsername(query.username);
-- delete query.username;
-- if (!u) return res.json({ pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
-- query.user_id = u.id;
-- }
-- const p = typeof subPath === 'function' ? subPath(req) : subPath;
-- const out = await media.request('GET', `/pastes${p}`, { query, actingUser: media.actingUserFrom(req) });
-- // Media shows private pastes to the app itself, which is what an anonymous call looks like.
-- if (anon && out && out.paste && out.paste.visibility === 'private') return res.status(404).json({ error: 'Paste not found' });
-- if (anon && out && Array.isArray(out.pastes)) out.pastes = out.pastes.filter((x) => !x || x.visibility !== 'private');
-- _nameUsers(pick(out));
-- res.json(out);
-- } catch (err) {
-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
-- console.warn('[Pastes proxy]', err.message);
-- res.status(502).json({ error: 'Media service unavailable' });
-- }
-- };
--}
--router.get('/', optionalAuth, forwardEnriched('', (o) => o?.pastes));
--router.post('/', optionalAuth, anonWriteLimiter, forward(''));
--router.get('/config', forward('/config'));
--router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));
--router.delete('/admin/forks', requireAdmin, forwardAsApp('/admin/forks'));
--router.post('/bulk', requireAdmin, forwardAsApp('/bulk'));
--// Media doesn't know usernames — resolve locally, then list by user id.
--// Rows written before the id-space fix (when the SPA's Network JWT was forwarded to
--// Media, which then filed the write under the NETWORK id) still carry that id, so keep
--// asking for both and merge. New rows are always written with the local id.
--router.get('/by-user/:username', optionalAuth, async (req, res) => {
-- try {
-- const user = db.getUserByUsername(req.params.username);
-- if (!user) return res.status(404).json({ error: 'User not found' });
-- let networkId = null;
-- try {
-- const row = db.getDb().prepare("SELECT service_user_id FROM linked_accounts WHERE service = 'network' AND user_id = ?").get(user.id);
-- if (row && row.service_user_id != null) networkId = row.service_user_id;
-- } catch { /* */ }
-- const limit = Math.min(Math.max(parseInt(req.query.limit || '30', 10), 1), 100);
-- const sort = req.query.sort === 'oldest' ? 'oldest' : 'newest';
-- // Viewing your OWN paste list includes your unlisted/private ones — otherwise an
-- // unlisted paste is invisible everywhere and looks like it was never created.
-- // Anyone else viewing this user's page still sees public pastes only.
-- //
-- // Local ids only. req.user.id has always been the local one, so also accepting a
-- // match against the VIEWED user's network id just meant that whoever happened to
-- // hold that number as their local id — an unrelated person — was treated as the
-- // owner here, and Media trusts this gate rather than re-checking it.
-- const viewingSelf = !!(req.user && String(req.user.id) === String(user.id));
-- const mine = viewingSelf ? { include_unlisted: 1 } : {};
-- const lists = await Promise.all([
-- media.listPastes({ user_id: user.id, limit, sort, ...mine }).catch(() => null),
-- networkId != null && String(networkId) !== String(user.id)
-- ? media.listPastes({ user_id: networkId, limit, sort, ...mine }).catch(() => null)
-- : null,
-- ]);
-- const seen = new Set();
-- const pastes = lists.flatMap(l => l?.pastes || [])
-- .filter(p => !seen.has(p.slug) && seen.add(p.slug))
-- .sort((a, b) => (sort === 'oldest' ? 1 : -1) * (new Date(a.created_at) - new Date(b.created_at)))
-- .slice(0, limit);
-- res.json({ pastes: _nameUsers(pastes), total: pastes.length, username: user.username });
-- } catch (err) {
-- console.warn('[Pastes proxy] by-user:', err.message);
-- res.status(502).json({ error: 'Media service unavailable' });
-- }
--});
--router.get('/:slug', optionalAuth, forwardEnriched(slugPath(), (o) => (o?.paste ? [o.paste] : [o])));
--router.put('/:slug', requireAuth, forward(slugPath()));
--router.delete('/:slug', requireAuth, forward(slugPath()));
--// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)
--// have no Media API v1 endpoints yet — they 404 until Media grows them.
--// Censor replaces the screenshot: multer consumed the multipart body, so re-wrap
--// it as a fresh multipart upstream request (app-key auth — Media censor is admin-only).
--router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), async (req, res) => {
-- try {
-- if (!req.file) return res.status(400).json({ error: 'No replacement screenshot uploaded' });
-- const fd = media._formData({}, {
-- buffer: req.file.buffer,
-- filename: req.file.originalname || 'censored.png',
-- contentType: req.file.mimetype || 'image/png',
-- }, 'screenshot');
-- const out = await media.request('POST', `/pastes/${encodeURIComponent(req.params.slug)}/censor`, { body: fd, timeoutMs: 60000 });
-- res.json(out);
-- } catch (err) {
-- if (err && err.name === 'MediaApiError' && err.status) return res.status(err.status).json(err.body || { error: err.message });
-- console.warn('[Pastes proxy] censor:', err.message);
-- res.status(502).json({ error: 'Media service unavailable' });
-- }
--});
--router.post('/:slug/fork', optionalAuth, anonWriteLimiter, forward(slugPath('/fork')));
--// Raw content is public on Media (/p/:slug/raw) — bounce the API-shaped URL there.
--router.get('/:slug/raw', (req, res) => res.redirect(302, media.pasteRawUrl(req.params.slug)));
--router.post('/:slug/like', requireAuth, forward(slugPath('/like')));
--router.post('/:slug/copy', optionalAuth, forward(slugPath('/copy')));
--router.get('/:slug/comments', optionalAuth, forwardEnriched(slugPath('/comments'), (o) => o?.comments));
--// optionalAuth, not requireAuth: anonymous comments are allowed, and Media decides
--// whether they are enabled. What matters is that a signed-in commenter is named.
--router.post('/:slug/comments', optionalAuth, anonWriteLimiter, forward(slugPath('/comments')));
--// 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)}`));
--
--// ── 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) {
++/** `whenDown(req)`: the answer while Community is unreachable or failing (5xx), instead of a 502. */
++function toCommunity(subPath, whenDown = null) {
return async (req, res) => {
try {
const p = typeof subPath === 'function' ? subPath(req) : subPath;
@@@ -280,6 +53,7 @@ function toCommunity(subPath) {
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 (whenDown && upstream.status >= 500) return res.json(whenDown(req));
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']) {
@@@ -289,7 +63,10 @@ function toCommunity(subPath) {
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' });
++ if (!res.headersSent) {
++ if (whenDown) return res.json(whenDown(req));
++ res.status(502).json({ error: 'Paste service unavailable' });
++ }
}
};
}
@@@ -316,8 +93,13 @@ 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));
++// Editing, deleting, liking and removing a comment need a signed-in person: refused here, before Community.
++communityRouter.put('/:slug', requireAuth, toCommunity((req) => req.path));
++communityRouter.delete(['/:slug', '/:slug/comments/:commentId'], requireAuth, toCommunity((req) => req.path));
++communityRouter.post('/:slug/like', requireAuth, toCommunity((req) => req.path));
++// A channel's or dashboard's paste tab shows an empty list while Community is down, not an error.
++communityRouter.get('/by-user/:username', optionalAuth, toCommunity((req) => req.path, (req) => ({ pastes: [], total: 0, username: req.params.username })));
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;
module.exports.communityRouter = communityRouter;
ddiff --git a/server/pastes-client.js b/server/pastes-client.js
iindex 66d3e1c5..151ac78d 100644
---- a/server/pastes-client.js
++++ b/server/pastes-client.js
@@@ -2,20 +2,15 @@
/**
* 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.
++ * 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.
*/
--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) {
@@@ -83,9 +78,8 @@ async function request(method, path, { query, body, act = {}, ip, timeoutMs = 20
return out;
}
--/** media-client-compatible createPaste: user_id -> acting person; opts.origin 'ai' -> ownerless. */
++/** 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 };
@@@ -100,24 +94,20 @@ async function createPaste({ screenshot, user_id, ...fields } = {}, opts = {}) {
}
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 };
++module.exports = { request, createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, subjectForLiveUser, COMMUNITY_URL, CommunityApiError };
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex abb78ba5..3b0a7c01 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -57,7 +57,7 @@ const _feedPage = (feed, page = 1) => _cached(`feed:${feed}:${page}`, async () =
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
return {
items: (out.items || []).map((it) => ({
-- url: it.kind === 'paste' && PASTES_BASE ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
++ url: it.kind === 'paste' ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
desc: it.excerpt,
})),
@@@ -153,11 +153,12 @@ function _overlayAiState(row, kind) {
} catch { /* */ }
}
--// Pastes live on openvibe.community when PASTES_ON_COMMUNITY=1: /p/<slug> is a redirect there
--// (server/web/paste-handover.js, mounted before this middleware), the SPA router sends it there
--// too, and every paste link Live renders points straight at Community, the paste's one canonical
--// page. Live's sitemap never lists /p/ (roadmap 32.2).
--const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
++// Pastes live on openvibe.community: /p/<slug> is a redirect there (server/web/paste-handover.js,
++// mounted before this middleware), and every paste link Live renders, here and in the SPA (which
++// reads the base from the ov-pastes-base meta on every shell), points straight at Community, the
++// paste's one canonical page. Live's sitemap never lists /p/ (roadmap 32.2).
++const PASTES_BASE = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
++const PASTES_META = `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">`;
const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
const SITE_NAME = 'OpenVibe.Live';
@@@ -887,7 +888,7 @@ function _headBlock(meta) {
const ogUrl = meta.ogUrlPath ? abs(meta.ogUrlPath) : canonical;
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const parts = [
-- PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',
++ PASTES_META,
`<title>${esc(meta.title)}</title>`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
@@@ -973,7 +974,7 @@ function shellHtml(urlPath, status) {
let html = _base();
if (!html) return null;
const p = String(urlPath || '/');
-- html = assets.renderRoute(html, p);
++ html = assets.renderRoute(html, p).replace(/<\/head>/i, `${PASTES_META}</head>`);
if (p === '/' && status !== 404) return html;
const headEnd = html.search(/<\/head>/i);
if (headEnd === -1) return html;
@@@ -1029,7 +1030,7 @@ async function middleware(req, res, next) {
if (!SEO_ROUTE_RE.test(p)) return next();
// A paste Community owns is never rendered here (the handover answers it first; this keeps it
// so if the mount order ever changes).
-- if (PASTES_BASE && p.startsWith('/p/')) return next();
++ if (p.startsWith('/p/')) return next();
// Every client gets the same page, whatever it sends as Accept: browsers, crawlers, curl and
// monitors. (It used to be skipped unless Accept named text/html, so a crawler or tool that sent
// */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
ddiff --git a/server/web/paste-handover.js b/server/web/paste-handover.js
iindex 3921e332..9e032161 100644
---- a/server/web/paste-handover.js
++++ b/server/web/paste-handover.js
@@@ -1,36 +1,23 @@
'use strict';
/**
-- * Pastes live on openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep working
-- * as redirects, so links in chat, search results and clipboards survive.
++ * Pastes live on openvibe.community: the old Live paste URLs keep working as a permanent redirect,
++ * so links in chat, search results and clipboards survive. Every paste link Live renders already
++ * points at Community, so this only answers old links.
*
* Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every
* HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
* canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
* each naming itself canonical, and a different answer for people and machines. Now every client
-- * gets the same answer and openvibe.community is the one canonical home (roadmap 32.2).
-- *
-- * Someone signed in here should arrive signed in there: they go through Community's silent sign-in
-- * (one quiet round trip; it skips itself when Community already has a valid session and falls back
-- * to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.
++ * gets the same 301 and openvibe.community is the one canonical home (roadmap 32.2).
*
* /pastes itself stays on Live: it is the Content feed with the Pastes filter (public/js/content-feed.js).
*/
--const onCommunity = () => process.env.PASTES_ON_COMMUNITY === '1';
const communityUrl = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
--function handOver(req, res, target) {
-- const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));
-- if (!signedIn) return res.redirect(301, `${communityUrl()}${target}`);
-- res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });
-- return res.redirect(302, `${communityUrl()}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
--}
--
--/** Mount the /p/:slug handover when pastes are on Community. Returns whether it was mounted. */
++/** Mount the /p/:slug redirect to Community. */
function register(app) {
-- if (!onCommunity()) return false;
-- app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
-- return true;
++ app.get('/p/:slug', (req, res) => res.redirect(301, `${communityUrl()}/p/${encodeURIComponent(req.params.slug)}`));
}
--module.exports = { register, onCommunity, communityUrl };
++module.exports = { register, communityUrl };
ddiff --git a/test/content-feed.test.js b/test/content-feed.test.js
iindex 82ff4643..17129f7a 100644
---- a/test/content-feed.test.js
++++ b/test/content-feed.test.js
@@@ -22,7 +22,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
--process.env.PASTES_AUTHORITY = 'community';
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
@@@ -315,17 +314,6 @@ server.on('listening', async () => {
assert.ok(none.json.next, 'the client may try again');
});
-- await check('pastes are listed only when Community owns them', async () => {
-- process.env.PASTES_AUTHORITY = '';
-- try {
-- const r = await get('/api/content/feed?limit=30');
-- assert.strictEqual(r.json.sources.pastes, 'unavailable');
-- assert.strictEqual(r.json.partial, false, 'not a failure to retry');
-- assert.ok(!r.json.items.some((i) => i.kind === 'paste'));
-- assert.strictEqual(communityCalls.length, 0);
-- } finally { process.env.PASTES_AUTHORITY = 'community'; }
-- });
--
await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {
communityMode = 'lying-total';
const r = await get('/api/content/feed?type=pastes&limit=5');
ddiff --git a/test/media-identity-space.test.js b/test/media-identity-space.test.js
iindex 9caded10..c766f00c 100644
---- a/test/media-identity-space.test.js
++++ b/test/media-identity-space.test.js
@@@ -64,28 +64,19 @@ assert.ok(/headers\['X-Forwarded-For'\] = req\.ip/.test(client),
"Media's per-IP comment cooldown and its stored address both need the real client IP");
console.log('OK D: proxied requests carry the client IP, not this process\'s loopback');
--// ── E: every forwarding route resolves identity first ────────────────────────────────
++// ── E: every paste route resolves identity before it reaches Community ───────────────
{
-- // A forward() route sends whatever actingUserFrom finds on req — so a route with no
-- // auth middleware silently posts as nobody.
-- const lines = pastes.split('\n').filter(l => /^router\.(get|post|put|delete)\(/.test(l.trim()));
-- const needsIdentity = lines.filter(l => /forward\(|forwardEnriched\(/.test(l) && !/forwardAsApp\(/.test(l));
-- assert.ok(needsIdentity.length >= 8, `expected the forwarding routes, found ${needsIdentity.length}`);
-- for (const line of needsIdentity) {
-- if (/'\/config'/.test(line)) continue; // static config, no caller identity
++ // toCommunity() names whoever optionalAuth/requireAuth resolved, so a route with no auth
++ // middleware silently forwards as nobody.
++ const lines = pastes.split('\n').filter(l => /^communityRouter\.(get|post|put|delete|all)\(/.test(l.trim()));
++ assert.ok(lines.length >= 6, `expected the forwarding routes, found ${lines.length}`);
++ for (const line of lines) {
++ if (/'\/:slug\/raw'/.test(line)) continue; // a plain redirect, no caller identity
assert.ok(/optionalAuth|requireAuth|requireAdmin/.test(line),
`route must resolve the caller before forwarding: ${line.trim()}`);
}
-- console.log(`OK E: all ${needsIdentity.length - 1} identity-bearing paste routes resolve the caller first`);
--}
--
--// ── F: the owner gate compares one id space ──────────────────────────────────────────
--{
-- const gate = pastes.slice(pastes.indexOf('const viewingSelf'), pastes.indexOf('const mine ='));
-- assert.ok(/String\(req\.user\.id\) === String\(user\.id\)/.test(gate), 'owner check must compare local ids');
-- assert.ok(!/networkId/.test(gate),
-- 'matching the viewed user\'s NETWORK id let whoever holds that number locally read their unlisted pastes');
-- console.log('OK F: unlisted-paste owner gate compares local ids only');
++ assert.ok(!/^router\./m.test(pastes) && !/mediaRouter/.test(pastes), 'the Media paste router is gone');
++ console.log(`OK E: all ${lines.length - 1} identity-bearing paste routes resolve the caller first`);
}
console.log('✅ media identity space test passed');
ddiff --git a/test/media-lookups.test.js b/test/media-lookups.test.js
iindex 1afa01fb..d9fdb42d 100644
---- a/test/media-lookups.test.js
++++ b/test/media-lookups.test.js
@@@ -76,7 +76,6 @@ media.request = async (method, p) => {
if (p === '/stats') return { vods: 11, clips: 22, pastes: 33, pasteImages: 3, pasteText: 30, durationSeconds: 7200, recent: { vods: { d: 1, w: 2, m: 3 } } };
throw new media.MediaApiError('unexpected', 500);
};
--media.listPastes = async (q = {}) => { asked.push({ kind: 'media-pastes', ...q }); return { pastes: [{ slug: 'm1', visibility: 'public' }, { slug: 'm2', visibility: 'private' }], total: 2 }; };
// ── OpenVibe.Community stand-in (pastes-client.request) ──
const pastesClient = require('../server/pastes-client');
@@@ -194,45 +193,33 @@ async function check(name, fn) {
});
await check('channel badges: pastes from Community, taken clips from Media; hidden ones for owner/staff only', async () => {
-- process.env.PASTES_AUTHORITY = 'community';
-- try {
-- const anon = await call('GET', '/api/streams/channel/alice');
-- assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));
-- assert.strictEqual(anon.json.pasteTotal, 2);
-- assert.strictEqual(anon.json.clipsTakenTotal, 1);
-- lookups._resetCaches();
-- const owner = await call('GET', '/api/streams/channel/alice', 3);
-- assert.strictEqual(owner.json.pasteTotal, 4);
-- assert.strictEqual(owner.json.clipsTakenTotal, 2);
-- assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');
-- lookups._resetCaches();
-- const staff = await call('GET', '/api/streams/channel/alice', 1);
-- assert.strictEqual(staff.json.pasteTotal, 4);
-- assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');
-- lookups._resetCaches();
-- communityCalls.length = 0;
-- await call('GET', '/api/streams/channel/alice', 7);
-- assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');
-- const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');
-- assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');
-- } finally { delete process.env.PASTES_AUTHORITY; }
-- });
--
-- await check('pastes while Media still holds them (PASTES_AUTHORITY unset): Media list, public filter', async () => {
-- const out = await lookups.userPastes(db.getUserById(3));
-- assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['m1']);
-- assert.ok(asked.some((q) => q.kind === 'media-pastes' && q.user_id === 3 && q.include_unlisted === undefined));
++ const anon = await call('GET', '/api/streams/channel/alice');
++ assert.strictEqual(anon.status, 200, anon.text.slice(0, 200));
++ assert.strictEqual(anon.json.pasteTotal, 2);
++ assert.strictEqual(anon.json.clipsTakenTotal, 1);
++ lookups._resetCaches();
++ const owner = await call('GET', '/api/streams/channel/alice', 3);
++ assert.strictEqual(owner.json.pasteTotal, 4);
++ assert.strictEqual(owner.json.clipsTakenTotal, 2);
++ assert.ok(communityCalls.some((c) => c.act.liveUserId === 3 && c.query.include_unlisted === 1), 'the owner is asked as themselves');
++ lookups._resetCaches();
++ const staff = await call('GET', '/api/streams/channel/alice', 1);
++ assert.strictEqual(staff.json.pasteTotal, 4);
++ assert.ok(communityCalls.some((c) => c.act.staff && c.query.include_unlisted === 1), 'staff are vouched for with X-OV-Staff');
++ lookups._resetCaches();
++ communityCalls.length = 0;
++ await call('GET', '/api/streams/channel/alice', 7);
++ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && !c.act.liveUserId), 'anyone else: public only');
++ const poll = await call('GET', '/api/streams/channel/alice?pollOnly=1');
++ assert.strictEqual(poll.json.pasteTotal, 0, 'the status poll asks nobody');
});
await check('setup hub: the paste task counts the person\'s own pastes', async () => {
-- process.env.PASTES_AUTHORITY = 'community';
-- try {
-- const r = await call('GET', '/api/streams/setup-progress', 3);
-- assert.strictEqual(r.status, 200);
-- const task = r.json.tasks.find((t) => t.id === 'paste');
-- assert.strictEqual(task.count, 4);
-- assert.strictEqual(task.done, true);
-- } finally { delete process.env.PASTES_AUTHORITY; }
++ const r = await call('GET', '/api/streams/setup-progress', 3);
++ assert.strictEqual(r.status, 200);
++ const task = r.json.tasks.find((t) => t.id === 'paste');
++ assert.strictEqual(task.count, 4);
++ assert.strictEqual(task.done, true);
});
await check('setup hub: the emote count and moderator count come from OpenVibe.Chat', async () => {
@@@ -255,14 +242,11 @@ async function check(name, fn) {
});
await check('avatar history: the avatar-tagged screenshots, from Community', async () => {
-- process.env.PASTES_AUTHORITY = 'community';
-- try {
-- raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();
-- const r = await call('GET', '/api/auth/avatar/history', 3);
-- assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);
-- assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));
-- } finally { delete process.env.PASTES_AUTHORITY; }
++ raw.prepare("UPDATE users SET avatar_url = 'https://media.test/f/a.png' WHERE id = 3").run();
++ const r = await call('GET', '/api/auth/avatar/history', 3);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json.avatars.map((a) => [a.slug, a.url, a.active]), [['ava', 'https://media.test/f/a.png', true]]);
++ assert.ok(communityCalls.every((c) => c.query.type === 'screenshot' && c.act.liveUserId === 3));
});
await check('offline-screen ranges: most-viewed public VOD and clip per window', async () => {
@@@ -295,36 +279,30 @@ async function check(name, fn) {
});
await check('admin: VOD counts from Media; AI explorer pastes from Community and VODs/clips from Media', async () => {
-- process.env.PASTES_AUTHORITY = 'community';
-- try {
-- raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));
-- const st = await call('GET', '/api/admin/stats', 1);
-- assert.strictEqual(st.status, 200, st.text.slice(0, 200));
-- assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });
-- const ex = await call('GET', '/api/admin/ai/explorer/3', 1);
-- assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));
-- assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');
-- const v11 = ex.json.vods.find((v) => v.id === 11);
-- assert.strictEqual(v11.ai_overview, 'private overview');
-- assert.strictEqual(v11.ai_transcript, 'hello there');
-- assert.strictEqual(ex.json.counts.clips, 3);
-- assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');
-- } finally { delete process.env.PASTES_AUTHORITY; }
++ raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_overview, ai_transcript_json) VALUES (11, ?, ?)').run('private overview', JSON.stringify([{ start: 0, text: 'hello' }, { start: 2, text: 'there' }]));
++ const st = await call('GET', '/api/admin/stats', 1);
++ assert.strictEqual(st.status, 200, st.text.slice(0, 200));
++ assert.deepStrictEqual(st.json.vods, { total: 4, public: 2 });
++ const ex = await call('GET', '/api/admin/ai/explorer/3', 1);
++ assert.strictEqual(ex.status, 200, ex.text.slice(0, 200));
++ assert.deepStrictEqual(ex.json.pastes.map((p) => p.slug), ['pub', 'hid', 'ava', 'shot'], 'staff see every visibility');
++ const v11 = ex.json.vods.find((v) => v.id === 11);
++ assert.strictEqual(v11.ai_overview, 'private overview');
++ assert.strictEqual(v11.ai_transcript, 'hello there');
++ assert.strictEqual(ex.json.counts.clips, 3);
++ assert.strictEqual((await call('GET', '/api/admin/stats', 7)).status, 403, 'still admin-only');
});
await check('home stats: Live counts none of the archive itself; Media and Community fill it in', async () => {
const local = db._computeHomeStats();
for (const k of ['vods', 'clips', 'pastes', 'pasteImages', 'pasteText', 'streamHours']) assert.strictEqual(local[k], null, k);
assert.strictEqual(db.getHomeStatSeries('vods'), null, 'the vods series is Media\'s');
-- process.env.PASTES_AUTHORITY = 'community';
-- try {
-- const s = await lookups.withArchiveStats({ ...local });
-- assert.strictEqual(s.vods, 11);
-- assert.strictEqual(s.streamHours, 2);
-- assert.strictEqual(s.pastes, 44, 'Community owns the paste count');
-- assert.strictEqual(s.pasteImages, 4);
-- assert.strictEqual(s.recent.vods.w, 2);
-- } finally { delete process.env.PASTES_AUTHORITY; }
++ const s = await lookups.withArchiveStats({ ...local });
++ assert.strictEqual(s.vods, 11);
++ assert.strictEqual(s.streamHours, 2);
++ assert.strictEqual(s.pastes, 44, 'Community owns the paste count');
++ assert.strictEqual(s.pasteImages, 4);
++ assert.strictEqual(s.recent.vods.w, 2);
});
await check('streamer overview job: memories are the signal', async () => {
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex 57162504..f2d59a4f 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -22,7 +22,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
--delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
@@@ -77,7 +76,6 @@ media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Num
media.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
media.listVods = async () => ({ vods: [] });
media.listClips = async () => ({ clips: [] });
--media.listPastes = async () => ({ pastes: [] });
media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
const pastesClient = require('../server/pastes-client');
pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };
ddiff --git a/test/paste-canonical.test.js b/test/paste-canonical.test.js
iindex cbb8226b..1b098f15 100644
---- a/test/paste-canonical.test.js
++++ b/test/paste-canonical.test.js
@@@ -1,14 +1,15 @@
/**
-- * One canonical page per paste (roadmap 32.2): with PASTES_ON_COMMUNITY=1, /p/<slug> on Live is a
-- * redirect to openvibe.community for every client, crawlers and browsers alike.
++ * One canonical page per paste (roadmap 32.2): /p/<slug> on Live is a permanent redirect to
++ * openvibe.community for every client, crawlers, browsers and signed-in visitors alike.
*
* The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
* got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
* the 301: two self-canonical pages and a different answer for people and machines. Checked here:
* - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;
-- * - /p/<slug> answers 301 to Community whatever the Accept header, and a signed-in visitor goes
-- * through Community's silent sign-in (302);
-- * - Live's sitemap lists no /p/ URL, and the paste links Live renders point at Community.
++ * - /p/<slug> answers 301 to Community whatever the Accept header or cookie, and /p/<slug>/raw
++ * 302s to Community's raw text (not Media's);
++ * - Live's sitemap lists no /p/ URL, and the paste links Live renders, server-side and in the
++ * SPA, are absolute Community URLs (no relative /p/ href left to bounce through Live).
*
* node test/paste-canonical.test.js
*/
@@@ -22,7 +23,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-paste-canonical-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
--process.env.PASTES_ON_COMMUNITY = '1';
process.env.OV_COMMUNITY_URL = 'https://openvibe.community';
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@@ -90,10 +90,18 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.strictEqual(pasteReads, 0, 'Live never rendered its own copy');
});
-- await check('a signed-in visitor goes through Community\'s silent sign-in', async () => {
++ await check('a signed-in visitor gets the same plain 301 (no silent sign-in hop)', async () => {
const r = await get('/p/abc123', { accept: 'text/html', cookie: 'ov_sso_hint=account' });
-- assert.strictEqual(r.status, 302);
-- assert.strictEqual(r.location, 'https://openvibe.community/auth/login?silent=1&next=%2Fp%2Fabc123');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.location, 'https://openvibe.community/p/abc123');
++ });
++
++ await check('server/index.js sends /p/<slug>/raw to Community, screenshots to Media', () => {
++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
++ const raw = src.slice(src.indexOf("app.get('/p/:slug/raw'"), src.indexOf("app.get('/p/:slug/raw'") + 300);
++ assert.ok(/paste-handover'\)\.communityUrl\(\)\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/raw/.test(raw), raw);
++ assert.ok(!/pasteRawUrl/.test(raw), 'raw no longer goes through Media');
++ assert.ok(/MEDIA_PUBLIC_URL\}\/p\/\$\{encodeURIComponent\(req\.params\.slug\)\}\/screenshot/.test(src), 'screenshot bytes still on Media');
});
await check('Live\'s sitemap lists no paste', async () => {
@@@ -108,6 +116,21 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(!m.snapshot.includes('https://openvibe.live/p/'), 'no Live paste link');
});
++ await check('the SPA renders absolute Community paste hrefs from the shell\'s ov-pastes-base', async () => {
++ for (const f of ['pastes.js', 'app-home.js', 'app-channel.js']) {
++ const src = fs.readFileSync(path.join(__dirname, '../public/js', f), 'utf8');
++ assert.ok(!/href="\/p\//.test(src), `${f} renders a relative /p/ href`);
++ assert.ok(/href="\$\{(?:esc|escapeHtml)\(pasteHref\(p\.slug\)\)\}"/.test(src), `${f} links through pasteHref()`);
++ }
++ const app = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
++ assert.ok(/function pasteHref\(slug\)[\s\S]{0,200}meta\[name="ov-pastes-base"\]/.test(app), 'pasteHref reads the shell meta');
++ const meta = '<meta name="ov-pastes-base" content="https://openvibe.community">';
++ assert.ok(seo.render(await seo._pageMeta('/pastes'), '/pastes').includes(meta), 'SEO pages name the Community base');
++ for (const [p, status] of [['/dashboard', 200], ['/no-such-page', 404]]) {
++ assert.ok(String(seo.shellHtml(p, status)).includes(meta), `${p} shell names the Community base`);
++ }
++ });
++
server.close();
try { fs.unlinkSync(tmp); } catch { /* */ }
for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
ddiff --git a/test/pastes-community.test.js b/test/pastes-community.test.js
iindex 4df532ea..520523d6 100644
---- a/test/pastes-community.test.js
++++ b/test/pastes-community.test.js
@@@ -1,6 +1,6 @@
'use strict';
--// PASTES_AUTHORITY=community (roadmap Wave 5): Live writes and reads pastes through OpenVibe.Community with
++// Community is the only paste authority (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.
@@@ -18,7 +18,6 @@ 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';
@@@ -51,6 +50,7 @@ const community = http.createServer((req, res) => {
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.url.startsWith('/api/pastes/by-user/failing')) { res.statusCode = 503; return res.end('{}'); }
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 }));
});
@@@ -120,9 +120,27 @@ const community = http.createServer((req, res) => {
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);
++ // Writes that need a person are refused here, before Community.
++ const before = seen.length;
++ for (const [method, p] of [['PUT', '/some-slug'], ['DELETE', '/some-slug'], ['POST', '/some-slug/like'], ['DELETE', '/some-slug/comments/1']]) {
++ r = await fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json' }, body: method === 'DELETE' ? undefined : '{}' });
++ assert.strictEqual(r.status, 401, `anonymous ${method} ${p}`);
++ }
++ assert.strictEqual(seen.length, before, 'none of them reached Community');
++ // A person's paste list is forwarded, and reads as empty (not an error) while Community fails.
++ r = await fetch(`${base}/by-user/ann?limit=30`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(seen.pop().url, '/api/pastes/by-user/ann?limit=30');
++ r = await fetch(`${base}/by-user/failing?limit=30`);
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(await r.json(), { pastes: [], total: 0, username: 'failing' });
++
++ // Community is the only mode: no switch, no Media fallback, no Media router.
++ assert.strictEqual(client.onCommunity, undefined, 'the paste-authority switch is gone');
++ assert.strictEqual(require('../server/media-proxy/pastes'), require('../server/media-proxy/pastes').communityRouter, '/api/pastes is the Community router');
++ assert.strictEqual(require('../server/media-proxy/pastes').mediaRouter, undefined, 'the Media router is gone');
++ const clientSrc = fs.readFileSync(path.join(__dirname, '../server/pastes-client.js'), 'utf8');
++ assert.ok(!/media-client/.test(clientSrc) && !clientSrc.includes('PASTES_' + 'AUTHORITY'), 'the pastes client never falls back to Media');
srv.close(); net.close(); community.close();
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/seo-moments.test.js b/test/seo-moments.test.js
iindex 5c7211e7..0b7c6e3d 100644
---- a/test/seo-moments.test.js
++++ b/test/seo-moments.test.js
@@@ -22,7 +22,6 @@ const path = require('path');
const tmp = path.join(os.tmpdir(), `ov-seo-moments-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
--delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 579742f0..b4ec79e9 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -28,7 +28,6 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-seo-ssr-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
--delete process.env.PASTES_ON_COMMUNITY;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};