openvibers/openvibe.events Commits

Events: OpenVibe.Space publishes (source space); contracts 0.110.0, shared 2.12.0 (#19)

OP OpenVibers committed 7 Oct 2026 · 146187eb7b · 8 files, +38 −20

## Changes

- `package.json:27` — pin `openvibe-contracts` → `…/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0`.
- `package-lock.json:15,832` — root spec plus `version`/`resolved`/`integrity` for v0.110.0 (integrity `sha512-+65Kn…` read from the npm cache index; bare `npm install` is refused in this shell, so the lock was hand-edited, then `ds-deps.js`'s own `npm ci` validated it and relinked `node_modules` → contracts 0.110.0).
- `server/config.js:30` — `space` joins `DEFAULT_SOURCES`, with the Space comment (`space.thread.created`, `space.post.created`, `space.moderation.action`), mirroring the `run` entry.
- `README.md:78` — the source-prefix list gains `space.*` (after `run.*`).
- `test/publish.test.js:120` — new test: a `svc:space` token publishes the three real `space.*` ids, is refused `live.*`, and a stranger cannot claim `space.*`.
- `README.md:41,69` — `events.resource.read` is now `active` (not `planned`) in contracts, and the pinned version is named as v0.110.0.
- `server/auth.js:36-38` and `server/api/resources.js:26-27` — corrected the same `planned` → `active` claim: `capabilities.check()` now applies the contracts rule for this id; the local fallback only covers ids the installed contracts don't know.

## Tests

`nice -n 15 ov test --no-cache test/publish.test.js test/pull-health.test.js test/readme-examples.test.js test/resource-index.test.js test/observability.test.js` → **5 passed in 15 s** (Node 22.23.3). These are exactly the 5 of 30 files `ov` maps to the 8 changed files.

## For Opus

The v0.110.0 bump does two things beyond adding Space: it adds `ai.app.run` and `services.resource.read` capabilities, and it flips `events.resource.read` from `planned` to `active` — so the "planned until the deploy lands" caveats in README §Capabilities/§Overview, `server/auth.js`, and `server/api/resources.js` were stale and are now corrected (`events.resource.read` still guards via the same contracts rule, tests unchanged and green). No existing test enumerated `DEFAULT_SOURCES`, so I added a focused Space test in the `watch`/`bot` style; no source-enumeration test needed updating.

Changes

ddiff --git a/README.md b/README.md
iindex 15245b8..9cb172e 100644
---- a/README.md
++++ b/README.md
@@@ -38,7 +38,7 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
| `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. `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).
++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), `active` in `openvibe-contracts` since v0.110.0 — 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.110.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`.
@@@ -66,7 +66,7 @@ Implemented here (the service manifest's `capabilities`): the four internal ones
(`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
++[Resource index](#resource-index); `active` in `openvibe-contracts` since v0.110.0). 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).
@@@ -75,7 +75,7 @@ project's hourly publishing and delivery rollup, for Network; never visible to a
`POST /api/v1/events` takes one `events.event-envelope@1` envelope, or `{ "events": [ … ] }` (up to 100, stored atomically).
--- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `EVENTS_SHARED_PREFIXES` overrides.
++- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`, `bot.*`, `watch.*`, `run.*`, `space.*`, and the publication products (`wiki.*`, `blog.*`, `news.*`, `reviews.*`, `deals.*`, `coupons.*`, `trade.*`, `sources.*`, `search.*`); `EVENTS_SOURCE_PREFIXES` overrides). A network-wide prefix may be published by any first-party service: `provider.*` carries storage provider health/capacity telemetry (`provider.health.degraded`, `provider.capacity.warning`), so whichever service observes a provider can report it; `EVENTS_SHARED_PREFIXES` overrides.
- Idempotent on `event_id`: a repeat answers `200 { event_id, seq, duplicate: true }` and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.
- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.
ddiff --git a/STATUS.json b/STATUS.json
iindex 97de433..a3ba795 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.107.0",
++ "contracts": "openvibe-contracts v0.110.0",
"capabilities": [
"events.event.publish",
"events.subscription.manage",
ddiff --git a/package-lock.json b/package-lock.json
iindex 8717a2a..727373f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,9 +12,9 @@
"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.107.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.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.12.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -828,9 +828,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.107.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
-- "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
++ "version": "0.110.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
++ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -873,9 +873,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.10.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
-- "integrity": "sha512-6fUOw22bSvxg0K33BefAyAkNRkETe9euLNT0CHs2mLIvzYR1q38KiHRUQIWj8FESbVANF0pXMojhBz5TeqpMtg==",
++ "version": "2.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
++ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 458dbf1..f0d7974 100644
---- a/package.json
++++ b/package.json
@@@ -24,9 +24,9 @@
"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.107.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.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.12.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/api/resources.js b/server/api/resources.js
iindex 489caae..8f6bad8 100644
---- a/server/api/resources.js
++++ b/server/api/resources.js
@@@ -23,8 +23,8 @@
* 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.
++ * The capability is `active` in openvibe-contracts since v0.110.0; the guard is the same service-token check
++ * every other first-party route uses.
*/
const express = require('express');
const contracts = require('openvibe-contracts');
ddiff --git a/server/auth.js b/server/auth.js
iindex 0e59f83..56f12aa 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -33,9 +33,9 @@ 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.
++ // The authority resource index (ADR-048, plan T13 step 8). `active` in openvibe-contracts since v0.110.0,
++ // so capabilities.check() applies the contracts rule (exact id or a `family.*` grant); the local fallback
++ // below only covers an id the installed contracts do not know yet.
resourceRead: 'events.resource.read',
});
ddiff --git a/server/config.js b/server/config.js
iindex 1639fda..5ac14fa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -25,7 +25,9 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
// Watch (OpenVibe.Watch): watch.observation.recorded, watch.watch.triggered, watch.check.failed
'watch',
// Run (OpenVibe.Run, plan T14): run.job.queued|started|succeeded|failed|cancelled|expired
-- 'run'];
++ 'run',
++ // Space (OpenVibe.Space, plan T10 step 3): space.thread.created, space.post.created, space.moderation.action
++ 'space'];
/**
* Namespaces that belong to the network rather than to one service. `provider.*` carries storage
ddiff --git a/test/publish.test.js b/test/publish.test.js
iindex ce6813e..6e95657 100644
---- a/test/publish.test.js
++++ b/test/publish.test.js
@@@ -117,6 +117,22 @@ t('a watch service principal publishes watch.* only', async () => {
assert.strictEqual(r.body.code, 'events.unknown_source');
});
++t('a space service principal publishes space.* only', async () => {
++ const space = serviceToken('space', ['events.event.publish']);
++ // Real ids from openvibe-contracts (Space, v0.110.0+): space.thread.created, space.post.created, space.moderation.action.
++ for (const event_type of ['space.thread.created', 'space.post.created', 'space.moderation.action']) {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: space, body: envelope('space', { event_type }) });
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ let r = await request(h.base, 'POST', '/api/v1/events', { token: space, body: envelope('space', { event_type: 'live.stream.started' }) });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'events.type_not_allowed');
++ // A source not in the list cannot claim the space namespace.
++ r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('stranger', ['events.event.publish']), body: envelope('stranger', { event_type: 'space.thread.created' }) });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'events.unknown_source');
++});
++
t('unknown source (not in the manifest map) -> 403', async () => {
const r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('stranger', ['events.event.publish']), body: envelope('stranger') });
assert.strictEqual(r.status, 403);