openvibers/openvibe.watch Commits
Resource index: watch for OpenVibe.Services (watch.resource.read) (#28)
Changes
@@@ -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,@@@ -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"@@@ -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. ──nnew file mode 100644@@@ -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 };@@@ -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 }));nnew file mode 100644@@@ -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();
@@ -219,6 +219,11 @@ an app or module may not.
@@ -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.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.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.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
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,
`before=<the last watch's id>`, since a `wch_` id is a ULID, i.e. creation order. Every watch view,
@@ -14,13 +14,15 @@
@@ -14,13 +14,15 @@
"watch.watch.read",
"watch.watch.read",
"watch.watch.manage",
"watch.watch.manage",
"watch.observation.read",
"watch.observation.read",
- "watch.check.run"
+ "watch.check.run",
+ "watch.resource.read"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"contractsUsed": [
"contractsUsed": [
"watch.watch@1, watch.watch-request@1, watch.watch-result@1",
"watch.watch@1, watch.watch-request@1, watch.watch-result@1",
"watch.observation@1, watch.check-run@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": [
"carriers": [
"http",
"http",
@@ -65,6 +67,7 @@
@@ -65,6 +67,7 @@
"observations with value hashes, capped snapshots and retention",
"observations with value hashes, capped snapshots and retention",
"check runs with the full terminal-state set, exponential backoff and Retry-After",
"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",
"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",
"watch.* events through the outbox in the same transaction as the state change",
"per-actor limits (openvibe-sdk/limits)",
"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"
"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"
@@ -102,6 +102,8 @@ server {
@@ -102,6 +102,8 @@ server {
# Metrics are for host-local scrapers only (http://127.0.0.1:4730/metrics). Case-insensitive so
# 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.
# /Metrics and friends are refused the same way.
location ~* ^/metrics(/|$) { return 404; }
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
# ── 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. ──
# page budget instead, because it runs on every page view. ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
@@ -21,6 +21,7 @@ const { createWatchReadiness, registerWatchGauges } = require('./observability')
@@ -21,6 +21,7 @@ const { createWatchReadiness, registerWatchGauges } = require('./observability')
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { watchesRouter } = require('./api/watches');
const { watchesRouter } = require('./api/watches');
+const { resourceIndexRouter } = require('./api/resource-index');
const { checksRouter } = require('./api/checks');
const { checksRouter } = require('./api/checks');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
const { assetVersion, send, setRelease, SITE_NAME, TAGLINE } = require('./render/layout');
@@ -118,6 +119,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -118,6 +119,8 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
// ── The API (unchanged: bearer tokens, one capability per route, problem+json) ──
// ── The API (unchanged: bearer tokens, one capability per route, problem+json) ──
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log });
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(watchesRouter({ registry, auth, limits }));
app.use(checksRouter({ registry, check, observations, 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 }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'watch.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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();