openvibers/openvibe.events Commits

Events: a hyphenated service publishes in its namespace without the hyphen (media-hub → mediahub.*) (#91)

OP OpenVibers committed 28 days ago · a0ea2f0105 · 4 files, +25 −10

An event type has no hyphen (events.event-envelope@1), so a service whose id has one could not publish at all.
Its namespace is now its id without hyphens: svc:media-hub publishes source media-hub with mediahub.* types, and
no other source can claim them. media-hub joins the default sources (OpenVibe.Video, Pics and Download).

Changes

ddiff --git a/README.md b/README.md
iindex b293328..1dca24d 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/server/config.js b/server/config.js
iindex 5e37148..2774357 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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', 'openr
const 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 };
ddiff --git a/test/publish.test.js b/test/publish.test.js
iindex e5a4536..80eb2bc 100644
---- a/test/publish.test.js
++++ b/test/publish.test.js
@@@ -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.
ddiff --git a/test/sources-cover-producers.test.js b/test/sources-cover-producers.test.js
iindex b3aacaa..8c72c78 100644
---- a/test/sources-cover-producers.test.js
++++ b/test/sources-cover-producers.test.js
@@@ -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`);