openvibers/openvibe.media Commits
Media: the resource index (GET /api/v1/resources, media.object) under media.resource.read; contracts v0.107.0 (T13 step 8). (#47)
Changes
@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.79.0, `openvibe-sdk` v0.32.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.107.0, `openvibe-sdk` v0.32.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v2.10.0, pinned by release tarball## Capabilities@@@ -56,6 +56,10 @@ namespace against the token's `ns`): `media.object.upload`, `media.object.read`,`media.lifecycle.read` and `media.lifecycle.transition`. First-party apps also use their tenant APIkey ([Tenancy & auth](#tenancy--auth)).++| Capability | Audience | Route | Notes |++|---|---|---|---|++| `media.resource.read` | first-party | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | the authority resource index ([Resource index](#resource-index-apiv1resources-first-party)). Its manifest is `planned` in openvibe-contracts until Network grants it after this deploys; the route already enforces it (a Network service token holding it, loopback, audience `openvibe.media`) |++Called elsewhere, as the service principal `media` (the OAuth client `media`):| Service | Grant | Why |@@@ -93,6 +97,7 @@ server/pastes/storage.js screenshot dir + slug minting kept after the pastes API was retiredfiles/routes.js /api/v1/:app/filesadmin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)++ registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)thumbnails/ thumbnail service + /api/v1/:app/thumbnailspublic/routes.js public /v /c /p /t /fme/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,@@@ -380,6 +385,37 @@ envelopes inside Media's own transactions (projection sync, `announce()`, soft dcommit with their change, and the relay drains anything else. No visibility event for an objectthat is deleted; none for sandbox tenants. `test/object-events.test.js`.++## Resource index (`/api/v1/resources`, first-party)++++Media's authority resource index (ADR-048 §3, plan T13 step 8): the resources Media owns, as++`common.resource-summary@1` — the shape OpenVibe.Services fans out over and merges. It lists Media's++**objects alone** (`kind` `media.object`, id `med_<ULID>`, the `media_objects` table): v1 vods, clips and++files are projections over objects (bigint ids) and are never listed.++++```++GET /api/v1/resources[?project=<prj_ id>&kind=<kind>&cursor=<opaque>&limit=<n>] → common.resource-list-result@1++GET /api/v1/resources/:ovrn → common.resource-summary@1++```++++| Param | Meaning |++|---|---|++| `project` | the tenancy boundary: a `prj_` id. Only that project's rows answer (its production and sandbox tenants); never another project's. Omitted, the first-party caller sees every Media resource |++| `kind` | `media.object` (the only kind Media owns). An unknown kind is an empty page, not an error |++| `cursor` | opaque keyset cursor from the previous page's `next_cursor`, ordered by id |++| `limit` | page size, default 100, max 1000 |++++Auth is a Network service token holding `media.resource.read`, loopback only (audience `openvibe.media`;++`server/service-guard.js`) — the same first-party check as the other internal routes. No token is++401, a token without the capability 403. A bad query is 400 `resources.bad_query`; an unknown or++unnameable resource is 404 `resources.unknown_resource`.++++An object's `project_id` is its tenant's (`apps.project_id`); a first-party tenant (`live`, …) has none, so++its objects carry no `project_id` and no `ovrn`. The summary's `ovrn` is composed by openvibe-contracts'++`contracts.resources.nameOf` — the one formatter — so it is present exactly when the object has a project++(`ovrn:media:<project_id>:object/<med_…>`); `GET /api/v1/resources/:ovrn` reads one by that name, and only a++resource whose computed name equals the one asked for answers. `owner` is a `subject-ref` when the object++stores a `usr_` owner. `test/resource-index.test.js`.++## Restore drills (`MEDIA_DRILL=1`)A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg","port": 4100,"domain": "openvibe.media",-- "contracts": "openvibe-contracts v0.79.0",++ "contracts": "openvibe-contracts v0.107.0","shared": "openvibe-shared v2.10.0","capabilities": ["media.object.upload",@@@ -46,5 +46,5 @@"HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own","materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"],-- "updated": "2026-10-05"++ "updated": "2026-10-07"}@@@ -17,7 +17,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0","pg": "^8.23.0",@@@ -2010,9 +2010,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.79.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",++ "version": "0.107.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",++ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -21,7 +21,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0","pg": "^8.23.0",@@@ -218,6 +218,11 @@ const ready = (async () => {const me = require('./me/routes').createMeRoutes({ auth: userAuthClient });app.use('/api/v2/me', me.api);app.use('/me', me.pages);++ // The authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read): the resources++ // Media owns — its media objects — as common.resource-summary@1, for OpenVibe.Services to fan out and merge.++ // Loopback and a Network service token, like the other first-party routes (server/service-guard.js); mounted++ // ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".++ app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));app.use('/api/v1/:app/views', require('./views/routes'));app.use('/api/v1/:app/vods', require('./vod/routes'));app.use('/api/v1/:app/clips', require('./vod/clips-routes'));nnew file mode 100644@@@ -0,0 +1,152 @@++'use strict';++/**++ * Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):++ *++ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1++ * GET /api/v1/resources/:ovrn → common.resource-summary@1++ *++ * It pages the resources OpenVibe.Media owns — its media objects (med_<ULID>, media_objects) — as++ * common.resource-summary@1, the shape OpenVibe.Services fans out over and merges. Media lists objects++ * alone: its v1 vods, clips and files are projections over objects (bigint ids) and are never listed++ * (ADR-048, amended 2026-10-07).++ *++ * Tenancy: an object's boundary is the project of the tenant (apps) that owns it — the app_id -> apps.project_id++ * join below. An object under a developer-project tenant carries that project; one under a first-party app++ * tenant ('live', …) has no project and so no OVRN. `?project=prj_…` scopes to that project's rows only — a++ * resource of another project is never returned. Without it the first-party caller (the capability is++ * first-party, resourceConstraints none) sees every Media resource, which is what an authority-wide fan-out++ * needs; if media.resource.read is ever granted to a non-first-party principal, derive the scope from that++ * principal's grants here instead of trusting the query.++ *++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one formatter,++ * so it is present exactly when the object has a project (nameOf composes ovrn:media:<prj_…>:object/<med_…>++ * and returns null without one). That is also what GET /api/v1/resources/:ovrn reads: only a resource whose++ * computed ovrn equals the one asked for answers.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++++const SERVICE = 'media';++const OBJECT_KIND = 'media.object';++const KINDS = [OBJECT_KIND];++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEFAULT_LIMIT = 100;++const MAX_LIMIT = 1000;++++/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */++const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);++++/**++ * Media stores timestamps as text in the `YYYY-MM-DD HH:MM:SS` UTC shape (migrations/0001_initial.sql++ * ov_now). common.resource-summary@1's created_at is an RFC 3339 date-time, so render that one; anything++ * already in another shape is passed through.++ */++function isoUtc(ts) {++ const s = String(ts || '');++ return /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(s) ? `${s.replace(' ', 'T')}Z` : s;++}++++/**++ * common.resource-summary@1 for a media_objects row joined with its tenant's project (`project` is the++ * `apps.project_id` alias, null for a first-party tenant). Media objects have no name column, so no name.++ */++function objectSummary(o) {++ return {++ id: o.id, kind: OBJECT_KIND, service: SERVICE,++ ...(o.project ? { project_id: o.project } : {}),++ ...(userRef(o.owner_subject) ? { owner: userRef(o.owner_subject) } : {}),++ state: o.lifecycle_status, created_at: isoUtc(o.created_at),++ };++}++++/** The summary's ovrn, or null when it has none. nameOf composes only with a project_id and a med_ id. */++function ovrnOf(summary) {++ return contracts.resources.nameOf(summary);++}++++/** The summary with its ovrn attached when it has one. */++function named(summary) {++ const ovrn = ovrnOf(summary);++ return ovrn ? { ...summary, ovrn } : summary;++}++++/** A stable (kind, id) ordering, so a cursor can be a position in it. */++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);++++/** The object + tenant-project select every read shares. */++const SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects o++ LEFT JOIN apps a ON a.app_id = o.app_id`;++++/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */++async function collect(db, { project = null, kind = null } = {}) {++ if (kind && kind !== OBJECT_KIND) return [];++ const rows = project++ ? await db.all(`${SELECT_OBJECT} WHERE a.project_id = ? ORDER BY o.id`, [project])++ : await db.all(`${SELECT_OBJECT} ORDER BY o.id`);++ return rows.map((o) => named(objectSummary(o))).sort(order);++}++++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');++function decodeCursor(raw) {++ let v;++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }++ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;++}++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);++++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */++function filtersOf(query) {++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;++ let limit = DEFAULT_LIMIT;++ if (typeof query.limit === 'string' && query.limit !== '') {++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };++ limit = Number(query.limit);++ }++ let cursor = null;++ if (typeof query.cursor === 'string' && query.cursor !== '') {++ cursor = decodeCursor(query.cursor);++ if (!cursor) return { error: 'cursor is not one this index issued' };++ }++ return { project, kind, limit, cursor };++}++++function router({ guard, db = require('../db/database') }) {++ const open = (res) => res.set('Cache-Control', 'private, max-age=60');++ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });++ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });++++ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */++ async function page(req, res) {++ const f = filtersOf(req.query);++ if (f.error) return bad(res, f.error);++ const all = await collect(db, f);++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;++ const resources = rest.slice(0, f.limit);++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;++ open(res).json({ resources, next_cursor });++ }++++ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */++ async function one(req, res) {++ const name = String(req.params.ovrn);++ const parsed = contracts.resources.parse(name);++ let summary = null;++ if (parsed && parsed.service === SERVICE && parsed.type === 'object') {++ const row = await db.get(`${SELECT_OBJECT} WHERE o.id = ?`, [parsed.id]);++ if (row) summary = named(objectSummary(row));++ }++ if (!summary || summary.ovrn !== name) return unknown(res, name);++ open(res).json(summary);++ }++++ const r = express.Router();++ r.get('/', guard, page);++ r.get('/:ovrn', guard, one);++ return r;++}++++module.exports = { router, SERVICE, KINDS, OBJECT_KIND, DEFAULT_LIMIT, MAX_LIMIT, objectSummary, ovrnOf, collect, encodeCursor, isoUtc };nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):++// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects++// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists++// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&kind=&cursor=&limit=++// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another++// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf++// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++(async () => {++ const express = require('express');++ const { ids, serviceAuth, validate } = require('openvibe-contracts');++ const auth = require('../server/auth');++ const db = require('../server/db/database');++ const { guard } = require('../server/service-guard');++ const resourceIndex = require('../server/registry/resource-index');++++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ auth._setNetworkPublicKeyForTests(keys.publicKey);++++ const now = Math.floor(Date.now() / 1000);++ const tok = (over = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub: 'svc:network', actor_type: 'service',++ aud: ['openvibe.media'], cap: ['media.resource.read'], iat: now, exp: now + 300, jti: `tok_ri_${crypto.randomBytes(6).toString('hex')}`, ...over,++ }, keys.privateKey);++++ const app = express();++ app.use(express.json());++ app.use('/api/v1/resources', resourceIndex.router({ guard: guard('media.resource.read'), db }));++ const server = http.createServer(app);++++ // ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──++ const prjA = ids.newId('project'); const prjB = ids.newId('project');++ const usrA = ids.newId('user');++ const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339++ const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-owned++ const objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading++ const objA3 = ids.newId('media', 1700000003000); // project A, deleted++ const objB1 = ids.newId('media', 1700000004000); // project B, file++ const objL1 = ids.newId('media', 1700000005000); // first-party tenant: no project, no ovrn++ const objL2 = ids.newId('media', 1700000006000); // first-party tenant, user-owned: owner but no project++++ await db.run("INSERT INTO apps (app_id, name, api_key_hash, project_id, env) VALUES (?, 'Project A', '', ?, 'production'), (?, 'Project A sandbox', '', ?, 'sandbox'), (?, 'Project B', '', ?, 'production'), ('live', 'OpenVibe.Live', '', NULL, NULL)",++ [prjA, prjA, `${prjA}-sandbox`, prjA, prjB, prjB]);++ const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";++ const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);++ await insertObject(objA1, prjA, 'vod', usrA, 'ready');++ await insertObject(objA2, `${prjA}-sandbox`, 'vod', null, 'uploading');++ await insertObject(objA3, prjA, 'clip', null, 'deleted');++ await insertObject(objB1, prjB, 'file', null, 'ready');++ await insertObject(objL1, 'live', 'file', null, 'ready');++ await insertObject(objL2, 'live', 'file', usrA, 'ready');++++ const idOrder = [objA1, objA2, objA3, objB1, objL1, objL2].sort(); // one kind: (kind, id) is id order++ const all = [objA1, objA2, objA3, objB1, objL1, objL2];++ const ovrnOf = (project, id) => `ovrn:media:${project}:object/${id}`;++++ (async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));++ const authHeaders = { authorization: `Bearer ${tok()}` };++ try {++ // ── Auth matrix: 401 without a token, 403 with a token lacking the capability, 200 with it ──++ assert.strictEqual((await get('/api/v1/resources')).status, 401, 'no token is 401');++ assert.strictEqual((await get('/api/v1/resources', { authorization: 'Bearer ' + tok({ cap: ['media.object.read'] }) })).status, 403, 'a token without media.resource.read is 403');++ assert.strictEqual((await get('/api/v1/resources', { authorization: 'Bearer ' + tok({ aud: ['openvibe.network'] }) })).status, 401, 'another audience is 401');++ assert.strictEqual((await get('/api/v1/resources', authHeaders)).status, 200, 'a token with media.resource.read is 200');++++ const list = await get('/api/v1/resources', authHeaders);++ assert.strictEqual(list.headers.get('cache-control'), 'private, max-age=60');++ assert.deepStrictEqual(Object.keys(list.body).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');++ const pageCheck = validate('common.resource-list-result@1', list.body);++ assert.ok(pageCheck.valid, JSON.stringify(pageCheck.errors));++ list.body.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid, `${s.id}: ${JSON.stringify(validate('common.resource-summary@1', s).errors)}`));++ assert.strictEqual(list.body.next_cursor, null, 'one page holds the whole index');++ assert.deepStrictEqual(list.body.resources.map((r) => r.id), idOrder, 'every object, sorted by id');++ assert.deepStrictEqual([...new Set(list.body.resources.map((r) => r.kind))], ['media.object'], 'Media lists objects alone');++ assert.deepStrictEqual([...new Set(list.body.resources.map((r) => r.service))], ['media']);++++ const byId = new Map(list.body.resources.map((r) => [r.id, r]));++ assert.strictEqual(byId.get(objA1).project_id, prjA, 'a project tenant object carries its project');++ assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');++ assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');++ assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');++ assert.strictEqual(byId.get(objA1).state, 'ready');++ assert.strictEqual(byId.get(objA2).state, 'uploading');++ assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');++ [objL1, objL2].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party tenant object has no project`));++ [objL1, objL2].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project there is no name`));++ assert.strictEqual(byId.get(objL2).owner.id, usrA, 'owner is independent of project');++ assert.match(byId.get(objA1).created_at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/, 'created_at is RFC 3339, converted from the stored shape');++++ // ── ?project= is the tenancy boundary; ?kind= narrows; an unknown kind is an empty page ──++ const aIds = async (q, headers = authHeaders) => {++ const y = await get(`/api/v1/resources${q}`, headers);++ assert.strictEqual(y.status, 200, `${q}: ${JSON.stringify(y.body)}`);++ assert.ok(validate('common.resource-list-result@1', y.body).valid);++ y.body.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid));++ return y.body.resources.map((r) => r.id);++ };++ assert.deepStrictEqual((await aIds(`?project=${prjA}`)).sort(), [objA1, objA2, objA3].sort(), 'project A: its production and sandbox objects, never B nor first-party');++ assert.deepStrictEqual(await aIds(`?project=${prjB}`), [objB1], 'project B is never mixed in');++ assert.deepStrictEqual((await aIds(`?kind=media.object`)).sort(), idOrder, 'kind = media.object lists every object');++ assert.deepStrictEqual((await aIds(`?project=${prjA}&kind=media.object`)).sort(), [objA1, objA2, objA3].sort(), 'kind narrows within the project');++ assert.deepStrictEqual(await aIds('?kind=media.unknown'), [], 'an unknown kind is an empty page, not an error');++ assert.deepStrictEqual(await aIds('?kind=vod'), [], 'a v1 kind (a projection) is not a resource kind');++ assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');++ assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');++++ // ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──++ const seen = [];++ let cursor = null;++ let pages = 0;++ for (;;) {++ const y = await get(`/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, authHeaders);++ assert.strictEqual(y.status, 200);++ assert.ok(validate('common.resource-list-result@1', y.body).valid);++ seen.push(...y.body.resources.map((r) => r.id));++ if (y.body.next_cursor === null) { assert.strictEqual(seen.length, all.length, 'the cursor chain ends at the end'); break; }++ assert.ok(typeof y.body.next_cursor === 'string' && y.body.next_cursor !== '');++ cursor = y.body.next_cursor;++ if (++pages > 50) assert.fail('the cursor chain never ended');++ }++ assert.deepStrictEqual(seen, idOrder, 'no duplicates, none skipped, order preserved');++++ // ── GET /:ovrn: found, and every miss 404s ──++ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`, authHeaders);++ assert.strictEqual(one.status, 200, JSON.stringify(one.body));++ assert.strictEqual(one.headers.get('cache-control'), 'private, max-age=60');++ assert.ok(validate('common.resource-summary@1', one.body).valid);++ assert.deepStrictEqual(one.body, byId.get(objA1), 'the same summary the list answers');++ const oneSandbox = await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA2))}`, authHeaders);++ assert.strictEqual(oneSandbox.status, 200, 'a sandbox object is named by its project');++++ const missing = [++ ['a first-party object (no project)', ovrnOf(prjA, objL1)],++ ["another project's id", ovrnOf(prjB, objA1)],++ ['an unknown object', ovrnOf(prjA, ids.newId('media'))],++ ['a non-resource type', `ovrn:media:${prjA}:watch/${objA1}`],++ ['another service', `ovrn:watch:${prjA}:object/${objA1}`],++ ['a non-OVRN', 'nope'],++ ];++ for (const [why, name] of missing) {++ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, authHeaders);++ assert.strictEqual(y.status, 404, why);++ assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);++ assert.strictEqual(y.body.code, 'resources.unknown_resource', why);++ }++ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');++++ // ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──++ const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];++ for (const [q, why] of bad) {++ const y = await get(`/api/v1/resources${q}`, authHeaders);++ assert.strictEqual(y.status, 400, why);++ assert.strictEqual(y.body.code, 'resources.bad_query', why);++ }++++ console.log('resource index: all tests passed');++ } finally {++ server.close();++ }++ })().catch((err) => { console.error(err); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.79.0, `openvibe-sdk` v0.32.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.107.0, `openvibe-sdk` v0.32.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v2.10.0, pinned by release tarball
`openvibe-shared` v2.10.0, pinned by release tarball
## Capabilities
## Capabilities
@@ -56,6 +56,10 @@ namespace against the token's `ns`): `media.object.upload`, `media.object.read`,
@@ -56,6 +56,10 @@ namespace against the token's `ns`): `media.object.upload`, `media.object.read`,
`media.lifecycle.read` and `media.lifecycle.transition`. First-party apps also use their tenant API
`media.lifecycle.read` and `media.lifecycle.transition`. First-party apps also use their tenant API
key ([Tenancy & auth](#tenancy--auth)).
key ([Tenancy & auth](#tenancy--auth)).
+| Capability | Audience | Route | Notes |
+|---|---|---|---|
+| `media.resource.read` | first-party | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | the authority resource index ([Resource index](#resource-index-apiv1resources-first-party)). Its manifest is `planned` in openvibe-contracts until Network grants it after this deploys; the route already enforces it (a Network service token holding it, loopback, audience `openvibe.media`) |
+
Called elsewhere, as the service principal `media` (the OAuth client `media`):
Called elsewhere, as the service principal `media` (the OAuth client `media`):
| Service | Grant | Why |
| Service | Grant | Why |
@@ -93,6 +97,7 @@ server/
@@ -93,6 +97,7 @@ server/
pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
files/routes.js /api/v1/:app/files
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
+ registry/resource-index.js the authority resource index (/api/v1/resources; common.resource-summary@1, ADR-048)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
public/routes.js public /v /c /p /t /f
public/routes.js public /v /c /p /t /f
me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,
me/ the object explorer: /me and /api/v2/me (a signed-in person's own objects, uploads,
@@ -380,6 +385,37 @@ envelopes inside Media's own transactions (projection sync, `announce()`, soft d
@@ -380,6 +385,37 @@ envelopes inside Media's own transactions (projection sync, `announce()`, soft d
commit with their change, and the relay drains anything else. No visibility event for an object
commit with their change, and the relay drains anything else. No visibility event for an object
that is deleted; none for sandbox tenants. `test/object-events.test.js`.
that is deleted; none for sandbox tenants. `test/object-events.test.js`.
+## Resource index (`/api/v1/resources`, first-party)
+
+Media's authority resource index (ADR-048 §3, plan T13 step 8): the resources Media owns, as
+`common.resource-summary@1` — the shape OpenVibe.Services fans out over and merges. It lists Media's
+**objects alone** (`kind` `media.object`, id `med_<ULID>`, the `media_objects` table): v1 vods, clips and
+files are projections over objects (bigint ids) and are never listed.
+
+```
+GET /api/v1/resources[?project=<prj_ id>&kind=<kind>&cursor=<opaque>&limit=<n>] → common.resource-list-result@1
+GET /api/v1/resources/:ovrn → common.resource-summary@1
+```
+
+| Param | Meaning |
+|---|---|
+| `project` | the tenancy boundary: a `prj_` id. Only that project's rows answer (its production and sandbox tenants); never another project's. Omitted, the first-party caller sees every Media resource |
+| `kind` | `media.object` (the only kind Media owns). An unknown kind is an empty page, not an error |
+| `cursor` | opaque keyset cursor from the previous page's `next_cursor`, ordered by id |
+| `limit` | page size, default 100, max 1000 |
+
+Auth is a Network service token holding `media.resource.read`, loopback only (audience `openvibe.media`;
+`server/service-guard.js`) — the same first-party check as the other internal routes. No token is
+401, a token without the capability 403. A bad query is 400 `resources.bad_query`; an unknown or
+unnameable resource is 404 `resources.unknown_resource`.
+
+An object's `project_id` is its tenant's (`apps.project_id`); a first-party tenant (`live`, …) has none, so
+its objects carry no `project_id` and no `ovrn`. The summary's `ovrn` is composed by openvibe-contracts'
+`contracts.resources.nameOf` — the one formatter — so it is present exactly when the object has a project
+(`ovrn:media:<project_id>:object/<med_…>`); `GET /api/v1/resources/:ovrn` reads one by that name, and only a
+resource whose computed name equals the one asked for answers. `owner` is a `subject-ref` when the object
+stores a `usr_` owner. `test/resource-index.test.js`.
+
## Restore drills (`MEDIA_DRILL=1`)
## Restore drills (`MEDIA_DRILL=1`)
A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and
A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"port": 4100,
"port": 4100,
"domain": "openvibe.media",
"domain": "openvibe.media",
- "contracts": "openvibe-contracts v0.79.0",
+ "contracts": "openvibe-contracts v0.107.0",
"shared": "openvibe-shared v2.10.0",
"shared": "openvibe-shared v2.10.0",
"capabilities": [
"capabilities": [
"media.object.upload",
"media.object.upload",
@@ -46,5 +46,5 @@
@@ -46,5 +46,5 @@
"HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
"HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
"materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"
"materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"
],
],
- "updated": "2026-10-05"
+ "updated": "2026-10-07"
}
}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2010,9 +2010,9 @@
@@ -2010,9 +2010,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.79.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
+ "version": "0.107.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
+ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.32.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -218,6 +218,11 @@ const ready = (async () => {
@@ -218,6 +218,11 @@ const ready = (async () => {
const me = require('./me/routes').createMeRoutes({ auth: userAuthClient });
const me = require('./me/routes').createMeRoutes({ auth: userAuthClient });
app.use('/api/v2/me', me.api);
app.use('/api/v2/me', me.api);
app.use('/me', me.pages);
app.use('/me', me.pages);
+ // The authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read): the resources
+ // Media owns — its media objects — as common.resource-summary@1, for OpenVibe.Services to fan out and merge.
+ // Loopback and a Network service token, like the other first-party routes (server/service-guard.js); mounted
+ // ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".
+ app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
+'use strict';
+/**
+ * Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
+ *
+ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
+ * GET /api/v1/resources/:ovrn → common.resource-summary@1
+ *
+ * It pages the resources OpenVibe.Media owns — its media objects (med_<ULID>, media_objects) — as
+ * common.resource-summary@1, the shape OpenVibe.Services fans out over and merges. Media lists objects
+ * alone: its v1 vods, clips and files are projections over objects (bigint ids) and are never listed
+ * (ADR-048, amended 2026-10-07).
+ *
+ * Tenancy: an object's boundary is the project of the tenant (apps) that owns it — the app_id -> apps.project_id
+ * join below. An object under a developer-project tenant carries that project; one under a first-party app
+ * tenant ('live', …) has no project and so no OVRN. `?project=prj_…` scopes to that project's rows only — a
+ * resource of another project is never returned. Without it the first-party caller (the capability is
+ * first-party, resourceConstraints none) sees every Media resource, which is what an authority-wide fan-out
+ * needs; if media.resource.read is ever granted to a non-first-party principal, derive the scope from that
+ * principal's grants here instead of trusting the query.
+ *
+ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one formatter,
+ * so it is present exactly when the object has a project (nameOf composes ovrn:media:<prj_…>:object/<med_…>
+ * and returns null without one). That is also what GET /api/v1/resources/:ovrn reads: only a resource whose
+ * computed ovrn equals the one asked for answers.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+const SERVICE = 'media';
+const OBJECT_KIND = 'media.object';
+const KINDS = [OBJECT_KIND];
+const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEFAULT_LIMIT = 100;
+const MAX_LIMIT = 1000;
+
+/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */
+const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);
+
+/**
+ * Media stores timestamps as text in the `YYYY-MM-DD HH:MM:SS` UTC shape (migrations/0001_initial.sql
+ * ov_now). common.resource-summary@1's created_at is an RFC 3339 date-time, so render that one; anything
+ * already in another shape is passed through.
+ */
+function isoUtc(ts) {
+ const s = String(ts || '');
+ return /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(s) ? `${s.replace(' ', 'T')}Z` : s;
+}
+
+/**
+ * common.resource-summary@1 for a media_objects row joined with its tenant's project (`project` is the
+ * `apps.project_id` alias, null for a first-party tenant). Media objects have no name column, so no name.
+ */
+function objectSummary(o) {
+ return {
+ id: o.id, kind: OBJECT_KIND, service: SERVICE,
+ ...(o.project ? { project_id: o.project } : {}),
+ ...(userRef(o.owner_subject) ? { owner: userRef(o.owner_subject) } : {}),
+ state: o.lifecycle_status, created_at: isoUtc(o.created_at),
+ };
+}
+
+/** The summary's ovrn, or null when it has none. nameOf composes only with a project_id and a med_ id. */
+function ovrnOf(summary) {
+ return contracts.resources.nameOf(summary);
+}
+
+/** The summary with its ovrn attached when it has one. */
+function named(summary) {
+ const ovrn = ovrnOf(summary);
+ return ovrn ? { ...summary, ovrn } : summary;
+}
+
+/** A stable (kind, id) ordering, so a cursor can be a position in it. */
+const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
+
+/** The object + tenant-project select every read shares. */
+const SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects o
+ LEFT JOIN apps a ON a.app_id = o.app_id`;
+
+/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
+async function collect(db, { project = null, kind = null } = {}) {
+ if (kind && kind !== OBJECT_KIND) return [];
+ const rows = project
+ ? await db.all(`${SELECT_OBJECT} WHERE a.project_id = ? ORDER BY o.id`, [project])
+ : await db.all(`${SELECT_OBJECT} ORDER BY o.id`);
+ return rows.map((o) => named(objectSummary(o))).sort(order);
+}
+
+/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
+const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
+function decodeCursor(raw) {
+ let v;
+ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
+ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
+}
+const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
+
+/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
+function filtersOf(query) {
+ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
+ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
+ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
+ let limit = DEFAULT_LIMIT;
+ if (typeof query.limit === 'string' && query.limit !== '') {
+ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
+ limit = Number(query.limit);
+ }
+ let cursor = null;
+ if (typeof query.cursor === 'string' && query.cursor !== '') {
+ cursor = decodeCursor(query.cursor);
+ if (!cursor) return { error: 'cursor is not one this index issued' };
+ }
+ return { project, kind, limit, cursor };
+}
+
+function router({ guard, db = require('../db/database') }) {
+ const open = (res) => res.set('Cache-Control', 'private, max-age=60');
+ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });
+ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });
+
+ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
+ async function page(req, res) {
+ const f = filtersOf(req.query);
+ if (f.error) return bad(res, f.error);
+ const all = await collect(db, f);
+ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
+ const resources = rest.slice(0, f.limit);
+ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
+ open(res).json({ resources, next_cursor });
+ }
+
+ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
+ async function one(req, res) {
+ const name = String(req.params.ovrn);
+ const parsed = contracts.resources.parse(name);
+ let summary = null;
+ if (parsed && parsed.service === SERVICE && parsed.type === 'object') {
+ const row = await db.get(`${SELECT_OBJECT} WHERE o.id = ?`, [parsed.id]);
+ if (row) summary = named(objectSummary(row));
+ }
+ if (!summary || summary.ovrn !== name) return unknown(res, name);
+ open(res).json(summary);
+ }
+
+ const r = express.Router();
+ r.get('/', guard, page);
+ r.get('/:ovrn', guard, one);
+ return r;
+}
+
+module.exports = { router, SERVICE, KINDS, OBJECT_KIND, DEFAULT_LIMIT, MAX_LIMIT, objectSummary, ovrnOf, collect, encodeCursor, isoUtc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
+// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects
+// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists
+// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&kind=&cursor=&limit=
+// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another
+// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
+// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+(async () => {
+ const express = require('express');
+ const { ids, serviceAuth, validate } = require('openvibe-contracts');
+ const auth = require('../server/auth');
+ const db = require('../server/db/database');
+ const { guard } = require('../server/service-guard');
+ const resourceIndex = require('../server/registry/resource-index');
+
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ auth._setNetworkPublicKeyForTests(keys.publicKey);
+
+ const now = Math.floor(Date.now() / 1000);
+ const tok = (over = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub: 'svc:network', actor_type: 'service',
+ aud: ['openvibe.media'], cap: ['media.resource.read'], iat: now, exp: now + 300, jti: `tok_ri_${crypto.randomBytes(6).toString('hex')}`, ...over,
+ }, keys.privateKey);
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/resources', resourceIndex.router({ guard: guard('media.resource.read'), db }));
+ const server = http.createServer(app);
+
+ // ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──
+ const prjA = ids.newId('project'); const prjB = ids.newId('project');
+ const usrA = ids.newId('user');
+ const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339
+ const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-owned
+ const objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading
+ const objA3 = ids.newId('media', 1700000003000); // project A, deleted
+ const objB1 = ids.newId('media', 1700000004000); // project B, file
+ const objL1 = ids.newId('media', 1700000005000); // first-party tenant: no project, no ovrn
+ const objL2 = ids.newId('media', 1700000006000); // first-party tenant, user-owned: owner but no project
+
+ await db.run("INSERT INTO apps (app_id, name, api_key_hash, project_id, env) VALUES (?, 'Project A', '', ?, 'production'), (?, 'Project A sandbox', '', ?, 'sandbox'), (?, 'Project B', '', ?, 'production'), ('live', 'OpenVibe.Live', '', NULL, NULL)",
+ [prjA, prjA, `${prjA}-sandbox`, prjA, prjB, prjB]);
+ const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";
+ const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);
+ await insertObject(objA1, prjA, 'vod', usrA, 'ready');
+ await insertObject(objA2, `${prjA}-sandbox`, 'vod', null, 'uploading');
+ await insertObject(objA3, prjA, 'clip', null, 'deleted');
+ await insertObject(objB1, prjB, 'file', null, 'ready');
+ await insertObject(objL1, 'live', 'file', null, 'ready');
+ await insertObject(objL2, 'live', 'file', usrA, 'ready');
+
+ const idOrder = [objA1, objA2, objA3, objB1, objL1, objL2].sort(); // one kind: (kind, id) is id order
+ const all = [objA1, objA2, objA3, objB1, objL1, objL2];
+ const ovrnOf = (project, id) => `ovrn:media:${project}:object/${id}`;
+
+ (async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));
+ const authHeaders = { authorization: `Bearer ${tok()}` };
+ try {
+ // ── Auth matrix: 401 without a token, 403 with a token lacking the capability, 200 with it ──
+ assert.strictEqual((await get('/api/v1/resources')).status, 401, 'no token is 401');
+ assert.strictEqual((await get('/api/v1/resources', { authorization: 'Bearer ' + tok({ cap: ['media.object.read'] }) })).status, 403, 'a token without media.resource.read is 403');
+ assert.strictEqual((await get('/api/v1/resources', { authorization: 'Bearer ' + tok({ aud: ['openvibe.network'] }) })).status, 401, 'another audience is 401');
+ assert.strictEqual((await get('/api/v1/resources', authHeaders)).status, 200, 'a token with media.resource.read is 200');
+
+ const list = await get('/api/v1/resources', authHeaders);
+ assert.strictEqual(list.headers.get('cache-control'), 'private, max-age=60');
+ assert.deepStrictEqual(Object.keys(list.body).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
+ const pageCheck = validate('common.resource-list-result@1', list.body);
+ assert.ok(pageCheck.valid, JSON.stringify(pageCheck.errors));
+ list.body.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid, `${s.id}: ${JSON.stringify(validate('common.resource-summary@1', s).errors)}`));
+ assert.strictEqual(list.body.next_cursor, null, 'one page holds the whole index');
+ assert.deepStrictEqual(list.body.resources.map((r) => r.id), idOrder, 'every object, sorted by id');
+ assert.deepStrictEqual([...new Set(list.body.resources.map((r) => r.kind))], ['media.object'], 'Media lists objects alone');
+ assert.deepStrictEqual([...new Set(list.body.resources.map((r) => r.service))], ['media']);
+
+ const byId = new Map(list.body.resources.map((r) => [r.id, r]));
+ assert.strictEqual(byId.get(objA1).project_id, prjA, 'a project tenant object carries its project');
+ assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');
+ assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');
+ assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');
+ assert.strictEqual(byId.get(objA1).state, 'ready');
+ assert.strictEqual(byId.get(objA2).state, 'uploading');
+ assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');
+ [objL1, objL2].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party tenant object has no project`));
+ [objL1, objL2].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project there is no name`));
+ assert.strictEqual(byId.get(objL2).owner.id, usrA, 'owner is independent of project');
+ assert.match(byId.get(objA1).created_at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/, 'created_at is RFC 3339, converted from the stored shape');
+
+ // ── ?project= is the tenancy boundary; ?kind= narrows; an unknown kind is an empty page ──
+ const aIds = async (q, headers = authHeaders) => {
+ const y = await get(`/api/v1/resources${q}`, headers);
+ assert.strictEqual(y.status, 200, `${q}: ${JSON.stringify(y.body)}`);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid);
+ y.body.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid));
+ return y.body.resources.map((r) => r.id);
+ };
+ assert.deepStrictEqual((await aIds(`?project=${prjA}`)).sort(), [objA1, objA2, objA3].sort(), 'project A: its production and sandbox objects, never B nor first-party');
+ assert.deepStrictEqual(await aIds(`?project=${prjB}`), [objB1], 'project B is never mixed in');
+ assert.deepStrictEqual((await aIds(`?kind=media.object`)).sort(), idOrder, 'kind = media.object lists every object');
+ assert.deepStrictEqual((await aIds(`?project=${prjA}&kind=media.object`)).sort(), [objA1, objA2, objA3].sort(), 'kind narrows within the project');
+ assert.deepStrictEqual(await aIds('?kind=media.unknown'), [], 'an unknown kind is an empty page, not an error');
+ assert.deepStrictEqual(await aIds('?kind=vod'), [], 'a v1 kind (a projection) is not a resource kind');
+ assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
+ assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');
+
+ // ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ for (;;) {
+ const y = await get(`/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, authHeaders);
+ assert.strictEqual(y.status, 200);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid);
+ seen.push(...y.body.resources.map((r) => r.id));
+ if (y.body.next_cursor === null) { assert.strictEqual(seen.length, all.length, 'the cursor chain ends at the end'); break; }
+ assert.ok(typeof y.body.next_cursor === 'string' && y.body.next_cursor !== '');
+ cursor = y.body.next_cursor;
+ if (++pages > 50) assert.fail('the cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, idOrder, 'no duplicates, none skipped, order preserved');
+
+ // ── GET /:ovrn: found, and every miss 404s ──
+ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`, authHeaders);
+ assert.strictEqual(one.status, 200, JSON.stringify(one.body));
+ assert.strictEqual(one.headers.get('cache-control'), 'private, max-age=60');
+ assert.ok(validate('common.resource-summary@1', one.body).valid);
+ assert.deepStrictEqual(one.body, byId.get(objA1), 'the same summary the list answers');
+ const oneSandbox = await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA2))}`, authHeaders);
+ assert.strictEqual(oneSandbox.status, 200, 'a sandbox object is named by its project');
+
+ const missing = [
+ ['a first-party object (no project)', ovrnOf(prjA, objL1)],
+ ["another project's id", ovrnOf(prjB, objA1)],
+ ['an unknown object', ovrnOf(prjA, ids.newId('media'))],
+ ['a non-resource type', `ovrn:media:${prjA}:watch/${objA1}`],
+ ['another service', `ovrn:watch:${prjA}:object/${objA1}`],
+ ['a non-OVRN', 'nope'],
+ ];
+ for (const [why, name] of missing) {
+ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, authHeaders);
+ assert.strictEqual(y.status, 404, why);
+ assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
+ assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
+ }
+ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');
+
+ // ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──
+ const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
+ for (const [q, why] of bad) {
+ const y = await get(`/api/v1/resources${q}`, authHeaders);
+ assert.strictEqual(y.status, 400, why);
+ assert.strictEqual(y.body.code, 'resources.bad_query', why);
+ }
+
+ console.log('resource index: all tests passed');
+ } finally {
+ server.close();
+ }
+ })().catch((err) => { console.error(err); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });