openvibers/openrestream Commits
openvibe-contracts v0.131.0 + openre.stream resource index (#46)
Changes
@@@ -88,7 +88,7 @@ Node 22 (≥ 22.12 for `reusePort`): `fnm exec --using=22.22.1 npm test`.Implemented here (the service manifest's `capabilities`, audience `openvibe.openre`; routes under[Auth](#auth)): `openre.stream.read`, `openre.stream.write`, `openre.key.rotate`,--`openre.session.read`, `openre.session.end`, `openre.output.read` and `openre.output.write`.++`openre.session.read`, `openre.session.end`, `openre.output.read`, `openre.output.write` and `openre.resource.read`.Called elsewhere, as the service principal `openre` (client credentials from Network):@@@ -114,6 +114,9 @@ Services call with an OpenVibe.Network client-credentials token (audience `openv| `openre.session.end` | `POST /api/v1/sessions/:id/end` || `openre.output.read` | `GET /api/v1/streams/:id/destinations`, `GET /api/v1/sessions/:id/outputs`, `GET /api/v1/destinations/:id/logs`, `GET /api/v1/outputs/:id/logs` || `openre.output.write` | `POST /api/v1/streams/:id/destinations`, `PATCH`/`DELETE /api/v1/destinations/:id`, `POST /api/v1/destinations/:id/test|start|stop` |++| `openre.resource.read` | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` (service tokens only, loopback; kind `openre.stream`) |++++The resource index pages active and disabled stream definitions for OpenVibe.Services. Streams are person-owned, so summaries have no project ID or OVRN; the single-resource route returns 404 for every name.The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)) and are registered in `openvibe-contracts` since v0.16.0 (this repository pins v0.49.0). `server/auth/index.js` grants them with the contracts rule (exact id or `family.*`) and defers to `capabilities.check()` for every id the installed contracts know. Errors are RFC 9457 problem+json with a stable `code`.@@@ -307,7 +310,7 @@ control characters in sign-in's `next`, rate-limit the signed-in pages' posts anPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.129.0++- openvibe-contracts: v0.131.0- openvibe-sdk: v0.38.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -8,7 +8,7 @@"port": 4500,"rtmpPort": 1936,"domain": "openre.stream",-- "contracts": "openvibe-contracts v0.129.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)",++ "contracts": "openvibe-contracts v0.131.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)","sdk": "openvibe-sdk v0.38.0","shared": "openvibe-shared v3.0.0","protocols": {@@@ -24,9 +24,14 @@"openre.session.read","openre.session.end","openre.output.read",-- "openre.output.write"++ "openre.output.write",++ "openre.resource.read"],"capabilitiesRegistered": true,++ "resourceRoutes": [++ "GET /api/v1/resources",++ "GET /api/v1/resources/:ovrn"++ ],"livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRestream yet","deployed": true,"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh wraps deploy, rollback and plan, and hands workers/status/prune to deploy/scripts/workers.sh): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).",@@@ -79,6 +79,9 @@ server {send_timeout 12h;}++ # OpenVibe.Services reads the resource index on loopback (127.0.0.1:4500).++ location ^~ /api/v1/resources { return 404; }++location /api/ {limit_req zone=openre_api burst=40 nodelay;proxy_pass http://127.0.0.1:4500;@@@ -15,7 +15,7 @@"iovalkey": "^0.4.0","mediasoup": "^3.14.14","node-media-server": "2.7.4",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0",@@@ -1638,9 +1638,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.129.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",-- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",++ "version": "0.131.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",++ "integrity": "sha512-ZojAzdJCkPnUPvZcS583p7roPmfaG4u8RL1r9yFrn+77JO9yg7Nmz2gvjQoTtX2AzWR7tI1bCtFd9MhprUioCg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -31,7 +31,7 @@"iovalkey": "^0.4.0","mediasoup": "^3.14.14","node-media-server": "2.7.4",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0",@@@ -6,6 +6,7 @@ const cookieParser = require('cookie-parser');const contracts = require('openvibe-contracts');const { createDiscoveryRoutes } = require('./discovery');const { createV1Router } = require('./api/v1');++const resourceIndex = require('./registry/resource-index');const { createSsoRoutes } = require('./auth/sso');const { createUiRouter } = require('./ui/routes');const pkg = require('../package.json');@@@ -133,7 +134,9 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {const v1 = createV1Router({ rt, auth, liveLink });// Per-actor limits on a signed-in person's writes (server/api/actor-limits.js; roadmap WS-R task 4).const actorLimits = require('./api/actor-limits').createOpenReActorLimits({ registry: metrics.registry, valkey });-- app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits, v1.router);++ app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits);++ app.use('/api/v1/resources', resourceIndex.router({ db, guard: auth.guard('openre.resource.read') }));++ app.use('/api/v1', v1.router);// Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer// deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned. */++const express = require('express');++const contracts = require('openvibe-contracts');++++const SERVICE = 'openre';++const KIND = 'openre.stream';++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;++++/** The public resource shape for a definition; no private definition fields leave this index. */++function streamSummary(row) {++ const summary = {++ id: row.id, kind: KIND, service: SERVICE,++ name: row.title, state: row.state,++ created_at: new Date(Number(row.created_at)).toISOString(),++ updated_at: new Date(Number(row.updated_at)).toISOString(),++ };++ if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };++ const ovrn = contracts.resources.nameOf(summary);++ if (ovrn) summary.ovrn = ovrn;++ return summary;++}++++/** An opaque keyset position, encoded like Host's [kind, id] cursor. */++const encodeCursor = (row) => Buffer.from(JSON.stringify([KIND, row.id])).toString('base64url');++function decodeCursor(raw) {++ let value;++ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }++ return Array.isArray(value) && value.length === 2 && value[0] === KIND &&++ typeof value[1] === 'string' && value[1] !== '' ? value[1] : null;++}++++function filtersOf(query) {++ const project = query.project === undefined || query.project === '' ? null : query.project;++ if (project !== null && (typeof project !== 'string' || !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({ db, guard }) {++ const r = express.Router();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const handle = (fn) => async (req, res, next) => { try { await fn(req, res); } catch (err) { next(err); } };++++ // The shared guard allows owners on ordinary API routes; this first-party index requires a service.++ r.use((req, res, next) => {++ const caller = req.caller || { kind: 'anonymous' };++ if (caller.kind === 'service') return next();++ if (caller.kind === 'anonymous') return problem(req, res, 401, 'token.missing', 'sign in, or call with a service token');++ return problem(req, res, 403, 'capability.denied', 'openre.resource.read is first-party and requires a service token');++ });++++ r.get('/', guard, handle(async (req, res) => {++ const f = filtersOf(req.query);++ if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);++ if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });++ const rows = await db.prepare("SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ? ORDER BY id LIMIT ?").all(f.cursor || '', f.limit + 1);++ const resources = rows.slice(0, f.limit).map(streamSummary);++ const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 1]) : null;++ return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });++ }));++ r.get('/:ovrn', guard, (req, res) => problem(req, res, 404, 'resources.unknown_resource', `no resource named ${req.params.ovrn}`));++ return r;++}++++module.exports = { router, streamSummary, filtersOf, encodeCursor, SERVICE, KIND, DEFAULT_LIMIT, MAX_LIMIT };@@@ -33,4 +33,16 @@ t('the service manifest proposal validates and lists exactly the guarded capabilassert.deepStrictEqual([...manifest.eventsProduced].sort(), Object.values(TYPES).sort());});++t('the active resource capability names both guarded index routes', () => {++ const resource = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules', 'openvibe-contracts', 'manifests', 'capabilities', 'openre.resource.read.json'), 'utf8'));++ assert.deepStrictEqual(resource.implementedBy, ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']);++ const code = fs.readFileSync(path.join(ROOT, 'server', 'registry', 'resource-index.js'), 'utf8');++ assert.match(code, /r\.get\('\/', guard,/);++ assert.match(code, /r\.get\('\/:ovrn', guard,/);++ const app = fs.readFileSync(path.join(ROOT, 'server', 'app.js'), 'utf8');++ assert.match(app, /resourceIndex\.router\(\{ db, guard: auth\.guard\('openre\.resource\.read'\) \}\)/);++ const status = JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8'));++ assert.ok(status.capabilities.includes(resource.id));++});++t.run();nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++// OpenVibe.Services reads only the public stream summaries, using a first-party service token.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { streamSummary, filtersOf } = require('../server/registry/resource-index');++const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER } = require('./helpers');++++const t = suite('resource-index');++const reader = serviceToken('services', ['openre.resource.read']);++let api;++let ids;++++const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });++++t('summary and query functions keep the contract shape without a socket', () => {++ const row = { id: 'std_01J0000000000000000000000A', owner_subject: OWNER, title: 'Public title',++ state: 'disabled', created_at: 1000, updated_at: 2000, description: 'private', ingest_key: 'secret' };++ const summary = streamSummary(row);++ assert.deepStrictEqual(summary, { id: row.id, kind: 'openre.stream', service: 'openre',++ name: row.title, state: row.state, created_at: new Date(1000).toISOString(),++ updated_at: new Date(2000).toISOString(), owner: { type: 'user', id: OWNER } });++ assert.ok(contracts.validate('common.resource-summary@1', summary).valid);++ assert.strictEqual(contracts.resources.nameOf(summary), null);++ assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');++ assert.deepStrictEqual(filtersOf({}), { project: null, kind: null, limit: 100, cursor: null });++});++++t('boot and list schema, ordered rows, and safe summary fields', async () => {++ api = await bootApi();++ const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;++ const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;++ const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;++ ids = [a.id, b.id].sort();++ await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);++ await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);++ const page = await get('');++ assert.strictEqual(page.status, 200, page.text);++ const validation = contracts.validate('common.resource-list-result@1', page.body);++ assert.ok(validation.valid, JSON.stringify(validation.errors));++ assert.deepStrictEqual(page.body.resources.map((s) => s.id), ids);++ assert.strictEqual(page.body.next_cursor, null);++ for (const summary of page.body.resources) {++ assert.deepStrictEqual(Object.keys(summary).sort(), ['created_at', 'id', 'kind', 'name', 'owner', 'service', 'state', 'updated_at'].sort());++ assert.strictEqual(summary.kind, 'openre.stream');++ assert.strictEqual(summary.service, 'openre');++ assert.match(summary.created_at, /^\d{4}-\d\d-\d\dT/);++ assert.match(summary.updated_at, /^\d{4}-\d\d-\d\dT/);++ }++ assert.deepStrictEqual(new Set(page.body.resources.map((s) => s.state)), new Set(['active', 'disabled']));++ assert.deepStrictEqual(new Set(page.body.resources.map((s) => s.owner.id)), new Set([OWNER, OTHER]));++});++++t('cursor visits each row exactly once and filters follow person ownership', async () => {++ const visited = [];++ let cursor = null;++ do {++ const page = await get(`?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(contracts.validate('common.resource-list-result@1', page.body).valid);++ visited.push(...page.body.resources.map((s) => s.id));++ cursor = page.body.next_cursor;++ } while (cursor);++ assert.deepStrictEqual(visited, ids);++ assert.deepStrictEqual((await get('?kind=openre.stream')).body.resources.map((s) => s.id), ids);++ assert.deepStrictEqual((await get('?kind=unknown.kind')).body, { resources: [], next_cursor: null });++ assert.deepStrictEqual((await get('?project=prj_01J0000000000000000000000A')).body, { resources: [], next_cursor: null });++ assert.strictEqual((await get('?project=bad')).status, 400);++ assert.strictEqual((await get('?cursor=bad')).status, 400);++ assert.strictEqual((await get('?limit=1001')).status, 400);++});++++// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.++t('only a service token with the capability reaches either route', async () => {++ for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {++ const absent = await get(path, null);++ assert.strictEqual(absent.status, 401);++ assert.strictEqual(absent.body.code, 'token.missing');++ const missingCap = await get(path, serviceToken('services', ['openre.stream.read']));++ assert.strictEqual(missingCap.status, 403);++ const person = await get(path, userToken());++ assert.strictEqual(person.status, 403);++ assert.strictEqual(person.body.code, 'capability.denied');++ }++});++++t('person-owned rows have no OVRN and every single-resource lookup is unknown', async () => {++ const page = await get('');++ for (const summary of page.body.resources) assert.strictEqual(contracts.resources.nameOf(summary), null);++ const one = await get(`/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`);++ assert.strictEqual(one.status, 404);++ assert.strictEqual(one.body.code, 'resources.unknown_resource');++ await api.close();++});++++t.run();
@@ -88,7 +88,7 @@ Node 22 (≥ 22.12 for `reusePort`): `fnm exec --using=22.22.1 npm test`.
@@ -88,7 +88,7 @@ Node 22 (≥ 22.12 for `reusePort`): `fnm exec --using=22.22.1 npm test`.
Implemented here (the service manifest's `capabilities`, audience `openvibe.openre`; routes under
Implemented here (the service manifest's `capabilities`, audience `openvibe.openre`; routes under
[Auth](#auth)): `openre.stream.read`, `openre.stream.write`, `openre.key.rotate`,
[Auth](#auth)): `openre.stream.read`, `openre.stream.write`, `openre.key.rotate`,
-`openre.session.read`, `openre.session.end`, `openre.output.read` and `openre.output.write`.
+`openre.session.read`, `openre.session.end`, `openre.output.read`, `openre.output.write` and `openre.resource.read`.
Called elsewhere, as the service principal `openre` (client credentials from Network):
Called elsewhere, as the service principal `openre` (client credentials from Network):
@@ -114,6 +114,9 @@ Services call with an OpenVibe.Network client-credentials token (audience `openv
@@ -114,6 +114,9 @@ Services call with an OpenVibe.Network client-credentials token (audience `openv
| `openre.session.end` | `POST /api/v1/sessions/:id/end` |
| `openre.session.end` | `POST /api/v1/sessions/:id/end` |
| `openre.output.read` | `GET /api/v1/streams/:id/destinations`, `GET /api/v1/sessions/:id/outputs`, `GET /api/v1/destinations/:id/logs`, `GET /api/v1/outputs/:id/logs` |
| `openre.output.read` | `GET /api/v1/streams/:id/destinations`, `GET /api/v1/sessions/:id/outputs`, `GET /api/v1/destinations/:id/logs`, `GET /api/v1/outputs/:id/logs` |
| `openre.output.write` | `POST /api/v1/streams/:id/destinations`, `PATCH`/`DELETE /api/v1/destinations/:id`, `POST /api/v1/destinations/:id/test|start|stop` |
| `openre.output.write` | `POST /api/v1/streams/:id/destinations`, `PATCH`/`DELETE /api/v1/destinations/:id`, `POST /api/v1/destinations/:id/test|start|stop` |
+| `openre.resource.read` | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` (service tokens only, loopback; kind `openre.stream`) |
+
+The resource index pages active and disabled stream definitions for OpenVibe.Services. Streams are person-owned, so summaries have no project ID or OVRN; the single-resource route returns 404 for every name.
The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)) and are registered in `openvibe-contracts` since v0.16.0 (this repository pins v0.49.0). `server/auth/index.js` grants them with the contracts rule (exact id or `family.*`) and defers to `capabilities.check()` for every id the installed contracts know. Errors are RFC 9457 problem+json with a stable `code`.
The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)) and are registered in `openvibe-contracts` since v0.16.0 (this repository pins v0.49.0). `server/auth/index.js` grants them with the contracts rule (exact id or `family.*`) and defers to `capabilities.check()` for every id the installed contracts know. Errors are RFC 9457 problem+json with a stable `code`.
@@ -307,7 +310,7 @@ control characters in sign-in's `next`, rate-limit the signed-in pages' posts an
@@ -307,7 +310,7 @@ control characters in sign-in's `next`, rate-limit the signed-in pages' posts an
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.129.0
+- openvibe-contracts: v0.131.0
- openvibe-sdk: v0.38.0
- openvibe-sdk: v0.38.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4500,
"port": 4500,
"rtmpPort": 1936,
"rtmpPort": 1936,
"domain": "openre.stream",
"domain": "openre.stream",
- "contracts": "openvibe-contracts v0.129.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)",
+ "contracts": "openvibe-contracts v0.131.0 (openvibe-sdk v0.26.0, openvibe-shared v2.13.0)",
"sdk": "openvibe-sdk v0.38.0",
"sdk": "openvibe-sdk v0.38.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"protocols": {
"protocols": {
@@ -24,9 +24,14 @@
@@ -24,9 +24,14 @@
"openre.session.read",
"openre.session.read",
"openre.session.end",
"openre.session.end",
"openre.output.read",
"openre.output.read",
- "openre.output.write"
+ "openre.output.write",
+ "openre.resource.read"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
+ "resourceRoutes": [
+ "GET /api/v1/resources",
+ "GET /api/v1/resources/:ovrn"
+ ],
"livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRestream yet",
"livePatch": "applied in Live f0ca18b and deployed; Live wired on 2026-09-23 (OPENRE_URL and the openre.session.* subscription, docs/cutover.md phase A); every Live slot keeps ingest_authority=live until its per-slot cutover (phase B), so no broadcaster uses OpenRestream yet",
"deployed": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh wraps deploy, rollback and plan, and hands workers/status/prune to deploy/scripts/workers.sh): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).",
"deployment": "Deployed with `sudo ovhost deploy openre` (OpenVibe.Host strategy release-layout since the 2026-09-27 cutover; deploy/scripts/deploy.sh wraps deploy, rollback and plan, and hands workers/status/prune to deploy/scripts/workers.sh): API + coordinator on 127.0.0.1:4500, RTMP ingest public on 0.0.0.0:1936 (ingest.openre.stream, DNS-only; reachable from outside since 2026-09-23), Live wired (OPENRE_URL, openre.session.* subscription), preflight all PASS, restore drill passed. No slot switched yet: every slot is still ingested by Live until the per-slot cutover (docs/cutover.md phase B).",
@@ -79,6 +79,9 @@ server {
@@ -79,6 +79,9 @@ server {
send_timeout 12h;
send_timeout 12h;
}
}
+ # OpenVibe.Services reads the resource index on loopback (127.0.0.1:4500).
+ location ^~ /api/v1/resources { return 404; }
+
location /api/ {
location /api/ {
limit_req zone=openre_api burst=40 nodelay;
limit_req zone=openre_api burst=40 nodelay;
proxy_pass http://127.0.0.1:4500;
proxy_pass http://127.0.0.1:4500;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1638,9 +1638,9 @@
@@ -1638,9 +1638,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.129.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",
+ "version": "0.131.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
+ "integrity": "sha512-ZojAzdJCkPnUPvZcS583p7roPmfaG4u8RL1r9yFrn+77JO9yg7Nmz2gvjQoTtX2AzWR7tI1bCtFd9MhprUioCg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"mediasoup": "^3.14.14",
"mediasoup": "^3.14.14",
"node-media-server": "2.7.4",
"node-media-server": "2.7.4",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.131.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -6,6 +6,7 @@ const cookieParser = require('cookie-parser');
@@ -6,6 +6,7 @@ const cookieParser = require('cookie-parser');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { createDiscoveryRoutes } = require('./discovery');
const { createDiscoveryRoutes } = require('./discovery');
const { createV1Router } = require('./api/v1');
const { createV1Router } = require('./api/v1');
+const resourceIndex = require('./registry/resource-index');
const { createSsoRoutes } = require('./auth/sso');
const { createSsoRoutes } = require('./auth/sso');
const { createUiRouter } = require('./ui/routes');
const { createUiRouter } = require('./ui/routes');
const pkg = require('../package.json');
const pkg = require('../package.json');
@@ -133,7 +134,9 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
@@ -133,7 +134,9 @@ function createApp({ rt, auth, keys, log = console, fetchImpl }) {
const v1 = createV1Router({ rt, auth, liveLink });
const v1 = createV1Router({ rt, auth, liveLink });
// Per-actor limits on a signed-in person's writes (server/api/actor-limits.js; roadmap WS-R task 4).
// Per-actor limits on a signed-in person's writes (server/api/actor-limits.js; roadmap WS-R task 4).
const actorLimits = require('./api/actor-limits').createOpenReActorLimits({ registry: metrics.registry, valkey });
const actorLimits = require('./api/actor-limits').createOpenReActorLimits({ registry: metrics.registry, valkey });
- app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits, v1.router);
+ app.use('/api/v1', apiJson, auth.middleware({ services: true }), actorLimits);
+ app.use('/api/v1/resources', resourceIndex.router({ db, guard: auth.guard('openre.resource.read') }));
+ app.use('/api/v1', v1.router);
// Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer
// Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer
// deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.
// deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned. */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+
+const SERVICE = 'openre';
+const KIND = 'openre.stream';
+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;
+
+/** The public resource shape for a definition; no private definition fields leave this index. */
+function streamSummary(row) {
+ const summary = {
+ id: row.id, kind: KIND, service: SERVICE,
+ name: row.title, state: row.state,
+ created_at: new Date(Number(row.created_at)).toISOString(),
+ updated_at: new Date(Number(row.updated_at)).toISOString(),
+ };
+ if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };
+ const ovrn = contracts.resources.nameOf(summary);
+ if (ovrn) summary.ovrn = ovrn;
+ return summary;
+}
+
+/** An opaque keyset position, encoded like Host's [kind, id] cursor. */
+const encodeCursor = (row) => Buffer.from(JSON.stringify([KIND, row.id])).toString('base64url');
+function decodeCursor(raw) {
+ let value;
+ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
+ return Array.isArray(value) && value.length === 2 && value[0] === KIND &&
+ typeof value[1] === 'string' && value[1] !== '' ? value[1] : null;
+}
+
+function filtersOf(query) {
+ const project = query.project === undefined || query.project === '' ? null : query.project;
+ if (project !== null && (typeof project !== 'string' || !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({ db, guard }) {
+ const r = express.Router();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const handle = (fn) => async (req, res, next) => { try { await fn(req, res); } catch (err) { next(err); } };
+
+ // The shared guard allows owners on ordinary API routes; this first-party index requires a service.
+ r.use((req, res, next) => {
+ const caller = req.caller || { kind: 'anonymous' };
+ if (caller.kind === 'service') return next();
+ if (caller.kind === 'anonymous') return problem(req, res, 401, 'token.missing', 'sign in, or call with a service token');
+ return problem(req, res, 403, 'capability.denied', 'openre.resource.read is first-party and requires a service token');
+ });
+
+ r.get('/', guard, handle(async (req, res) => {
+ const f = filtersOf(req.query);
+ if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
+ if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
+ const rows = await db.prepare("SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ? ORDER BY id LIMIT ?").all(f.cursor || '', f.limit + 1);
+ const resources = rows.slice(0, f.limit).map(streamSummary);
+ const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 1]) : null;
+ return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });
+ }));
+ r.get('/:ovrn', guard, (req, res) => problem(req, res, 404, 'resources.unknown_resource', `no resource named ${req.params.ovrn}`));
+ return r;
+}
+
+module.exports = { router, streamSummary, filtersOf, encodeCursor, SERVICE, KIND, DEFAULT_LIMIT, MAX_LIMIT };
@@ -33,4 +33,16 @@ t('the service manifest proposal validates and lists exactly the guarded capabil
@@ -33,4 +33,16 @@ t('the service manifest proposal validates and lists exactly the guarded capabil
assert.deepStrictEqual([...manifest.eventsProduced].sort(), Object.values(TYPES).sort());
assert.deepStrictEqual([...manifest.eventsProduced].sort(), Object.values(TYPES).sort());
});
});
+t('the active resource capability names both guarded index routes', () => {
+ const resource = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules', 'openvibe-contracts', 'manifests', 'capabilities', 'openre.resource.read.json'), 'utf8'));
+ assert.deepStrictEqual(resource.implementedBy, ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']);
+ const code = fs.readFileSync(path.join(ROOT, 'server', 'registry', 'resource-index.js'), 'utf8');
+ assert.match(code, /r\.get\('\/', guard,/);
+ assert.match(code, /r\.get\('\/:ovrn', guard,/);
+ const app = fs.readFileSync(path.join(ROOT, 'server', 'app.js'), 'utf8');
+ assert.match(app, /resourceIndex\.router\(\{ db, guard: auth\.guard\('openre\.resource\.read'\) \}\)/);
+ const status = JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8'));
+ assert.ok(status.capabilities.includes(resource.id));
+});
+
t.run();
t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+// OpenVibe.Services reads only the public stream summaries, using a first-party service token.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { streamSummary, filtersOf } = require('../server/registry/resource-index');
+const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER } = require('./helpers');
+
+const t = suite('resource-index');
+const reader = serviceToken('services', ['openre.resource.read']);
+let api;
+let ids;
+
+const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });
+
+t('summary and query functions keep the contract shape without a socket', () => {
+ const row = { id: 'std_01J0000000000000000000000A', owner_subject: OWNER, title: 'Public title',
+ state: 'disabled', created_at: 1000, updated_at: 2000, description: 'private', ingest_key: 'secret' };
+ const summary = streamSummary(row);
+ assert.deepStrictEqual(summary, { id: row.id, kind: 'openre.stream', service: 'openre',
+ name: row.title, state: row.state, created_at: new Date(1000).toISOString(),
+ updated_at: new Date(2000).toISOString(), owner: { type: 'user', id: OWNER } });
+ assert.ok(contracts.validate('common.resource-summary@1', summary).valid);
+ assert.strictEqual(contracts.resources.nameOf(summary), null);
+ assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');
+ assert.deepStrictEqual(filtersOf({}), { project: null, kind: null, limit: 100, cursor: null });
+});
+
+t('boot and list schema, ordered rows, and safe summary fields', async () => {
+ api = await bootApi();
+ const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;
+ const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;
+ const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;
+ ids = [a.id, b.id].sort();
+ await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);
+ await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);
+ const page = await get('');
+ assert.strictEqual(page.status, 200, page.text);
+ const validation = contracts.validate('common.resource-list-result@1', page.body);
+ assert.ok(validation.valid, JSON.stringify(validation.errors));
+ assert.deepStrictEqual(page.body.resources.map((s) => s.id), ids);
+ assert.strictEqual(page.body.next_cursor, null);
+ for (const summary of page.body.resources) {
+ assert.deepStrictEqual(Object.keys(summary).sort(), ['created_at', 'id', 'kind', 'name', 'owner', 'service', 'state', 'updated_at'].sort());
+ assert.strictEqual(summary.kind, 'openre.stream');
+ assert.strictEqual(summary.service, 'openre');
+ assert.match(summary.created_at, /^\d{4}-\d\d-\d\dT/);
+ assert.match(summary.updated_at, /^\d{4}-\d\d-\d\dT/);
+ }
+ assert.deepStrictEqual(new Set(page.body.resources.map((s) => s.state)), new Set(['active', 'disabled']));
+ assert.deepStrictEqual(new Set(page.body.resources.map((s) => s.owner.id)), new Set([OWNER, OTHER]));
+});
+
+t('cursor visits each row exactly once and filters follow person ownership', async () => {
+ const visited = [];
+ let cursor = null;
+ do {
+ const page = await get(`?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(contracts.validate('common.resource-list-result@1', page.body).valid);
+ visited.push(...page.body.resources.map((s) => s.id));
+ cursor = page.body.next_cursor;
+ } while (cursor);
+ assert.deepStrictEqual(visited, ids);
+ assert.deepStrictEqual((await get('?kind=openre.stream')).body.resources.map((s) => s.id), ids);
+ assert.deepStrictEqual((await get('?kind=unknown.kind')).body, { resources: [], next_cursor: null });
+ assert.deepStrictEqual((await get('?project=prj_01J0000000000000000000000A')).body, { resources: [], next_cursor: null });
+ assert.strictEqual((await get('?project=bad')).status, 400);
+ assert.strictEqual((await get('?cursor=bad')).status, 400);
+ assert.strictEqual((await get('?limit=1001')).status, 400);
+});
+
+// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.
+t('only a service token with the capability reaches either route', async () => {
+ for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {
+ const absent = await get(path, null);
+ assert.strictEqual(absent.status, 401);
+ assert.strictEqual(absent.body.code, 'token.missing');
+ const missingCap = await get(path, serviceToken('services', ['openre.stream.read']));
+ assert.strictEqual(missingCap.status, 403);
+ const person = await get(path, userToken());
+ assert.strictEqual(person.status, 403);
+ assert.strictEqual(person.body.code, 'capability.denied');
+ }
+});
+
+t('person-owned rows have no OVRN and every single-resource lookup is unknown', async () => {
+ const page = await get('');
+ for (const summary of page.body.resources) assert.strictEqual(contracts.resources.nameOf(summary), null);
+ const one = await get(`/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`);
+ assert.strictEqual(one.status, 404);
+ assert.strictEqual(one.body.code, 'resources.unknown_resource');
+ await api.close();
+});
+
+t.run();