openvibers/openvibe.media Commits

Media serves Live and OpenRe on their own service tokens (plan T4, Media cleanup D8): every v1 tenant route Live or OpenRe calls names the verb it performs (read, list, upload, delete), so a token is checked against the right capability and namespace; under a first-party service token (svc:) X-OV-User-Id scopes a write to that user exactly as the app key did (a developer app's token never names a user this way); only routes that opt in (project: true) treat a /<project_id>/ segment as a developer-project tenant, so naming a verb never opens a first-party route to app tokens. test/service-token-tenant.test.js. Both suites green (verified); the token tests rerun after the svc-only tightening.

OP OpenVibers committed 29 Sept 2026 · 29be23a78e · 11 files, +273 −45


Changes

ddiff --git a/server/assets/routes.js b/server/assets/routes.js
iindex 3dfca64..f452578 100644
---- a/server/assets/routes.js
++++ b/server/assets/routes.js
@@@ -36,7 +36,7 @@ function assetPublic(a) {
};
}
--router.post('/', tenantAuth(), upload.single('file'), async (req, res) => {
++router.post('/', tenantAuth({ verb: 'write' }), upload.single('file'), async (req, res) => {
try {
const { kind, name } = req.body || {};
if (!['emote', 'sound'].includes(kind)) return res.status(400).json({ error: "kind must be 'emote' or 'sound'" });
@@@ -69,7 +69,7 @@ router.post('/', tenantAuth(), upload.single('file'), async (req, res) => {
}
});
--router.get('/', tenantAuth(), async (req, res) => {
++router.get('/', tenantAuth({ verb: 'list' }), async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '200', 10), 1), 1000);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -81,7 +81,7 @@ router.get('/', tenantAuth(), async (req, res) => {
}
});
--router.delete('/:id', tenantAuth(), async (req, res) => {
++router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
try {
const a = await db.getAssetById(parseInt(req.params.id, 10));
if (!a || a.app_id !== req.appId) return res.status(404).json({ error: 'Not found' });
ddiff --git a/server/auth.js b/server/auth.js
iindex c8ee4d4..3956d1e 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -300,8 +300,12 @@ function actingUserId(req) {
* @param {boolean} [opts.namespaced=false] with `verb`: the route checks each namespace it touches
* (namespaceGrant); a token holding the verb for any namespace of the tenant gets through
* @param {string} [opts.capability] instead of a verb: one capability, for the root namespace
++ * @param {boolean} [opts.project=false] the route also serves developer-project tenants, so a
++ * /<project_id>/ `:app` segment resolves to that project's tenant — app tokens and their
++ * sandbox twins are accepted there. Without it a project id is no more than an unknown app
++ * (naming a verb must not open a first-party route, like the v1 VOD/clip API, to app tokens).
*/
--function tenantAuth({ allowUser = false, capability = null, verb = null, namespaced = false } = {}) {
++function tenantAuth({ allowUser = false, capability = null, verb = null, namespaced = false, project = false } = {}) {
if (verb && !VERBS[verb]) throw new Error(`tenantAuth: unknown verb ${verb}`);
const named = verb ? VERBS[verb][0] : capability;
const grant = { capability, verb, namespaced };
@@@ -310,9 +314,9 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
const token = bearerToken(req);
// 0) Principal tokens first, so a sandbox token is refused as such (never a 404 that hints
-- // at which tenants exist). Only developer-project tenant routes (a route that names its
-- // verb or capability, under /<project_id>/) opt in to sandbox tokens.
-- const appRoute = !!named && PROJECT_ID_RE.test(appId);
++ // at which tenants exist). Only a route that opts in (`project: true`) treats a
++ // /<project_id>/ segment as that project's tenant and accepts an app token (or sandbox twin).
++ const appRoute = project && !!named && PROJECT_ID_RE.test(appId);
let svc = null;
if (token && token.split('.').length === 3) {
const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
@@@ -358,7 +362,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
// 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on
// routes that name the verb (or capability) they perform, and only for the namespaces of this
// tenant the token was granted (the root is the :app id). It carries the app's authority for
-- // that one action — no acting user.
++ // that one action.
if (svc) {
if (!named) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
const c = await tenantGrant(svc, grant, appId, db.rootNamespace(app));
@@@ -366,6 +370,15 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
req.authType = 'app';
req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
req.grant = svc;
++ // X-OV-User-Id, exactly as under the app key above: a service token is issued to this app's
++ // own backend for this tenant and never reaches a browser — the same reasoning. The tenant
++ // was granted just above, so a token can never borrow another tenant's user-id space.
++ // First-party service principals only (svc:…): a developer app's token never names a user this way.
++ const onBehalf = String(svc.sub || '').startsWith('svc:') ? actingUserId(req) : null;
++ if (onBehalf != null) {
++ req.authType = 'user';
++ req.userId = onBehalf;
++ }
return next();
}
ddiff --git a/server/files/routes.js b/server/files/routes.js
iindex 1681bd4..56804b2 100644
---- a/server/files/routes.js
++++ b/server/files/routes.js
@@@ -88,7 +88,7 @@ function sha256File(filePath) {
// ── Upload ───────────────────────────────────────────────────
// Per-actor limits (server/actor-limits.js) sit after the credential and before multer reads the body.
// An upload stores up to MAX_FILE_SIZE_MB and counts against the quota: one every two seconds at most.
--router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), limits('media.file.upload', { minute: 30, hour: 600 }), upload.single('file'), async (req, res) => {
++router.post('/', tenantAuth({ allowUser: true, verb: 'write', project: true }), limits('media.file.upload', { minute: 30, hour: 600 }), upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
@@@ -161,7 +161,7 @@ router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), limits('media.f
});
// ── List ─────────────────────────────────────────────────────
--router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), limits('media.file.list'), async (req, res) => {
++router.get('/', tenantAuth({ allowUser: true, verb: 'list', project: true }), limits('media.file.list'), async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -178,7 +178,7 @@ router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), limits('media.fil
});
// ── Meta ─────────────────────────────────────────────────────
--router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), limits('media.file.read'), async (req, res) => {
++router.get('/:key', tenantAuth({ allowUser: true, verb: 'read', project: true }), limits('media.file.read'), async (req, res) => {
try {
const row = await db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
@@@ -190,7 +190,7 @@ router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), limits('media
// ── Delete ───────────────────────────────────────────────────
// A delete removes bytes and frees quota: a write, tighter than reads.
--router.delete('/:key', tenantAuth({ allowUser: true, verb: 'delete' }), limits('media.file.delete', { minute: 60, hour: 1200 }), async (req, res) => {
++router.delete('/:key', tenantAuth({ allowUser: true, verb: 'delete', project: true }), limits('media.file.delete', { minute: 60, hour: 1200 }), async (req, res) => {
try {
const row = await db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
ddiff --git a/server/index.js b/server/index.js
iindex cecaa41..4a5beb1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -168,7 +168,7 @@ const ready = (async () => {
}
// Per-app media stats (app-key only) — hero/dashboard counters in the owning app.
-- app.get('/api/v1/:app/stats', auth.tenantAuth(), async (req, res) => {
++ app.get('/api/v1/:app/stats', auth.tenantAuth({ verb: 'read' }), async (req, res) => {
try {
res.json(await db.getAppStats(req.appId));
} catch (err) {
@@@ -178,7 +178,7 @@ const ready = (async () => {
});
// Daily series behind a stat (vods, clips, pastes, hours) — "over time" charts in the owning app.
-- app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth(), async (req, res) => {
++ app.get('/api/v1/:app/stats/series/:metric', auth.tenantAuth({ verb: 'read' }), async (req, res) => {
try {
const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
if (!series) return res.status(404).json({ error: 'Unknown metric' });
ddiff --git a/server/jobs/routes.js b/server/jobs/routes.js
iindex 85c43ad..a198f83 100644
---- a/server/jobs/routes.js
++++ b/server/jobs/routes.js
@@@ -27,9 +27,9 @@ const db = require('../db/database');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
const { limits } = require('../actor-limits');
--const transform = tenantAuth({ verb: 'transform', namespaced: true });
--const read = tenantAuth({ verb: 'read' });
--const list = tenantAuth({ verb: 'list' });
++const transform = tenantAuth({ verb: 'transform', namespaced: true, project: true });
++const read = tenantAuth({ verb: 'read', project: true });
++const list = tenantAuth({ verb: 'list', project: true });
// Per-actor limits (server/actor-limits.js): reads take the defaults. A job runs sharp or ffmpeg on a
// worker with one or two slots, so a caller queues or approves at most one every six seconds.
const QUEUE = { minute: 10, hour: 200 };
ddiff --git a/server/objects/namespace-routes.js b/server/objects/namespace-routes.js
iindex a8e5c13..9b17b36 100644
---- a/server/objects/namespace-routes.js
++++ b/server/objects/namespace-routes.js
@@@ -31,7 +31,7 @@ async function shape(req, row) {
}
// Reads, with the default per-actor limits (server/actor-limits.js).
--router.get('/', tenantAuth({ verb: 'list', namespaced: true }), limits('media.namespace.list'), async (req, res) => {
++router.get('/', tenantAuth({ verb: 'list', namespaced: true, project: true }), limits('media.namespace.list'), async (req, res) => {
try {
const rows = [];
for (const r of await namespaces.listForTenant(req.appId)) if ((await namespaceGrant(req, 'list', r.namespace)).allowed) rows.push(r);
@@@ -42,7 +42,7 @@ router.get('/', tenantAuth({ verb: 'list', namespaced: true }), limits('media.na
}
});
--router.get('/:namespace', tenantAuth({ verb: 'read', namespaced: true }), limits('media.namespace.read'), async (req, res) => {
++router.get('/:namespace', tenantAuth({ verb: 'read', namespaced: true, project: true }), limits('media.namespace.read'), async (req, res) => {
try {
const named = namespaces.resolveName(req.appRow, req.params.namespace);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex e7a217c..3d5954e 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -58,10 +58,10 @@ const { limits } = require('../actor-limits');
const MB = 1024 * 1024;
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
--const upload = tenantAuth({ verb: 'write', namespaced: true });
--const read = tenantAuth({ verb: 'read', namespaced: true });
--const list = tenantAuth({ verb: 'list', namespaced: true });
--const remove = tenantAuth({ verb: 'delete', namespaced: true });
++const upload = tenantAuth({ verb: 'write', namespaced: true, project: true });
++const read = tenantAuth({ verb: 'read', namespaced: true, project: true });
++const list = tenantAuth({ verb: 'list', namespaced: true, project: true });
++const remove = tenantAuth({ verb: 'delete', namespaced: true, project: true });
const appOnly = tenantAuth();
// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the
// defaults (MEDIA_LIMITS_MINUTE / _HOUR). An init reserves quota and makes a row, and each upload step
@@@ -126,7 +126,7 @@ async function contentAuth(req, res, next) {
/** Multipart session token (from POST /:id/multipart) or the usual tenant credential with `verb`. */
function multipartAuth(verb) {
-- const fallback = tenantAuth({ verb, namespaced: true });
++ const fallback = tenantAuth({ verb, namespaced: true, project: true });
return async (req, res, next) => {
const token = req.query.token || req.headers['x-upload-token'];
if (!token) return fallback(req, res, next);
ddiff --git a/server/thumbnails/routes.js b/server/thumbnails/routes.js
iindex 43d6de3..c207cc4 100644
---- a/server/thumbnails/routes.js
++++ b/server/thumbnails/routes.js
@@@ -39,7 +39,7 @@ function _imageFromRequest(req) {
return null;
}
--router.post('/:kind/:id', tenantAuth({ allowUser: true }), upload.single('thumbnail'), async (req, res) => {
++router.post('/:kind/:id', tenantAuth({ verb: 'write', allowUser: true }), upload.single('thumbnail'), async (req, res) => {
try {
const kind = String(req.params.kind || '').toLowerCase();
const id = String(req.params.id);
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 3634ba7..7481c59 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -135,7 +135,7 @@ const flag = (v) => (['1', 'true'].includes(String(v)) ? 1 : ['0', 'false'].incl
// JSON body: cut a window out of a VOD (202, background cut + webhook).
// Multipart `video`: direct upload of an already-cut blob (201, ready) —
// inherited browser-MediaRecorder clip path.
--router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), async (req, res) => {
++router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.single('video'), async (req, res) => {
try {
const body = req.body || {};
if (req.file) return await _createUploadedClip(req, res);
@@@ -299,7 +299,7 @@ async function _createUploadedClip(req, res) {
// auto_generated=1|0 clips the app's automation cut (1) or a person made (0)
// status=ready playable clips only (no processing / failed rows)
// since=<datetime> created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC)
--router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
++router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -329,7 +329,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
// ── Get clip ─────────────────────────────────────────────────
// A short-lived URL of a clip's bytes for a reader with no key (OpenVibe.AI transcribing it); the owning app only.
--router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
++router.get('/:id/signed-url', tenantAuth({ verb: 'read' }), async (req, res) => {
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const clip = await _getClipScoped(req, res);
if (!clip) return;
@@@ -337,7 +337,7 @@ router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
res.json(require('../objects/signing').signedMediaUrl('clip', clip.id, req.query.ttl));
});
--router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
++router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, res) => {
try {
const clip = await _getClipScoped(req, res);
if (!clip) return;
@@@ -360,7 +360,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
* permanent — previously a failed clip stayed broken forever with no retry path,
* and the viewer was shown "the server is cutting your clip" indefinitely.
*/
--router.post('/:id/recut', tenantAuth(), async (req, res) => {
++router.post('/:id/recut', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const clip = await _getClipForWrite(req, res);
if (!clip) return;
@@@ -377,7 +377,7 @@ router.post('/:id/recut', tenantAuth(), async (req, res) => {
}
});
--router.put('/:id', tenantAuth(), async (req, res) => {
++router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const clip = await _getClipForWrite(req, res);
if (!clip) return;
@@@ -400,7 +400,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
});
// ── Delete ───────────────────────────────────────────────────
--router.delete('/:id', tenantAuth(), async (req, res) => {
++router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
try {
const clip = await _getClipForWrite(req, res);
if (!clip) return;
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 2a69ec3..7a10f77 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -95,7 +95,7 @@ async function _getVodForWrite(req, res) {
}
// ── Create VOD ───────────────────────────────────────────────
--router.post('/', tenantAuth(), async (req, res) => {
++router.post('/', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const { title, stream_id, stream_key, managed_stream_id, user_id, meta, visibility, clips_only } = req.body || {};
const result = await db.createVod({
@@@ -118,7 +118,7 @@ router.post('/', tenantAuth(), async (req, res) => {
});
// ── RTMP ingest ──────────────────────────────────────────────
--router.post('/:id/ingest/rtmp', tenantAuth(), async (req, res) => {
++router.post('/:id/ingest/rtmp', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) return;
@@@ -132,7 +132,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), async (req, res) => {
});
// ── RTP ingest ───────────────────────────────────────────────
--router.post('/:id/ingest/rtp/start', tenantAuth(), async (req, res) => {
++router.post('/:id/ingest/rtp/start', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) return;
@@@ -146,7 +146,7 @@ router.post('/:id/ingest/rtp/start', tenantAuth(), async (req, res) => {
}
});
--router.post('/:id/ingest/rtp/stop', tenantAuth(), async (req, res) => {
++router.post('/:id/ingest/rtp/stop', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) return;
@@@ -161,7 +161,7 @@ router.post('/:id/ingest/rtp/stop', tenantAuth(), async (req, res) => {
});
// ── Chunked upload (browser MediaRecorder; user JWT ok) ──────
--router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('chunk'), async (req, res) => {
++router.post('/:id/chunks', tenantAuth({ verb: 'write', allowUser: true }), chunkUpload.single('chunk'), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
@@@ -263,14 +263,14 @@ async function _finalizeHandler(req, res) {
}
}
--router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
--router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
++router.post('/:id/complete', tenantAuth({ verb: 'write', allowUser: true }), _finalizeHandler);
++router.post('/:id/finalize', tenantAuth({ verb: 'write' }), _finalizeHandler);
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: user_id, stream_id,
// managed_stream_id, include_private (private+unlisted too), order
// (newest|oldest|views|peak_viewers), since (created at or after), limit/offset.
--router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
++router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@@ -300,7 +300,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
// ── Latest VOD thumbnail per managed stream (batch) ──────────
// GET /latest-thumbs?managed_stream_ids=1,2,3 → { "<msid>": { vod_id, thumbnail_url } }
--router.get('/latest-thumbs', tenantAuth({ allowUser: true }), async (req, res) => {
++router.get('/latest-thumbs', tenantAuth({ verb: 'list', allowUser: true }), async (req, res) => {
try {
const ids = String(req.query.managed_stream_ids || '').split(',').filter(Boolean);
res.json({ thumbs: await db.latestVodThumbsByManagedStreams(req.appId, ids) });
@@@ -314,7 +314,7 @@ router.get('/latest-thumbs', tenantAuth({ allowUser: true }), async (req, res) =
// The owning app (its key alone, not a call acting for one of its users) asks for a short-lived URL of a VOD's bytes
// to hand to a reader that holds no key: OpenVibe.AI transcribing it (roadmap WS-O task 2). ttl in seconds, 30 s to
// 6 h (default 1 h). Public VODs get the same kind of URL; it works for any visibility.
--router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
++router.get('/:id/signed-url', tenantAuth({ verb: 'read' }), async (req, res) => {
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const vod = await _getVodScoped(req, res);
if (!vod) return;
@@@ -323,7 +323,7 @@ router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
});
// ── Get VOD meta ─────────────────────────────────────────────
--router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
++router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, res) => {
try {
const vod = await _getVodScoped(req, res);
if (!vod) return;
@@@ -355,7 +355,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
});
// ── Update ───────────────────────────────────────────────────
--router.put('/:id', tenantAuth(), async (req, res) => {
++router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) return;
@@@ -380,7 +380,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
});
// ── Delete ───────────────────────────────────────────────────
--router.delete('/:id', tenantAuth(), async (req, res) => {
++router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
try {
const vod = await _getVodForWrite(req, res);
if (!vod) return;
ddiff --git a/test/service-token-tenant.test.js b/test/service-token-tenant.test.js
nnew file mode 100644
iindex 0000000..0139d03
---- /dev/null
++++ b/test/service-token-tenant.test.js
@@@ -0,0 +1,215 @@
++'use strict';
++// Service tokens on Media's v1 tenant API (plan T4 / decision D8): Live and OpenRe stop presenting
++// Media's `live` app key (MEDIA_API_KEY) and present a Network service token for audience
++// openvibe.media instead. Media must serve them identically.
++//
++// * every v1 route they call names the verb it performs, so the token is checked against the right
++// capability (auth.js VERBS): read → media.object.read, list → media.object.list,
++// write → media.object.upload, delete → media.object.delete. A collection GET is `list`, so a
++// token holding media.object.read can still list it (the VERBS map keeps that older grant).
++// * a service token for the SAME tenant honours X-OV-User-Id exactly as the app key does, and a
++// token for another tenant is refused before the header is ever read.
++// * the app key keeps answering every one of these routes unchanged.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-svctenant-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods');
++ process.env.FILES_PATH = dir('files');
++ process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails');
++ process.env.ASSETS_PATH = dir('assets');
++ process.env.OV_NETWORK_URL = 'https://openvibe.network';
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++
++ const db = require('../server/db/database');
++ const auth = require('../server/auth');
++ const { serviceAuth } = require('openvibe-contracts');
++ const express = require('express');
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ auth._setNetworkPublicKeyForTests(keys.publicKey);
++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++ await db.upsertApp({ app_id: 'community', api_key: 'community-key' });
++
++ const now = Math.floor(Date.now() / 1000);
++ let jti = 0;
++ // A Network service token for the `live` tenant, as Network will issue it (audience openvibe.media).
++ const tok = (over = {}) => serviceAuth.signServiceToken({
++ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.media'],
++ cap: ['media.object.read', 'media.object.list', 'media.object.upload', 'media.object.delete'],
++ ns: ['live'], iat: now, exp: now + 300, jti: `tok_tenant_${++jti}`,
++ ...over,
++ }, keys.privateKey);
++ const uploadTok = () => tok({ cap: ['media.object.upload'] });
++ const KEY = 'live-key';
++
++ 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/thumbnails', require('../server/thumbnails/routes'));
++ app.use('/api/v1/:app/assets', require('../server/assets/routes'));
++ app.use('/api/v1/:app/views', require('../server/views/routes'));
++ const server = http.createServer(app);
++
++ const collect = (r) => ({ status: r.status, code: r.body && r.body.code });
++
++ (async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, p, { bearer = null, body, headers = {}, form } = {}) => {
++ const h = { ...headers };
++ if (bearer) h.authorization = `Bearer ${bearer}`;
++ let payload;
++ if (form) payload = form;
++ else 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();
++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, body: json, text };
++ };
++ const denied = (r, code = 'capability.denied') => assert.deepStrictEqual([r.status, r.body && r.body.code], [403, code], r.text);
++ const allowed = (r, what) => assert.ok(r.status !== 401 && r.status !== 403, `${what} refused a credited token: ${r.status} ${r.text}`);
++ const file = (buf, name, type) => { const fd = new FormData(); fd.append('file', new Blob([buf], { type }), name); return fd; };
++
++ // ── VOD: create / finalize / update / delete under a write+delete token ──
++ // Finalize gets its own VOD: one with no recording file is legitimately discarded there (409,
++ // or the empty row deleted) — the point is that it is not an auth refusal.
++ let r = await call('POST', '/api/v1/live/vods', { bearer: uploadTok(), body: { title: 'Rec', user_id: 7 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const emptyVod = r.body.id;
++ r = await call('POST', `/api/v1/live/vods/${emptyVod}/finalize`, { bearer: uploadTok() });
++ allowed(r, 'finalize');
++
++ r = await call('POST', '/api/v1/live/vods', { bearer: uploadTok(), body: { title: 'Rec', user_id: 7 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const vodId = r.body.id;
++ r = await call('PUT', `/api/v1/live/vods/${vodId}`, { bearer: uploadTok(), body: { title: 'Renamed' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', `/api/v1/live/vods/${vodId}`, { bearer: tok({ cap: ['media.object.read'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.title, 'Renamed'); // bare object per CONTRACTS.md
++ console.log('✅ a service token holding media.object.upload creates/finalizes/updates a VOD');
++
++ // ── list vs read: collection GET is `list`, single GET is `read` ──
++ r = await call('GET', '/api/v1/live/vods', { bearer: tok({ cap: ['media.object.list'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', '/api/v1/live/vods', { bearer: tok({ cap: ['media.object.read'] }) });
++ assert.strictEqual(r.status, 200, 'read lists (the VERBS map keeps the older grant)');
++ r = await call('GET', '/api/v1/live/vods', { bearer: uploadTok() });
++ denied(r, 'capability.denied');
++ r = await call('GET', `/api/v1/live/vods/${vodId}`, { bearer: tok({ cap: ['media.object.list'] }) });
++ denied(r, 'capability.denied');
++ console.log('✅ list needs media.object.list (read may list); read needs media.object.read');
++
++ // ── A token without the write capability is refused the write ──
++ r = await call('POST', '/api/v1/live/vods', { bearer: tok({ cap: ['media.object.read'] }), body: { title: 'nope' } });
++ denied(r);
++ r = await call('DELETE', `/api/v1/live/vods/${vodId}`, { bearer: tok({ cap: ['media.object.read'] }) });
++ denied(r);
++ console.log('✅ a token without media.object.upload gets 403 capability.denied');
++
++ // ── Clip: create (cut) / update / delete, on a VOD with a local source ──
++ const mediaName = `vod-${vodId}.webm`;
++ fs.writeFileSync(path.join(process.env.VOD_PATH, mediaName), Buffer.alloc(64 * 1024, 7));
++ await db.run('UPDATE vods SET file_path = ?, duration_seconds = ? WHERE id = ?', [mediaName, 30, vodId]);
++ r = await call('POST', '/api/v1/live/clips', { bearer: uploadTok(), body: { vod_id: vodId, start_s: 0, end_s: 2, user_id: 7 } });
++ assert.strictEqual(r.status, 202, r.text);
++ const clipId = r.body.id;
++ r = await call('PUT', `/api/v1/live/clips/${clipId}`, { bearer: uploadTok(), body: { title: 'Cut' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', `/api/v1/live/clips/${clipId}`, { bearer: tok({ cap: ['media.object.read'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', '/api/v1/live/clips', { bearer: tok({ cap: ['media.object.list'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('POST', '/api/v1/live/clips', { bearer: tok({ cap: ['media.object.read'] }), body: { vod_id: vodId, start_s: 0, end_s: 2 } });
++ denied(r);
++ console.log('✅ a service token creates/updates/lists a clip; read alone cannot cut');
++
++ // ── Thumbnail (live stream) + asset upload under the same token ──
++ const jpeg = Buffer.concat([Buffer.from([0xFF, 0xD8]), Buffer.alloc(8, 3)]);
++ const thumbForm = new FormData(); thumbForm.append('thumbnail', new Blob([jpeg], { type: 'image/jpeg' }), 'live.jpg');
++ r = await call('POST', '/api/v1/live/thumbnails/live/stream-1', { bearer: uploadTok(), form: thumbForm });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(String(r.body.url).startsWith('/t/'), r.text);
++ const thumbForm2 = new FormData(); thumbForm2.append('thumbnail', new Blob([jpeg], { type: 'image/jpeg' }), 'live.jpg');
++ r = await call('POST', '/api/v1/live/thumbnails/live/stream-2', { bearer: tok({ cap: ['media.object.read'] }), form: thumbForm2 });
++ denied(r);
++
++ let assetForm = file(Buffer.from('wav'), 'boop.wav', 'audio/wav');
++ assetForm.append('kind', 'sound');
++ assetForm.append('name', 'boop');
++ r = await call('POST', '/api/v1/live/assets', { bearer: uploadTok(), form: assetForm });
++ assert.strictEqual(r.status, 201, r.text);
++ const assetId = r.body.asset.id;
++ r = await call('GET', '/api/v1/live/assets', { bearer: tok({ cap: ['media.object.list'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', '/api/v1/live/assets', { bearer: uploadTok() });
++ denied(r);
++ r = await call('DELETE', `/api/v1/live/assets/${assetId}`, { bearer: tok({ cap: ['media.object.delete'] }) });
++ assert.strictEqual(r.status, 200, r.text);
++ console.log('✅ thumbnail and asset upload need media.object.upload; assets list needs list');
++
++ // ── X-OV-User-Id under a service token, exactly as under the app key ──
++ r = await call('POST', '/api/v1/live/vods', { bearer: uploadTok(), body: { title: 'Mine', user_id: 7 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const mine = r.body.id;
++ const scoped = async (bearer, uid) => collect(await call('PUT', `/api/v1/live/vods/${mine}`, { bearer, headers: uid == null ? {} : { 'x-ov-user-id': String(uid) }, body: { title: 'x' } }));
++ // The app key is the control: the token must answer each of these exactly as the key does.
++ for (const uid of [7, 8, null]) {
++ const byKey = await scoped(KEY, uid);
++ const byTok = await scoped(uploadTok(), uid);
++ assert.deepStrictEqual(byTok, byKey, `X-OV-User-Id ${uid}: token answered differently from the key`);
++ if (uid === 7 || uid == null) assert.deepStrictEqual(byKey, { status: 200, code: undefined }, JSON.stringify(byKey));
++ else assert.deepStrictEqual(byKey, { status: 403, code: undefined }, 'another user\'s VOD is refused');
++ }
++ // …and the scoping is the same on delete.
++ r = await call('DELETE', `/api/v1/live/vods/${mine}`, { bearer: uploadTok(), headers: { 'x-ov-user-id': '8' } });
++ assert.strictEqual(r.status, 403, 'a token naming another of the tenant\'s users cannot delete their VOD');
++ r = await call('DELETE', `/api/v1/live/vods/${mine}`, { bearer: uploadTok(), headers: { 'x-ov-user-id': '7' } });
++ assert.strictEqual(r.status, 200, r.text);
++ console.log('✅ X-OV-User-Id under a service token scopes writes exactly as under the key');
++
++ // ── A token for another tenant borrows nothing (and its header is never read) ──
++ r = await call('POST', '/api/v1/live/vods', { bearer: tok({ ns: ['community'] }), body: { title: 'x' } });
++ denied(r, 'capability.namespace_denied');
++ r = await call('POST', '/api/v1/live/vods', { bearer: tok({ ns: ['community'] }), headers: { 'x-ov-user-id': '7' }, body: { title: 'x' } });
++ denied(r, 'capability.namespace_denied');
++ r = await call('POST', '/api/v1/live/vods', { bearer: tok({ aud: ['openvibe.network'] }), body: { title: 'x' } });
++ assert.strictEqual(r.status, 401, 'wrong audience is not a credential here');
++ r = await call('POST', '/api/v1/live/views', { bearer: tok(), body: { kind: 'vod' } });
++ denied(r, 'capability.denied');
++ console.log('✅ another tenant\'s token, a wrong audience and an un-verb\'d route are all refused');
++
++ // ── The app key still answers every route unchanged ──
++ r = await call('POST', '/api/v1/live/vods', { bearer: KEY, body: { title: 'Key Rec', user_id: 9 } });
++ assert.strictEqual(r.status, 201, r.text);
++ const keyVod = r.body.id;
++ r = await call('GET', '/api/v1/live/vods', { bearer: KEY });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('PUT', `/api/v1/live/vods/${keyVod}`, { bearer: KEY, body: { title: 'Key Renamed' } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('POST', '/api/v1/live/clips', { bearer: KEY, body: { vod_id: vodId, start_s: 4, end_s: 6 } });
++ assert.ok([200, 202].includes(r.status), r.text); // 200 when an identical window was just cut
++ r = await call('DELETE', `/api/v1/live/clips/${clipId}`, { bearer: KEY }); // the token's clip
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('GET', '/api/v1/live/assets', { bearer: KEY });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('DELETE', `/api/v1/live/vods/${keyVod}`, { bearer: KEY });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('POST', '/api/v1/live/vods', { bearer: 'community-key', body: { title: 'x' } });
++ assert.strictEqual(r.status, 403, 'another app\'s key is still not valid for this tenant');
++ console.log('✅ the `live` app key answers every one of these routes unchanged');
++
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('service tokens on the v1 tenant API: all checks passed');
++ })().catch((err) => { console.error(err); server.close(); process.exit(1); });
++})().catch((err) => { console.error(err); process.exit(1); });