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.
Changes
@@@ -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' });@@@ -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, namespaconst 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, namespareq.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();}@@@ -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' });@@@ -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' });@@@ -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 };@@@ -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);@@@ -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);@@@ -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);@@@ -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;@@@ -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;nnew file mode 100644@@@ -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); });
@@ -36,7 +36,7 @@ function assetPublic(a) {
@@ -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 {
try {
const { kind, name } = req.body || {};
const { kind, name } = req.body || {};
if (!['emote', 'sound'].includes(kind)) return res.status(400).json({ error: "kind must be 'emote' or 'sound'" });
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) => {
@@ -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 {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '200', 10), 1), 1000);
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);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@ -81,7 +81,7 @@ router.get('/', tenantAuth(), async (req, res) => {
@@ -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 {
try {
const a = await db.getAssetById(parseInt(req.params.id, 10));
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' });
if (!a || a.app_id !== req.appId) return res.status(404).json({ error: 'Not found' });
@@ -300,8 +300,12 @@ function actingUserId(req) {
@@ -300,8 +300,12 @@ function actingUserId(req) {
* @param {boolean} [opts.namespaced=false] with `verb`: the route checks each namespace it touches
* @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
* (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 {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}`);
if (verb && !VERBS[verb]) throw new Error(`tenantAuth: unknown verb ${verb}`);
const named = verb ? VERBS[verb][0] : capability;
const named = verb ? VERBS[verb][0] : capability;
const grant = { capability, verb, namespaced };
const grant = { capability, verb, namespaced };
@@ -310,9 +314,9 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
@@ -310,9 +314,9 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
const token = bearerToken(req);
const token = bearerToken(req);
// 0) Principal tokens first, so a sandbox token is refused as such (never a 404 that hints
// 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;
let svc = null;
if (token && token.split('.').length === 3) {
if (token && token.split('.').length === 3) {
const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
@@ -358,7 +362,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
@@ -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
// 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
// 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
// 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 (svc) {
if (!named) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
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));
const c = await tenantGrant(svc, grant, appId, db.rootNamespace(app));
@@ -366,6 +370,15 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
@@ -366,6 +370,15 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
req.authType = 'app';
req.authType = 'app';
req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
req.grant = svc;
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();
return next();
}
}
@@ -88,7 +88,7 @@ function sha256File(filePath) {
@@ -88,7 +88,7 @@ function sha256File(filePath) {
// ── Upload ───────────────────────────────────────────────────
// ── Upload ───────────────────────────────────────────────────
// Per-actor limits (server/actor-limits.js) sit after the credential and before multer reads the body.
// 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.
// 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 {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
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
@@ -161,7 +161,7 @@ router.post('/', tenantAuth({ allowUser: true, verb: 'write' }), limits('media.f
});
});
// ── List ─────────────────────────────────────────────────────
// ── 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 {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
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);
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
@@ -178,7 +178,7 @@ router.get('/', tenantAuth({ allowUser: true, verb: 'list' }), limits('media.fil
});
});
// ── Meta ─────────────────────────────────────────────────────
// ── 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 {
try {
const row = await db.getFileByKey(String(req.params.key), req.appId);
const row = await db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
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
@@ -190,7 +190,7 @@ router.get('/:key', tenantAuth({ allowUser: true, verb: 'read' }), limits('media
// ── Delete ───────────────────────────────────────────────────
// ── Delete ───────────────────────────────────────────────────
// A delete removes bytes and frees quota: a write, tighter than reads.
// 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 {
try {
const row = await db.getFileByKey(String(req.params.key), req.appId);
const row = await db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
if (!row) return res.status(404).json({ error: 'File not found' });
@@ -168,7 +168,7 @@ const ready = (async () => {
@@ -168,7 +168,7 @@ const ready = (async () => {
}
}
// Per-app media stats (app-key only) — hero/dashboard counters in the owning app.
// 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 {
try {
res.json(await db.getAppStats(req.appId));
res.json(await db.getAppStats(req.appId));
} catch (err) {
} catch (err) {
@@ -178,7 +178,7 @@ const ready = (async () => {
@@ -178,7 +178,7 @@ const ready = (async () => {
});
});
// Daily series behind a stat (vods, clips, pastes, hours) — "over time" charts in the owning app.
// 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 {
try {
const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
const series = await db.getAppStatSeries(req.appId, String(req.params.metric), req.query.days);
if (!series) return res.status(404).json({ error: 'Unknown metric' });
if (!series) return res.status(404).json({ error: 'Unknown metric' });
@@ -27,9 +27,9 @@ const db = require('../db/database');
@@ -27,9 +27,9 @@ const db = require('../db/database');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, namespaceGrant } = require('../auth');
const { limits } = require('../actor-limits');
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
// 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.
// worker with one or two slots, so a caller queues or approves at most one every six seconds.
const QUEUE = { minute: 10, hour: 200 };
const QUEUE = { minute: 10, hour: 200 };
@@ -31,7 +31,7 @@ async function shape(req, row) {
@@ -31,7 +31,7 @@ async function shape(req, row) {
}
}
// Reads, with the default per-actor limits (server/actor-limits.js).
// 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 {
try {
const rows = [];
const rows = [];
for (const r of await namespaces.listForTenant(req.appId)) if ((await namespaceGrant(req, 'list', r.namespace)).allowed) rows.push(r);
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
@@ -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 {
try {
const named = namespaces.resolveName(req.appRow, req.params.namespace);
const named = namespaces.resolveName(req.appRow, req.params.namespace);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
if (named.error) return problem(res, 400, 'media.namespace.invalid', named.error);
@@ -58,10 +58,10 @@ const { limits } = require('../actor-limits');
@@ -58,10 +58,10 @@ const { limits } = require('../actor-limits');
const MB = 1024 * 1024;
const MB = 1024 * 1024;
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
// 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();
const appOnly = tenantAuth();
// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the
// 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
// 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) {
@@ -126,7 +126,7 @@ async function contentAuth(req, res, next) {
/** Multipart session token (from POST /:id/multipart) or the usual tenant credential with `verb`. */
/** Multipart session token (from POST /:id/multipart) or the usual tenant credential with `verb`. */
function multipartAuth(verb) {
function multipartAuth(verb) {
- const fallback = tenantAuth({ verb, namespaced: true });
+ const fallback = tenantAuth({ verb, namespaced: true, project: true });
return async (req, res, next) => {
return async (req, res, next) => {
const token = req.query.token || req.headers['x-upload-token'];
const token = req.query.token || req.headers['x-upload-token'];
if (!token) return fallback(req, res, next);
if (!token) return fallback(req, res, next);
@@ -39,7 +39,7 @@ function _imageFromRequest(req) {
@@ -39,7 +39,7 @@ function _imageFromRequest(req) {
return null;
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 {
try {
const kind = String(req.params.kind || '').toLowerCase();
const kind = String(req.params.kind || '').toLowerCase();
const id = String(req.params.id);
const id = String(req.params.id);
@@ -135,7 +135,7 @@ const flag = (v) => (['1', 'true'].includes(String(v)) ? 1 : ['0', 'false'].incl
@@ -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).
// JSON body: cut a window out of a VOD (202, background cut + webhook).
// Multipart `video`: direct upload of an already-cut blob (201, ready) —
// Multipart `video`: direct upload of an already-cut blob (201, ready) —
// inherited browser-MediaRecorder clip path.
// 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 {
try {
const body = req.body || {};
const body = req.body || {};
if (req.file) return await _createUploadedClip(req, res);
if (req.file) return await _createUploadedClip(req, res);
@@ -299,7 +299,7 @@ async function _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)
// 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)
// 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)
// 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 {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@ -329,7 +329,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -329,7 +329,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
// ── Get clip ─────────────────────────────────────────────────
// ── 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.
// 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' });
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const clip = await _getClipScoped(req, res);
const clip = await _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
@@ -337,7 +337,7 @@ router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
@@ -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));
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 {
try {
const clip = await _getClipScoped(req, res);
const clip = await _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
@@ -360,7 +360,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -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,
* permanent — previously a failed clip stayed broken forever with no retry path,
* and the viewer was shown "the server is cutting your clip" indefinitely.
* 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 {
try {
const clip = await _getClipForWrite(req, res);
const clip = await _getClipForWrite(req, res);
if (!clip) return;
if (!clip) return;
@@ -377,7 +377,7 @@ router.post('/:id/recut', tenantAuth(), async (req, res) => {
@@ -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 {
try {
const clip = await _getClipForWrite(req, res);
const clip = await _getClipForWrite(req, res);
if (!clip) return;
if (!clip) return;
@@ -400,7 +400,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
@@ -400,7 +400,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
});
});
// ── Delete ───────────────────────────────────────────────────
// ── Delete ───────────────────────────────────────────────────
-router.delete('/:id', tenantAuth(), async (req, res) => {
+router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
try {
try {
const clip = await _getClipForWrite(req, res);
const clip = await _getClipForWrite(req, res);
if (!clip) return;
if (!clip) return;
@@ -95,7 +95,7 @@ async function _getVodForWrite(req, res) {
@@ -95,7 +95,7 @@ async function _getVodForWrite(req, res) {
}
}
// ── Create VOD ───────────────────────────────────────────────
// ── Create VOD ───────────────────────────────────────────────
-router.post('/', tenantAuth(), async (req, res) => {
+router.post('/', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
try {
const { title, stream_id, stream_key, managed_stream_id, user_id, meta, visibility, clips_only } = req.body || {};
const { title, stream_id, stream_key, managed_stream_id, user_id, meta, visibility, clips_only } = req.body || {};
const result = await db.createVod({
const result = await db.createVod({
@@ -118,7 +118,7 @@ router.post('/', tenantAuth(), async (req, res) => {
@@ -118,7 +118,7 @@ router.post('/', tenantAuth(), async (req, res) => {
});
});
// ── RTMP ingest ──────────────────────────────────────────────
// ── RTMP ingest ──────────────────────────────────────────────
-router.post('/:id/ingest/rtmp', tenantAuth(), async (req, res) => {
+router.post('/:id/ingest/rtmp', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
@@ -132,7 +132,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), async (req, res) => {
@@ -132,7 +132,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), async (req, res) => {
});
});
// ── RTP ingest ───────────────────────────────────────────────
// ── 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 {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
@@ -146,7 +146,7 @@ router.post('/:id/ingest/rtp/start', tenantAuth(), async (req, res) => {
@@ -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 {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
@@ -161,7 +161,7 @@ router.post('/:id/ingest/rtp/stop', tenantAuth(), async (req, res) => {
@@ -161,7 +161,7 @@ router.post('/:id/ingest/rtp/stop', tenantAuth(), async (req, res) => {
});
});
// ── Chunked upload (browser MediaRecorder; user JWT ok) ──────
// ── 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 {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
@@ -263,14 +263,14 @@ async function _finalizeHandler(req, res) {
@@ -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 ─────────────────────────────────────────────────────
// ── List ─────────────────────────────────────────────────────
// Filters follow the inherited query shapes: user_id, stream_id,
// Filters follow the inherited query shapes: user_id, stream_id,
// managed_stream_id, include_private (private+unlisted too), order
// managed_stream_id, include_private (private+unlisted too), order
// (newest|oldest|views|peak_viewers), since (created at or after), limit/offset.
// (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 {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
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);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
@@ -300,7 +300,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -300,7 +300,7 @@ router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
// ── Latest VOD thumbnail per managed stream (batch) ──────────
// ── Latest VOD thumbnail per managed stream (batch) ──────────
// GET /latest-thumbs?managed_stream_ids=1,2,3 → { "<msid>": { vod_id, thumbnail_url } }
// 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 {
try {
const ids = String(req.query.managed_stream_ids || '').split(',').filter(Boolean);
const ids = String(req.query.managed_stream_ids || '').split(',').filter(Boolean);
res.json({ thumbs: await db.latestVodThumbsByManagedStreams(req.appId, ids) });
res.json({ thumbs: await db.latestVodThumbsByManagedStreams(req.appId, ids) });
@@ -314,7 +314,7 @@ router.get('/latest-thumbs', tenantAuth({ allowUser: true }), async (req, res) =
@@ -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
// 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
// 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.
// 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' });
if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
const vod = await _getVodScoped(req, res);
const vod = await _getVodScoped(req, res);
if (!vod) return;
if (!vod) return;
@@ -323,7 +323,7 @@ router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
@@ -323,7 +323,7 @@ router.get('/:id/signed-url', tenantAuth(), async (req, res) => {
});
});
// ── Get VOD meta ─────────────────────────────────────────────
// ── Get VOD meta ─────────────────────────────────────────────
-router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
+router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, res) => {
try {
try {
const vod = await _getVodScoped(req, res);
const vod = await _getVodScoped(req, res);
if (!vod) return;
if (!vod) return;
@@ -355,7 +355,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -355,7 +355,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
});
});
// ── Update ───────────────────────────────────────────────────
// ── Update ───────────────────────────────────────────────────
-router.put('/:id', tenantAuth(), async (req, res) => {
+router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
try {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
@@ -380,7 +380,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
@@ -380,7 +380,7 @@ router.put('/:id', tenantAuth(), async (req, res) => {
});
});
// ── Delete ───────────────────────────────────────────────────
// ── Delete ───────────────────────────────────────────────────
-router.delete('/:id', tenantAuth(), async (req, res) => {
+router.delete('/:id', tenantAuth({ verb: 'delete' }), async (req, res) => {
try {
try {
const vod = await _getVodForWrite(req, res);
const vod = await _getVodForWrite(req, res);
if (!vod) return;
if (!vod) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,215 @@
@@ -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); });