openvibers/openvibe.mediahub Commits

Add agent file grants and source library view (#390)

OP OpenVibers committed 2 days ago · c7c56406a1 · 14 files, +337 −9

* Add agent file grants and source library view

* Document agent file grant routes

* Include agent grants in account data

Changes

ddiff --git a/README.md b/README.md
iindex 2ca1a94..4f07343 100644
---- a/README.md
++++ b/README.md
@@@ -82,7 +82,13 @@ The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) l
The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
--Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.
++`download.file.read` is for agent tokens only, and reads one file only when its owner created an active grant for that
++agent. A capability never lists the drive or replaces the per-file grant. Every other drive write still requires a
++signed-in person (or that person's scoped sync key).
++
++The signed-in `/files` root and search also show that person's Pics images and ready Video uploads as read-only
++entries. Their bytes and lifecycle remain with Pics and Video; opening an entry goes to its owning service. Those
++source entries are not yet eligible for Download agent grants, which apply to files uploaded into the drive.
On other services it calls:
@@@ -406,12 +412,15 @@ local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENV
| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |
| `GET /folders`, `POST /folders` | your folders |
| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |
++| `GET /agent-grants`, `POST /agent-grants`, `DELETE /agent-grants/:id` | list, make and revoke a read-only grant for one agent and one file; grants expire after 1–720 hours |
++| `GET /agent/files/:id/content` | agent-only `download.file.read`; streams the granted file with byte-range support |
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /usage` | your usage against the limits |
On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
--with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`
--routes are unchanged and remain a signed-in person only.
++with the right capability, or (for public reads) no token — see **Playlist API** below. On the drive API, the
++agent file-content route requires both an agent token with `download.file.read` and that agent's active per-file grant.
++Other drive routes remain person-only, except for the owner's scoped sync key.
## Playlist API
ddiff --git a/migrations/0031_agent_grants.sql b/migrations/0031_agent_grants.sql
nnew file mode 100644
iindex 0000000..9fdc0f3
---- /dev/null
++++ b/migrations/0031_agent_grants.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- Read-only agent access to a person's explicitly selected files. A capability is necessary to use the read route,
++-- but each row is also bound to one file, one owner and one agent subject; grants can be revoked or expire.
++CREATE TABLE mh_agent_grants (
++ id text COLLATE "C" PRIMARY KEY, -- agr_<ULID>
++ owner text COLLATE "C" NOT NULL, -- user:usr_…
++ file_id text COLLATE "C" NOT NULL REFERENCES mh_files (id) ON DELETE CASCADE,
++ agent_subject text COLLATE "C" NOT NULL, -- agent:agt_…
++ created_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C" NOT NULL,
++ revoked_at text COLLATE "C"
++);
++
++CREATE INDEX mh_agent_grants_owner ON mh_agent_grants (owner, created_at DESC);
++CREATE INDEX mh_agent_grants_live ON mh_agent_grants (file_id, agent_subject, expires_at)
++ WHERE revoked_at IS NULL;
ddiff --git a/server/account.js b/server/account.js
iindex 4ff65ae..7faf808 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -30,6 +30,7 @@ const TABLES = [
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
// Radio publications refer to drive files. Export and erase them before the file rows cascade.
{ table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },
++ { table: 'mh_agent_grants', subject: 'owner', value: AS_REQUESTER, file: 'agent_grants.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
ddiff --git a/server/drive/service.js b/server/drive/service.js
iindex 8ae0a2a..36bc4fc 100644
---- a/server/drive/service.js
++++ b/server/drive/service.js
@@@ -146,6 +146,7 @@ function createService({ s, media, identity, config, log = console }) {
const row = await ownFile(owner, id);
if (!row) return { error: 'file.not_found' };
await store.deleteSharesForFile(s, id);
++ await store.revokeGrantsForFile(s, id, nowIso());
await store.binFile(s, id, nowIso());
return { file: await store.getFile(s, id) };
}
@@@ -163,12 +164,43 @@ function createService({ s, media, identity, config, log = console }) {
/** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */
async function hardDelete(row) {
await store.deleteSharesForFile(s, row.id);
++ await store.revokeGrantsForFile(s, row.id, nowIso());
await store.softDeleteFile(s, row.id, nowIso());
const removed = await media.remove(row.object_id);
if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
return { file: row, media: removed };
}
++ /** Give one agent read access to one of this person's files for a bounded period. */
++ async function createAgentGrant(owner, { fileId, agentSubject, hours = 24 }) {
++ const file = await ownFile(owner, fileId);
++ if (!file) return { error: 'file.not_found' };
++ if (!/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(agentSubject || ''))) return { error: 'agent.invalid' };
++ const ttl = Number(hours);
++ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 720) return { error: 'grant.invalid_expiry' };
++ const createdAt = nowIso();
++ return { grant: await store.insertAgentGrant(s, {
++ id: s.newId('agr'), owner, file_id: file.id, agent_subject: agentSubject,
++ created_at: createdAt, expires_at: new Date(Date.parse(createdAt) + ttl * 60 * 60 * 1000).toISOString(),
++ }) };
++ }
++
++ async function revokeAgentGrant(owner, id) {
++ const row = await store.getAgentGrant(s, id);
++ if (!row || row.owner !== owner) return { error: 'grant.not_found' };
++ await store.revokeAgentGrant(s, id, owner, nowIso());
++ return { grant: await store.getAgentGrant(s, id) };
++ }
++
++ const listAgentGrants = (owner, fileId = null) => store.listAgentGrants(s, owner, fileId);
++ const activeAgentGrant = (fileId, agentSubject) => store.activeAgentGrant(s, fileId, agentSubject, nowIso());
++ async function agentFile(fileId, agentSubject) {
++ const grant = await activeAgentGrant(fileId, agentSubject);
++ if (!grant) return null;
++ const row = await store.getFile(s, fileId);
++ return row && !row.deleted_at && !row.binned_at ? row : null;
++ }
++
/** Delete one binned file forever. Only a file already in the bin resolves here. */
async function purgeFile(owner, id) {
const row = await ownBinnedFile(owner, id);
@@@ -414,6 +446,28 @@ function createService({ s, media, identity, config, log = console }) {
// ── Reads the pages and the API both need (no SQL outside this module) ──
const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
++ /** Read-only source records from Pics and Video in the same MediaHub database; bytes remain with each product. */
++ async function listSourceFiles(owner, { query = '', include = true } = {}) {
++ if (!include) return [];
++ const q = String(query || '').trim();
++ const like = q ? `%${q.replace(/[\\%_]/g, '\\$&')}%` : null;
++ const [pictures, videos] = await Promise.all([
++ s.db.many(
++ `SELECT id, slug, title, object_id, mime_type AS content_type, bytes AS size, created_at
++ FROM pics_images WHERE owner = $1 AND state IN ('active', 'hidden')
++ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR slug ILIKE $2 ESCAPE '\\')
++ ORDER BY id DESC LIMIT 200`, [owner, like]),
++ s.db.many(
++ `SELECT id, title, media_vod_id, content_type, size_bytes AS size, created_at
++ FROM video_uploads WHERE owner = $1 AND state <> 'removed' AND status = 'ready' AND media_vod_id IS NOT NULL
++ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR id ILIKE $2 ESCAPE '\\')
++ ORDER BY id DESC LIMIT 200`, [owner, like]),
++ ]);
++ return [
++ ...pictures.map((row) => ({ ...row, name: String(row.title || '').trim() || `Picture ${row.slug}`, source: 'pics', source_label: 'OpenVibe.Pics', source_url: `${config.pics.url}/i/${encodeURIComponent(row.slug)}` })),
++ ...videos.map((row) => ({ ...row, name: String(row.title || '').trim() || 'Video', source: 'video', source_label: 'OpenVibe.Video', source_url: `${config.video.url}/u/${encodeURIComponent(row.id)}` })),
++ ];
++ }
const listBinned = (owner, opts) => store.listBinned(s, owner, opts);
const binCount = (owner) => store.countBinned(s, owner);
const listFolders = (owner) => store.listFolders(s, owner);
@@@ -426,9 +480,10 @@ function createService({ s, media, identity, config, log = console }) {
uploadAllowed, shareAllowed, usage, dayOf,
recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,
renameFile, moveFile, searchFiles,
-- listFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,
++ listFiles, listSourceFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,
ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
createShare, ownShare, revokeShare,
++ createAgentGrant, revokeAgentGrant, listAgentGrants, activeAgentGrant, agentFile,
report, restoreShare, removeShared,
stateOf, allowedFor, parseAllow,
};
ddiff --git a/server/drive/store.js b/server/drive/store.js
iindex d5bf1fc..06a9ea5 100644
---- a/server/drive/store.js
++++ b/server/drive/store.js
@@@ -17,6 +17,7 @@ const SHARES = 'mh_shares';
const REPORTS = 'mh_reports';
const USAGE = 'mh_usage';
const UPLOADS = 'mh_uploads';
++const AGENT_GRANTS = 'mh_agent_grants';
// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────
@@@ -126,6 +127,29 @@ const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = d
const deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);
const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);
++// ── Agent grants ──────────────────────────────────────────────────────────────────────────────────────────────
++
++async function insertAgentGrant(s, row) {
++ await s.db.query(
++ `INSERT INTO ${AGENT_GRANTS} (id, owner, file_id, agent_subject, created_at, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6)`,
++ [row.id, row.owner, row.file_id, row.agent_subject, row.created_at, row.expires_at]);
++ return getAgentGrant(s, row.id);
++}
++
++const getAgentGrant = (s, id) => s.db.maybe(`SELECT * FROM ${AGENT_GRANTS} WHERE id = $1`, [id]);
++const listAgentGrants = (s, owner, fileId = null) => s.db.many(
++ `SELECT g.*, f.name AS file_name FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id
++ WHERE g.owner = $1 AND ($2::text IS NULL OR g.file_id = $2) ORDER BY g.created_at DESC`, [owner, fileId]);
++const activeAgentGrant = (s, fileId, agentSubject, nowIso) => s.db.maybe(
++ `SELECT g.* FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id
++ WHERE g.file_id = $1 AND g.agent_subject = $2 AND g.revoked_at IS NULL AND g.expires_at > $3
++ AND f.deleted_at IS NULL AND f.binned_at IS NULL`, [fileId, agentSubject, nowIso]);
++const revokeAgentGrant = (s, id, owner, at) => s.db.exec(
++ `UPDATE ${AGENT_GRANTS} SET revoked_at = $3 WHERE id = $1 AND owner = $2 AND revoked_at IS NULL`, [id, owner, at]);
++const revokeGrantsForFile = (s, fileId, at) => s.db.exec(
++ `UPDATE ${AGENT_GRANTS} SET revoked_at = $2 WHERE file_id = $1 AND revoked_at IS NULL`, [fileId, at]);
++
// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────
/** Record a report. The primary key is the truth: one per person per share, raced or not. */
@@@ -200,6 +224,7 @@ module.exports = {
insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
deleteSharesForFile, deleteSharesForFolder,
++ insertAgentGrant, getAgentGrant, listAgentGrants, activeAgentGrant, revokeAgentGrant, revokeGrantsForFile,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
bumpUploads, uploadsToday, usageFor,
insertUpload, getUpload, setUploadFile, dropUpload,
ddiff --git a/server/drive/ui.js b/server/drive/ui.js
iindex e906717..6a1da7e 100644
---- a/server/drive/ui.js
++++ b/server/drive/ui.js
@@@ -105,7 +105,7 @@ function when(iso) {
/** The thumbnail of a file: its own picture when it is an image, else a tile in its kind's colour with its icon. */
function thumb(row, { big = false } = {}) {
const kind = kindOf(row);
-- if (rules.isPreviewable(row.content_type)) {
++ if (!row.source && rules.isPreviewable(row.content_type)) {
return html`<span class="dr-thumb is-pic" data-kind="${kind}"><img src="/files/${row.id}/preview?w=${big ? 640 : 96}" alt="" loading="lazy" decoding="async"></span>`;
}
const ext = extOf(row.name);
@@@ -137,6 +137,13 @@ ${thumb(row, { big: true })}
<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form>
<form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form>
</div></li>`;
++ }
++ if (row.source) {
++ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
++<a class="dr-file-open" href="${row.source_url}">${thumb(row, { big: true })}
++<span class="dr-file-text"><span class="dr-name">${row.name}</span>
++<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>${when(row.created_at)}</span><span class="dr-where">${row.source_label}</span></span></span></a>
++<a class="dr-btn is-quiet" href="${row.source_url}">Open in ${row.source_label.replace('OpenVibe.', '')}</a></li>`;
}
return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 13cf952..230ff98 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -29,6 +29,8 @@
* apart from the rate limiter's `rate_limited`, and answered before anything is written.
*/
const express = require('express');
++const { Readable } = require('stream');
++const { pipeline } = require('stream/promises');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
@@@ -56,7 +58,7 @@ function readBody(req, cap) {
}
function createApi(ctx) {
-- const { config, s, principal, limits, drive, uploads, syncKeys, drops } = ctx;
++ const { config, s, principal, limits, drive, uploads, syncKeys, drops, media } = ctx;
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@@ -283,6 +285,56 @@ function createApi(ctx) {
return res.json({ file: wireFile(out.file) });
});
++ // A person grants one agent access to one file; the read capability never expands that grant to a drive listing.
++ r.get('/agent-grants', person, limits.reads('media-hub.file.read'), async (req, res) => {
++ const fileId = req.query.file_id ? String(req.query.file_id) : null;
++ if (fileId && !rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');
++ return res.json({ grants: await drive.listAgentGrants(personOf(req), fileId) });
++ });
++
++ r.post('/agent-grants', person, limits.budget('media-hub.file.grant'), async (req, res) => {
++ const b = req.body || {};
++ const fileId = String(b.file_id || '');
++ if (!rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');
++ const out = await drive.createAgentGrant(personOf(req), { fileId, agentSubject: b.agent_subject, hours: b.hours });
++ if (out.error) return problem(req, res, out.error === 'file.not_found' ? 404 : 422, out.error, 'Choose one of your files, a valid agent subject, and an expiry from 1 to 720 hours.');
++ res.set('Location', `/api/v1/agent-grants/${out.grant.id}`);
++ return res.status(201).json({ grant: out.grant });
++ });
++
++ r.delete('/agent-grants/:id', person, async (req, res) => {
++ const out = await drive.revokeAgentGrant(personOf(req), String(req.params.id || ''));
++ if (out.error) return problem(req, res, 404, 'grant.not_found', 'No such grant.');
++ return res.status(204).end();
++ });
++
++ // Link workers use this read-only endpoint for job inputs. Both the capability and the owner's file grant are
++ // required. Stream through Media so a provider URL never becomes the agent's durable credential.
++ r.get('/agent/files/:id/content', principal.requireCapability('download.file.read'), limits.reads('media-hub.file.read'), async (req, res) => {
++ const caller = req.principal;
++ if (!caller || caller.kind !== 'app' || !/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(caller.requester || '')) {
++ return problem(req, res, 403, 'agent.required', 'This endpoint is for an agent with download.file.read.');
++ }
++ const fileId = String(req.params.id || '');
++ const row = rules.isFileId(fileId) ? await drive.agentFile(fileId, caller.requester) : null;
++ if (!row) {
++ return problem(req, res, 404, 'grant.not_found', 'No active grant for this file.');
++ }
++ const result = await media.bytes(row.object_id, { range: req.get('range') || null });
++ if (!result.ok) return problem(req, res, result.status || 502, result.code || 'media.unreachable', result.detail || 'OpenVibe.Media could not read this file.');
++ const upstream = result.res;
++ res.status(result.status);
++ res.set('Content-Type', row.content_type || 'application/octet-stream');
++ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(row.name)}`);
++ res.set('Accept-Ranges', 'bytes');
++ for (const header of ['content-length', 'content-range', 'etag', 'last-modified']) {
++ const value = upstream.headers.get(header);
++ if (value) res.set(header, value);
++ }
++ if (!upstream.body) return res.end();
++ await pipeline(Readable.fromWeb(upstream.body), res);
++ });
++
// ── Folders ─────────────────────────────────────────────
r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
const rows = await drive.listFolders(personOf(req));
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 02859d9..2b3d84d 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -44,6 +44,7 @@ const BUDGETS = {
'media-hub.upload.part': { minute: 300, hour: 5_000 },
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.folder.create': { minute: 20, hour: 120 },
++ 'media-hub.file.grant': { minute: 10, hour: 60 },
'media-hub.sync_key.create': { minute: 5, hour: 20 },
// A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.
'media-hub.drop.start': { minute: 10, hour: 40 },
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex b949253..aa85346 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -108,6 +108,30 @@ route('post', '/files/{id}/restore', {
responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },
});
++// ── Agent file grants ─────────────────────────────────────────────────────────────────────────
++route('get', '/agent-grants', {
++ operationId: 'listAgentGrants', summary: 'List your file grants', tags: ['agent grants'], security: PERSON,
++ parameters: [query('file_id', str(), 'Limit the list to one of your files')],
++ responses: { 200: json('Your grants', { type: 'object', properties: { grants: arrayOf({ type: 'object' }) } }), ...errors([401, 429]) },
++});
++route('post', '/agent-grants', {
++ operationId: 'createAgentGrant', summary: 'Give one agent read access to one file', tags: ['agent grants'], security: PERSON,
++ description: 'The grant is bound to one agent subject and expires after 1 to 720 hours. The agent also needs the download.file.read capability.',
++ requestBody: body({ type: 'object', required: ['file_id', 'agent_subject'], properties: { file_id: str(), agent_subject: str(), hours: integer({ minimum: 1, maximum: 720, default: 24 }) } }),
++ responses: { 201: json('The grant', { type: 'object', properties: { grant: { type: 'object' } } }), ...errors([401, 404, 422, 429]) },
++});
++route('delete', '/agent-grants/{id}', {
++ operationId: 'revokeAgentGrant', summary: 'Revoke an agent file grant', tags: ['agent grants'], security: PERSON,
++ parameters: [path('id', str())],
++ responses: { 204: { description: 'Revoked' }, ...errors([401, 404, 429]) },
++});
++route('get', '/agent/files/{id}/content', {
++ operationId: 'readGrantedAgentFile', summary: 'Read a file granted to this agent', tags: ['agent grants'], security: [{ bearer: [] }],
++ description: 'Requires the download.file.read capability and an active grant from the file owner. Supports Range requests; the response streams bytes without exposing a provider URL.',
++ parameters: [path('id', str()), { name: 'Range', in: 'header', required: false, schema: str() }],
++ responses: { 200: { description: 'File bytes' }, 206: { description: 'Requested byte range' }, ...errors([401, 403, 404, 416, 429, 502]) },
++});
++
// ── Folders ────────────────────────────────────────────────────────────────────────────────────
route('get', '/folders', {
operationId: 'listFolders', summary: 'Your folders', tags: ['folders'], security: PERSON,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2065c76..5b79364 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -340,7 +340,11 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
if (sort === 'name') folders.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));
else if (sort === 'new') folders.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
else if (sort === 'old') folders.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
-- const files = sortRows(q ? await drive.searchFiles(owner, q, { limit: 200 }) : await drive.listFiles(owner, { folderId: here, limit: 200 }), sort);
++ const [ownFiles, sourceFiles] = await Promise.all([
++ q ? drive.searchFiles(owner, q, { limit: 200 }) : drive.listFiles(owner, { folderId: here, limit: 200 }),
++ drive.listSourceFiles(owner, { query: q, include: Boolean(q || !here) }),
++ ]);
++ const files = sortRows([...ownFiles, ...sourceFiles], sort);
const liveShares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
const showShares = !q && !folder && liveShares.length > 0;
const shareNames = new Map();
@@@ -442,6 +446,7 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
const row = await drive.ownFile(owner, String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
++ const agentGrants = await drive.listAgentGrants(owner, row.id);
const allFolders = await drive.listFolders(owner);
const labels = folderLabels(allFolders);
const path = row.folder_id ? await drive.folderPath(owner, row.folder_id) : [];
@@@ -486,6 +491,15 @@ ${shares.length ? html`<h3 class="fv-h3">Links to this file</h3><ul class="dr-li
${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button><form method="post" action="/shares/${sh.slug}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`; })}</ul>` : ''}
</section>
++<section class="fv-card" id="agent-access" aria-labelledby="h-agent-access"><h2 id="h-agent-access">Give an agent access</h2>
++<p>Read access to this file only. The grant expires automatically and you can revoke it here. The agent must already have the <code>download.file.read</code> capability.</p>
++<form class="fv-row" method="post" action="/files/${row.id}/agent-grants"><label for="agent-subject">Agent subject</label><input id="agent-subject" name="agent_subject" type="text" placeholder="agent:agt_…" required><label for="agent-hours">Hours</label><input id="agent-hours" name="hours" type="number" min="1" max="720" value="24" required><button class="dr-btn" type="submit">Grant read access</button></form>
++${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul class="dr-links">${agentGrants.map((grant) => {
++ const live = !grant.revoked_at && grant.expires_at > s.iso();
++ return html`<li class="dr-link"><span class="dr-link-main"><code>${grant.agent_subject}</code><small>${live ? 'Read access until' : grant.revoked_at ? 'Revoked' : 'Expired'} ${live ? time(grant.expires_at) : ''}</small></span>${live ? html`<form method="post" action="/agent-grants/${grant.id}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`;
++ })}</ul>` : ''}
++</section>
++
<section class="fv-card" id="move" aria-labelledby="h-move"><h2 id="h-move">${ui.icon('move')}Move</h2>
<form class="fv-row" method="post" action="/files/${row.id}/move"><label class="sr-only" for="move-to">Move to</label>
<select id="move-to" name="to"><option value=""${!row.folder_id ? raw(' selected') : ''}>My files (the top)</option>${choices.map(([id, label]) => html`<option value="${id}"${id === row.folder_id ? raw(' selected') : ''}>${label}</option>`)}</select>
@@@ -624,6 +638,29 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
});
});
++ r.post('/files/:id/agent-grants', mustSignIn('/files'), budgeted('media-hub.file.grant'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const owner = requesterOf(req);
++ const out = await drive.createAgentGrant(owner, {
++ fileId: String(req.params.id || ''),
++ agentSubject: String(req.body.agent_subject || '').trim(),
++ hours: req.body.hours,
++ });
++ if (out.error) return page(req, res, {
++ title: 'Agent access not granted', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
++ body: html`<h1>Agent access not granted</h1><p>${out.error === 'file.not_found' ? 'No such file.' : 'Use an agent subject such as agent:agt_… and an expiry from 1 to 720 hours.'}</p><p><a href="/files/${req.params.id}#agent-access">Back to this file</a></p>`,
++ }, 422);
++ return res.redirect(303, `/files/${req.params.id}#agent-access`);
++ });
++
++ r.post('/agent-grants/:id/revoke', mustSignIn('/files'), async (req, res) => {
++ if (!sameSite(req, res, '/files')) return undefined;
++ const out = await drive.revokeAgentGrant(requesterOf(req), String(req.params.id || ''));
++ const from = String(req.body.from || '');
++ if (!out.error && rules.isFileId(from) && out.grant.file_id === from) return res.redirect(303, `/files/${from}#agent-access`);
++ return res.redirect(303, '/files');
++ });
++
r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {
if (!sameSite(req, res, '/files')) return undefined;
const { folder } = await folderOf(req);
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 442cffc..144d3e8 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// route can never be guarded by a capability the service does not declare.
--const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];
++const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage', 'download.file.read'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/test/agent-grants.test.js b/test/agent-grants.test.js
nnew file mode 100644
iindex 0000000..7c27053
---- /dev/null
++++ b/test/agent-grants.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++
++/** Per-file agent access: an owner grants one agent a bounded read, range reads stream from Media, and revocation wins. */
++const assert = require('assert');
++const crypto = require('crypto');
++const { ids, serviceAuth } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { bytes, upload, SAME } = require('./helpers/drive');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('owner');
++ const fileBytes = bytes(512, 'granted file');
++ const file = (await upload(t, owner, fileBytes, { name: 'agent-input.bin' })).json().file;
++ const agentSubject = `agent:${ids.newId('agent')}`;
++ const projectId = ids.newId('project');
++ const hostId = ids.newId('app');
++ const tokenFor = (caps) => serviceAuth.signServiceToken({
++ iss: t.network.url, sub: agentSubject, actor_type: 'agent', aud: ['openvibe.download'],
++ project_id: projectId, env: 'production', ns: [projectId], on_behalf_of: owner.subject, act: { sub: `app:${hostId}` },
++ cap: caps, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600,
++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ }, t.network.privatePem);
++ const agentBearer = tokenFor(['download.file.read']);
++
++ await check('a capability alone cannot list or read the owner drive', async () => {
++ assert.strictEqual((await t.get('/api/v1/files', { bearer: agentBearer })).status, 403);
++ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);
++ });
++
++ await check('the owner grants a single file, range reads work, and revocation takes effect', async () => {
++ const created = await t.get('/api/v1/agent-grants', {
++ as: owner, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject, hours: 24 },
++ });
++ assert.strictEqual(created.status, 201, created.text);
++ const grant = created.json().grant;
++ assert.strictEqual(grant.agent_subject, agentSubject);
++ assert.strictEqual(grant.file_id, file.id);
++
++ const range = await t.get(`/api/v1/agent/files/${file.id}/content`, {
++ bearer: agentBearer, headers: { range: 'bytes=8-23' },
++ });
++ assert.strictEqual(range.status, 206, `${range.text}\n${t.logs()}`);
++ assert.deepStrictEqual(range.buffer, fileBytes.subarray(8, 24));
++ assert.strictEqual(range.headers.get('content-range'), `bytes 8-23/${fileBytes.length}`);
++
++ const list = await t.get('/api/v1/agent-grants', { as: owner });
++ assert.ok(list.json().grants.some((g) => g.id === grant.id));
++ assert.strictEqual((await t.get(`/api/v1/agent-grants/${grant.id}`, { as: owner, method: 'DELETE', headers: SAME })).status, 204);
++ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);
++ });
++
++ await check('a non-owner cannot grant access to another person\'s file', async () => {
++ const stranger = t.network.addUser('stranger');
++ const r = await t.get('/api/v1/agent-grants', {
++ as: stranger, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject },
++ });
++ assert.strictEqual(r.status, 404);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex e809700..7051261 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
-- 'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
++ 'media-hub.drop.start', 'media-hub.file.grant', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create', 'media-hub.studio.publish', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
ddiff --git a/test/drive-sources.test.js b/test/drive-sources.test.js
nnew file mode 100644
iindex 0000000..bdf58b8
---- /dev/null
++++ b/test/drive-sources.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++/** The drive's root and search results include a person's read-only Pics and Video records. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const owner = `user:${kim.subject}`;
++ const stamp = '2026-11-10T10:00:00.000Z';
++ await t.ctx.s.db.exec(
++ `INSERT INTO pics_images (id, slug, owner, title, object_id, mime_type, ext, width, height, bytes, sha256, source_sha256, dhash, created_at, updated_at)
++ VALUES ('pic_01J0000000000000000000000A', 'AbCd1234', $1, 'Summer lake', 'med_pic_test', 'image/jpeg', 'jpg', 640, 480, 1234, 'a', 'b', '0123456789abcdef', $2, $2)`,
++ [owner, stamp]);
++ await t.ctx.s.db.exec(
++ `INSERT INTO video_uploads (id, owner, title, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)
++ VALUES ('vup_01J0000000000000000000000B', $1, 'Lake walk', 'private', 77, 'video/mp4', 5678, 100, 1, 'ready', 'active', $2, $2)`,
++ [owner, stamp]);
++ await t.ctx.s.db.exec(
++ `INSERT INTO video_uploads (id, owner, title, visibility, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)
++ VALUES ('vup_01J0000000000000000000000C', $1, 'Still uploading', 'private', 'video/mp4', 9, 9, 1, 'uploading', 'active', $2, $2)`,
++ [owner, stamp]);
++
++ await check('root includes own Pics and ready Video records with source links', async () => {
++ const page = await t.get('/files', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Summer lake') && page.text.includes('Open in Pics'));
++ assert.ok(page.text.includes('Lake walk') && page.text.includes('Open in Video'));
++ assert.ok(!page.text.includes('Still uploading'));
++ });
++ await check('search includes matching source records and excludes other owners', async () => {
++ const page = await t.get('/files?q=lake', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Summer lake') && page.text.includes('Lake walk'));
++ assert.ok(!page.text.includes('Still uploading'));
++ });
++ await done(t);
++})().catch((e) => { console.error(e); process.exitCode = 1; });