openvibers/openvibe.contracts Commits

v0.28.0: developer-app events (ADR-014) — public capabilities events.app.publish, events.app.read, events.app.subscribe (project-scoped; app.<project_key>.* types, source app-<lowercased ULID>, envelope unchanged); Events manifest 0.2.0

OP OpenVibers committed 23 Sept 2026 · b31c8fa71d · 8 files, +117 −3


Changes

ddiff --git a/README.md b/README.md
iindex 6c040b39..b7354a59 100644
---- a/README.md
++++ b/README.md
@@@ -68,6 +68,18 @@ Manifests: `manifests/services/` (the 7 running services plus the 21 charter rep
`manifests/namespaces/*.json` define per-subject module namespaces (`modules.namespace@1`): owner, data schema, writers (`owner` service and/or the `user`), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (`modules.module-record@1`, revision-checked writes). `contracts.modules.validateData / publicView / canWrite` apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.
++## Developer-app events (v0.28)
++
++Three `public` capabilities let a developer app (ADR-014) use OpenVibe.Events with a scoped app token, without any first-party `events.*` capability (those stay `internal`):
++
++| Capability | What the app may do |
++|---|---|
++| `events.app.publish` | publish event types `app.<project_key>.<name>[.<more>]` only |
++| `events.app.read` | pull its own project's events (same environment) plus first-party `public` events |
++| `events.app.subscribe` | webhook subscriptions in the same scope, to public https endpoints only |
++
++`project_key` is `p` followed by the project's ULID in lowercase (`prj_01JAB…` → `p01jab…`), so it fits an event-type segment. An app event's `source` is `app-` followed by the app's ULID in lowercase (`app:app_01JAB…` → `app-01jab…`), which keeps `events.event-envelope@1` unchanged (its `source` pattern already allows it). OpenVibe.Events enforces the scope, the sandbox separation and per-project quotas.
++
## Versioning and compatibility
- A contract id is permanent. Minor versions only add optional fields.
ddiff --git a/fixtures/events.event-envelope/valid/app-event.json b/fixtures/events.event-envelope/valid/app-event.json
nnew file mode 100644
iindex 00000000..8d52d3a7
---- /dev/null
++++ b/fixtures/events.event-envelope/valid/app-event.json
@@@ -0,0 +1,19 @@
++{
++ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "event_type": "app.p01jab2c3d4e5f6g7h8j9k0mnpq.order.created",
++ "version": 1,
++ "source": "app-01jab2c3d4e5f6g7h8j9k0mnps",
++ "actor": {
++ "type": "app",
++ "id": "app_01JAB2C3D4E5F6G7H8J9K0MNPS"
++ },
++ "timestamp": "2026-09-23T12:00:00Z",
++ "visibility": "internal",
++ "subject": {
++ "type": "order",
++ "id": "42"
++ },
++ "payload": {
++ "total": 3
++ }
++}
ddiff --git a/manifests/capabilities/events.app.publish.json b/manifests/capabilities/events.app.publish.json
nnew file mode 100644
iindex 00000000..80aded83
---- /dev/null
++++ b/manifests/capabilities/events.app.publish.json
@@@ -0,0 +1,20 @@
++{
++ "id": "events.app.publish",
++ "version": "1.0",
++ "owner": "events",
++ "status": "active",
++ "visibility": "public",
++ "description": "Publish durable events (events.event-envelope@1) as a developer app (ADR-014). The app token (sub app:app_<ULID>, project_id prj_<ULID>, env) may publish only event types app.<project_key>.<name>[.<more>], where project_key is 'p' followed by the project's ULID in lowercase (prj_01JAB... -> p01jab...). source must be 'app-' followed by the app's ULID in lowercase; actor is the app (or the on_behalf_of user). Events stores the token's env with each event: sandbox events are never delivered to production subscriptions nor shown to production apps. Rate and retained bytes are limited per project and environment by OpenVibe.Events.",
++ "inputSchema": "events.event-envelope@1",
++ "permissions": [
++ "events:write"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "events-app-publish",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/events"
++ ]
++}
ddiff --git a/manifests/capabilities/events.app.read.json b/manifests/capabilities/events.app.read.json
nnew file mode 100644
iindex 00000000..5ff7f9a8
---- /dev/null
++++ b/manifests/capabilities/events.app.read.json
@@@ -0,0 +1,22 @@
++{
++ "id": "events.app.read",
++ "version": "1.0",
++ "owner": "events",
++ "status": "active",
++ "visibility": "public",
++ "description": "Pull events as a developer app (ADR-014): only the app's own project's events (app.<project_key>.*, same environment as the token) plus first-party platform events with visibility public. Topic patterns must start with a literal segment; app.* patterns must name the caller's own project_key. Checkpoints are stored per app.",
++ "permissions": [
++ "events:read"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "events-app-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/events",
++ "GET /api/v1/events/:event_id",
++ "GET /api/v1/checkpoints",
++ "PUT /api/v1/checkpoints"
++ ]
++}
ddiff --git a/manifests/capabilities/events.app.subscribe.json b/manifests/capabilities/events.app.subscribe.json
nnew file mode 100644
iindex 00000000..3fafc918
---- /dev/null
++++ b/manifests/capabilities/events.app.subscribe.json
@@@ -0,0 +1,23 @@
++{
++ "id": "events.app.subscribe",
++ "version": "1.0",
++ "owner": "events",
++ "status": "active",
++ "visibility": "public",
++ "description": "Manage webhook subscriptions as a developer app (ADR-014). Topic patterns are limited to the scope of events.app.read (own project's app.<project_key>.* events in the token's environment, plus public platform events). Endpoints must be https URLs whose host resolves only to public IP addresses; OpenVibe.Events re-checks the resolved address when it delivers and never follows redirects. Deliveries are signed with the subscription secret (X-OpenVibe-Signature: sha256=<HMAC of the raw body>). Subscriptions carry project_id and env; sandbox events reach sandbox subscriptions only.",
++ "permissions": [
++ "events:subscribe"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "events-app-subscribe",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/subscriptions",
++ "GET /api/v1/subscriptions",
++ "GET /api/v1/subscriptions/:id",
++ "POST /api/v1/subscriptions/:id/disable",
++ "POST /api/v1/subscriptions/:id/enable"
++ ]
++}
ddiff --git a/manifests/services/events.json b/manifests/services/events.json
iindex 5acefddb..3100c5af 100644
---- a/manifests/services/events.json
++++ b/manifests/services/events.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.1.0",
++ "version": "0.2.0",
"contractRanges": {
"openvibe-contracts": ">=0.5.1 <1.0.0"
},
@@@ -17,7 +17,10 @@
"events.event.publish",
"events.event.read",
"events.subscription.manage",
-- "events.delivery.admin"
++ "events.delivery.admin",
++ "events.app.publish",
++ "events.app.read",
++ "events.app.subscribe"
],
"eventsProduced": [],
"eventsConsumed": [],
ddiff --git a/package.json b/package.json
iindex 380ec056..34b76200 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.26.0",
++ "version": "0.28.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/run.js b/test/run.js
iindex dc00f839..210b7d76 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -58,6 +58,21 @@ for (const cap of capabilities.manifests) {
for (const k of ['inputSchema', 'outputSchema']) if (cap[k]) ok(contracts.resolve(cap[k]), `${cap.id} ${k} resolves`);
ok(cap.status !== 'active' || cap.implementedBy.length > 0, `active ${cap.id} names the route that implements it`);
}
++// Developer-app events (v0.28, ADR-014): grantable to apps, owned by Events, project-scoped. The
++// existing service-level events.* capabilities stay internal.
++for (const id of ['events.app.publish', 'events.app.read', 'events.app.subscribe']) {
++ const c = capabilities.get(id);
++ ok(c && c.owner === 'events' && c.visibility === 'public' && c.status === 'active' && c.resourceConstraints.includes('project'), `${id} is a public, active, project-scoped Events capability`);
++}
++for (const id of ['events.event.publish', 'events.event.read', 'events.subscription.manage', 'events.delivery.admin']) {
++ ok(capabilities.get(id).visibility === 'internal', `${id} stays internal`);
++}
++{
++ // An app event keeps source and event_type inside the envelope's existing patterns.
++ const env = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/events.event-envelope/valid/app-event.json'), 'utf8'));
++ ok(contracts.validate('events.event-envelope@1', { ...env, source: `app-${'01JAB2C3D4E5F6G7H8J9K0MNPS'.toLowerCase()}` }).valid, 'app-<lowercased ULID> is a valid source');
++ ok(!contracts.validate('events.event-envelope@1', { ...env, source: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPS' }).valid, 'the raw app subject is not a valid source');
++}
// ── Ids ──────────────────────────────────────────────────────────────────
for (const kind of ['user', 'guest', 'app', 'mod']) {