openvibers/openvibe.events Commits
Events: a hyphenated service publishes in its namespace without the hyphen (media-hub → mediahub.*) (#91)
Changes
@@@ -79,7 +79,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.*`, `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.++- `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 source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. 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, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) 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.@@@ -14,8 +14,11 @@ const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s =/*** Which sources may publish which event types. The first segment of every prefix must equal the-- * source, so a service can only ever publish into its own namespace (svc:live -> live.*).++ * source's namespace, so a service can only ever publish into its own (svc:live -> live.*). A++ * namespace is the source id without its hyphens: an event type has none (contracts), so++ * svc:media-hub publishes mediahub.*.*/++const namespaceOf = (source) => String(source).replace(/-/g, '');const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openre', 'billing', 'tips','vip', 'ai', 'games', 'tools', 'codes', 'host', 'sources', 'search',// Publication products (Waves 16-19): each publishes <product>.<type>.<action> via openvibe-publishing.@@@ -34,7 +37,9 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr'services',// Record products that put their pages in OpenVibe.Search (openvibe-publishing/search-feed):// work.index_document.*, rent.index_document.*, help.index_document.*, quest.index_document.*-- 'work', 'rent', 'help', 'quest'];++ 'work', 'rent', 'help', 'quest',++ // MediaHub (OpenVibe.Video, Pics and Download), in its hyphen-free namespace: mediahub.*++ 'media-hub'];/*** Namespaces that belong to the network rather than to one service. `provider.*` carries storage@@@ -46,7 +51,7 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openrconst DEFAULT_SHARED_PREFIXES = ['provider.'];function sourcePrefixes(env) {-- let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${s}.`]]));++ let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${namespaceOf(s)}.`]]));if (env.EVENTS_SOURCE_PREFIXES) {// JSON { "<source>": ["<source>.x.", ...] } replaces the default map.map = JSON.parse(env.EVENTS_SOURCE_PREFIXES);@@@ -57,8 +62,8 @@ function sourcePrefixes(env) {if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SOURCE_PREFIXES: "${source}" is reserved for developer apps`);if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);for (const p of prefixes) {-- if (typeof p !== 'string' || p.split('.')[0] !== source) {-- throw new Error(`EVENTS_SOURCE_PREFIXES: prefix "${p}" of ${source} must start with "${source}."`);++ if (typeof p !== 'string' || p.split('.')[0] !== namespaceOf(source)) {++ throw new Error(`EVENTS_SOURCE_PREFIXES: prefix "${p}" of ${source} must start with "${namespaceOf(source)}."`);}}}@@@ -71,7 +76,7 @@ function sharedPrefixes(env, map) {if (!/^[a-z][a-z0-9-]*\.$/.test(p)) throw new Error(`EVENTS_SHARED_PREFIXES: bad prefix "${p}"`);const source = p.slice(0, -1);if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" is reserved for developer apps`);-- if (Object.prototype.hasOwnProperty.call(map, source)) {++ if (Object.keys(map).some((k) => namespaceOf(k) === source)) {throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" belongs to source "${source}", not to the network`);}}@@@ -239,4 +244,4 @@ function load(env = process.env) {};}--module.exports = { load, DEFAULT_SOURCES };++module.exports = { load, DEFAULT_SOURCES, namespaceOf };@@@ -104,6 +104,16 @@ t('a bot service principal publishes bot.* only', async () => {assert.strictEqual(r.body.code, 'events.type_not_allowed');});++t('a hyphenated service publishes in its namespace without the hyphen: media-hub → mediahub.* only', async () => {++ const hub = serviceToken('media-hub', ['events.event.publish']);++ let r = await request(h.base, 'POST', '/api/v1/events', { token: hub, body: envelope('media-hub', { event_type: 'mediahub.video.published' }) });++ assert.strictEqual(r.status, 201, r.text);++ r = await request(h.base, 'POST', '/api/v1/events', { token: hub, body: envelope('media-hub', { event_type: 'media.object.deleted' }) });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'events.type_not_allowed'], 'not Media\'s namespace');++ r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('media', ['events.event.publish']), body: envelope('media', { event_type: 'mediahub.video.published' }) });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'events.type_not_allowed'], 'and Media cannot claim MediaHub\'s');++});++t('a watch service principal publishes watch.* only', async () => {const watch = serviceToken('watch', ['events.event.publish']);// Real ids from openvibe-contracts (Watch, v0.102.0+): watch.watch.triggered, not watch.triggered.@@@ -5,13 +5,13 @@const assert = require('assert');const fs = require('fs');const path = require('path');--const { DEFAULT_SOURCES } = require('../server/config');++const { DEFAULT_SOURCES, namespaceOf } = require('../server/config');const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'manifests', 'services');const manifests = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));const producers = manifests.filter((m) => (m.eventsProduced || []).length).map((m) => m.id || m.service).filter((id) => id !== 'events');const missing = producers.filter((id) => !DEFAULT_SOURCES.includes(id));assert.deepStrictEqual(missing, [], `producers that may not publish: ${missing.join(', ')}`);--const outside = manifests.flatMap((m) => (m.eventsProduced || []).filter((t) => (m.id || m.service) !== 'events' && t.split('.')[0] !== (m.id || m.service) && !t.startsWith('provider.')).map((t) => `${m.id || m.service}: ${t}`));++const outside = manifests.flatMap((m) => (m.eventsProduced || []).filter((t) => (m.id || m.service) !== 'events' && t.split('.')[0] !== namespaceOf(m.id || m.service) && !t.startsWith('provider.')).map((t) => `${m.id || m.service}: ${t}`));assert.deepStrictEqual(outside, [], 'every produced event type is in its producer\'s own namespace');console.log(`sources cover producers: ${producers.length} producers, all allowed`);
@@ -79,7 +79,7 @@ project's hourly publishing and delivery rollup, for Network; never visible to a
@@ -79,7 +79,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).
`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.*`, `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.
+- `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 source's namespace is its id without hyphens, since an event type has none: `svc:media-hub` publishes `source: "media-hub"` with `mediahub.*` types, and no other source can claim `mediahub.*`. 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, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Idempotent on `event_id`: a repeat answers `200 { event_id, cursor, duplicate: true }` (`pruned: true` and no cursor when retention already removed the stored copy) 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`.
- 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.
- 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.
@@ -14,8 +14,11 @@ const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s =
@@ -14,8 +14,11 @@ const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s =
/**
/**
* Which sources may publish which event types. The first segment of every prefix must equal the
* Which sources may publish which event types. The first segment of every prefix must equal the
- * source, so a service can only ever publish into its own namespace (svc:live -> live.*).
+ * source's namespace, so a service can only ever publish into its own (svc:live -> live.*). A
+ * namespace is the source id without its hyphens: an event type has none (contracts), so
+ * svc:media-hub publishes mediahub.*.
*/
*/
+const namespaceOf = (source) => String(source).replace(/-/g, '');
const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openre', 'billing', 'tips',
const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openre', 'billing', 'tips',
'vip', 'ai', 'games', 'tools', 'codes', 'host', 'sources', 'search',
'vip', 'ai', 'games', 'tools', 'codes', 'host', 'sources', 'search',
// Publication products (Waves 16-19): each publishes <product>.<type>.<action> via openvibe-publishing.
// Publication products (Waves 16-19): each publishes <product>.<type>.<action> via openvibe-publishing.
@@ -34,7 +37,9 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
@@ -34,7 +37,9 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
'services',
'services',
// Record products that put their pages in OpenVibe.Search (openvibe-publishing/search-feed):
// Record products that put their pages in OpenVibe.Search (openvibe-publishing/search-feed):
// work.index_document.*, rent.index_document.*, help.index_document.*, quest.index_document.*
// work.index_document.*, rent.index_document.*, help.index_document.*, quest.index_document.*
- 'work', 'rent', 'help', 'quest'];
+ 'work', 'rent', 'help', 'quest',
+ // MediaHub (OpenVibe.Video, Pics and Download), in its hyphen-free namespace: mediahub.*
+ 'media-hub'];
/**
/**
* Namespaces that belong to the network rather than to one service. `provider.*` carries storage
* Namespaces that belong to the network rather than to one service. `provider.*` carries storage
@@ -46,7 +51,7 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
@@ -46,7 +51,7 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
const DEFAULT_SHARED_PREFIXES = ['provider.'];
const DEFAULT_SHARED_PREFIXES = ['provider.'];
function sourcePrefixes(env) {
function sourcePrefixes(env) {
- let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${s}.`]]));
+ let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${namespaceOf(s)}.`]]));
if (env.EVENTS_SOURCE_PREFIXES) {
if (env.EVENTS_SOURCE_PREFIXES) {
// JSON { "<source>": ["<source>.x.", ...] } replaces the default map.
// JSON { "<source>": ["<source>.x.", ...] } replaces the default map.
map = JSON.parse(env.EVENTS_SOURCE_PREFIXES);
map = JSON.parse(env.EVENTS_SOURCE_PREFIXES);
@@ -57,8 +62,8 @@ function sourcePrefixes(env) {
@@ -57,8 +62,8 @@ function sourcePrefixes(env) {
if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SOURCE_PREFIXES: "${source}" is reserved for developer apps`);
if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SOURCE_PREFIXES: "${source}" is reserved for developer apps`);
if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);
if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);
for (const p of prefixes) {
for (const p of prefixes) {
- if (typeof p !== 'string' || p.split('.')[0] !== source) {
- throw new Error(`EVENTS_SOURCE_PREFIXES: prefix "${p}" of ${source} must start with "${source}."`);
+ if (typeof p !== 'string' || p.split('.')[0] !== namespaceOf(source)) {
+ throw new Error(`EVENTS_SOURCE_PREFIXES: prefix "${p}" of ${source} must start with "${namespaceOf(source)}."`);
}
}
}
}
}
}
@@ -71,7 +76,7 @@ function sharedPrefixes(env, map) {
@@ -71,7 +76,7 @@ function sharedPrefixes(env, map) {
if (!/^[a-z][a-z0-9-]*\.$/.test(p)) throw new Error(`EVENTS_SHARED_PREFIXES: bad prefix "${p}"`);
if (!/^[a-z][a-z0-9-]*\.$/.test(p)) throw new Error(`EVENTS_SHARED_PREFIXES: bad prefix "${p}"`);
const source = p.slice(0, -1);
const source = p.slice(0, -1);
if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" is reserved for developer apps`);
if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" is reserved for developer apps`);
- if (Object.prototype.hasOwnProperty.call(map, source)) {
+ if (Object.keys(map).some((k) => namespaceOf(k) === source)) {
throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" belongs to source "${source}", not to the network`);
throw new Error(`EVENTS_SHARED_PREFIXES: "${p}" belongs to source "${source}", not to the network`);
}
}
}
}
@@ -239,4 +244,4 @@ function load(env = process.env) {
@@ -239,4 +244,4 @@ function load(env = process.env) {
};
};
}
}
-module.exports = { load, DEFAULT_SOURCES };
+module.exports = { load, DEFAULT_SOURCES, namespaceOf };
@@ -104,6 +104,16 @@ t('a bot service principal publishes bot.* only', async () => {
@@ -104,6 +104,16 @@ t('a bot service principal publishes bot.* only', async () => {
assert.strictEqual(r.body.code, 'events.type_not_allowed');
assert.strictEqual(r.body.code, 'events.type_not_allowed');
});
});
+t('a hyphenated service publishes in its namespace without the hyphen: media-hub → mediahub.* only', async () => {
+ const hub = serviceToken('media-hub', ['events.event.publish']);
+ let r = await request(h.base, 'POST', '/api/v1/events', { token: hub, body: envelope('media-hub', { event_type: 'mediahub.video.published' }) });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await request(h.base, 'POST', '/api/v1/events', { token: hub, body: envelope('media-hub', { event_type: 'media.object.deleted' }) });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'events.type_not_allowed'], 'not Media\'s namespace');
+ r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('media', ['events.event.publish']), body: envelope('media', { event_type: 'mediahub.video.published' }) });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'events.type_not_allowed'], 'and Media cannot claim MediaHub\'s');
+});
+
t('a watch service principal publishes watch.* only', async () => {
t('a watch service principal publishes watch.* only', async () => {
const watch = serviceToken('watch', ['events.event.publish']);
const watch = serviceToken('watch', ['events.event.publish']);
// Real ids from openvibe-contracts (Watch, v0.102.0+): watch.watch.triggered, not watch.triggered.
// Real ids from openvibe-contracts (Watch, v0.102.0+): watch.watch.triggered, not watch.triggered.
@@ -5,13 +5,13 @@
@@ -5,13 +5,13 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const { DEFAULT_SOURCES } = require('../server/config');
+const { DEFAULT_SOURCES, namespaceOf } = require('../server/config');
const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'manifests', 'services');
const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'manifests', 'services');
const manifests = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
const manifests = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
const producers = manifests.filter((m) => (m.eventsProduced || []).length).map((m) => m.id || m.service).filter((id) => id !== 'events');
const producers = manifests.filter((m) => (m.eventsProduced || []).length).map((m) => m.id || m.service).filter((id) => id !== 'events');
const missing = producers.filter((id) => !DEFAULT_SOURCES.includes(id));
const missing = producers.filter((id) => !DEFAULT_SOURCES.includes(id));
assert.deepStrictEqual(missing, [], `producers that may not publish: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `producers that may not publish: ${missing.join(', ')}`);
-const outside = manifests.flatMap((m) => (m.eventsProduced || []).filter((t) => (m.id || m.service) !== 'events' && t.split('.')[0] !== (m.id || m.service) && !t.startsWith('provider.')).map((t) => `${m.id || m.service}: ${t}`));
+const outside = manifests.flatMap((m) => (m.eventsProduced || []).filter((t) => (m.id || m.service) !== 'events' && t.split('.')[0] !== namespaceOf(m.id || m.service) && !t.startsWith('provider.')).map((t) => `${m.id || m.service}: ${t}`));
assert.deepStrictEqual(outside, [], 'every produced event type is in its producer\'s own namespace');
assert.deepStrictEqual(outside, [], 'every produced event type is in its producer\'s own namespace');
console.log(`sources cover producers: ${producers.length} producers, all allowed`);
console.log(`sources cover producers: ${producers.length} producers, all allowed`);