openvibers/openvibe.events Commits
Events: the resource index (GET /api/v1/resources, events.subscription) under events.resource.read; contracts v0.107.0 (T13 step 8). (#17)
Changes
@@@ -36,8 +36,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to| `events.subscription.manage` | `/api/v1/subscriptions…` (own subscriptions only) || `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service || `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |++| `events.resource.read` | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` (the [resource index](#resource-index)) |--These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.80.0 knows all seven).++These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `events.resource.read` is `first-party` (ADR-048) and still `planned` in `openvibe-contracts` until the deploy lands — the guard is the same service-token check, and the same 401/403, every other route here gets. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.107.0 knows all eight).Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.@@@ -62,12 +63,13 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/limits.json`, `/m## CapabilitiesImplemented here (the service manifest's `capabilities`): the four internal ones in the table above--(`events.event.publish`, `events.event.read`, `events.subscription.manage`, `events.delivery.admin`)--and the three public developer-app ones (`events.app.publish`, `events.app.read`,--`events.app.subscribe`, [Developer apps](#developer-apps)). Events calls no other service with a--grant: it only loads the Network signing key (JWKS) and makes the signed deliveries its subscriptions--ask for. It produces one event of its own, `events.usage.recorded` (a project's hourly publishing and--delivery rollup, for Network; never visible to apps).++(`events.event.publish`, `events.event.read`, `events.subscription.manage`, `events.delivery.admin`),++the three public developer-app ones (`events.app.publish`, `events.app.read`, `events.app.subscribe`,++[Developer apps](#developer-apps)) and the first-party `events.resource.read` (ADR-048,++[Resource index](#resource-index); `planned` in `openvibe-contracts` until the deploy lands). Events++calls no other service with a grant: it only loads the Network signing key (JWKS) and makes the signed++deliveries its subscriptions ask for. It produces one event of its own, `events.usage.recorded` (a++project's hourly publishing and delivery rollup, for Network; never visible to apps).## Publishing@@@ -138,6 +140,15 @@ node scripts/events-archive.js restore --month 2026-01 [--hold-days 30]`export` writes the closed month's replay rows older than the cutoff to `<YYYY>/events-<YYYY-MM>.ndjson.gz` (one row per line: every column of the hot row, payload and actor as their stored JSON text, plus `epoch` and `archived_at`) and `<YYYY>/events-<YYYY-MM>.json` (`count`, `sha256`). It deletes those rows from `events_archive` only after reading the object back and matching both, and only rows unchanged since it read them (a row redacted meanwhile stays; rerun). Re-running, or exporting the rest of a month later, merges into the month's object. `restore` checks the object against its manifest, then inserts it back into `events_archive`, idempotent on `event_id`, held for `--hold-days` so the replay prune does not delete it at once. A redaction published after a month was exported does not reach its object, so restore only what you need. Export and restore of one month are serialised by a PostgreSQL advisory lock. Storage (`scripts/archive-storage.js`): a local directory, `EVENTS_ARCHIVE_DIR` (default `data/archive`), or an S3-compatible bucket when `EVENTS_ARCHIVE_S3_BUCKET` is set (`EVENTS_ARCHIVE_S3_ENDPOINT`, `_REGION`, `_ACCESS_KEY_ID`, `_SECRET_ACCESS_KEY`, `_PREFIX`; path-style, SigV4 signed with node:crypto and sent with node's `fetch`, no dependency). Each object is built in memory.++## Resource index++++`GET /api/v1/resources` is this authority's resource index (ADR-048 section 3; capability `events.resource.read`): the surface OpenVibe.Services fans out over and merges, so it can list every resource of the network without owning any service's rows. It pages `common.resource-summary@1` for the resources Events owns — today its subscriptions (`sub_<ULID>`, kind `events.subscription`, read from the `subscriptions` table) — as `common.resource-list-result@1`; `GET /api/v1/resources/:ovrn` reads one by its OVRN (`common.resource-summary@1`).++++- Query: `?project=prj_…&kind=events.subscription&cursor=…&limit=…`. `?project=` is the tenancy boundary: only that project's subscriptions answer, never another project's and never a project-less one. Without it the first-party caller sees everything. `?kind=` picks a kind (an unknown one is an empty page, not an error); `cursor` is the opaque cursor from the previous page's `next_cursor`; `limit` defaults to 100 and is at most 1000. Pages are ordered by `(kind, id)`.++- Summary: `id`, `kind`, `service: "events"`, `project_id` (when the subscription is a project's), `owner` (when the consumer is a `usr_` subject), `name` (the topic pattern), `state` (`active` | `disabled`, from `enabled`), `created_at`. Every summary and every page is validated against the released `common.resource-*` schemas (`test/resource-index.test.js`).++- OVRN: present exactly when `openvibe-contracts`' `contracts.resources.nameOf` composes one — `ovrn:events:<prj_…>:subscription/sub_…` for a project-scoped subscription. A project-less subscription is a first-party one (a service consumer with no project): it has no project segment, so no name, and only the unscoped caller sees it at all. An event is not a resource (`evt_` is what `common.resource-name@1` refuses by design) and a project's queue is a derived carrier class, not a stored row, so `events.queue` joins the index only when Events stores a queue row.++- Errors: `400 resources.bad_query` for a value the index cannot honour (a project that is not a `prj_` id, a `limit` outside 1-1000, a cursor it did not issue) and `404 resources.unknown_resource` for an OVRN that names nothing it composes, both problem+json. Answers carry `Cache-Control: private, max-age=60`. The capability is `first-party`: a developer app's token is a `403`, as on every first-party route, and the two routes are not per-actor limited — they are one fan-out call each, not a browser's traffic.++## The fabric: carriers, the planner and explainHow an event type is carried is its delivery policy (`events.delivery-policy@1`, [ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 1). The carrier class — TOPIC (fan-out, no ack), QUEUE (competing consumers, ack) or STREAM (retained, ordered per key) — is a derived, internal label, never in the envelope. PostgreSQL stays the record: every event and every delivery is committed before any carrier sees it, and the worker's lease is the only thing that makes a send happen. A publish commits, then the chosen carrier is signalled; a lost or duplicated signal changes latency, never the outcome.@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4300,"domain": "events.openvibe.network",-- "contracts": "openvibe-contracts v0.94.2",++ "contracts": "openvibe-contracts v0.107.0","capabilities": ["events.event.publish","events.subscription.manage",@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0"@@@ -828,9 +828,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.94.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",-- "integrity": "sha512-taCtyQMTtm/KnyDVxkWICGe/5kmKJVXBQDvBASqBxO0CDs88kPse5raWAtFNG5HPJWyS7B48CvWuHGIHnEBxYQ==",++ "version": "0.107.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",++ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * The authority resource index (ADR-048 section 3; capability events.resource.read):++ *++ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] -> common.resource-list-result@1++ * GET /api/v1/resources/:ovrn -> common.resource-summary@1++ *++ * It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind++ * events.subscription — as common.resource-summary@1, the shape OpenVibe.Services fans out over and merges++ * (openvibe-sdk/resources' createResourceIndex). An event is not a resource: evt_<ULID> is exactly what++ * common.resource-name@1 refuses by design. A project's queue is a derived carrier class, not a stored row,++ * so events.queue joins the index only when Events stores a queue row (ADR-048, amended 2026-10-07).++ *++ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's subscriptions++ * answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller++ * (the capability is first-party, resourceConstraints none) sees every subscription, which is what an++ * authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is++ * exactly the row only that caller sees.++ *++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one++ * formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is++ * ovrn:events:<prj_…>:subscription/sub_…. A project-less subscription has no project segment and so no name.++ * That is also what GET /api/v1/resources/:ovrn can read: only a resource whose computed ovrn equals the one++ * asked for answers, so an OVRN naming another project, or a project-less subscription's id, is a 404.++ *++ * The capability stays `planned` in openvibe-contracts until the deploy lands and Opus flips it (this step++ * does not change Contracts); the guard is the same service-token check every other first-party route uses.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { CAPS } = require('../auth');++++const SERVICE = 'events';++const SUBSCRIPTION_KIND = 'events.subscription';++const KINDS = [SUBSCRIPTION_KIND];++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;++++/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */++const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);++++/**++ * common.resource-summary@1 for a subscriptions row. Only the fields the schema allows: the row's id and++ * kind, the pattern as its name (a subscription has no other human-readable one), the state its `enabled`++ * flag describes, its creation time, and — as the one stored subject — the consumer, when it is a person.++ */++function subscriptionSummary(s) {++ const owner = userRef(s.consumer);++ return {++ id: s.id, kind: SUBSCRIPTION_KIND, service: SERVICE,++ ...(s.project_id ? { project_id: s.project_id } : {}),++ ...(owner ? { owner } : {}),++ name: s.topic_pattern,++ state: s.enabled ? 'active' : 'disabled',++ created_at: new Date(s.created_at).toISOString(),++ };++}++++/** The summary's ovrn, or null when it has none (a project-less subscription has no project segment). */++function ovrnOf(summary) {++ return contracts.resources.nameOf(summary);++}++++/** The summary with its ovrn attached when it has one. */++function named(summary) {++ const ovrn = ovrnOf(summary);++ return ovrn ? { ...summary, ovrn } : summary;++}++++/** A stable (kind, id) ordering, so a cursor can be a position in it. */++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);++++/**++ * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id).++ *++ * Scope: events.resource.read is first-party (resourceConstraints none), so its holder sees every project and++ * ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that++ * principal's grants here instead of trusting the query.++ */++async function collect(store, { project = null, kind = null } = {}) {++ const out = [];++ if (!kind || kind === SUBSCRIPTION_KIND) {++ const rows = project ? await store.listProjectSubscriptions(project) : await store.listSubscriptions();++ for (const s of rows) out.push(named(subscriptionSummary(s)));++ }++ return out.sort(order);++}++++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */++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 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;++}++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);++++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */++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 resourcesRouter({ store, auth }) {++ const guard = auth.requireCap(CAPS.resourceRead);++ const open = (res) => res.set('Cache-Control', 'private, max-age=60');++ const bad = (res, req, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail, ctx: req.ov });++ const unknown = (res, req, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}`, ctx: req.ov });++++ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */++ async function page(req, res) {++ const f = filtersOf(req.query);++ if (f.error) return bad(res, req, f.error);++ const all = await collect(store, f);++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;++ const resources = rest.slice(0, f.limit);++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;++ return open(res).json({ resources, next_cursor });++ }++++ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */++ async function one(req, res) {++ const name = String(req.params.ovrn);++ const parsed = contracts.resources.parse(name);++ let summary = null;++ if (parsed && parsed.service === SERVICE && parsed.type === 'subscription') {++ const row = await store.getSubscription(parsed.id);++ if (row) summary = named(subscriptionSummary(row));++ }++ if (!summary || summary.ovrn !== name) return unknown(res, req, name);++ return open(res).json(summary);++ }++++ const router = express.Router();++ router.get('/api/v1/resources', guard, page);++ router.get('/api/v1/resources/:ovrn', guard, one);++ return router;++}++++module.exports = { resourcesRouter, SERVICE, KINDS, SUBSCRIPTION_KIND, DEFAULT_LIMIT, MAX_LIMIT, subscriptionSummary, ovrnOf, collect, encodeCursor };@@@ -11,6 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');const { readRouter } = require('./api/read');const { policiesRouter } = require('./api/policies');const { placementRouter } = require('./api/placement');++const { resourcesRouter } = require('./api/resources');const { mountLimits } = require('./limits');const { createLimits } = require('./actor-limits');const { renderHome, HOME_CSP } = require('./home');@@@ -90,6 +91,9 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLoapp.use(readRouter({ store, auth, worker, limits }));if (policies) app.use(policiesRouter({ policies, auth, limits }));if (planner) app.use(placementRouter({ planner, policies, auth, limits, store }));++ // The authority resource index (ADR-048, capability events.resource.read): the subscriptions Events owns,++ // for OpenVibe.Services' fan-out. First-party like the routes above; no per-actor limit of its own.++ app.use(resourcesRouter({ store, auth }));// Discovery for the product domain (openvibe.events): robots.txt, sitemap.xml, llms.txt.app.use(createDiscoveryRoutes({ config }));@@@ -33,6 +33,10 @@ const CAPS = Object.freeze({appPublish: 'events.app.publish',appRead: 'events.app.read',appSubscribe: 'events.app.subscribe',++ // The authority resource index (ADR-048, plan T13 step 8). `planned` in openvibe-contracts until the++ // deploy lands, so capabilities.check() answers capability.unknown and the local rule (exact id or a++ // `family.*` grant) decides — the same rule, and the same 401/403, every other service route gets.++ resourceRead: 'events.resource.read',});// ── Network public key ─────────────────────────────────────@@@ -496,6 +496,13 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usageasync function countSubscriptions(consumer) {return (await db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer)).n;}++ /**++ * Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`++ * is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.++ */++ async function listProjectSubscriptions(projectId) {++ return await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);++ }/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */async function rotateSubscriptionSecret(id, secret, overlapMs) {const now = clock.now();@@@ -799,7 +806,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usagereturn {db, insertBatch, redact, getEvent, resolvePolicy, revokedAt, lastSeq, oldestSeq, epoch, bumpEpoch, firstSeqSince, scan,-- createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,++ createSubscription, getSubscription, listSubscriptions, listProjectSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,claimDeliveries, claimDelivery, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,getCheckpoint, setCheckpoint, prune, ping, usage, flushUsage,};nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++// Events' authority resource index (ADR-048 section 3; capability events.resource.read):++// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions++// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.++// ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of++// another project - or a project-less one - is never returned under it, while the unscoped first-party++// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf++// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less++// (first-party) one has no project segment and so no name. events.queue is not a kind here: a project's++// queue is a derived carrier class, not a stored row.++const assert = require('assert');++const { validate, ids, resources } = require('openvibe-contracts');++const { boot, request, serviceToken, appToken, userToken, suite } = require('./helpers');++++const t = suite('resource-index');++let h;++++const reader = serviceToken('services', ['events.resource.read']);++const eventReader = serviceToken('live', ['events.event.read']); // a token without the capability++const family = serviceToken('services', ['events.*']); // the family grant the contracts rule honours++const app = appToken({ env: 'production', cap: ['events.resource.read'] }); // an app token is never judged on a first-party capability++const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });++++const prjA = `prj_${ids.ulid()}`;++const prjB = `prj_${ids.ulid()}`;++const usrA = ids.newId('user');++const appA = ids.newId('app');++const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)++const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project++const subA1 = ids.newId('subscription'); // project A++const subA2 = ids.newId('subscription'); // project A, later disabled++const subB = ids.newId('subscription'); // project B++// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).++const expected = [subSvc, subUsr, subA1, subA2, subB].sort();++const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;++++const get = (p, token) => request(h.base, 'GET', p, { token });++++t('boot + fixtures: first-party, person-owned and two projects\' subscriptions', async () => {++ h = await boot();++ const at = 'https://consumer.openvibe.network/hook';++ const add = (id, consumer, topic_pattern, projectId) => h.store.createSubscription({++ id, consumer, topicPattern: topic_pattern, endpoint: at, secret: 'whsec_test',++ ...(projectId ? { projectId, env: 'production' } : {}),++ });++ await add(subSvc, 'svc:live', 'live.*');++ await add(subUsr, usrA, 'live.vod.transcoded');++ await add(subA1, `app:${appA}`, 'live.vod.*', prjA);++ await add(subA2, `app:${appA}`, 'media.*', prjA);++ await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);++ await h.store.setSubscriptionEnabled(subA2, false);++});++++t('auth: 401 with no token, 403 without the capability (and for an app token), 200 with it', async () => {++ const noToken = await get('/api/v1/resources');++ assert.strictEqual(noToken.status, 401, noToken.text);++ assert.strictEqual(noToken.body.code, 'token.missing');++++ const denied = await get('/api/v1/resources', eventReader);++ assert.strictEqual(denied.status, 403, denied.text);++ assert.strictEqual(denied.body.code, 'capability.denied');++ assert.ok(denied.body.detail.includes('events.resource.read'), denied.body.detail);++++ // A first-party capability is never honoured from an app token, whatever its cap claim says; a sandbox++ // app token does not even reach that check (a first-party route refuses every sandbox token).++ const appDenied = await get('/api/v1/resources', app);++ assert.strictEqual(appDenied.status, 403, appDenied.text);++ assert.strictEqual(appDenied.body.code, 'capability.denied');++ const sandboxed = await get('/api/v1/resources', sandbox);++ assert.strictEqual(sandboxed.status, 401, sandboxed.text);++ assert.strictEqual(sandboxed.body.code, 'token.sandbox_refused');++ assert.strictEqual((await get('/api/v1/resources', userToken())).status, 401, 'a user JWT is not a service token');++++ const list = await get('/api/v1/resources', reader);++ assert.strictEqual(list.status, 200, list.text);++ assert.strictEqual(list.headers.get('cache-control'), 'private, max-age=60');++ assert.strictEqual((await get('/api/v1/resources', family)).status, 200, 'the contracts family rule grants events.*');++});++++t('the page: common.resource-list-result@1, every summary common.resource-summary@1, sorted by (kind, id)', async () => {++ const y = await get('/api/v1/resources', reader);++ assert.strictEqual(y.status, 200, y.text);++ assert.deepStrictEqual(Object.keys(y.body).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');++ const pageCheck = validate('common.resource-list-result@1', y.body);++ assert.ok(pageCheck.valid, JSON.stringify(pageCheck.errors));++ for (const s of y.body.resources) {++ const one = validate('common.resource-summary@1', s);++ assert.ok(one.valid, `${s.id}: ${JSON.stringify(one.errors)}`);++ }++ assert.strictEqual(y.body.next_cursor, null, 'one page holds the whole index');++ assert.deepStrictEqual(y.body.resources.map((r) => [r.kind, r.id]), expected.map((id) => ['events.subscription', id]));++ assert.deepStrictEqual([...new Set(y.body.resources.map((r) => r.service))], ['events']);++ assert.deepStrictEqual([...new Set(y.body.resources.map((r) => r.kind))], ['events.subscription']);++});++++t('each summary maps its own row: project_id, name, state, owner, and an ovrn only when nameable', async () => {++ const byId = new Map((await get('/api/v1/resources', reader)).body.resources.map((r) => [r.id, r]));++ assert.strictEqual(byId.get(subA1).project_id, prjA);++ assert.strictEqual(byId.get(subB).project_id, prjB);++ assert.strictEqual(byId.get(subSvc).name, 'live.*', 'the topic pattern is the subscription’s name');++ assert.strictEqual(byId.get(subA1).state, 'active');++ assert.strictEqual(byId.get(subA2).state, 'disabled', 'the state follows enabled');++ for (const id of expected) assert.ok(typeof byId.get(id).created_at === 'string' && byId.get(id).created_at.endsWith('Z'), id);++++ assert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));++ assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));++ assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });++ [subSvc, subUsr].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));++ [subSvc, subUsr].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));++ assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');++ assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');++});++++t('?project= is the tenancy boundary: only that project’s rows, never another’s and never a project-less one', async () => {++ const ids1 = async (q) => {++ const y = await get(`/api/v1/resources${q}`, reader);++ assert.strictEqual(y.status, 200, `${q}: ${y.text}`);++ assert.ok(validate('common.resource-list-result@1', y.body).valid);++ return y.body.resources.map((r) => r.id);++ };++ assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2].sort(), 'project A: never project B, never the project-less ones');++ assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');++ assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2].sort(), 'kind narrows within the project');++ assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');++});++++t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an empty page, not an error', async () => {++ const list = async (kind) => (await get(`/api/v1/resources?kind=${encodeURIComponent(kind)}`, reader)).body.resources.map((r) => r.id);++ assert.deepStrictEqual(await list('events.subscription'), expected);++ assert.deepStrictEqual(await list('events.queue'), [], 'a queue is a derived carrier class, not a stored row');++ assert.deepStrictEqual(await list('events.event'), [], 'an event is not a resource');++ assert.deepStrictEqual(await list('events.unknown'), []);++});++++t('the cursor pages the whole index once, in order, with no duplicates', async () => {++ const seen = [];++ let cursor = null;++ for (let pages = 0; ; pages++) {++ const y = await get(`/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, reader);++ assert.strictEqual(y.status, 200, y.text);++ assert.ok(validate('common.resource-list-result@1', y.body).valid);++ assert.ok(y.body.resources.length <= 2);++ seen.push(...y.body.resources.map((r) => r.id));++ if (y.body.next_cursor === null) { assert.strictEqual(seen.length, expected.length, 'the cursor chain ends at the end'); break; }++ assert.ok(typeof y.body.next_cursor === 'string' && y.body.next_cursor !== '');++ cursor = y.body.next_cursor;++ assert.ok(pages < 50, 'the cursor chain never ended');++ }++ assert.deepStrictEqual(seen, expected, 'no duplicates, none skipped, order preserved');++ assert.deepStrictEqual((await get(`/api/v1/resources?limit=1000`, reader)).body.resources.map((r) => r.id), expected, 'a large limit still pages one result');++ assert.strictEqual((await get('/api/v1/resources?limit=2', reader)).body.next_cursor, (await get('/api/v1/resources?limit=2', reader)).body.next_cursor, 'a cursor is derived from the page, not from the moment');++});++++t('GET /:ovrn reads the same summary the list answers, and only for a name it composes', async () => {++ const list = await get('/api/v1/resources', reader);++ const byId = new Map(list.body.resources.map((r) => [r.id, r]));++ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOfSub(prjA, subA1))}`, reader);++ assert.strictEqual(one.status, 200, one.text);++ assert.strictEqual(one.headers.get('cache-control'), 'private, max-age=60');++ const check = validate('common.resource-summary@1', one.body);++ assert.ok(check.valid, JSON.stringify(check.errors));++ assert.deepStrictEqual(one.body, byId.get(subA1), 'the same summary the list answers');++++ const missing = [++ ['a project-less subscription (no name)', ovrnOfSub(prjA, subSvc)],++ ['a person-owned, project-less subscription', ovrnOfSub(prjA, subUsr)],++ ["another project's segment", ovrnOfSub(prjB, subA1)],++ ['an unknown subscription', ovrnOfSub(prjA, ids.newId('subscription'))],++ ['another service', `ovrn:network:${prjA}:subscription/${subA1}`],++ ['a project OVRN', `ovrn:events:${prjA}:project/${prjA}`],++ ['an event OVRN', `ovrn:events:${prjA}:event/${ids.newId('event')}`],++ ['a non-OVRN', 'nope'],++ ];++ for (const [why, name] of missing) {++ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, reader);++ assert.strictEqual(y.status, 404, `${why}: ${y.text}`);++ assert.ok(/^application\/problem\+json/.test(y.headers.get('content-type')), why);++ assert.strictEqual(y.body.code, 'resources.unknown_resource', why);++ }++ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOfSub(prjA, subA1))}`)).status, 401, 'the read is guarded too');++});++++t('a bad query is 400 resources.bad_query', async () => {++ const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];++ for (const [q, why] of bad) {++ const y = await get(`/api/v1/resources${q}`, reader);++ assert.strictEqual(y.status, 400, `${why}: ${y.text}`);++ assert.ok(/^application\/problem\+json/.test(y.headers.get('content-type')), why);++ assert.strictEqual(y.body.code, 'resources.bad_query', why);++ }++});++++t('stop', async () => { await h.stop(); });++++t.run();
@@ -36,8 +36,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
@@ -36,8 +36,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
| `events.subscription.manage` | `/api/v1/subscriptions…` (own subscriptions only) |
| `events.subscription.manage` | `/api/v1/subscriptions…` (own subscriptions only) |
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
+| `events.resource.read` | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` (the [resource index](#resource-index)) |
-These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.80.0 knows all seven).
+These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `events.resource.read` is `first-party` (ADR-048) and still `planned` in `openvibe-contracts` until the deploy lands — the guard is the same service-token check, and the same 401/403, every other route here gets. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.107.0 knows all eight).
Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.
Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.
@@ -62,12 +63,13 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/limits.json`, `/m
@@ -62,12 +63,13 @@ Never limited: `/api/health`, `/api/ready`, `/release.json`, `/limits.json`, `/m
## Capabilities
## Capabilities
Implemented here (the service manifest's `capabilities`): the four internal ones in the table above
Implemented here (the service manifest's `capabilities`): the four internal ones in the table above
-(`events.event.publish`, `events.event.read`, `events.subscription.manage`, `events.delivery.admin`)
-and the three public developer-app ones (`events.app.publish`, `events.app.read`,
-`events.app.subscribe`, [Developer apps](#developer-apps)). Events calls no other service with a
-grant: it only loads the Network signing key (JWKS) and makes the signed deliveries its subscriptions
-ask for. It produces one event of its own, `events.usage.recorded` (a project's hourly publishing and
-delivery rollup, for Network; never visible to apps).
+(`events.event.publish`, `events.event.read`, `events.subscription.manage`, `events.delivery.admin`),
+the three public developer-app ones (`events.app.publish`, `events.app.read`, `events.app.subscribe`,
+[Developer apps](#developer-apps)) and the first-party `events.resource.read` (ADR-048,
+[Resource index](#resource-index); `planned` in `openvibe-contracts` until the deploy lands). Events
+calls no other service with a grant: it only loads the Network signing key (JWKS) and makes the signed
+deliveries its subscriptions ask for. It produces one event of its own, `events.usage.recorded` (a
+project's hourly publishing and delivery rollup, for Network; never visible to apps).
## Publishing
## Publishing
@@ -138,6 +140,15 @@ node scripts/events-archive.js restore --month 2026-01 [--hold-days 30]
@@ -138,6 +140,15 @@ node scripts/events-archive.js restore --month 2026-01 [--hold-days 30]
`export` writes the closed month's replay rows older than the cutoff to `<YYYY>/events-<YYYY-MM>.ndjson.gz` (one row per line: every column of the hot row, payload and actor as their stored JSON text, plus `epoch` and `archived_at`) and `<YYYY>/events-<YYYY-MM>.json` (`count`, `sha256`). It deletes those rows from `events_archive` only after reading the object back and matching both, and only rows unchanged since it read them (a row redacted meanwhile stays; rerun). Re-running, or exporting the rest of a month later, merges into the month's object. `restore` checks the object against its manifest, then inserts it back into `events_archive`, idempotent on `event_id`, held for `--hold-days` so the replay prune does not delete it at once. A redaction published after a month was exported does not reach its object, so restore only what you need. Export and restore of one month are serialised by a PostgreSQL advisory lock. Storage (`scripts/archive-storage.js`): a local directory, `EVENTS_ARCHIVE_DIR` (default `data/archive`), or an S3-compatible bucket when `EVENTS_ARCHIVE_S3_BUCKET` is set (`EVENTS_ARCHIVE_S3_ENDPOINT`, `_REGION`, `_ACCESS_KEY_ID`, `_SECRET_ACCESS_KEY`, `_PREFIX`; path-style, SigV4 signed with node:crypto and sent with node's `fetch`, no dependency). Each object is built in memory.
`export` writes the closed month's replay rows older than the cutoff to `<YYYY>/events-<YYYY-MM>.ndjson.gz` (one row per line: every column of the hot row, payload and actor as their stored JSON text, plus `epoch` and `archived_at`) and `<YYYY>/events-<YYYY-MM>.json` (`count`, `sha256`). It deletes those rows from `events_archive` only after reading the object back and matching both, and only rows unchanged since it read them (a row redacted meanwhile stays; rerun). Re-running, or exporting the rest of a month later, merges into the month's object. `restore` checks the object against its manifest, then inserts it back into `events_archive`, idempotent on `event_id`, held for `--hold-days` so the replay prune does not delete it at once. A redaction published after a month was exported does not reach its object, so restore only what you need. Export and restore of one month are serialised by a PostgreSQL advisory lock. Storage (`scripts/archive-storage.js`): a local directory, `EVENTS_ARCHIVE_DIR` (default `data/archive`), or an S3-compatible bucket when `EVENTS_ARCHIVE_S3_BUCKET` is set (`EVENTS_ARCHIVE_S3_ENDPOINT`, `_REGION`, `_ACCESS_KEY_ID`, `_SECRET_ACCESS_KEY`, `_PREFIX`; path-style, SigV4 signed with node:crypto and sent with node's `fetch`, no dependency). Each object is built in memory.
+## Resource index
+
+`GET /api/v1/resources` is this authority's resource index (ADR-048 section 3; capability `events.resource.read`): the surface OpenVibe.Services fans out over and merges, so it can list every resource of the network without owning any service's rows. It pages `common.resource-summary@1` for the resources Events owns — today its subscriptions (`sub_<ULID>`, kind `events.subscription`, read from the `subscriptions` table) — as `common.resource-list-result@1`; `GET /api/v1/resources/:ovrn` reads one by its OVRN (`common.resource-summary@1`).
+
+- Query: `?project=prj_…&kind=events.subscription&cursor=…&limit=…`. `?project=` is the tenancy boundary: only that project's subscriptions answer, never another project's and never a project-less one. Without it the first-party caller sees everything. `?kind=` picks a kind (an unknown one is an empty page, not an error); `cursor` is the opaque cursor from the previous page's `next_cursor`; `limit` defaults to 100 and is at most 1000. Pages are ordered by `(kind, id)`.
+- Summary: `id`, `kind`, `service: "events"`, `project_id` (when the subscription is a project's), `owner` (when the consumer is a `usr_` subject), `name` (the topic pattern), `state` (`active` | `disabled`, from `enabled`), `created_at`. Every summary and every page is validated against the released `common.resource-*` schemas (`test/resource-index.test.js`).
+- OVRN: present exactly when `openvibe-contracts`' `contracts.resources.nameOf` composes one — `ovrn:events:<prj_…>:subscription/sub_…` for a project-scoped subscription. A project-less subscription is a first-party one (a service consumer with no project): it has no project segment, so no name, and only the unscoped caller sees it at all. An event is not a resource (`evt_` is what `common.resource-name@1` refuses by design) and a project's queue is a derived carrier class, not a stored row, so `events.queue` joins the index only when Events stores a queue row.
+- Errors: `400 resources.bad_query` for a value the index cannot honour (a project that is not a `prj_` id, a `limit` outside 1-1000, a cursor it did not issue) and `404 resources.unknown_resource` for an OVRN that names nothing it composes, both problem+json. Answers carry `Cache-Control: private, max-age=60`. The capability is `first-party`: a developer app's token is a `403`, as on every first-party route, and the two routes are not per-actor limited — they are one fan-out call each, not a browser's traffic.
+
## The fabric: carriers, the planner and explain
## The fabric: carriers, the planner and explain
How an event type is carried is its delivery policy (`events.delivery-policy@1`, [ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 1). The carrier class — TOPIC (fan-out, no ack), QUEUE (competing consumers, ack) or STREAM (retained, ordered per key) — is a derived, internal label, never in the envelope. PostgreSQL stays the record: every event and every delivery is committed before any carrier sees it, and the worker's lease is the only thing that makes a send happen. A publish commits, then the chosen carrier is signalled; a lost or duplicated signal changes latency, never the outcome.
How an event type is carried is its delivery policy (`events.delivery-policy@1`, [ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 1). The carrier class — TOPIC (fan-out, no ack), QUEUE (competing consumers, ack) or STREAM (retained, ordered per key) — is a derived, internal label, never in the envelope. PostgreSQL stays the record: every event and every delivery is committed before any carrier sees it, and the worker's lease is the only thing that makes a send happen. A publish commits, then the chosen carrier is signalled; a lost or duplicated signal changes latency, never the outcome.
@@ -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": 4300,
"port": 4300,
"domain": "events.openvibe.network",
"domain": "events.openvibe.network",
- "contracts": "openvibe-contracts v0.94.2",
+ "contracts": "openvibe-contracts v0.107.0",
"capabilities": [
"capabilities": [
"events.event.publish",
"events.event.publish",
"events.subscription.manage",
"events.subscription.manage",
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -828,9 +828,9 @@
@@ -828,9 +828,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.94.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
- "integrity": "sha512-taCtyQMTtm/KnyDVxkWICGe/5kmKJVXBQDvBASqBxO0CDs88kPse5raWAtFNG5HPJWyS7B48CvWuHGIHnEBxYQ==",
+ "version": "0.107.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
+ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * The authority resource index (ADR-048 section 3; capability events.resource.read):
+ *
+ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] -> common.resource-list-result@1
+ * GET /api/v1/resources/:ovrn -> common.resource-summary@1
+ *
+ * It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind
+ * events.subscription — as common.resource-summary@1, the shape OpenVibe.Services fans out over and merges
+ * (openvibe-sdk/resources' createResourceIndex). An event is not a resource: evt_<ULID> is exactly what
+ * common.resource-name@1 refuses by design. A project's queue is a derived carrier class, not a stored row,
+ * so events.queue joins the index only when Events stores a queue row (ADR-048, amended 2026-10-07).
+ *
+ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's subscriptions
+ * answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller
+ * (the capability is first-party, resourceConstraints none) sees every subscription, which is what an
+ * authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is
+ * exactly the row only that caller sees.
+ *
+ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
+ * formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is
+ * ovrn:events:<prj_…>:subscription/sub_…. A project-less subscription has no project segment and so no name.
+ * That is also what GET /api/v1/resources/:ovrn can read: only a resource whose computed ovrn equals the one
+ * asked for answers, so an OVRN naming another project, or a project-less subscription's id, is a 404.
+ *
+ * The capability stays `planned` in openvibe-contracts until the deploy lands and Opus flips it (this step
+ * does not change Contracts); the guard is the same service-token check every other first-party route uses.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { CAPS } = require('../auth');
+
+const SERVICE = 'events';
+const SUBSCRIPTION_KIND = 'events.subscription';
+const KINDS = [SUBSCRIPTION_KIND];
+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;
+
+/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */
+const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);
+
+/**
+ * common.resource-summary@1 for a subscriptions row. Only the fields the schema allows: the row's id and
+ * kind, the pattern as its name (a subscription has no other human-readable one), the state its `enabled`
+ * flag describes, its creation time, and — as the one stored subject — the consumer, when it is a person.
+ */
+function subscriptionSummary(s) {
+ const owner = userRef(s.consumer);
+ return {
+ id: s.id, kind: SUBSCRIPTION_KIND, service: SERVICE,
+ ...(s.project_id ? { project_id: s.project_id } : {}),
+ ...(owner ? { owner } : {}),
+ name: s.topic_pattern,
+ state: s.enabled ? 'active' : 'disabled',
+ created_at: new Date(s.created_at).toISOString(),
+ };
+}
+
+/** The summary's ovrn, or null when it has none (a project-less subscription has no project segment). */
+function ovrnOf(summary) {
+ return contracts.resources.nameOf(summary);
+}
+
+/** The summary with its ovrn attached when it has one. */
+function named(summary) {
+ const ovrn = ovrnOf(summary);
+ return ovrn ? { ...summary, ovrn } : summary;
+}
+
+/** A stable (kind, id) ordering, so a cursor can be a position in it. */
+const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
+
+/**
+ * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id).
+ *
+ * Scope: events.resource.read is first-party (resourceConstraints none), so its holder sees every project and
+ * ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
+ * principal's grants here instead of trusting the query.
+ */
+async function collect(store, { project = null, kind = null } = {}) {
+ const out = [];
+ if (!kind || kind === SUBSCRIPTION_KIND) {
+ const rows = project ? await store.listProjectSubscriptions(project) : await store.listSubscriptions();
+ for (const s of rows) out.push(named(subscriptionSummary(s)));
+ }
+ return out.sort(order);
+}
+
+/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
+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 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
+}
+const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
+
+/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
+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 resourcesRouter({ store, auth }) {
+ const guard = auth.requireCap(CAPS.resourceRead);
+ const open = (res) => res.set('Cache-Control', 'private, max-age=60');
+ const bad = (res, req, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail, ctx: req.ov });
+ const unknown = (res, req, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}`, ctx: req.ov });
+
+ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
+ async function page(req, res) {
+ const f = filtersOf(req.query);
+ if (f.error) return bad(res, req, f.error);
+ const all = await collect(store, f);
+ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
+ const resources = rest.slice(0, f.limit);
+ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
+ return open(res).json({ resources, next_cursor });
+ }
+
+ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
+ async function one(req, res) {
+ const name = String(req.params.ovrn);
+ const parsed = contracts.resources.parse(name);
+ let summary = null;
+ if (parsed && parsed.service === SERVICE && parsed.type === 'subscription') {
+ const row = await store.getSubscription(parsed.id);
+ if (row) summary = named(subscriptionSummary(row));
+ }
+ if (!summary || summary.ovrn !== name) return unknown(res, req, name);
+ return open(res).json(summary);
+ }
+
+ const router = express.Router();
+ router.get('/api/v1/resources', guard, page);
+ router.get('/api/v1/resources/:ovrn', guard, one);
+ return router;
+}
+
+module.exports = { resourcesRouter, SERVICE, KINDS, SUBSCRIPTION_KIND, DEFAULT_LIMIT, MAX_LIMIT, subscriptionSummary, ovrnOf, collect, encodeCursor };
@@ -11,6 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');
@@ -11,6 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');
const { readRouter } = require('./api/read');
const { readRouter } = require('./api/read');
const { policiesRouter } = require('./api/policies');
const { policiesRouter } = require('./api/policies');
const { placementRouter } = require('./api/placement');
const { placementRouter } = require('./api/placement');
+const { resourcesRouter } = require('./api/resources');
const { mountLimits } = require('./limits');
const { mountLimits } = require('./limits');
const { createLimits } = require('./actor-limits');
const { createLimits } = require('./actor-limits');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
@@ -90,6 +91,9 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -90,6 +91,9 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
app.use(readRouter({ store, auth, worker, limits }));
app.use(readRouter({ store, auth, worker, limits }));
if (policies) app.use(policiesRouter({ policies, auth, limits }));
if (policies) app.use(policiesRouter({ policies, auth, limits }));
if (planner) app.use(placementRouter({ planner, policies, auth, limits, store }));
if (planner) app.use(placementRouter({ planner, policies, auth, limits, store }));
+ // The authority resource index (ADR-048, capability events.resource.read): the subscriptions Events owns,
+ // for OpenVibe.Services' fan-out. First-party like the routes above; no per-actor limit of its own.
+ app.use(resourcesRouter({ store, auth }));
// Discovery for the product domain (openvibe.events): robots.txt, sitemap.xml, llms.txt.
// Discovery for the product domain (openvibe.events): robots.txt, sitemap.xml, llms.txt.
app.use(createDiscoveryRoutes({ config }));
app.use(createDiscoveryRoutes({ config }));
@@ -33,6 +33,10 @@ const CAPS = Object.freeze({
@@ -33,6 +33,10 @@ const CAPS = Object.freeze({
appPublish: 'events.app.publish',
appPublish: 'events.app.publish',
appRead: 'events.app.read',
appRead: 'events.app.read',
appSubscribe: 'events.app.subscribe',
appSubscribe: 'events.app.subscribe',
+ // The authority resource index (ADR-048, plan T13 step 8). `planned` in openvibe-contracts until the
+ // deploy lands, so capabilities.check() answers capability.unknown and the local rule (exact id or a
+ // `family.*` grant) decides — the same rule, and the same 401/403, every other service route gets.
+ resourceRead: 'events.resource.read',
});
});
// ── Network public key ─────────────────────────────────────
// ── Network public key ─────────────────────────────────────
@@ -496,6 +496,13 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
@@ -496,6 +496,13 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
async function countSubscriptions(consumer) {
async function countSubscriptions(consumer) {
return (await db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer)).n;
return (await db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer)).n;
}
}
+ /**
+ * Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`
+ * is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.
+ */
+ async function listProjectSubscriptions(projectId) {
+ return await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);
+ }
/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
async function rotateSubscriptionSecret(id, secret, overlapMs) {
async function rotateSubscriptionSecret(id, secret, overlapMs) {
const now = clock.now();
const now = clock.now();
@@ -799,7 +806,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
@@ -799,7 +806,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
return {
return {
db, insertBatch, redact, getEvent, resolvePolicy, revokedAt, lastSeq, oldestSeq, epoch, bumpEpoch, firstSeqSince, scan,
db, insertBatch, redact, getEvent, resolvePolicy, revokedAt, lastSeq, oldestSeq, epoch, bumpEpoch, firstSeqSince, scan,
- createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,
+ createSubscription, getSubscription, listSubscriptions, listProjectSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,
claimDeliveries, claimDelivery, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
claimDeliveries, claimDelivery, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
getCheckpoint, setCheckpoint, prune, ping, usage, flushUsage,
getCheckpoint, setCheckpoint, prune, ping, usage, flushUsage,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+// Events' authority resource index (ADR-048 section 3; capability events.resource.read):
+// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions
+// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.
+// ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of
+// another project - or a project-less one - is never returned under it, while the unscoped first-party
+// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
+// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less
+// (first-party) one has no project segment and so no name. events.queue is not a kind here: a project's
+// queue is a derived carrier class, not a stored row.
+const assert = require('assert');
+const { validate, ids, resources } = require('openvibe-contracts');
+const { boot, request, serviceToken, appToken, userToken, suite } = require('./helpers');
+
+const t = suite('resource-index');
+let h;
+
+const reader = serviceToken('services', ['events.resource.read']);
+const eventReader = serviceToken('live', ['events.event.read']); // a token without the capability
+const family = serviceToken('services', ['events.*']); // the family grant the contracts rule honours
+const app = appToken({ env: 'production', cap: ['events.resource.read'] }); // an app token is never judged on a first-party capability
+const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });
+
+const prjA = `prj_${ids.ulid()}`;
+const prjB = `prj_${ids.ulid()}`;
+const usrA = ids.newId('user');
+const appA = ids.newId('app');
+const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)
+const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project
+const subA1 = ids.newId('subscription'); // project A
+const subA2 = ids.newId('subscription'); // project A, later disabled
+const subB = ids.newId('subscription'); // project B
+// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).
+const expected = [subSvc, subUsr, subA1, subA2, subB].sort();
+const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;
+
+const get = (p, token) => request(h.base, 'GET', p, { token });
+
+t('boot + fixtures: first-party, person-owned and two projects\' subscriptions', async () => {
+ h = await boot();
+ const at = 'https://consumer.openvibe.network/hook';
+ const add = (id, consumer, topic_pattern, projectId) => h.store.createSubscription({
+ id, consumer, topicPattern: topic_pattern, endpoint: at, secret: 'whsec_test',
+ ...(projectId ? { projectId, env: 'production' } : {}),
+ });
+ await add(subSvc, 'svc:live', 'live.*');
+ await add(subUsr, usrA, 'live.vod.transcoded');
+ await add(subA1, `app:${appA}`, 'live.vod.*', prjA);
+ await add(subA2, `app:${appA}`, 'media.*', prjA);
+ await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);
+ await h.store.setSubscriptionEnabled(subA2, false);
+});
+
+t('auth: 401 with no token, 403 without the capability (and for an app token), 200 with it', async () => {
+ const noToken = await get('/api/v1/resources');
+ assert.strictEqual(noToken.status, 401, noToken.text);
+ assert.strictEqual(noToken.body.code, 'token.missing');
+
+ const denied = await get('/api/v1/resources', eventReader);
+ assert.strictEqual(denied.status, 403, denied.text);
+ assert.strictEqual(denied.body.code, 'capability.denied');
+ assert.ok(denied.body.detail.includes('events.resource.read'), denied.body.detail);
+
+ // A first-party capability is never honoured from an app token, whatever its cap claim says; a sandbox
+ // app token does not even reach that check (a first-party route refuses every sandbox token).
+ const appDenied = await get('/api/v1/resources', app);
+ assert.strictEqual(appDenied.status, 403, appDenied.text);
+ assert.strictEqual(appDenied.body.code, 'capability.denied');
+ const sandboxed = await get('/api/v1/resources', sandbox);
+ assert.strictEqual(sandboxed.status, 401, sandboxed.text);
+ assert.strictEqual(sandboxed.body.code, 'token.sandbox_refused');
+ assert.strictEqual((await get('/api/v1/resources', userToken())).status, 401, 'a user JWT is not a service token');
+
+ const list = await get('/api/v1/resources', reader);
+ assert.strictEqual(list.status, 200, list.text);
+ assert.strictEqual(list.headers.get('cache-control'), 'private, max-age=60');
+ assert.strictEqual((await get('/api/v1/resources', family)).status, 200, 'the contracts family rule grants events.*');
+});
+
+t('the page: common.resource-list-result@1, every summary common.resource-summary@1, sorted by (kind, id)', async () => {
+ const y = await get('/api/v1/resources', reader);
+ assert.strictEqual(y.status, 200, y.text);
+ assert.deepStrictEqual(Object.keys(y.body).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
+ const pageCheck = validate('common.resource-list-result@1', y.body);
+ assert.ok(pageCheck.valid, JSON.stringify(pageCheck.errors));
+ for (const s of y.body.resources) {
+ const one = validate('common.resource-summary@1', s);
+ assert.ok(one.valid, `${s.id}: ${JSON.stringify(one.errors)}`);
+ }
+ assert.strictEqual(y.body.next_cursor, null, 'one page holds the whole index');
+ assert.deepStrictEqual(y.body.resources.map((r) => [r.kind, r.id]), expected.map((id) => ['events.subscription', id]));
+ assert.deepStrictEqual([...new Set(y.body.resources.map((r) => r.service))], ['events']);
+ assert.deepStrictEqual([...new Set(y.body.resources.map((r) => r.kind))], ['events.subscription']);
+});
+
+t('each summary maps its own row: project_id, name, state, owner, and an ovrn only when nameable', async () => {
+ const byId = new Map((await get('/api/v1/resources', reader)).body.resources.map((r) => [r.id, r]));
+ assert.strictEqual(byId.get(subA1).project_id, prjA);
+ assert.strictEqual(byId.get(subB).project_id, prjB);
+ assert.strictEqual(byId.get(subSvc).name, 'live.*', 'the topic pattern is the subscription’s name');
+ assert.strictEqual(byId.get(subA1).state, 'active');
+ assert.strictEqual(byId.get(subA2).state, 'disabled', 'the state follows enabled');
+ for (const id of expected) assert.ok(typeof byId.get(id).created_at === 'string' && byId.get(id).created_at.endsWith('Z'), id);
+
+ assert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));
+ assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));
+ assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });
+ [subSvc, subUsr].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
+ [subSvc, subUsr].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));
+ assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');
+ assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');
+});
+
+t('?project= is the tenancy boundary: only that project’s rows, never another’s and never a project-less one', async () => {
+ const ids1 = async (q) => {
+ const y = await get(`/api/v1/resources${q}`, reader);
+ assert.strictEqual(y.status, 200, `${q}: ${y.text}`);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid);
+ return y.body.resources.map((r) => r.id);
+ };
+ assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2].sort(), 'project A: never project B, never the project-less ones');
+ assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');
+ assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2].sort(), 'kind narrows within the project');
+ assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
+});
+
+t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an empty page, not an error', async () => {
+ const list = async (kind) => (await get(`/api/v1/resources?kind=${encodeURIComponent(kind)}`, reader)).body.resources.map((r) => r.id);
+ assert.deepStrictEqual(await list('events.subscription'), expected);
+ assert.deepStrictEqual(await list('events.queue'), [], 'a queue is a derived carrier class, not a stored row');
+ assert.deepStrictEqual(await list('events.event'), [], 'an event is not a resource');
+ assert.deepStrictEqual(await list('events.unknown'), []);
+});
+
+t('the cursor pages the whole index once, in order, with no duplicates', async () => {
+ const seen = [];
+ let cursor = null;
+ for (let pages = 0; ; pages++) {
+ const y = await get(`/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, reader);
+ assert.strictEqual(y.status, 200, y.text);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid);
+ assert.ok(y.body.resources.length <= 2);
+ seen.push(...y.body.resources.map((r) => r.id));
+ if (y.body.next_cursor === null) { assert.strictEqual(seen.length, expected.length, 'the cursor chain ends at the end'); break; }
+ assert.ok(typeof y.body.next_cursor === 'string' && y.body.next_cursor !== '');
+ cursor = y.body.next_cursor;
+ assert.ok(pages < 50, 'the cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, expected, 'no duplicates, none skipped, order preserved');
+ assert.deepStrictEqual((await get(`/api/v1/resources?limit=1000`, reader)).body.resources.map((r) => r.id), expected, 'a large limit still pages one result');
+ assert.strictEqual((await get('/api/v1/resources?limit=2', reader)).body.next_cursor, (await get('/api/v1/resources?limit=2', reader)).body.next_cursor, 'a cursor is derived from the page, not from the moment');
+});
+
+t('GET /:ovrn reads the same summary the list answers, and only for a name it composes', async () => {
+ const list = await get('/api/v1/resources', reader);
+ const byId = new Map(list.body.resources.map((r) => [r.id, r]));
+ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOfSub(prjA, subA1))}`, reader);
+ assert.strictEqual(one.status, 200, one.text);
+ assert.strictEqual(one.headers.get('cache-control'), 'private, max-age=60');
+ const check = validate('common.resource-summary@1', one.body);
+ assert.ok(check.valid, JSON.stringify(check.errors));
+ assert.deepStrictEqual(one.body, byId.get(subA1), 'the same summary the list answers');
+
+ const missing = [
+ ['a project-less subscription (no name)', ovrnOfSub(prjA, subSvc)],
+ ['a person-owned, project-less subscription', ovrnOfSub(prjA, subUsr)],
+ ["another project's segment", ovrnOfSub(prjB, subA1)],
+ ['an unknown subscription', ovrnOfSub(prjA, ids.newId('subscription'))],
+ ['another service', `ovrn:network:${prjA}:subscription/${subA1}`],
+ ['a project OVRN', `ovrn:events:${prjA}:project/${prjA}`],
+ ['an event OVRN', `ovrn:events:${prjA}:event/${ids.newId('event')}`],
+ ['a non-OVRN', 'nope'],
+ ];
+ for (const [why, name] of missing) {
+ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, reader);
+ assert.strictEqual(y.status, 404, `${why}: ${y.text}`);
+ assert.ok(/^application\/problem\+json/.test(y.headers.get('content-type')), why);
+ assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
+ }
+ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOfSub(prjA, subA1))}`)).status, 401, 'the read is guarded too');
+});
+
+t('a bad query is 400 resources.bad_query', async () => {
+ const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
+ for (const [q, why] of bad) {
+ const y = await get(`/api/v1/resources${q}`, reader);
+ assert.strictEqual(y.status, 400, `${why}: ${y.text}`);
+ assert.ok(/^application\/problem\+json/.test(y.headers.get('content-type')), why);
+ assert.strictEqual(y.body.code, 'resources.bad_query', why);
+ }
+});
+
+t('stop', async () => { await h.stop(); });
+
+t.run();