openvibers/openvibe.live Commits

Pastes: name the commenter correctly, and stop losing stylesheets

OP OpenVibers committed 20 Aug 2026 · f92d4af5fb · 6 files, +191 −50

Two unrelated reports, both from the same session.

The site sometimes rendered as raw unstyled HTML until a refresh. nginx caps
concurrent connections per address at 30, and this app holds five or six
long-lived WebSockets open per tab — chat, control, SFU viewer, PiP camera,
notifications. A viewer with a few tabs open sits at the ceiling on its own, and
every new request then got a bare 503: whichever assets happened to be in flight
were lost, usually a stylesheet. Shared addresses reach it far sooner. Static
files now draw on their own budget so a page can always finish loading, and the
ceiling for everything else is one ordinary traffic cannot reach.

A comment from Maticus appeared under fakefitz's name. Live forwarded the
caller's Network JWT to Media and let Media read the identity out of it — but
its "sub" claim is the NETWORK's id for the account (57), while every user_id Media
stores for us is a LIVE-LOCAL id, and local 57 is a different person. Live now
resolves the caller itself and names them by local id, which also settles who
may open a private paste or VOD. Same reason the unlisted-paste owner check no
longer accepts a match against the viewed user's network id: that meant whoever
held that number locally was treated as the owner, and Media trusts this gate.

Media's per-IP comment cooldown was also collapsing into a single site-wide
bucket, because proxied requests reached it as 127.0.0.1.

Needs OpenVibe.Media 667e59a, which adds the X-OV-User-Id it now sends.

Co-Authored-By: Claude Opus 5 <[email protected]>

Changes

ddiff --git a/deploy/nginx/openvibe.live.conf b/deploy/nginx/openvibe.live.conf
iindex fcfe2ec9..10760984 100644
---- a/deploy/nginx/openvibe.live.conf
++++ b/deploy/nginx/openvibe.live.conf
@@@ -1,17 +1,23 @@
## ═══════════════════════════════════════════════════════════════
## openvibe.live — Nginx Configuration (REFERENCE)
##
--## NOTE: This file is now a REFERENCE template.
--## Production configs are generated and applied automatically
--## via the openvibe.tools admin panel (Deploy → Nginx).
++## This file is what is actually deployed to /etc/nginx/sites-available/openvibe.live.conf
++## — keep them in sync, and reload nginx after changing it.
##
--## See OpenVibeApp/openvibe-tools/server/deploy/nginx-generator.js
--## for the config generation system.
++## There is also a generator (openvibe.network server/deploy/nginx-generator.js, driven
++## from the admin panel's Deploy → Nginx). Its entry for this site has drifted: it knows
++## nothing about whip.openvibe.live or the connection limits below, so regenerating from
++## it would silently drop them. Update the generator too before using that path.
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
++# Two separate per-IP connection budgets. They must not share one counter: the app
++# holds a lot of long-lived WebSockets open (chat, control, SFU viewer, PiP camera,
++# notifications, messenger — 5-6 per open tab), and those sockets would otherwise eat
++# the budget that a page load needs for its stylesheets and scripts.
limit_conn_zone $binary_remote_addr zone=addr_limit:10m;
++limit_conn_zone $binary_remote_addr zone=asset_limit:10m;
server {
listen 80;
@@@ -32,7 +38,15 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 3m;
-- limit_conn addr_limit 30;
++
++ # 30 was far too low. A single viewer with a few tabs open holds ~30 WebSockets on
++ # its own, and once an address hit the ceiling every NEW request from it was answered
++ # with a bare 503 — including stylesheets, which is why the site would occasionally
++ # render as raw unstyled HTML until a refresh happened to find room. Shared addresses
++ # (a household, a campus, a mobile carrier) reach it far sooner. Abuse is already
++ # handled by limit_req on /api/; this stays only as a slowloris backstop, at a
++ # ceiling ordinary traffic cannot reach.
++ limit_conn addr_limit 256;
location = /api/auth/register {
limit_req zone=login_limit burst=5 nodelay;
@@@ -96,6 +110,22 @@ server {
proxy_set_header Connection "upgrade";
}
++ # Static files get their own budget, so a page can always finish loading its CSS and
++ # JS no matter how many sockets the same address is already holding open. Matched by
++ # extension rather than by directory because render-blocking scripts are served from
++ # the web root too (/live-notify.js), not only from /js/. The lookahead keeps /api/
++ # and /ws/ out: a regex location would otherwise win over their prefix locations and
++ # quietly strip their rate limiting and WebSocket upgrade headers.
++ location ~ ^/(?!api/|ws/).*\.(?:css|js|mjs|map|woff2?|ttf|otf|svg|png|jpe?g|gif|ico|webp|mp3|wav)$ {
++ limit_conn asset_limit 100;
++ proxy_pass http://127.0.0.1:3000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
ddiff --git a/server/media-client.js b/server/media-client.js
iindex b4f570df..5b929764 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -5,9 +5,10 @@
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* lives in Media now; Live talks to it via this module.
*
-- * Auth: `Authorization: Bearer <MEDIA_API_KEY>` (per-app server key) by default.
-- * When a request is made on behalf of a browser user, pass `userToken` (their
-- * Network JWT) instead — Media verifies it offline and applies user-level ACLs.
++ * Auth: `Authorization: Bearer <MEDIA_API_KEY>` (per-app server key). For a request
++ * made on behalf of a browser user, add `actingUser` (their LIVE-LOCAL user id) — Media
++ * then applies that user's ACLs and stores their id. See _authHeader for why the id is
++ * sent explicitly rather than left for Media to read out of the caller's Network JWT.
*
* Env:
* MEDIA_URL internal base URL (default http://127.0.0.1:4100)
@@@ -33,9 +34,21 @@ class MediaApiError extends Error {
}
}
++/**
++ * Authenticate to Media as this app, optionally acting as one of our users.
++ *
++ * We used to forward the caller's Network JWT instead, and let Media read the identity
++ * out of it. That was wrong: the JWT's `sub` is the NETWORK's id for the account, while
++ * every user_id Media stores for us is a LIVE-LOCAL id. The two spaces overlap by
++ * coincidence, so a comment posted by Maticus (local 80, network 57) was filed under
++ * user 57 and rendered as fakefitz — and the same mismatch decided who could open a
++ * private paste or VOD. Sending the local id explicitly keeps one id space end to end.
++ */
function _authHeader(opts = {}) {
-- const token = opts.userToken || MEDIA_API_KEY;
-- return token ? { Authorization: `Bearer ${token}` } : {};
++ if (!MEDIA_API_KEY) return {};
++ const h = { Authorization: `Bearer ${MEDIA_API_KEY}` };
++ if (opts.actingUser != null) h['X-OV-User-Id'] = String(opts.actingUser);
++ return h;
}
function _qs(query) {
@@@ -53,11 +66,11 @@ function _qs(query) {
* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
*/
--async function request(method, apiPath, { body, query, userToken, headers = {}, timeoutMs = 30000 } = {}) {
++async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000 } = {}) {
const url = `${API_BASE}${apiPath}${_qs(query)}`;
const opts = {
method,
-- headers: { Accept: 'application/json', ..._authHeader({ userToken }), ...headers },
++ headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
};
if (body !== undefined && body !== null) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
@@@ -287,13 +300,18 @@ function publicUrl(u) {
/**
* Stream a request through to Media and pipe the response back. Preserves method,
* query string, JSON body and content-type. Used by the thin /api/* proxy routers.
-- * `userToken` (Network JWT) is forwarded when present so Media applies user ACLs;
-- * otherwise the app key is used.
++ * Pass `actingUser` (a Live-local user id) to have Media apply that user's ACLs;
++ * omit it for an anonymous call.
*/
--async function proxy(req, res, apiPath, { userToken, method, query, body } = {}) {
++async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}) {
const m = method || req.method;
const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;
-- const headers = { Accept: 'application/json', ..._authHeader({ userToken }) };
++ const headers = { Accept: 'application/json', ..._authHeader({ actingUser }) };
++ // Media applies per-IP comment cooldowns and stores the address for moderation, and
++ // it trusts X-Forwarded-For. Without this every browser request arrived as 127.0.0.1
++ // — one shared cooldown bucket for the whole site, and an address column that
++ // recorded this proxy instead of the commenter.
++ if (req.ip) headers['X-Forwarded-For'] = req.ip;
const opts = { method: m, headers };
if (!['GET', 'HEAD'].includes(m)) {
const ct = req.headers['content-type'] || '';
@@@ -326,21 +344,22 @@ async function proxy(req, res, apiPath, { userToken, method, query, body } = {})
Readable.fromWeb(upstream.body).pipe(res);
}
--/** Extract the caller's Network JWT (for user-context forwarding to Media). */
--function userTokenFrom(req) {
-- try {
-- const { extractToken } = require('./auth/auth');
-- const tok = extractToken(req);
-- // Only forward real JWTs — hbt_ API tokens are Live-local.
-- if (tok && !tok.startsWith('hbt_')) return tok;
-- } catch { /* */ }
-- return null;
++/**
++ * The Live-local user id to act as for this request, or null for an anonymous call.
++ *
++ * Requires the route to have run requireAuth/optionalAuth first — that is what turns a
++ * Network JWT or an hbt_ API token into a local account. Both kinds of caller land in
++ * the same id space here, which the raw-token forwarding this replaced could not do.
++ */
++function actingUserFrom(req) {
++ const id = req?.user?.id;
++ return Number.isInteger(id) && id > 0 ? id : null;
}
module.exports = {
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
-- request, proxy, userTokenFrom, _formData,
++ request, proxy, actingUserFrom, _formData,
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, completeVodChunks, finalizeVod,
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 13b86219..9a856423 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -175,7 +175,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });
let clip;
-- try { clip = await media.getClip(req.params.id, { userToken: media.userTokenFrom(req) }); }
++ try { clip = await media.getClip(req.params.id, { actingUser: media.actingUserFrom(req) }); }
catch (err) { return mediaErr(res, err, 'Failed to get clip'); }
if (!clip) return res.status(404).json({ error: 'Clip not found' });
withUserFields(clip);
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex 58b91025..0478d3c6 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -3,8 +3,12 @@
*
* 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, passing the
-- * caller's Network JWT through when present so Media applies user-level ACLs.
++ * 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.
*
* Live-local exceptions:
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
@@@ -36,7 +40,7 @@ const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize:
function forward(subPath) {
return (req, res) => {
const p = typeof subPath === 'function' ? subPath(req) : subPath;
-- media.proxy(req, res, `/pastes${p}`, { userToken: media.userTokenFrom(req) })
++ 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' });
@@@ -63,7 +67,7 @@ router.post('/screenshot', optionalAuth, shotUpload.single('screenshot'), async
filename: req.file.originalname || 'screenshot.png',
contentType: req.file.mimetype || 'image/png',
},
-- }, { userToken: media.userTokenFrom(req) });
++ }, { 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) {
@@@ -115,7 +119,7 @@ function forwardEnriched(subPath, pick) {
query.user_id = u.id;
}
const p = typeof subPath === 'function' ? subPath(req) : subPath;
-- const out = await media.request('GET', `/pastes${p}`, { query, userToken: media.userTokenFrom(req) });
++ const out = await media.request('GET', `/pastes${p}`, { query, actingUser: media.actingUserFrom(req) });
_nameUsers(pick(out));
res.json(out);
} catch (err) {
@@@ -125,15 +129,16 @@ function forwardEnriched(subPath, pick) {
}
};
}
--router.get('/', forwardEnriched('', (o) => o?.pastes));
--router.post('/', forward(''));
++router.get('/', optionalAuth, forwardEnriched('', (o) => o?.pastes));
++router.post('/', optionalAuth, 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.
--// Browser-JWT-created pastes carry the NETWORK id while migrated/app-key rows
--// carry the LOCAL id, so query both id spaces and merge.
++// 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);
@@@ -147,12 +152,13 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
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. Media
-- // re-checks this, so the flag alone cannot leak another user's pastes.
-- const viewingSelf = !!(req.user && (
-- String(req.user.id) === String(user.id) ||
-- (networkId != null && String(req.user.id) === String(networkId))
-- ));
++ // 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),
@@@ -171,7 +177,7 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
res.status(502).json({ error: 'Media service unavailable' });
}
});
--router.get('/:slug', forwardEnriched(slugPath(), (o) => (o?.paste ? [o.paste] : [o])));
++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)
@@@ -194,13 +200,15 @@ router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), asyn
res.status(502).json({ error: 'Media service unavailable' });
}
});
--router.post('/:slug/fork', forward(slugPath('/fork')));
++router.post('/:slug/fork', optionalAuth, 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', forward(slugPath('/copy')));
--router.get('/:slug/comments', forwardEnriched(slugPath('/comments'), (o) => o?.comments));
--router.post('/:slug/comments', forward(slugPath('/comments')));
--router.delete('/:slug/comments/:commentId', forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
++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, forward(slugPath('/comments')));
++router.delete('/:slug/comments/:commentId', optionalAuth, forward((req) => `/${encodeURIComponent(req.params.slug)}/comments/${encodeURIComponent(req.params.commentId)}`));
module.exports = router;
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex a2b0fae2..a9e06571 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -338,7 +338,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
let vod;
-- try { vod = await media.getVod(req.params.id, { userToken: media.userTokenFrom(req) }); }
++ try { vod = await media.getVod(req.params.id, { actingUser: media.actingUserFrom(req) }); }
catch (err) { return mediaErr(res, err, 'Failed to get VOD'); }
if (!vod) return res.status(404).json({ error: 'VOD not found' });
withUserFields(vod);
ddiff --git a/test/media-identity-space.test.js b/test/media-identity-space.test.js
nnew file mode 100644
iindex 00000000..89b4bc82
---- /dev/null
++++ b/test/media-identity-space.test.js
@@@ -0,0 +1,84 @@
++/**
++ * Media stores OUR user ids, so every call must speak our id space.
++ *
++ * Live used to forward the caller's Network JWT to Media and let Media read the identity
++ * out of it. Media took `sub` — the NETWORK's id for that account — and wrote it into
++ * user_id columns that hold LIVE-LOCAL ids everywhere else. The two spaces are unrelated
++ * numbers over the same accounts, so a comment posted by Maticus (local 80, network 57)
++ * was stored as user 57 and rendered under fakefitz's name, and the same mismatch decided
++ * who could open a private paste or VOD.
++ *
++ * The fix authenticates as the app and names the acting user explicitly, so nothing
++ * downstream has to guess which space a number is in.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
++const client = read('server/media-client.js');
++const pastes = read('server/media-proxy/pastes.js');
++
++// ── A: the raw token is gone from every Media call site ──────────────────────────────
++for (const f of ['server/media-client.js', 'server/media-proxy/pastes.js',
++ 'server/media-proxy/vods.js', 'server/media-proxy/clips.js']) {
++ assert.ok(!/userTokenFrom/.test(read(f)), `${f} must not forward the caller's token to Media`);
++}
++console.log("OK A: no Media call site forwards the caller's Network JWT any more");
++
++// ── B: the acting user is a Live-local id, taken from the resolved account ───────────
++{
++ const media = require('../server/media-client');
++ assert.strictEqual(typeof media.actingUserFrom, 'function', 'actingUserFrom must be exported');
++ assert.strictEqual(media.userTokenFrom, undefined, 'the token-forwarding helper must be retired');
++ assert.strictEqual(media.actingUserFrom({ user: { id: 80 } }), 80, 'a signed-in caller acts as their local id');
++ assert.strictEqual(media.actingUserFrom({}), null, 'an anonymous caller acts as nobody');
++ assert.strictEqual(media.actingUserFrom({ user: {} }), null, 'a user record with no id acts as nobody');
++ // req.user is produced by requireAuth/optionalAuth, which resolve BOTH a Network JWT
++ // and an hbt_ API token to the same local account — that is what makes one space.
++ assert.strictEqual(media.actingUserFrom({ user: { id: 0 } }), null, 'id 0 is not an account');
++ assert.strictEqual(media.actingUserFrom({ user: { id: '80' } }), null,
++ 'only a real integer id may be sent — a string would mask a bad lookup');
++ console.log('OK B: actingUserFrom yields the resolved Live-local id, or nobody');
++}
++
++// ── C: identity travels beside the app key, never as the caller's credential ─────────
++assert.ok(/Authorization: `Bearer \$\{MEDIA_API_KEY\}`/.test(client),
++ 'Media calls must authenticate as this app');
++assert.ok(/h\['X-OV-User-Id'\] = String\(opts\.actingUser\)/.test(client),
++ 'the acting user must ride along as a header, not be inferred by Media');
++assert.ok(/if \(opts\.actingUser != null\)/.test(client),
++ 'an anonymous call must not claim to act as anyone');
++console.log('OK C: app key authenticates, X-OV-User-Id names the acting user');
++
++// ── D: the commenter's own address reaches Media ─────────────────────────────────────
++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 ────────────────────────────────
++{
++ // 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
++ 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');
++}
++
++console.log('✅ media identity space test passed');