openvibers/openvibe.media Commits

Private items answer exactly like missing ones: /v and /c returned 403 "This media is private", which confirmed that a private recording (or, through the basename form of /v, a file name) exists; now the same 404 and body as an unknown id, for the bytes and the watch page. Same rule on /v/:id/transcript.json (legacy rows with no visibility and is_public 0 count as private, clips-only sources are hidden like on /v), the legacy /api/thumbnails redirect (no longer hands out a private row's current thumbnail URL), /p/:slug/raw (no screenshot bounce or Community redirect that only real private pastes got), /o/:id (a deleted private object said 410 before the signature check), and the v1 detail routes when an app acts for a user via X-OV-User-Id (GET /vods/:id, /clips/:id, cutting a clip from someone else's private VOD, GET /pastes/:slug, which also no longer counts a view before refusing). test/private-not-found.test.js compares every refused answer with the missing-id answer

OP OpenVibers committed 23 Sept 2026 · dfb2172ff0 · 9 files, +246 −26


Changes

ddiff --git a/README.md b/README.md
iindex ba74b38..6083167 100644
---- a/README.md
++++ b/README.md
@@@ -266,12 +266,17 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
--Private items respond 403/404 unless the request bears the owning app's API
--key or the owning user's JWT.
++Private items (and legacy rows with no visibility and `is_public = 0`) answer
++exactly like a missing id — the same 404 and body — unless the request bears the
++owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds
++for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect
++and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)
++apply it to an app acting for someone other than the owner.
`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlisted
objects openly, private ones only with a valid signature from
--`/api/v2/:app/objects/:id/download`, deleted ones 410 (see below).
++`/api/v2/:app/objects/:id/download`, deleted ones 410 (a deleted private object
++without a signature is a 404 like any other private one).
## Object model and API v2
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 2c515c4..dc9b084 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -145,7 +145,7 @@ All routes live under `/api/v2/:app/objects`.
- Public and unlisted `ready` objects are served openly.
- Private objects need a valid `?exp=&sig=` from `/download`. Without one the answer is 404, so a private object cannot be told apart from a missing one.
--- A deleted object answers 410.
++- A deleted object answers 410 — after the check above, so a deleted private object is a 404 without a signature.
- A local copy streams with Range support. Otherwise the route redirects 302 to a presigned R2 URL, then a presigned B2 URL.
- Headers: `X-Content-Type-Options: nosniff` and `X-Robots-Tag: noindex`. Content is `inline` only for images (not SVG), video, audio, PDF and plain text, and `attachment` for everything else.
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex cfc144e..63754c0 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -379,13 +379,14 @@ publicRouter.get('/:id', async (req, res) => {
try {
const obj = model.getObject(String(req.params.id || ''));
if (!obj) return res.status(404).json({ error: 'Not found' });
-- if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
-- if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
// Private objects (and every developer-project sandbox object) are indistinguishable from
-- // missing ones without a valid signature.
++ // missing ones without a valid signature — checked before the lifecycle answers, or a 410
++ // for a deleted private object would still say it existed.
const sandbox = db.isSandboxTenant(obj.app_id);
if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });
++ if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
++ if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
const mime = obj.mime_type || 'application/octet-stream';
const md = model.parseJson(obj.metadata, {});
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 648bb5a..79988d0 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -451,6 +451,11 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
const paste = _getPasteScoped(req, res);
if (!paste) return;
++ // Private pastes: owner (or the app itself) only — decided before anything is counted.
++ if (paste.visibility === 'private') {
++ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
++ if (!allowed) return res.status(404).json({ error: 'Paste not found' });
++ }
// The owning app renders the paste page itself — count the view here (real client
// IP via X-Forwarded-For, viewer via the user JWT / X-OV-User-Id) unless it says
// this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
@@@ -458,11 +463,6 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try { const r = require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
}
-- // Private pastes: owner (or the app itself) only.
-- if (paste.visibility === 'private') {
-- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
-- if (!allowed) return res.status(404).json({ error: 'Paste not found' });
-- }
paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;
res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
ddiff --git a/server/public/dev-data.js b/server/public/dev-data.js
iindex 8ef3fd7..bd5c1b5 100644
---- a/server/public/dev-data.js
++++ b/server/public/dev-data.js
@@@ -155,8 +155,11 @@ function getTranscriptTimeline(appId, sel, limit) {
/** Transcript + AI overview for one existing VOD id. */
function getVodTranscript(vodId) {
return cached(`vt:${vodId}`, async () => {
-- const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public FROM vods WHERE id = ?`, [vodId]);
-- if (!v || v.visibility === 'private') return { status: 404, body: { error: 'VOD not found' } };
++ const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public, clips_only FROM vods WHERE id = ?`, [vodId]);
++ // Private (including legacy rows with no visibility and is_public = 0) and clips-only
++ // source recordings answer exactly like a missing id — the same rule /v/:id applies.
++ const hidden = v && ((v.visibility || (v.is_public ? 'public' : 'private')) === 'private' || v.clips_only);
++ if (!v || hidden) return { status: 404, body: { error: 'VOD not found' } };
const txs = await _appTranscripts(v.app_id, [v.id]);
return { status: 200, body: { ..._vodEntry(v, { tx: txs[v.id] }), app_id: v.app_id, generated_at: new Date().toISOString() } };
});
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 41d17fa..5ed6e96 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -47,6 +47,16 @@ function trackUniqueView(kind, id, req, ownerUserId = null) {
try { if (views.isInitialPlaybackRequest(req)) views.recordView(kind, id, { req, ownerUserId }); } catch { /* non-critical */ }
}
++/** public | unlisted | private. Rows without a visibility fall back to is_public (0 = private). */
++function recordVisibility(record) {
++ return record.visibility || (record.is_public ? 'public' : 'private');
++}
++
++/** The single "no such thing" answer for every id-addressed route, used for private items too. */
++function notFound(res) {
++ return res.status(404).json({ error: 'Not found' });
++}
++
function canAccessPrivate(record, req) {
// Private items: the owning app (its API key) or the owning user's JWT.
if (req.authType === 'app' && req.appId === record.app_id) return true;
@@@ -111,10 +121,10 @@ async function serveMediaRecord(kind, record, req, res) {
const vodStorage = require('../vod/vod-storage');
const noindex = { 'X-Robots-Tag': 'noindex' };
-- const visibility = record.visibility || (record.is_public ? 'public' : 'private');
-- if (visibility === 'private' && !canAccessPrivate(record, req)) {
-- return res.status(403).json({ error: 'This media is private' });
-- }
++ const visibility = recordVisibility(record);
++ // Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
++ // recordings exist (and, through the basename form of /v, which file names are real).
++ if (visibility === 'private' && !canAccessPrivate(record, req)) return notFound(res);
// A person (or a link-preview crawler) landing on the URL gets the watch
// page; its <video> comes back here with ?raw=1 for the bytes.
@@@ -181,14 +191,14 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
if (/^\d+$/.test(req.params.id)) {
const vod = db.getVodById(parseInt(req.params.id, 10));
-- if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
++ if (!vod || vod.clips_only) return notFound(res);
return await serveMediaRecord('vod', vod, req, res);
}
const vod = db.getVodByFileBasename(req.params.id);
if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);
const clip = db.getClipByFileBasename(req.params.id);
if (clip) return await serveMediaRecord('clip', clip, req, res);
-- res.status(404).json({ error: 'Not found' });
++ notFound(res);
} catch (err) {
console.error('[Public] /v error:', err.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
@@@ -198,9 +208,9 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
// ── Clip playback ────────────────────────────────────────────
router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
++ if (!/^\d+$/.test(req.params.id)) return notFound(res);
const clip = db.getClipById(parseInt(req.params.id, 10));
-- if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });
++ if (!clip || !clip.file_path) return notFound(res);
await serveMediaRecord('clip', clip, req, res);
} catch (err) {
console.error('[Public] /c error:', err.message);
@@@ -355,7 +365,9 @@ router.get('/api/thumbnails/:name', (req, res) => {
const row = m[1].toLowerCase() === 'vod'
? db.getVodById(parseInt(m[2], 10), 'live')
: db.getClipById(parseInt(m[2], 10), 'live');
-- if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {
++ // Never for a private row: the redirect would hand out the current thumbnail URL of any
++ // private recording to whoever guesses vod-<id>-0.jpg. The exact file name still serves.
++ if (row && row.thumbnail_url && recordVisibility(row) !== 'private' && !row.thumbnail_url.endsWith(`/${name}`)) {
res.set('Cache-Control', 'public, max-age=3600');
return res.redirect(302, row.thumbnail_url);
}
@@@ -484,16 +496,18 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
router.get('/p/:slug/raw', (req, res) => {
try {
-- const paste = db.getPasteBySlug(String(req.params.slug));
++ const found = db.getPasteBySlug(String(req.params.slug));
++ // A private paste answers exactly like a missing slug (no redirect that proves it exists).
++ const paste = found && found.visibility !== 'private' ? found : null;
// Image pastes have no raw text — bounce to the screenshot (stale
// consumers stored /raw URLs for hero-moment images).
if (paste && paste.type === 'screenshot') {
res.set('Cache-Control', 'public, max-age=3600');
return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);
}
++ // Moved: every slug goes to Community, found or not, like /p/:slug.
++ if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/raw`);
if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
-- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(paste.slug)}/raw`);
-- if (paste.visibility === 'private') return res.status(404).send('Not found');
// Burn after read
if (paste.burn_after_read && paste.views > 0) {
@@@ -523,3 +537,4 @@ router.get('/p/:slug/screenshot', (req, res) => {
module.exports = router;
module.exports.streamFileWithRange = streamFileWithRange;
++module.exports.recordVisibility = recordVisibility;
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 0b2bf4f..5e18955 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -98,6 +98,11 @@ function sanitizeClipTitle(title, fallback = 'Untitled Clip') {
return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;
}
++/** Private, including legacy rows that have no visibility and is_public = 0. */
++function _isPrivate(row) {
++ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
++}
++
function _getClipScoped(req, res) {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
@@@ -120,6 +125,10 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
const vod = db.getVodById(vodId, req.appId);
if (!vod) return res.status(404).json({ error: 'VOD not found' });
++ // An acting user cannot cut someone else's private VOD, and is not told it exists.
++ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
++ return res.status(404).json({ error: 'VOD not found' });
++ }
let startTime = Number.parseFloat(body.start_s ?? body.start_time);
let endTime = Number.parseFloat(body.end_s ?? body.end_time);
@@@ -313,6 +322,10 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
const clip = _getClipScoped(req, res);
if (!clip) return;
++ // Acting for one of the app's users: another user's private clip looks missing (see GET /vods/:id).
++ if (req.authType === 'user' && _isPrivate(clip) && !(clip.user_id != null && String(clip.user_id) === String(req.userId))) {
++ return res.status(404).json({ error: 'Clip not found' });
++ }
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
// Detail responses carry the transcript too (lists stay light).
res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 92403bd..0a9e39a 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -66,6 +66,11 @@ const chunkUpload = multer({
*/
const activeChunkUploads = new Map();
++/** Private, including legacy rows that have no visibility and is_public = 0. */
++function _isPrivate(row) {
++ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
++}
++
function _getVodScoped(req, res) {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }
@@@ -308,6 +313,11 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
try {
const vod = _getVodScoped(req, res);
if (!vod) return;
++ // Acting for one of the app's users (X-OV-User-Id): their private VODs only — anyone
++ // else's answers exactly like a missing id. The app key alone sees the namespace.
++ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
++ return res.status(404).json({ error: 'VOD not found' });
++ }
// Self-heal a missing duration on a finished local VOD.
if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
nnew file mode 100644
iindex 0000000..bcfe62e
---- /dev/null
++++ b/test/private-not-found.test.js
@@@ -0,0 +1,173 @@
++'use strict';
++// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.
++// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all
++// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug/raw,
++// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts
++// for one of its users (X-OV-User-Id).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-private404-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.VOD_PATH = dir('vods');
++process.env.CLIPS_PATH = dir('clips');
++process.env.FILES_PATH = dir('files');
++process.env.OBJECTS_PATH = dir('objects');
++process.env.THUMBNAILS_PATH = dir('thumbnails');
++process.env.PASTES_PATH = dir('pastes');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
++delete process.env.PASTES_MOVED_TO;
++
++const db = require('../server/db/database');
++require('../server/views/service').ensureSchema();
++const model = require('../server/objects/model');
++const signing = require('../server/objects/signing');
++const express = require('express');
++
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++const raw = db.getDb();
++const vodFile = (n) => { const p = path.join(process.env.VOD_PATH, n); fs.writeFileSync(p, Buffer.alloc(64, 1)); return p; };
++const clipFile = (n) => { const p = path.join(process.env.CLIPS_PATH, n); fs.writeFileSync(p, Buffer.alloc(32, 2)); return p; };
++
++const insVod = raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, clips_only, thumbnail_url)
++ VALUES (?, 'live', ?, ?, ?, ?, ?, 10, ?, ?)`);
++insVod.run(1, 5, 'Public', vodFile('vod-1.webm'), 1, 'public', 0, '/t/vod-1-111.jpg');
++insVod.run(2, 5, 'Secret title', vodFile('vod-2-secret.webm'), 0, 'private', 0, '/t/vod-2-222.jpg');
++insVod.run(3, 5, 'Legacy hidden', vodFile('vod-3.webm'), 0, null, 0, null);
++insVod.run(4, 5, 'Clips only', vodFile('vod-4.webm'), 0, 'unlisted', 1, null);
++insVod.run(6, 5, 'Unlisted', vodFile('vod-6.webm'), 0, 'unlisted', 0, null);
++const insClip = raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status)
++ VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready')`);
++insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');
++insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');
++const insPaste = raw.prepare(`INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility, screenshot_path)
++ VALUES (?, 'live', 5, 'P', ?, ?, 'text', ?, ?)`);
++insPaste.run('pub-text', 'paste', 'hello', 'public', null);
++insPaste.run('priv-text', 'paste', 'secret', 'private', null);
++insPaste.run('priv-shot', 'screenshot', 'caption', 'private', '/nonexistent.png');
++
++const mkObj = (visibility, lifecycle_status) => model.createObject({
++ app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,
++ visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},
++});
++const privDeleted = mkObj('private', 'deleted');
++const privUploading = mkObj('private', 'uploading');
++const pubDeleted = mkObj('public', 'deleted');
++
++const app = express();
++app.use(express.json());
++app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
++app.use('/o', require('../server/objects/routes').publicRouter);
++app.use('/', require('../server/public/routes'));
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = async (p, headers = {}, method = 'GET', body) => {
++ const h = { ...headers };
++ let payload;
++ if (body !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
++ const res = await fetch(base + p, { method, headers: h, body: payload, redirect: 'manual' });
++ const text = await res.text();
++ return { status: res.status, text, location: res.headers.get('location') };
++ };
++ const same = async (a, b, msg) => {
++ const [x, y] = [await get(...a), await get(...b)];
++ assert.deepStrictEqual([x.status, x.text, x.location], [y.status, y.text, y.location], msg);
++ return x;
++ };
++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
++ const video = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };
++ const asUser = (id) => ({ authorization: 'Bearer live-key', 'x-ov-user-id': String(id) });
++
++ // ── /v and /c: private = missing, for bytes and for the watch page ──
++ let r = await same(['/v/2', video], ['/v/999', video], 'private VOD bytes look missing');
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('private'), 'the answer never says "private"');
++ await same(['/v/2', nav], ['/v/999', nav], 'private VOD watch page looks missing');
++ await same(['/v/3', video], ['/v/999', video], 'legacy row (no visibility, is_public 0) is private');
++ await same(['/v/4', video], ['/v/999', video], 'clips-only source recordings are not served');
++ await same(['/v/vod-2-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form: private file names are not confirmed');
++ await same(['/v/clip-11-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form reaching a private clip');
++ await same(['/c/11', video], ['/c/999', video], 'private clip looks missing');
++ await same(['/c/11', nav], ['/c/999', nav], 'private clip watch page looks missing');
++ await same(['/v/2', { ...video, ...asUser(77) }], ['/v/999', { ...video, ...asUser(77) }], 'another user of the app is nobody special');
++
++ assert.strictEqual((await get('/v/1', video)).status, 200, 'public VOD serves');
++ assert.strictEqual((await get('/v/6', video)).status, 200, 'unlisted VOD serves by direct link');
++ assert.strictEqual((await get('/c/10', video)).status, 200, 'public clip serves');
++ assert.strictEqual((await get('/v/2', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private VOD');
++ assert.strictEqual((await get('/v/2', { ...video, authorization: 'Bearer live-key' })).status, 200, 'the owning app gets it');
++ assert.strictEqual((await get('/c/11', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private clip');
++ console.log('✅ /v /c: private looks missing');
++
++ // ── Transcript companion ──
++ r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('Secret title'));
++ await same(['/v/3/transcript.json'], ['/v/999/transcript.json'], 'legacy private transcript looks missing');
++ await same(['/v/4/transcript.json'], ['/v/999/transcript.json'], 'clips-only transcript looks missing');
++ console.log('✅ /v/:id/transcript.json: private looks missing');
++
++ // ── Legacy thumbnail names never hand out a private row's current thumbnail URL ──
++ r = await get('/api/thumbnails/vod-1-0.jpg');
++ assert.deepStrictEqual([r.status, r.location], [302, '/t/vod-1-111.jpg'], 'public rows still redirect to the current thumbnail');
++ await same(['/api/thumbnails/vod-2-0.jpg'], ['/api/thumbnails/vod-999-0.jpg'], 'private row: same answer as a missing one');
++ r = await get('/api/thumbnails/vod-2-0.jpg');
++ assert.strictEqual(r.location, null, 'no redirect for a private row');
++ console.log('✅ /api/thumbnails: no redirect for private rows');
++
++ // ── /o: a private object is missing without a signature, whatever its lifecycle ──
++ await same([`/o/${privDeleted}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'deleted private object: 404, not 410');
++ await same([`/o/${privUploading}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'unfinished private object: 404');
++ assert.strictEqual((await get(`/o/${pubDeleted}`)).status, 410, 'a deleted public object may say Gone');
++ const s = signing.signedDownloadUrl(privDeleted, 60);
++ assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');
++ console.log('✅ /o: private objects look missing before any lifecycle answer');
++
++ // ── Paste raw: no redirect or answer that singles out a private paste ──
++ await same(['/p/priv-text/raw'], ['/p/no-such/raw'], 'private text paste looks missing');
++ await same(['/p/priv-shot/raw'], ['/p/no-such/raw'], 'private screenshot paste: no redirect to its screenshot');
++ assert.strictEqual((await get('/p/pub-text/raw')).text, 'hello', 'public paste raw still serves');
++ process.env.PASTES_MOVED_TO = 'https://community.test';
++ r = await get('/p/no-such/raw');
++ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/raw'], 'moved: every slug goes to Community');
++ r = await get('/p/priv-shot/raw');
++ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/raw'], 'moved: a private screenshot is not bounced to /screenshot');
++ delete process.env.PASTES_MOVED_TO;
++ console.log('✅ /p/:slug/raw: private looks missing');
++
++ // ── v1 detail routes when the app acts for a user ──
++ await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');
++ await same(['/api/v1/live/vods/3', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: legacy private VOD looks missing');
++ await same(['/api/v1/live/clips/11', asUser(77)], ['/api/v1/live/clips/999', asUser(77)], 'acting non-owner: private clip looks missing');
++ await same(['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 2, start_s: 0, end_s: 5 }],
++ ['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 999, start_s: 0, end_s: 5 }], 'acting non-owner cannot probe a private VOD by clipping it');
++ r = await get('/api/v1/live/vods/2', asUser(5));
++ assert.strictEqual(r.status, 200, 'the owner, acting, sees their VOD');
++ assert.strictEqual(JSON.parse(r.text).title, 'Secret title');
++ assert.strictEqual((await get('/api/v1/live/vods/2', { authorization: 'Bearer live-key' })).status, 200, 'the app key alone sees the namespace');
++ assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);
++ assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');
++
++ const views = () => raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get().views;
++ const before = views();
++ await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');
++ assert.strictEqual(views(), before, 'a refused read counts no view');
++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);
++ console.log('✅ v1 detail: acting non-owners get the missing answer');
++
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('private-not-found: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); server.close(); process.exit(1); });