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)

OP OpenVibers committed 7 Oct 2026 · 446da58b3d · 7 files, +372 −8

**Changes (file:line)**
- `server/registry/resource-index.js:1` — new authority resource index: router (117), `objectSummary`/`isoUtc` (54), `ovrnOf` via `contracts.resources.nameOf` (64), `collect` tenancy join on `apps.project_id` (82), `filtersOf` (100), `page`/`one` (123/134).
- `server/index.js:225` — mounts `/api/v1/resources` ahead of the `/api/v1/:app/…` tenant routes, guarded by `service-guard.guard('media.resource.read')`.
- `package.json:24` — `openvibe-contracts` codeload URL → `v0.107.0`.
- `package-lock.json` — regenerated via node child_process `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` (v0.107.0 + integrity).
- `test/resource-index.test.js:1` — new test (auth matrix, schema validation of page + single read, `?project=`/`?kind=`, cursor paging, `/:ovrn` found/404, bad query 400).
- `README.md:47,55,113,101` — contracts pin, capability row for `media.resource.read`, new “Resource index” section, layout entry.
- `STATUS.json:12,42` — contracts pin `v0.107.0`, updated `2026-10-07`.

**implementedBy routes** (for the `media.resource.read` manifest — Contracts follow-up, not done here):
- `GET /api/v1/resources`
- `GET /api/v1/resources/:ovrn`

**Test results** — `nice -n 15 ov test --no-cache resource-index indexnow trust-proxy avatar-ingest-auth service-token` → **7 passed** (resource-index, indexnow, indexnow-ping, trust-proxy, avatar-ingest-auth, service-token, service-token-tenant). No commit/push.

**For Opus**
1. Flip `media.resource.read` from `planned` and set `implementedBy` to the two routes above (manifest `manifests/capabilities/media.resource.read.json`), then grant it to Network's service principal for audience `openvibe.media`.
2. The join maps an object's tenant (`media_objects.app_id` → `apps.project_id`), so both a project's production (`prj_…`) and sandbox (`prj_…-sandbox`) objects carry the same `project_id` and are listed under `?project=`. Deleted objects are listed with `state: "deleted"` (no exclusion prescribed by ADR-048). Media objects have no name column, so summaries carry no `name`.
3. Media stores timestamps as `YYYY-MM-DD HH:MM:SS`; the index renders `created_at` as RFC 3339 (`isoUtc`) because `common.resource-summary@1` enforces `format: date-time` (a raw space-separated value fails validation).
4. The v0.107.0 bump affects every test; I only ran the targeted set above (7 pass). Full-suite verification is left to CI.

Changes

ddiff --git a/README.md b/README.md
iindex 47ba7b5..5cd408f 100644
---- a/README.md
++++ b/README.md
@@@ -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
the 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 API
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`):
| Service | Grant | Why |
@@@ -93,6 +97,7 @@ server/
pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
files/routes.js /api/v1/:app/files
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
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,
@@@ -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
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`)
A restore drill starts a second Media from this checkout on 127.0.0.1:14100 with `MEDIA_DRILL=1` and
ddiff --git a/STATUS.json b/STATUS.json
iindex 6772ad8..e9a675b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex ab275fb..8d9ec6e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 9a9d6e7..8ec23c0 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/index.js b/server/index.js
iindex cac89bb..3adeb83 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..8a0de34
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -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 };
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..f90a204
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -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); });