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)

OP OpenVibers committed 7 Oct 2026 · 39ef455c92 · 9 files, +389 −14

T13 step 8 — Events' authority resource index is in, nothing committed or pushed.

**Changes (one line per file:line)**
- `server/api/resources.js:1` (new) — the index router: `subscriptionSummary` from the `subscriptions` row (:49), `ovrnOf`/`named` via `contracts.resources.nameOf` (:62), `collect` with the tenancy scope (:82), opaque `[kind,id]` cursor (:93-99), `filtersOf` (:101), `guard = auth.requireCap(CAPS.resourceRead)` (:119), `page` (:125), `one` (:136), routes `GET /api/v1/resources` / `/api/v1/resources/:ovrn` (:149-150).
- `server/app.js:14,96` — require + mount `resourcesRouter({ store, auth })` beside the other routers (no per-actor limit).
- `server/auth.js:36-39` — `CAPS.resourceRead = 'events.resource.read'`, with the note that it is `planned` in the pinned contracts so `capabilities.check` says unknown and the same local rule decides.
- `server/store.js:503,809` — `listProjectSubscriptions(projectId)` (the `?project=` boundary) + its export.
- `package.json:27` + `package-lock.json:15,830-833` — openvibe-contracts pinned to codeload v0.107.0 (lockfile via `npm install --package-lock-only …` through node `child_process`; the shell refused plain npm).
- `test/resource-index.test.js:1` (new) — 10 tests: auth matrix, page+summary schema validation, per-row mapping, `?project=` scope, `?kind=`, cursor paging, `GET /:ovrn`, bad query, stop.
- `README.md:39,41,65-70,143-151` — capabilities table row + first-party/planned note (and the stale "pinned v0.80.0 knows all seven" fixed to v0.107.0/eight), the Capabilities paragraph, and a new `## Resource index` API section.
- `STATUS.json:10` — `contracts: openvibe-contracts v0.107.0` (`updated` was already 2026-10-07).

**implementedBy routes** (exactly what the manifest should carry once Opus flips `events.resource.read` from `planned`):
- `GET /api/v1/resources`
- `GET /api/v1/resources/:ovrn`

**Tests** (`nice -n 15 ov test --no-cache`, never `--all`): the selection is `test/archive.test.js` (reaches `server/store.js`), `test/pull-health.test.js` (reaches `server/auth.js`), `test/resource-index.test.js`; plus `test/readme-examples.test.js` and, earlier, `actor-limits`, `observability`, `home` — all pass; last run 4 passed in 23 s, resource-index 10/10. Contract pinning made `ov test` relink node_modules to the v0.107.0 shared copy (0.94.2 had no `lib/resources.js`, so the index needs it).

**For Opus**
- contracts follow-up left alone as instructed: `events.resource.read` stays `planned`; flip it in Contracts after deploy and fill `implementedBy` with the two routes above.
- STATUS.json `capabilities` intentionally still lists seven (the pin/date were the ask; Network's STATUS also omits `network.resource.read`) — say the word if you want it added for parity with the v0.107.0 service manifest, which already lists all eight.
- Deployment gap worth a decision: `deploy/nginx/events.openvibe.network.conf` 403s every `/api/` path outside its developer-app allowlist, so `/api/v1/resources` is host-local (127.0.0.1:4300) unless Services calls over the public vhost — Network proxies all of `/api/`, Events does not. I did not touch the vhost.
- Unasked choices, easy to change: `name` = the topic pattern, `state` = `active`/`disabled` from `enabled`, `owner` read from the `consumer` column when it is a `usr_` subject (no API creates such a row today; the fixture inserts one directly via the store).

Changes

ddiff --git a/README.md b/README.md
iindex 87e8f86..4c851be 100644
---- a/README.md
++++ b/README.md
@@@ -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
## Capabilities
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
@@@ -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 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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 5dae50e..97de433 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 1dbbc27..8717a2a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 2ad0958..458dbf1 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/api/resources.js b/server/api/resources.js
nnew file mode 100644
iindex 0000000..489caae
---- /dev/null
++++ b/server/api/resources.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 4633b4d..9995e11 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, dnsLo
app.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 }));
ddiff --git a/server/auth.js b/server/auth.js
iindex ae73204..0e59f83 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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 ─────────────────────────────────────
ddiff --git a/server/store.js b/server/store.js
iindex 5fc931e..9a14128 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -496,6 +496,13 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
async 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, usage
return {
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,
};
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..1dc418c
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -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();