openvibers/openrestream Commits

openvibe-contracts v0.131.0 + openre.stream resource index (#46)

OP OpenVibers committed 10 Oct 2026 · a64f0f54bc · 9 files, +210 −10

* openvibe-contracts v0.131.0 (was v0.129.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.131.0 release.

* Resource index: openre.stream for OpenVibe.Services (openre.resource.read)

GET /api/v1/resources and /:ovrn (common.resource-list-result@1 / resource-summary@1) list the stream definitions
that are not archived, person-owned (no OVRN); service tokens with openre.resource.read only; the public vhost 404s
the index (Services reads it on 127.0.0.1:4500).

Changes

ddiff --git a/README.md b/README.md
iindex 36da3a2..f65aad5 100644
---- a/README.md
++++ b/README.md
@@@ -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 an
Part 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 793c060..e1b587c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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).",
ddiff --git a/deploy/nginx/openre.stream.conf b/deploy/nginx/openre.stream.conf
iindex c865f3d..57d8ac4 100644
---- a/deploy/nginx/openre.stream.conf
++++ b/deploy/nginx/openre.stream.conf
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex eaad2cb..86d2b28 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 13333ee..efad8bb 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/app.js b/server/app.js
iindex 9371360..eff9d22 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..1c84200
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -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 };
ddiff --git a/test/proposals.test.js b/test/proposals.test.js
iindex 6dccf21..4ee103d 100644
---- a/test/proposals.test.js
++++ b/test/proposals.test.js
@@@ -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());
});
++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();
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..b25cb05
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -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();