openvibers/openvibe.community Commits
Community: resource index for OpenVibe.Services (a person's pastes and spaces) (#59)
Changes
@@@ -152,6 +152,7 @@ API and machine endpoints:| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) || `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) || `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |++| `GET /api/v1/resources` | OpenVibe.Services' index of a person's pastes (`community.paste`) and the spaces they created (`community.space`): `common.resource-list-result@1`, filters `kind`, `owner`, `project` (always empty), `cursor`, `limit`. A service token with `community.resource.read` only; loopback (nginx answers 404 publicly). Summaries only, never paste content; deleted pastes left out; no OVRN (`/:ovrn` is 404) || `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) || `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) || `GET /release.json`, `POST /release-metrics` | What this server runs (ADR-016 release manifest, `metrics_url: /release-metrics`); open tabs' update reports (release-watch's same-origin beacon, no auth) |@@@ -575,6 +576,7 @@ same shape as its `manifests/capabilities`):| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse || `community.post.create` | active in contracts (v0.7.0) | forum writes || `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |++| `community.resource.read` | active in contracts (0.134.0) | OpenVibe.Services' resource index: a person's pastes and spaces in "Your resources" |This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all gothrough the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id@@@ -824,7 +826,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)<!-- versions:start -->--- openvibe-contracts: v0.129.0++- openvibe-contracts: v0.134.0- openvibe-sdk: v0.42.0- openvibe-shared: v3.0.1<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4200,"domain": "openvibe.community",-- "contracts": "openvibe-contracts v0.129.0",++ "contracts": "openvibe-contracts v0.134.0","sdk": "openvibe-sdk v0.42.0","shared": "openvibe-shared v3.0.1","capabilities": [@@@ -33,6 +33,7 @@"typed comment threads embedded by other products","forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)","per-space moderators (migration 0005)",++ "resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)","members-only spaces and threads gated by OpenVibe.VIP","a space's chat room on OpenVibe.Chat","Pulse: the network's public activity from Events",@@@ -130,6 +130,8 @@ server {# Regex locations match in order, so these sit before the static-asset regex. ──location ~* ^/metrics(/|$) { return 404; }location ~* ^/internal(/|$) { return 404; }++ # The resource index (community.resource.read) is OpenVibe.Services' on loopback, never public.++ location ~* ^/api/v1/resources(/|$) { return 404; }# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {@@@ -17,7 +17,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "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.134.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1","pg": "^8.23.0"@@@ -917,9 +917,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.134.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.134.0",++ "integrity": "sha512-sWjPIaJtv/V1evsLbNcXecsiHVHd7w+A7/VD7o41j4Veotd+zfQRnNHFHS2gFlOVGR3WBycEzOZzo7+r1jk3sg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"highlight.js": "^11.11.1","iovalkey": "^0.4.0","multer": "^2.4.0",-- "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.134.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1","pg": "^8.23.0"@@@ -239,6 +239,9 @@ async function createApp(opts = {}) {app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));++ // OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;++ // nginx answers 404 for the path on the public vhost).++ app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-originnnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * Community's authority index for OpenVibe.Services (ADR-048, plan T13; capability community.resource.read). A++ * person's pastes and the spaces they created, so "Your resources" on openvibe.services shows them beside their++ * robots, rooms, streams, watches and Actor tasks. Both kinds are person-owned: no project, no OVRN.++ *++ * GET /api/v1/resources?kind=&owner=&project=&cursor=&limit= common.resource-list-result@1++ * community.paste id the slug, named by its title, state its visibility, owner its owner (deleted ones left out)++ * community.space id the slug, named by its name, state its visibility, owner the person who created it++ * Pastes first, then spaces, each by slug; the cursor is opaque ([kind, slug]). owner (a usr_/agt_ id) keeps++ * what that subject owns; project always answers an empty page (nothing here belongs to a project).++ * GET /api/v1/resources/:ovrn 404 for every name (nothing here has one)++ *++ * First-party only: a service token holding community.resource.read. nginx answers 404 for the path on the public++ * vhost; Services reads it on loopback.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { hasCap } = require('../identity/viewer');++++const SERVICE = 'community';++const KINDS = ['community.paste', 'community.space'];++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEFAULT_LIMIT = 100;++const MAX_LIMIT = 1000;++++/** Community stores UTC as 'YYYY-MM-DD HH:MM:SS'; a summary carries ISO 8601. */++function iso(text) {++ if (!text) return null;++ const d = new Date(String(text).includes('T') ? String(text) : `${String(text).replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++function ownerOf(subject) {++ const s = String(subject || '');++ if (!OWNER_SUBJECT_RE.test(s)) return null;++ return { type: s.startsWith('agt_') ? 'agent' : 'user', id: s };++}++++/** One row → common.resource-summary@1. Nothing else of a paste (its content, its views) leaves this index. */++function summaryOf(kind, row) {++ const out = {++ id: row.slug, kind, service: SERVICE,++ name: kind === 'community.paste' ? row.title : row.name,++ state: row.visibility,++ created_at: iso(row.created_at),++ };++ const updated = iso(row.updated_at);++ if (updated) out.updated_at = updated;++ const owner = ownerOf(row.owner);++ if (owner) out.owner = owner;++ return out;++}++++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 && KINDS.includes(v[0]) && typeof v[1] === 'string' && v[1] !== '' ? { kind: v[0], id: v[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 owner = query.owner === undefined || query.owner === '' ? null : query.owner;++ if (owner !== null && (typeof owner !== 'string' || !OWNER_SUBJECT_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ 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, owner, kind, limit, cursor };++}++++const QUERIES = {++ 'community.paste': (owner) => `SELECT slug, title, visibility, owner_subject AS owner, created_at, updated_at FROM pastes++ WHERE deleted_at IS NULL AND slug > ?${owner ? ' AND owner_subject = ?' : ''} ORDER BY slug LIMIT ?`,++ 'community.space': (owner) => `SELECT slug, name, visibility, created_by AS owner, created_at, NULL AS updated_at FROM spaces++ WHERE slug > ?${owner ? ' AND created_by = ?' : ''} ORDER BY slug LIMIT ?`,++};++++/** One page across both kinds (pastes, then spaces), limit + 1 rows to know whether there is a next page. */++async function page(db, f) {++ const kinds = f.kind ? KINDS.filter((k) => k === f.kind) : KINDS;++ const out = [];++ for (const kind of kinds) {++ if (f.cursor && KINDS.indexOf(kind) < KINDS.indexOf(f.cursor.kind)) continue;++ const need = f.limit + 1 - out.length;++ if (need <= 0) break;++ const from = f.cursor && f.cursor.kind === kind ? f.cursor.id : '';++ const rows = await db.prepare(QUERIES[kind](f.owner)).all(from, ...(f.owner ? [f.owner] : []), need);++ for (const row of rows) out.push(summaryOf(kind, row));++ }++ return { resources: out.slice(0, f.limit), next_cursor: out.length > f.limit ? encodeCursor(out[f.limit - 1]) : null };++}++++function router({ db, viewers }) {++ 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); } };++++ r.use(viewers.middleware());++ r.use((req, res, next) => {++ const v = req.viewer || { kind: 'anonymous' };++ if (v.kind === 'service' && hasCap(v, 'community.resource.read')) return next();++ if (v.kind === 'anonymous') return problem(req, res, 401, 'token.missing', 'call with a service token holding community.resource.read');++ return problem(req, res, 403, 'capability.denied', 'community.resource.read is first-party and requires a service token that holds it');++ });++++ r.get('/', handle(async (req, res) => {++ const f = filtersOf(req.query);++ if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);++ res.set('Cache-Control', 'private, max-age=60');++ if (f.project) return res.json({ resources: [], next_cursor: null });++ return res.json(await page(db, f));++ }));++ r.get('/:ovrn', (req, res) => problem(req, res, 404, 'resources.unknown_resource', `no resource named ${String(req.params.ovrn).slice(0, 200)}`));++ return r;++}++++module.exports = { router, summaryOf, filtersOf, encodeCursor, decodeCursor, iso, SERVICE, KINDS, DEFAULT_LIMIT, MAX_LIMIT };@@@ -15,4 +15,6 @@ assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, '/inconst catchAll = conf.lastIndexOf('location / {');assert.ok(conf.indexOf('location ~* ^/metrics(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the metrics block precedes the static-asset regex');assert.ok(conf.indexOf('location ~* ^/internal(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the internal block precedes the static-asset regex');--console.log('nginx internal paths: /metrics and /internal/ are blocked in the vhost');++assert.match(conf, /location ~\* \^\/api\/v1\/resources\(\/\|\$\) \{ return 404; \}/, 'the resource index (OpenVibe.Services, loopback) is never public');++assert.ok(conf.indexOf('location ~* ^/api/v1/resources(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the resources block precedes the static-asset regex');++console.log('nginx internal paths: /metrics, /internal/ and /api/v1/resources are blocked in the vhost');nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * Community's resource index for OpenVibe.Services (server/registry/resource-index.js, community.resource.read): a++ * person's pastes and the spaces they created as common.resource-summary@1, pastes then spaces by slug on an opaque++ * cursor; owner keeps one subject's own, project is always empty, a deleted paste never appears, nothing but the++ * summary leaves (no content); only a service token holding the capability reads it, and no name resolves by OVRN.++ */++const assert = require('assert');++const { validate, ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++const forumStore = require('../server/forum/store');++++(async () => {++ const t = await boot();++ const db = t.app.locals.db || t.db;++ const alice = ids.newId('user');++ const bob = ids.newId('user');++ const agent = ids.newId('agent');++ const paste = async (slug, owner, extra = {}) => db.prepare(`INSERT INTO pastes (slug, owner_subject, title, content, visibility, deleted_at, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, owner, extra.title || `Paste ${slug}`, 'SECRET-CONTENT-never-in-the-index', extra.visibility || 'public',++ extra.deleted ? '2026-10-10 12:00:00' : null, '2026-10-01 10:00:00', '2026-10-02 11:30:00');++ await paste('ri-a1', alice, { title: 'Alice one' });++ await paste('ri-a2', alice, { visibility: 'private' });++ await paste('ri-b1', bob);++ await paste('ri-ai', null, { title: 'From a stream' });++ await paste('ri-gone', alice, { deleted: true });++ await paste('ri-ag', agent);++ await forumStore.createSpace(db, { slug: 'ri-space-alice', name: 'Alice\'s space', created_by: alice, visibility: 'members' });++++ const reader = t.network.signService({ sub: 'svc:services', cap: ['community.resource.read'] });++ const list = (q = '', token = reader) => t.get(`/api/v1/resources${q}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });++ const ours = (rows) => rows.filter((r) => r.id.startsWith('ri-'));++++ await check('pastes then spaces, as resource summaries that validate, with nothing but the summary', async () => {++ const r = await list('?limit=1000');++ assert.strictEqual(r.status, 200, r.text);++ const body = r.json();++ assert.ok(validate('common.resource-list-result@1', body).valid, JSON.stringify(validate('common.resource-list-result@1', body).errors));++ const mine = ours(body.resources);++ assert.deepStrictEqual(mine.map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-ag', 'ri-ai', 'ri-b1', 'ri-space-alice'], 'by kind, then slug; the deleted paste is left out');++ const a1 = mine[0];++ assert.deepStrictEqual(a1, { id: 'ri-a1', kind: 'community.paste', service: 'community', name: 'Alice one', state: 'public', created_at: '2026-10-01T10:00:00.000Z', updated_at: '2026-10-02T11:30:00.000Z', owner: { type: 'user', id: alice } });++ assert.strictEqual(mine.find((x) => x.id === 'ri-a2').state, 'private');++ assert.deepStrictEqual(mine.find((x) => x.id === 'ri-ag').owner, { type: 'agent', id: agent });++ assert.strictEqual('owner' in mine.find((x) => x.id === 'ri-ai'), false, 'a paste no person owns has no owner');++ const sp = mine.find((x) => x.id === 'ri-space-alice');++ assert.deepStrictEqual([sp.kind, sp.name, sp.state, sp.owner.id], ['community.space', 'Alice\'s space', 'members', alice]);++ assert.ok(!r.text.includes('SECRET-CONTENT'), 'no paste content leaves the index');++ assert.ok(body.resources.every((x) => !('ovrn' in x) && !('project_id' in x)), 'person-owned: no project, no OVRN');++ });++++ await check('owner keeps one subject\'s own, across both kinds; kind and project narrow it', async () => {++ assert.deepStrictEqual(ours((await list(`?owner=${alice}&limit=1000`)).json().resources).map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-space-alice']);++ assert.deepStrictEqual(ours((await list(`?owner=${bob}`)).json().resources).map((x) => x.id), ['ri-b1']);++ assert.deepStrictEqual(ours((await list(`?owner=${alice}&kind=community.space`)).json().resources).map((x) => x.id), ['ri-space-alice']);++ assert.deepStrictEqual((await list('?kind=bot.robot')).json().resources, [], 'a kind this index does not hold: nothing');++ assert.deepStrictEqual((await list(`?project=prj_${'0'.repeat(26)}`)).json(), { resources: [], next_cursor: null }, 'nothing here belongs to a project');++ });++++ await check('the cursor walks every row exactly once, across the kinds', async () => {++ const seen = [];++ let cursor = null;++ for (let i = 0; i < 50; i++) {++ const body = (await list(`?owner=${alice}&limit=1${cursor ? `&cursor=${cursor}` : ''}`)).json();++ seen.push(...body.resources.map((x) => x.id));++ if (!body.next_cursor) break;++ cursor = body.next_cursor;++ }++ assert.deepStrictEqual(seen, ['ri-a1', 'ri-a2', 'ri-space-alice']);++ });++++ await check('a malformed filter is 400 resources.bad_query, never ignored', async () => {++ for (const q of ['?owner=svc:live', '?owner=usr_short', '?project=prj_x', '?limit=0', '?limit=1001', '?cursor=not-ours']) {++ const r = await list(q);++ assert.deepStrictEqual([r.status, r.json().code], [400, 'resources.bad_query'], q);++ }++ });++++ await check('only a service token holding community.resource.read; no OVRN resolves', async () => {++ assert.strictEqual((await list('', null)).status, 401);++ const other = t.network.signService({ sub: 'svc:live', cap: ['community.paste.create'] });++ assert.strictEqual((await list('', other)).status, 403);++ const user = t.network.sign({ id: 7, subject_id: alice, username: 'alice', role: 'user' });++ assert.strictEqual((await list('', user)).status, 403, 'a person\'s token is not a service token');++ const r = await list(`/ovrn:community:prj_${'0'.repeat(26)}:paste/abc_${'0'.repeat(26)}`);++ assert.strictEqual(r.status, 404);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -152,6 +152,7 @@ API and machine endpoints:
@@ -152,6 +152,7 @@ API and machine endpoints:
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) |
| `/api/v1/submissions/*` | Submissions — see [Submissions](#submissions) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
+| `GET /api/v1/resources` | OpenVibe.Services' index of a person's pastes (`community.paste`) and the spaces they created (`community.space`): `common.resource-list-result@1`, filters `kind`, `owner`, `project` (always empty), `cursor`, `limit`. A service token with `community.resource.read` only; loopback (nginx answers 404 publicly). Summaries only, never paste content; deleted pastes left out; no OVRN (`/:ovrn` is 404) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /release.json`, `POST /release-metrics` | What this server runs (ADR-016 release manifest, `metrics_url: /release-metrics`); open tabs' update reports (release-watch's same-origin beacon, no auth) |
| `GET /release.json`, `POST /release-metrics` | What this server runs (ADR-016 release manifest, `metrics_url: /release-metrics`); open tabs' update reports (release-watch's same-origin beacon, no auth) |
@@ -575,6 +576,7 @@ same shape as its `manifests/capabilities`):
@@ -575,6 +576,7 @@ same shape as its `manifests/capabilities`):
| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
| `community.pulse.write` | active in contracts (v0.7.0) | publishing to Pulse |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.post.create` | active in contracts (v0.7.0) | forum writes |
| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
| `community.space.read` / `.manage`, `community.thread.read`, `community.vote.set`, `community.pulse.read` | active in contracts | spaces, threads, votes and Pulse reads by services and apps |
+| `community.resource.read` | active in contracts (0.134.0) | OpenVibe.Services' resource index: a person's pastes and spaces in "Your resources" |
This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all go
This repository pins `openvibe-contracts` v0.86.0, which knows every id above, so they all go
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
through the library's `capabilities.check`. `server/identity/capabilities.js` still decides an id
@@ -824,7 +826,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
@@ -824,7 +826,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.129.0
+- openvibe-contracts: v0.134.0
- openvibe-sdk: v0.42.0
- openvibe-sdk: v0.42.0
- openvibe-shared: v3.0.1
- openvibe-shared: v3.0.1
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4200,
"port": 4200,
"domain": "openvibe.community",
"domain": "openvibe.community",
- "contracts": "openvibe-contracts v0.129.0",
+ "contracts": "openvibe-contracts v0.134.0",
"sdk": "openvibe-sdk v0.42.0",
"sdk": "openvibe-sdk v0.42.0",
"shared": "openvibe-shared v3.0.1",
"shared": "openvibe-shared v3.0.1",
"capabilities": [
"capabilities": [
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
"typed comment threads embedded by other products",
"typed comment threads embedded by other products",
"forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)",
"forum: spaces, threads, posts, categories and votes (returned from OpenVibe.Space 2026-10-08, owner decision)",
"per-space moderators (migration 0005)",
"per-space moderators (migration 0005)",
+ "resource index for OpenVibe.Services (community.resource.read: pastes and spaces, person-owned)",
"members-only spaces and threads gated by OpenVibe.VIP",
"members-only spaces and threads gated by OpenVibe.VIP",
"a space's chat room on OpenVibe.Chat",
"a space's chat room on OpenVibe.Chat",
"Pulse: the network's public activity from Events",
"Pulse: the network's public activity from Events",
@@ -130,6 +130,8 @@ server {
@@ -130,6 +130,8 @@ server {
# Regex locations match in order, so these sit before the static-asset regex. ──
# Regex locations match in order, so these sit before the static-asset regex. ──
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
location ~* ^/internal(/|$) { return 404; }
+ # The resource index (community.resource.read) is OpenVibe.Services' on loopback, never public.
+ location ~* ^/api/v1/resources(/|$) { return 404; }
# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
# ── Static assets: Node marks hashed ?v= URLs immutable; let browsers keep them ──
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
location ~ ^/(?!api/|auth/).*\.(?:css|js|svg|png|ico|webmanifest|woff2?)$ {
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "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.134.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -917,9 +917,9 @@
@@ -917,9 +917,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.134.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.134.0",
+ "integrity": "sha512-sWjPIaJtv/V1evsLbNcXecsiHVHd7w+A7/VD7o41j4Veotd+zfQRnNHFHS2gFlOVGR3WBycEzOZzo7+r1jk3sg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "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.134.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.42.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -239,6 +239,9 @@ async function createApp(opts = {}) {
@@ -239,6 +239,9 @@ async function createApp(opts = {}) {
app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
+ // OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
+ // nginx answers 404 for the path on the public vhost).
+ app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Community's authority index for OpenVibe.Services (ADR-048, plan T13; capability community.resource.read). A
+ * person's pastes and the spaces they created, so "Your resources" on openvibe.services shows them beside their
+ * robots, rooms, streams, watches and Actor tasks. Both kinds are person-owned: no project, no OVRN.
+ *
+ * GET /api/v1/resources?kind=&owner=&project=&cursor=&limit= common.resource-list-result@1
+ * community.paste id the slug, named by its title, state its visibility, owner its owner (deleted ones left out)
+ * community.space id the slug, named by its name, state its visibility, owner the person who created it
+ * Pastes first, then spaces, each by slug; the cursor is opaque ([kind, slug]). owner (a usr_/agt_ id) keeps
+ * what that subject owns; project always answers an empty page (nothing here belongs to a project).
+ * GET /api/v1/resources/:ovrn 404 for every name (nothing here has one)
+ *
+ * First-party only: a service token holding community.resource.read. nginx answers 404 for the path on the public
+ * vhost; Services reads it on loopback.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { hasCap } = require('../identity/viewer');
+
+const SERVICE = 'community';
+const KINDS = ['community.paste', 'community.space'];
+const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEFAULT_LIMIT = 100;
+const MAX_LIMIT = 1000;
+
+/** Community stores UTC as 'YYYY-MM-DD HH:MM:SS'; a summary carries ISO 8601. */
+function iso(text) {
+ if (!text) return null;
+ const d = new Date(String(text).includes('T') ? String(text) : `${String(text).replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+function ownerOf(subject) {
+ const s = String(subject || '');
+ if (!OWNER_SUBJECT_RE.test(s)) return null;
+ return { type: s.startsWith('agt_') ? 'agent' : 'user', id: s };
+}
+
+/** One row → common.resource-summary@1. Nothing else of a paste (its content, its views) leaves this index. */
+function summaryOf(kind, row) {
+ const out = {
+ id: row.slug, kind, service: SERVICE,
+ name: kind === 'community.paste' ? row.title : row.name,
+ state: row.visibility,
+ created_at: iso(row.created_at),
+ };
+ const updated = iso(row.updated_at);
+ if (updated) out.updated_at = updated;
+ const owner = ownerOf(row.owner);
+ if (owner) out.owner = owner;
+ return out;
+}
+
+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 && KINDS.includes(v[0]) && typeof v[1] === 'string' && v[1] !== '' ? { kind: v[0], id: v[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 owner = query.owner === undefined || query.owner === '' ? null : query.owner;
+ if (owner !== null && (typeof owner !== 'string' || !OWNER_SUBJECT_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ 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, owner, kind, limit, cursor };
+}
+
+const QUERIES = {
+ 'community.paste': (owner) => `SELECT slug, title, visibility, owner_subject AS owner, created_at, updated_at FROM pastes
+ WHERE deleted_at IS NULL AND slug > ?${owner ? ' AND owner_subject = ?' : ''} ORDER BY slug LIMIT ?`,
+ 'community.space': (owner) => `SELECT slug, name, visibility, created_by AS owner, created_at, NULL AS updated_at FROM spaces
+ WHERE slug > ?${owner ? ' AND created_by = ?' : ''} ORDER BY slug LIMIT ?`,
+};
+
+/** One page across both kinds (pastes, then spaces), limit + 1 rows to know whether there is a next page. */
+async function page(db, f) {
+ const kinds = f.kind ? KINDS.filter((k) => k === f.kind) : KINDS;
+ const out = [];
+ for (const kind of kinds) {
+ if (f.cursor && KINDS.indexOf(kind) < KINDS.indexOf(f.cursor.kind)) continue;
+ const need = f.limit + 1 - out.length;
+ if (need <= 0) break;
+ const from = f.cursor && f.cursor.kind === kind ? f.cursor.id : '';
+ const rows = await db.prepare(QUERIES[kind](f.owner)).all(from, ...(f.owner ? [f.owner] : []), need);
+ for (const row of rows) out.push(summaryOf(kind, row));
+ }
+ return { resources: out.slice(0, f.limit), next_cursor: out.length > f.limit ? encodeCursor(out[f.limit - 1]) : null };
+}
+
+function router({ db, viewers }) {
+ 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); } };
+
+ r.use(viewers.middleware());
+ r.use((req, res, next) => {
+ const v = req.viewer || { kind: 'anonymous' };
+ if (v.kind === 'service' && hasCap(v, 'community.resource.read')) return next();
+ if (v.kind === 'anonymous') return problem(req, res, 401, 'token.missing', 'call with a service token holding community.resource.read');
+ return problem(req, res, 403, 'capability.denied', 'community.resource.read is first-party and requires a service token that holds it');
+ });
+
+ r.get('/', handle(async (req, res) => {
+ const f = filtersOf(req.query);
+ if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
+ res.set('Cache-Control', 'private, max-age=60');
+ if (f.project) return res.json({ resources: [], next_cursor: null });
+ return res.json(await page(db, f));
+ }));
+ r.get('/:ovrn', (req, res) => problem(req, res, 404, 'resources.unknown_resource', `no resource named ${String(req.params.ovrn).slice(0, 200)}`));
+ return r;
+}
+
+module.exports = { router, summaryOf, filtersOf, encodeCursor, decodeCursor, iso, SERVICE, KINDS, DEFAULT_LIMIT, MAX_LIMIT };
@@ -15,4 +15,6 @@ assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, '/in
@@ -15,4 +15,6 @@ assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, '/in
const catchAll = conf.lastIndexOf('location / {');
const catchAll = conf.lastIndexOf('location / {');
assert.ok(conf.indexOf('location ~* ^/metrics(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the metrics block precedes the static-asset regex');
assert.ok(conf.indexOf('location ~* ^/metrics(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the metrics block precedes the static-asset regex');
assert.ok(conf.indexOf('location ~* ^/internal(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the internal block precedes the static-asset regex');
assert.ok(conf.indexOf('location ~* ^/internal(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the internal block precedes the static-asset regex');
-console.log('nginx internal paths: /metrics and /internal/ are blocked in the vhost');
+assert.match(conf, /location ~\* \^\/api\/v1\/resources\(\/\|\$\) \{ return 404; \}/, 'the resource index (OpenVibe.Services, loopback) is never public');
+assert.ok(conf.indexOf('location ~* ^/api/v1/resources(/|$)') < conf.indexOf('location ~ ^/(?!api/|auth/)'), 'the resources block precedes the static-asset regex');
+console.log('nginx internal paths: /metrics, /internal/ and /api/v1/resources are blocked in the vhost');
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * Community's resource index for OpenVibe.Services (server/registry/resource-index.js, community.resource.read): a
+ * person's pastes and the spaces they created as common.resource-summary@1, pastes then spaces by slug on an opaque
+ * cursor; owner keeps one subject's own, project is always empty, a deleted paste never appears, nothing but the
+ * summary leaves (no content); only a service token holding the capability reads it, and no name resolves by OVRN.
+ */
+const assert = require('assert');
+const { validate, ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+const forumStore = require('../server/forum/store');
+
+(async () => {
+ const t = await boot();
+ const db = t.app.locals.db || t.db;
+ const alice = ids.newId('user');
+ const bob = ids.newId('user');
+ const agent = ids.newId('agent');
+ const paste = async (slug, owner, extra = {}) => db.prepare(`INSERT INTO pastes (slug, owner_subject, title, content, visibility, deleted_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(slug, owner, extra.title || `Paste ${slug}`, 'SECRET-CONTENT-never-in-the-index', extra.visibility || 'public',
+ extra.deleted ? '2026-10-10 12:00:00' : null, '2026-10-01 10:00:00', '2026-10-02 11:30:00');
+ await paste('ri-a1', alice, { title: 'Alice one' });
+ await paste('ri-a2', alice, { visibility: 'private' });
+ await paste('ri-b1', bob);
+ await paste('ri-ai', null, { title: 'From a stream' });
+ await paste('ri-gone', alice, { deleted: true });
+ await paste('ri-ag', agent);
+ await forumStore.createSpace(db, { slug: 'ri-space-alice', name: 'Alice\'s space', created_by: alice, visibility: 'members' });
+
+ const reader = t.network.signService({ sub: 'svc:services', cap: ['community.resource.read'] });
+ const list = (q = '', token = reader) => t.get(`/api/v1/resources${q}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
+ const ours = (rows) => rows.filter((r) => r.id.startsWith('ri-'));
+
+ await check('pastes then spaces, as resource summaries that validate, with nothing but the summary', async () => {
+ const r = await list('?limit=1000');
+ assert.strictEqual(r.status, 200, r.text);
+ const body = r.json();
+ assert.ok(validate('common.resource-list-result@1', body).valid, JSON.stringify(validate('common.resource-list-result@1', body).errors));
+ const mine = ours(body.resources);
+ assert.deepStrictEqual(mine.map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-ag', 'ri-ai', 'ri-b1', 'ri-space-alice'], 'by kind, then slug; the deleted paste is left out');
+ const a1 = mine[0];
+ assert.deepStrictEqual(a1, { id: 'ri-a1', kind: 'community.paste', service: 'community', name: 'Alice one', state: 'public', created_at: '2026-10-01T10:00:00.000Z', updated_at: '2026-10-02T11:30:00.000Z', owner: { type: 'user', id: alice } });
+ assert.strictEqual(mine.find((x) => x.id === 'ri-a2').state, 'private');
+ assert.deepStrictEqual(mine.find((x) => x.id === 'ri-ag').owner, { type: 'agent', id: agent });
+ assert.strictEqual('owner' in mine.find((x) => x.id === 'ri-ai'), false, 'a paste no person owns has no owner');
+ const sp = mine.find((x) => x.id === 'ri-space-alice');
+ assert.deepStrictEqual([sp.kind, sp.name, sp.state, sp.owner.id], ['community.space', 'Alice\'s space', 'members', alice]);
+ assert.ok(!r.text.includes('SECRET-CONTENT'), 'no paste content leaves the index');
+ assert.ok(body.resources.every((x) => !('ovrn' in x) && !('project_id' in x)), 'person-owned: no project, no OVRN');
+ });
+
+ await check('owner keeps one subject\'s own, across both kinds; kind and project narrow it', async () => {
+ assert.deepStrictEqual(ours((await list(`?owner=${alice}&limit=1000`)).json().resources).map((x) => x.id), ['ri-a1', 'ri-a2', 'ri-space-alice']);
+ assert.deepStrictEqual(ours((await list(`?owner=${bob}`)).json().resources).map((x) => x.id), ['ri-b1']);
+ assert.deepStrictEqual(ours((await list(`?owner=${alice}&kind=community.space`)).json().resources).map((x) => x.id), ['ri-space-alice']);
+ assert.deepStrictEqual((await list('?kind=bot.robot')).json().resources, [], 'a kind this index does not hold: nothing');
+ assert.deepStrictEqual((await list(`?project=prj_${'0'.repeat(26)}`)).json(), { resources: [], next_cursor: null }, 'nothing here belongs to a project');
+ });
+
+ await check('the cursor walks every row exactly once, across the kinds', async () => {
+ const seen = [];
+ let cursor = null;
+ for (let i = 0; i < 50; i++) {
+ const body = (await list(`?owner=${alice}&limit=1${cursor ? `&cursor=${cursor}` : ''}`)).json();
+ seen.push(...body.resources.map((x) => x.id));
+ if (!body.next_cursor) break;
+ cursor = body.next_cursor;
+ }
+ assert.deepStrictEqual(seen, ['ri-a1', 'ri-a2', 'ri-space-alice']);
+ });
+
+ await check('a malformed filter is 400 resources.bad_query, never ignored', async () => {
+ for (const q of ['?owner=svc:live', '?owner=usr_short', '?project=prj_x', '?limit=0', '?limit=1001', '?cursor=not-ours']) {
+ const r = await list(q);
+ assert.deepStrictEqual([r.status, r.json().code], [400, 'resources.bad_query'], q);
+ }
+ });
+
+ await check('only a service token holding community.resource.read; no OVRN resolves', async () => {
+ assert.strictEqual((await list('', null)).status, 401);
+ const other = t.network.signService({ sub: 'svc:live', cap: ['community.paste.create'] });
+ assert.strictEqual((await list('', other)).status, 403);
+ const user = t.network.sign({ id: 7, subject_id: alice, username: 'alice', role: 'user' });
+ assert.strictEqual((await list('', user)).status, 403, 'a person\'s token is not a service token');
+ const r = await list(`/ovrn:community:prj_${'0'.repeat(26)}:paste/abc_${'0'.repeat(26)}`);
+ assert.strictEqual(r.status, 404);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });