openvibers/openvibe.watch Commits

Resource index: watch for OpenVibe.Services (watch.resource.read) (#28)

OP OpenVibers committed 10 Oct 2026 · 1c7f3abbcb · 6 files, +234 −2

Plan T13 step 8 phase 2 (ADR-048): Watch's authority resource index for OpenVibe.Services, against `watch.resource.read` (openvibe-contracts 0.129.0, planned).

- `GET /api/v1/resources?project=&kind=&cursor=&limit=` returns a common.resource-list-result@1 page of `watch.watch` summaries: non-deleted watches, named by the watch's name, state = its status. It has an opaque keyset cursor (Host's `[kind, id]` form), a default of 100 rows and a maximum of 1000. A bad `project` is a 400, and another kind gives an empty page.
- `GET /api/v1/resources/:ovrn` returns one summary, or 404. A watch made under a project has `ovrn:watch:<prj>:watch/<wch_…>`.
- Service tokens carrying `watch.resource.read` only, through Watch's `requireCap` plus a first-party check. A person's token is refused even where other routes accept one.
- Summaries carry id, kind, service, project/OVRN where they apply, owner (only a `usr_` subject), name, state and timestamps. Nothing else leaves the table.
- The public vhost 404s `/api/v1/resources`; Services calls 127.0.0.1:4730 (vhost reinstalled after deploy).

Services starts reading it once a Contracts release makes the capability active and Network grants the `services` principal.

`ov test` (files covering the change): 11 passed.

Changes

ddiff --git a/README.md b/README.md
iindex d3cf134..50441c4 100644
---- a/README.md
++++ b/README.md
@@@ -219,6 +219,11 @@ an app or module may not.
| `watch.watch.manage` | `POST /api/v1/watches`, `PATCH /api/v1/watches/:id`, `POST /api/v1/watches/:id/pause`, `POST /api/v1/watches/:id/resume`, `DELETE /api/v1/watches/:id` |
| `watch.observation.read` | `GET /api/v1/watches/:id/observations?before=&limit=`, `GET /api/v1/watches/:id/checks?before=&limit=` |
| `watch.check.run` | `POST /api/v1/watches/:id/check` (404 unknown, 409 `watch.busy` while one runs, 409 `watch.disabled` for a paused watch, 422 `watch.carrier_unavailable`) |
++| `watch.resource.read` | `GET /api/v1/resources?project=&kind=&cursor=&limit=`, `GET /api/v1/resources/:ovrn` (first-party service token only) |
++
++The loopback-only authority index lists non-deleted `watch.watch` resources as
++`common.resource-list-result@1`. It orders watches by id, pages with an opaque cursor, and
++includes an OVRN only for a watch with a valid project id.
The list answer is exactly `watch.watch-result@1` (`{ watches }`): page with
`before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view,
ddiff --git a/STATUS.json b/STATUS.json
iindex bf06ad2..7d77fc3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -14,13 +14,15 @@
"watch.watch.read",
"watch.watch.manage",
"watch.observation.read",
-- "watch.check.run"
++ "watch.check.run",
++ "watch.resource.read"
],
"capabilitiesRegistered": true,
"contractsUsed": [
"watch.watch@1, watch.watch-request@1, watch.watch-result@1",
"watch.observation@1, watch.check-run@1",
-- "watch.observation.recorded@1, watch.watch.triggered@1, watch.check.failed@1"
++ "watch.observation.recorded@1, watch.watch.triggered@1, watch.check.failed@1",
++ "common.resource-summary@1, common.resource-list-result@1"
],
"carriers": [
"http",
@@@ -65,6 +67,7 @@
"observations with value hashes, capped snapshots and retention",
"check runs with the full terminal-state set, exponential backoff and Retry-After",
"POST /api/v1/watches/:id/check plus the observations and checks reads",
++ "GET /api/v1/resources and GET /api/v1/resources/:ovrn (watch.resource.read)",
"watch.* events through the outbox in the same transaction as the state change",
"per-actor limits (openvibe-sdk/limits)",
"the public site: Network sign-in (PKCE S256, web_sessions with only the token hash), server-rendered pages complete without JavaScript, the four create templates, watch management, observation history and check runs, how-it-works and the changelog, robots/sitemap/llms.txt"
ddiff --git a/deploy/nginx/openvibe.watch.conf b/deploy/nginx/openvibe.watch.conf
iindex 467f2a5..c991150 100644
---- a/deploy/nginx/openvibe.watch.conf
++++ b/deploy/nginx/openvibe.watch.conf
@@@ -102,6 +102,8 @@ server {
# Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics). Case-insensitive so
# /Metrics and friends are refused the same way.
location ~* ^/metrics(/|$) { return 404; }
++ # OpenVibe.Services reads the resource index on loopback (127.0.0.1:4730) with a service token.
++ location ~* ^/api/v1/resources(/|$) { return 404; }
# ── Sign-in: the network's own budget. /auth/me is the navbar's session probe and gets the
# page budget instead, because it runs on every page view. ──
ddiff --git a/server/api/resource-index.js b/server/api/resource-index.js
nnew file mode 100644
iindex 0000000..db3b4f9
---- /dev/null
++++ b/server/api/resource-index.js
@@@ -0,0 +1,102 @@
++'use strict';
++/** Watch's authority resource index (ADR-048): non-deleted watches for OpenVibe.Services. */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++
++const SERVICE = 'watch';
++const KIND = 'watch.watch';
++const CAPABILITY = 'watch.resource.read';
++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;
++
++/** Only the public fields allowed by common.resource-summary@1 leave this table. */
++function watchSummary(row) {
++ const summary = {
++ id: row.id, kind: KIND, service: SERVICE,
++ ...(PROJECT_ID_RE.test(String(row.project_id || '')) ? { project_id: row.project_id } : {}),
++ ...(USER_SUBJECT_RE.test(String(row.owner_sub || '')) ? { owner: { type: 'user', id: row.owner_sub } } : {}),
++ name: row.name, state: row.status,
++ created_at: new Date(Number(row.created_at)).toISOString(),
++ updated_at: new Date(Number(row.updated_at)).toISOString(),
++ };
++ const ovrn = contracts.resources.nameOf(summary);
++ return ovrn ? { ...summary, ovrn } : summary;
++}
++
++/** Host's opaque [kind, id] cursor shape, with the id as the keyset position. */
++const encodeCursor = (id) => Buffer.from(JSON.stringify([KIND, 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' && /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(value[1]) ? value[1] : null;
++}
++
++function filtersOf(query) {
++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (typeof query.limit === 'string' && query.limit !== '') {
++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) {
++ return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ }
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (typeof query.cursor === 'string' && query.cursor !== '') {
++ cursor = decodeCursor(query.cursor);
++ if (!cursor) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, kind, limit, cursor };
++}
++
++function resourceIndexRouter({ db, auth }) {
++ const router = express.Router();
++ const guard = auth.requireCap(CAPABILITY);
++ // requireCap verifies the token and its capability. Person tokens can pass other Watch guards,
++ // so this first-party index also checks the verified principal after the shared guard.
++ const firstParty = (req, res, next) => {
++ if (req.principal.kind === 'service' && /^svc:/.test(req.principal.sub)) return next();
++ return contracts.http.sendProblem(res, 403, 'capability.denied', {
++ detail: `${CAPABILITY} is first-party and not granted to this principal`, ctx: req.ov,
++ });
++ };
++ const bad = (req, res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail, ctx: req.ov });
++ const unknown = (req, res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', {
++ detail: `no resource named ${name}`, ctx: req.ov,
++ });
++
++ router.get('/api/v1/resources', guard, firstParty, async (req, res) => {
++ const filters = filtersOf(req.query);
++ if (filters.error) return bad(req, res, filters.error);
++ if (filters.kind && filters.kind !== KIND) return res.json({ resources: [], next_cursor: null });
++ const clauses = ['deleted_at IS NULL'];
++ const args = [];
++ if (filters.project) { clauses.push('project_id = ?'); args.push(filters.project); }
++ if (filters.cursor) { clauses.push('id > ?'); args.push(filters.cursor); }
++ args.push(filters.limit + 1);
++ const rows = await db.prepare(`SELECT id, project_id, owner_sub, name, status, created_at, updated_at
++ FROM watches WHERE ${clauses.join(' AND ')} ORDER BY id ASC LIMIT ?`).all(...args);
++ const resources = rows.slice(0, filters.limit).map(watchSummary);
++ const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1].id) : null;
++ return res.json({ resources, next_cursor });
++ });
++
++ router.get('/api/v1/resources/:ovrn', guard, firstParty, async (req, res) => {
++ const name = String(req.params.ovrn);
++ const parsed = contracts.resources.parse(name);
++ if (!parsed || parsed.service !== SERVICE || parsed.type !== 'watch') return unknown(req, res, name);
++ const row = await db.prepare(`SELECT id, project_id, owner_sub, name, status, created_at, updated_at
++ FROM watches WHERE id = ? AND deleted_at IS NULL`).get(parsed.id);
++ const summary = row && watchSummary(row);
++ if (!summary || summary.ovrn !== name) return unknown(req, res, name);
++ return res.json(summary);
++ });
++
++ return router;
++}
++
++module.exports = { resourceIndexRouter, watchSummary, filtersOf, encodeCursor, SERVICE, KIND, CAPABILITY, DEFAULT_LIMIT, MAX_LIMIT };
ddiff --git a/server/app.js b/server/app.js
iindex 53010ef..53ea502 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -21,6 +21,7 @@ const { createWatchReadiness, registerWatchGauges } = require('./observability')
const { createSso } = require('./auth/sso');
const { createPageRoutes } = require('./http/pages');
const { watchesRouter } = require('./api/watches');
++const { resourceIndexRouter } = require('./api/resource-index');
const { checksRouter } = require('./api/checks');
const { createActorLimits } = require('./api/actor-limits');
const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
@@@ -118,6 +119,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
// ── The API (unchanged: bearer tokens, one capability per route, problem+json) ──
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
++ // OpenVibe.Services' resource index (watch.resource.read, service tokens only): read on loopback; the public vhost 404s it.
++ app.use(resourceIndexRouter({ db, auth }));
app.use(watchesRouter({ registry, auth, limits }));
app.use(checksRouter({ registry, check, observations, auth, limits }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..6c3b354
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++/** Watch's first-party authority resource index (ADR-048, plan T13 step 8). */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, request, serviceToken, userToken, newUser, newAgent, watchDef, suite } = require('./helpers');
++
++const t = suite('resource-index');
++let svc;
++const owner = newUser();
++const agent = newAgent();
++const projectA = contracts.ids.newId('project');
++const projectB = contracts.ids.newId('project');
++const token = serviceToken('services', ['watch.resource.read']);
++const api = (path, bearer = token) => request(svc.base, 'GET', path, { token: bearer });
++const pathOf = (name) => `/api/v1/resources/${encodeURIComponent(name)}`;
++let rows;
++
++t('boot and create watches with both project and person ownership', async () => {
++ svc = await boot();
++ const create = async (project_id, owner_sub, name) => {
++ const row = await svc.registry.create(watchDef({ name }), { sub: owner_sub === 'svc:site' ? owner : owner_sub });
++ if (project_id) {
++ await svc.db.prepare('UPDATE watches SET project_id = ? WHERE id = ?').run(project_id, row.id);
++ row.project_id = project_id;
++ }
++ if (owner_sub === 'svc:site') {
++ await svc.db.prepare('UPDATE watches SET owner_sub = ? WHERE id = ?').run(owner_sub, row.id);
++ row.owner_sub = owner_sub;
++ }
++ return row;
++ };
++ rows = [
++ await create(projectA, owner, 'project A'),
++ await create(projectB, agent, 'project B'),
++ await create(null, owner, 'personal'),
++ await create(projectA, 'svc:site', 'service owned'),
++ ];
++ const deleted = await create(projectA, owner, 'deleted');
++ await svc.registry.remove(deleted.id, { sub: owner });
++ rows.deleted = deleted;
++});
++
++t('only a service token carrying watch.resource.read can list or read', async () => {
++ const missing = await api('/api/v1/resources', null);
++ assert.strictEqual(missing.status, 401);
++ assert.strictEqual(missing.body.code, 'token.missing');
++ const denied = await api('/api/v1/resources', serviceToken('services', ['watch.watch.read']));
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.body.code, 'capability.denied');
++ const person = await api('/api/v1/resources', userToken(owner));
++ assert.strictEqual(person.status, 403);
++ assert.strictEqual(person.body.code, 'capability.denied');
++ assert.strictEqual((await api('/api/v1/resources', serviceToken('services', ['watch.resource.read'], { aud: 'openvibe.blog' }))).status, 401);
++ assert.strictEqual((await api(pathOf(`ovrn:watch:${projectA}:watch/${rows[0].id}`), null)).status, 401);
++});
++
++t('the complete page validates and carries only safe summary fields', async () => {
++ const page = await api('/api/v1/resources');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(contracts.validate('common.resource-list-result@1', page.body).valid,
++ JSON.stringify(contracts.validate('common.resource-list-result@1', page.body).errors));
++ assert.deepStrictEqual(page.body.resources.map(r => r.id), rows.map(r => r.id).sort());
++ assert.strictEqual(page.body.next_cursor, null);
++ const allowed = new Set(['created_at', 'id', 'kind', 'name', 'owner', 'ovrn', 'project_id', 'service', 'state', 'updated_at']);
++ for (const summary of page.body.resources) {
++ assert.ok(Object.keys(summary).every(key => allowed.has(key)));
++ assert.strictEqual(summary.kind, 'watch.watch');
++ assert.strictEqual(summary.service, 'watch');
++ }
++ assert.strictEqual(page.body.resources.find(r => r.id === rows[0].id).owner.id, owner);
++ assert.strictEqual(page.body.resources.find(r => r.id === rows[1].id).owner, undefined);
++ assert.strictEqual(page.body.resources.find(r => r.id === rows[2].id).ovrn, undefined);
++ assert.strictEqual(page.body.resources.find(r => r.id === rows[3].id).owner, undefined);
++});
++
++t('the id keyset cursor walks each row once', async () => {
++ const seen = [];
++ let cursor = null;
++ do {
++ const page = await api(`/api/v1/resources?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);
++ seen.push(...page.body.resources.map(r => r.id));
++ cursor = page.body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, rows.map(r => r.id).sort());
++ assert.strictEqual(new Set(seen).size, rows.length);
++});
++
++t('project and kind filters, and bad project or cursor', async () => {
++ const project = await api(`/api/v1/resources?project=${projectA}`);
++ assert.deepStrictEqual(project.body.resources.map(r => r.id), [rows[0].id, rows[3].id]);
++ assert.deepStrictEqual((await api('/api/v1/resources?kind=watch.watch')).body.resources.map(r => r.id), rows.map(r => r.id).sort());
++ assert.deepStrictEqual((await api('/api/v1/resources?kind=watch.unknown')).body, { resources: [], next_cursor: null });
++ const bad = await api('/api/v1/resources?project=bad');
++ assert.strictEqual(bad.status, 400);
++ assert.strictEqual(bad.body.code, 'resources.bad_query');
++ assert.strictEqual((await api('/api/v1/resources?cursor=bad')).status, 400);
++});
++
++t('OVRN reads require a computed name and exclude deleted watches', async () => {
++ for (const row of [rows[0], rows[1], rows[3]]) {
++ const name = `ovrn:watch:${row.project_id}:watch/${row.id}`;
++ const one = await api(pathOf(name));
++ assert.strictEqual(one.status, 200, one.text);
++ assert.strictEqual(one.body.ovrn, name);
++ assert.ok(contracts.validate('common.resource-summary@1', one.body).valid);
++ }
++ const personal = await api(pathOf(`ovrn:watch:${projectA}:watch/${rows[2].id}`));
++ assert.strictEqual(personal.status, 404);
++ assert.strictEqual(personal.body.code, 'resources.unknown_resource');
++ assert.strictEqual((await api(pathOf(`ovrn:watch:${projectB}:watch/${rows[0].id}`))).status, 404);
++ assert.strictEqual((await api(pathOf(`ovrn:watch:${projectA}:watch/${rows.deleted.id}`))).status, 404);
++});
++
++t('done', async () => { await svc.stop(); });
++t.run();