openvibers/openvibe.live Commits
Pastes: name the commenter correctly, and stop losing stylesheets
Changes
@@@ -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;@@@ -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,// vodscreateVod, ingestRtmp, ingestRtpStart, ingestRtpStop,uploadVodChunk, completeVodChunks, finalizeVod,@@@ -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);@@@ -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'), asyncfilename: 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'), asynres.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;@@@ -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);nnew file mode 100644@@@ -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');
@@ -1,17 +1,23 @@
@@ -1,17 +1,23 @@
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
## openvibe.live — Nginx Configuration (REFERENCE)
## 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=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
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=addr_limit:10m;
+limit_conn_zone $binary_remote_addr zone=asset_limit:10m;
server {
server {
listen 80;
listen 80;
@@ -32,7 +38,15 @@ server {
@@ -32,7 +38,15 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 3m;
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 {
location = /api/auth/register {
limit_req zone=login_limit burst=5 nodelay;
limit_req zone=login_limit burst=5 nodelay;
@@ -96,6 +110,22 @@ server {
@@ -96,6 +110,22 @@ server {
proxy_set_header Connection "upgrade";
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 / {
location / {
proxy_pass http://127.0.0.1:3000;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_http_version 1.1;
@@ -5,9 +5,10 @@
@@ -5,9 +5,10 @@
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* "Media API v1"). All VOD / clip / paste / file / thumbnail storage and processing
* lives in Media now; Live talks to it via this module.
* 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:
* Env:
* MEDIA_URL internal base URL (default http://127.0.0.1:4100)
* MEDIA_URL internal base URL (default http://127.0.0.1:4100)
@@ -33,9 +34,21 @@ class MediaApiError extends Error {
@@ -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 = {}) {
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) {
function _qs(query) {
@@ -53,11 +66,11 @@ function _qs(query) {
@@ -53,11 +66,11 @@ function _qs(query) {
* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).
* 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.
* 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 url = `${API_BASE}${apiPath}${_qs(query)}`;
const opts = {
const opts = {
method,
method,
- headers: { Accept: 'application/json', ..._authHeader({ userToken }), ...headers },
+ headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
};
};
if (body !== undefined && body !== null) {
if (body !== undefined && body !== null) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
@@ -287,13 +300,18 @@ function publicUrl(u) {
@@ -287,13 +300,18 @@ function publicUrl(u) {
/**
/**
* Stream a request through to Media and pipe the response back. Preserves method,
* 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.
* 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 m = method || req.method;
const url = `${API_BASE}${apiPath}${_qs({ ...(req.query || {}), ...(query || {}) })}`;
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 };
const opts = { method: m, headers };
if (!['GET', 'HEAD'].includes(m)) {
if (!['GET', 'HEAD'].includes(m)) {
const ct = req.headers['content-type'] || '';
const ct = req.headers['content-type'] || '';
@@ -326,21 +344,22 @@ async function proxy(req, res, apiPath, { userToken, method, query, body } = {})
@@ -326,21 +344,22 @@ async function proxy(req, res, apiPath, { userToken, method, query, body } = {})
Readable.fromWeb(upstream.body).pipe(res);
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 = {
module.exports = {
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MEDIA_URL, MEDIA_PUBLIC_URL, MEDIA_APP_ID,
MediaApiError,
MediaApiError,
- request, proxy, userTokenFrom, _formData,
+ request, proxy, actingUserFrom, _formData,
// vods
// vods
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
createVod, ingestRtmp, ingestRtpStart, ingestRtpStop,
uploadVodChunk, completeVodChunks, finalizeVod,
uploadVodChunk, completeVodChunks, finalizeVod,
@@ -175,7 +175,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -175,7 +175,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });
let clip;
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'); }
catch (err) { return mediaErr(res, err, 'Failed to get clip'); }
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
withUserFields(clip);
withUserFields(clip);
@@ -3,8 +3,12 @@
@@ -3,8 +3,12 @@
*
*
* The paste system (incl. like/comment/fork endpoints, preserved from the
* The paste system (incl. like/comment/fork endpoints, preserved from the
* inherited code) lives in OpenVibe.Media under /api/v1/live/pastes/... .
* 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:
* Live-local exceptions:
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
* - POST /:slug/set-avatar → updates users.avatar_url in live.db
@@ -36,7 +40,7 @@ const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize:
@@ -36,7 +40,7 @@ const shotUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize:
function forward(subPath) {
function forward(subPath) {
return (req, res) => {
return (req, res) => {
const p = typeof subPath === 'function' ? subPath(req) : subPath;
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) => {
.catch((err) => {
console.warn('[Pastes proxy]', err.message);
console.warn('[Pastes proxy]', err.message);
if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
if (!res.headersSent) res.status(502).json({ error: 'Media service unavailable' });
@@ -63,7 +67,7 @@ router.post('/screenshot', optionalAuth, shotUpload.single('screenshot'), async
@@ -63,7 +67,7 @@ router.post('/screenshot', optionalAuth, shotUpload.single('screenshot'), async
filename: req.file.originalname || 'screenshot.png',
filename: req.file.originalname || 'screenshot.png',
contentType: req.file.mimetype || 'image/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 }
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) });
res.status(201).json({ paste, url: out?.url || (paste?.slug ? `/p/${paste.slug}` : null) });
} catch (err) {
} catch (err) {
@@ -115,7 +119,7 @@ function forwardEnriched(subPath, pick) {
@@ -115,7 +119,7 @@ function forwardEnriched(subPath, pick) {
query.user_id = u.id;
query.user_id = u.id;
}
}
const p = typeof subPath === 'function' ? subPath(req) : subPath;
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));
_nameUsers(pick(out));
res.json(out);
res.json(out);
} catch (err) {
} catch (err) {
@@ -125,15 +129,16 @@ function forwardEnriched(subPath, pick) {
@@ -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('/config', forward('/config'));
router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));
router.get('/admin/stats', requireAdmin, forwardAsApp('/admin/stats'));
router.delete('/admin/forks', requireAdmin, forwardAsApp('/admin/forks'));
router.delete('/admin/forks', requireAdmin, forwardAsApp('/admin/forks'));
router.post('/bulk', requireAdmin, forwardAsApp('/bulk'));
router.post('/bulk', requireAdmin, forwardAsApp('/bulk'));
// Media doesn't know usernames — resolve locally, then list by user id.
// 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) => {
router.get('/by-user/:username', optionalAuth, async (req, res) => {
try {
try {
const user = db.getUserByUsername(req.params.username);
const user = db.getUserByUsername(req.params.username);
@@ -147,12 +152,13 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
@@ -147,12 +152,13 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
const sort = req.query.sort === 'oldest' ? 'oldest' : 'newest';
const sort = req.query.sort === 'oldest' ? 'oldest' : 'newest';
// Viewing your OWN paste list includes your unlisted/private ones — otherwise an
// Viewing your OWN paste list includes your unlisted/private ones — otherwise an
// unlisted paste is invisible everywhere and looks like it was never created.
// 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 mine = viewingSelf ? { include_unlisted: 1 } : {};
const lists = await Promise.all([
const lists = await Promise.all([
media.listPastes({ user_id: user.id, limit, sort, ...mine }).catch(() => null),
media.listPastes({ user_id: user.id, limit, sort, ...mine }).catch(() => null),
@@ -171,7 +177,7 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
@@ -171,7 +177,7 @@ router.get('/by-user/:username', optionalAuth, async (req, res) => {
res.status(502).json({ error: 'Media service unavailable' });
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.put('/:slug', requireAuth, forward(slugPath()));
router.delete('/:slug', requireAuth, forward(slugPath()));
router.delete('/:slug', requireAuth, forward(slugPath()));
// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)
// TODO(contract): paste admin tools (censor, admin/stats, admin/forks, bulk)
@@ -194,13 +200,15 @@ router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), asyn
@@ -194,13 +200,15 @@ router.post('/:slug/censor', requireAdmin, shotUpload.single('screenshot'), asyn
res.status(502).json({ error: 'Media service unavailable' });
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.
// 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.get('/:slug/raw', (req, res) => res.redirect(302, media.pasteRawUrl(req.params.slug)));
router.post('/:slug/like', requireAuth, forward(slugPath('/like')));
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;
module.exports = router;
@@ -338,7 +338,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -338,7 +338,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
let vod;
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'); }
catch (err) { return mediaErr(res, err, 'Failed to get VOD'); }
if (!vod) return res.status(404).json({ error: 'VOD not found' });
if (!vod) return res.status(404).json({ error: 'VOD not found' });
withUserFields(vod);
withUserFields(vod);
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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');