openvibers/openvibe.events Commits
Limits page source (roadmap WS-N task 7): GET /limits.json lists the developer limits enforced here per project and environment (publish rate, bytes kept, retention, webhook subscriptions, payload and batch size, realtime topics), read from the running config, with the problem each one answers past it. A quota config turns off with 0 is null. Public, CORS *, proxied by the vhost; openvibe.codes/docs/limits renders it
Changes
@@@ -24,7 +24,7 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/o`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.--`GET /metrics` (Prometheus text, direct loopback callers only — a request carrying `X-Forwarded-For` gets 404, and nginx refuses the path): HTTP golden signals by route template (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`; SSE sessions excluded), process metrics, `release_info`, and `events_deliveries{status}`, `events_dlq_depth`, `events_latest_seq`, `events_realtime_connections`, `events_delivery_latency_seconds{priority}` (acceptance → successful delivery, retries included), `events_delivery_attempts_total{outcome}`. `GET /release.json` is the deployed release (ADR-016, `release.mount` from openvibe-shared 1.5.0, with the manifest 1.1.0 fields under openvibe-contracts 0.32.0); its `POST /release-metrics` would count open tabs' update reports as `release_client_updates_total`, but Events has no pages and nginx does not proxy that path.++`GET /metrics` (Prometheus text, direct loopback callers only — a request carrying `X-Forwarded-For` gets 404, and nginx refuses the path): HTTP golden signals by route template (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`; SSE sessions excluded), process metrics, `release_info`, and `events_deliveries{status}`, `events_dlq_depth`, `events_latest_seq`, `events_realtime_connections`, `events_delivery_latency_seconds{priority}` (acceptance → successful delivery, retries included), `events_delivery_attempts_total{outcome}`. `GET /release.json` is the deployed release (ADR-016, `release.mount` from openvibe-shared 1.5.0, with the manifest 1.1.0 fields under openvibe-contracts 0.32.0); its `POST /release-metrics` would count open tabs' update reports as `release_client_updates_total`, but Events has no pages and nginx does not proxy that path. `GET /limits.json` lists the developer limits enforced here (per project and environment), read from the running config; [openvibe.codes/docs/limits](https://openvibe.codes/docs/limits) renders it.## Auth@@@ -78,6 +78,7 @@ server {location = /api/health { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /api/ready { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /release.json { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }++ location = /limits.json { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).location = /metrics { return 404; }@@@ -9,6 +9,7 @@ const { http } = require('openvibe-contracts');const { publishRouter } = require('./api/publish');const { subscriptionsRouter } = require('./api/subscriptions');const { readRouter } = require('./api/read');++const { mountLimits } = require('./limits');const pkg = require('../package.json');function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log = console }) {@@@ -22,6 +23,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLoapp.use(http.middleware());// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: instrumented.registry });++ // GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).++ mountLimits(app, config);app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');next();@@@ -79,7 +82,7 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo' /api/v1/subscriptions webhook subscriptions (events.subscription.manage; app token, events.app.subscribe)',' /api/v1/deliveries DLQ inspect and replay (events.delivery.admin)','GET /realtime/stream?topics=... server-sent events for browsers',-- 'GET /api/health, /api/ready, /release.json',++ 'GET /api/health, /api/ready, /release.json, /limits.json','','Source: https://github.com/OpenVibers/OpenVibe.Events','',nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * GET /limits.json (roadmap WS-N task 7): the limits a developer project meets here, read from the++ * running configuration, so OpenVibe.Codes' limits page (openvibe.codes/docs/limits) shows what is++ * enforced and never restates it. Public: nothing in it is per project or secret.++ *++ * limits[] id, label, capability (the grant it bounds; null for the anonymous realtime stream),++ * unit (count | bytes | per_minute | days), production and sandbox (the project's++ * environment; null where config turns the limit off with 0), exceeded (what the++ * caller gets past it)++ */++function limitsOf(config) {++ const q = config.apps.quotas;++ // A quota of 0 is off here (store.js); the page reads 0 as "none allowed", so off is null.++ const off = (v) => (v === 0 ? null : v);++ const both = (v) => ({ production: v, sandbox: v });++ return {++ service: 'events',++ scope: 'per project and environment (ADR-014)',++ limits: [++ { id: 'publish_per_minute', label: 'Events published per minute', capability: 'events.app.publish', unit: 'per_minute',++ production: off(q.production.publishPerMinute), sandbox: off(q.sandbox.publishPerMinute), exceeded: '429 events.quota_exceeded' },++ { id: 'retained_bytes', label: 'Event bytes kept', capability: 'events.app.publish', unit: 'bytes',++ production: off(q.production.retainedBytes), sandbox: off(q.sandbox.retainedBytes), exceeded: '429 events.quota_exceeded' },++ { id: 'retention_days', label: 'Days an event is kept', capability: 'events.app.read', unit: 'days',++ production: config.retentionDays, sandbox: config.apps.sandboxRetentionDays, exceeded: 'pruned' },++ { id: 'subscriptions', label: 'Webhook subscriptions', capability: 'events.app.subscribe', unit: 'count',++ production: off(q.production.maxSubscriptions), sandbox: off(q.sandbox.maxSubscriptions), exceeded: '429 events.quota_exceeded' },++ { id: 'payload_bytes', label: 'Payload size of one event', capability: 'events.app.publish', unit: 'bytes',++ ...both(config.maxPayloadBytes), exceeded: '413 events.payload_too_large' },++ { id: 'batch', label: 'Events in one request', capability: 'events.app.publish', unit: 'count',++ ...both(config.maxBatch), exceeded: '413 events.batch_too_large' },++ { id: 'realtime_topics', label: 'Topics on one realtime connection', capability: null, unit: 'count',++ ...both(config.realtime.maxTopics), exceeded: '400 realtime.too_many_topics' },++ ],++ };++}++++function mountLimits(app, config) {++ const body = limitsOf(config);++ app.get('/limits.json', (_req, res) => {++ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(body);++ });++}++++module.exports = { limitsOf, mountLimits };@@@ -103,6 +103,21 @@ t('health and ready', async () => {assert.ok(r.headers.get('x-openvibe-request-id'));});++t('limits.json: the developer limits, read from config (WS-N task 7)', async () => {++ const r = await request(h.base, 'GET', '/limits.json');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ const by = Object.fromEntries(r.body.limits.map((l) => [l.id, l]));++ assert.deepStrictEqual([by.publish_per_minute.production, by.publish_per_minute.sandbox], [120, 30]);++ assert.deepStrictEqual([by.retention_days.production, by.retention_days.sandbox], [30, 7]);++ assert.strictEqual(by.subscriptions.capability, 'events.app.subscribe');++ for (const l of r.body.limits) assert.ok(Number.isInteger(l.production) && Number.isInteger(l.sandbox) && l.exceeded, l.id);++ const { limitsOf } = require('../server/limits');++ assert.strictEqual(limitsOf(load({ NODE_ENV: 'test', EVENTS_APP_MAX_SUBSCRIPTIONS: '0', OV_NETWORK_PUBLIC_KEY: publicKey })).limits.find((l) => l.id === 'subscriptions').production, null, '0 (off) is null');++ const custom = limitsOf(load({ NODE_ENV: 'test', EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '7', OV_NETWORK_PUBLIC_KEY: publicKey }));++ assert.strictEqual(custom.limits.find((l) => l.id === 'publish_per_minute').sandbox, 7, 'an env override is what the page shows');++});++t('stop', async () => { await h.stop(); });t('key loader: JWKS {keys:[jwk]} and legacy {public_key}; ready is 503 until it loads', async () => {
@@ -24,7 +24,7 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/o
@@ -24,7 +24,7 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/o
`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.
`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.
-`GET /metrics` (Prometheus text, direct loopback callers only — a request carrying `X-Forwarded-For` gets 404, and nginx refuses the path): HTTP golden signals by route template (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`; SSE sessions excluded), process metrics, `release_info`, and `events_deliveries{status}`, `events_dlq_depth`, `events_latest_seq`, `events_realtime_connections`, `events_delivery_latency_seconds{priority}` (acceptance → successful delivery, retries included), `events_delivery_attempts_total{outcome}`. `GET /release.json` is the deployed release (ADR-016, `release.mount` from openvibe-shared 1.5.0, with the manifest 1.1.0 fields under openvibe-contracts 0.32.0); its `POST /release-metrics` would count open tabs' update reports as `release_client_updates_total`, but Events has no pages and nginx does not proxy that path.
+`GET /metrics` (Prometheus text, direct loopback callers only — a request carrying `X-Forwarded-For` gets 404, and nginx refuses the path): HTTP golden signals by route template (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`; SSE sessions excluded), process metrics, `release_info`, and `events_deliveries{status}`, `events_dlq_depth`, `events_latest_seq`, `events_realtime_connections`, `events_delivery_latency_seconds{priority}` (acceptance → successful delivery, retries included), `events_delivery_attempts_total{outcome}`. `GET /release.json` is the deployed release (ADR-016, `release.mount` from openvibe-shared 1.5.0, with the manifest 1.1.0 fields under openvibe-contracts 0.32.0); its `POST /release-metrics` would count open tabs' update reports as `release_client_updates_total`, but Events has no pages and nginx does not proxy that path. `GET /limits.json` lists the developer limits enforced here (per project and environment), read from the running config; [openvibe.codes/docs/limits](https://openvibe.codes/docs/limits) renders it.
## Auth
## Auth
@@ -78,6 +78,7 @@ server {
@@ -78,6 +78,7 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/health { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/ready { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/ready { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /release.json { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /release.json { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
+ location = /limits.json { proxy_pass http://127.0.0.1:4300; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).
location = /metrics { return 404; }
location = /metrics { return 404; }
@@ -9,6 +9,7 @@ const { http } = require('openvibe-contracts');
@@ -9,6 +9,7 @@ const { http } = require('openvibe-contracts');
const { publishRouter } = require('./api/publish');
const { publishRouter } = require('./api/publish');
const { subscriptionsRouter } = require('./api/subscriptions');
const { subscriptionsRouter } = require('./api/subscriptions');
const { readRouter } = require('./api/read');
const { readRouter } = require('./api/read');
+const { mountLimits } = require('./limits');
const pkg = require('../package.json');
const pkg = require('../package.json');
function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log = console }) {
function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log = console }) {
@@ -22,6 +23,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -22,6 +23,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
app.use(http.middleware());
app.use(http.middleware());
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: instrumented.registry });
release.mount(app, { registry: instrumented.registry });
+ // GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
+ mountLimits(app, config);
app.use((req, res, next) => {
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
next();
@@ -79,7 +82,7 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -79,7 +82,7 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
' /api/v1/subscriptions webhook subscriptions (events.subscription.manage; app token, events.app.subscribe)',
' /api/v1/subscriptions webhook subscriptions (events.subscription.manage; app token, events.app.subscribe)',
' /api/v1/deliveries DLQ inspect and replay (events.delivery.admin)',
' /api/v1/deliveries DLQ inspect and replay (events.delivery.admin)',
'GET /realtime/stream?topics=... server-sent events for browsers',
'GET /realtime/stream?topics=... server-sent events for browsers',
- 'GET /api/health, /api/ready, /release.json',
+ 'GET /api/health, /api/ready, /release.json, /limits.json',
'',
'',
'Source: https://github.com/OpenVibers/OpenVibe.Events',
'Source: https://github.com/OpenVibers/OpenVibe.Events',
'',
'',
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * GET /limits.json (roadmap WS-N task 7): the limits a developer project meets here, read from the
+ * running configuration, so OpenVibe.Codes' limits page (openvibe.codes/docs/limits) shows what is
+ * enforced and never restates it. Public: nothing in it is per project or secret.
+ *
+ * limits[] id, label, capability (the grant it bounds; null for the anonymous realtime stream),
+ * unit (count | bytes | per_minute | days), production and sandbox (the project's
+ * environment; null where config turns the limit off with 0), exceeded (what the
+ * caller gets past it)
+ */
+function limitsOf(config) {
+ const q = config.apps.quotas;
+ // A quota of 0 is off here (store.js); the page reads 0 as "none allowed", so off is null.
+ const off = (v) => (v === 0 ? null : v);
+ const both = (v) => ({ production: v, sandbox: v });
+ return {
+ service: 'events',
+ scope: 'per project and environment (ADR-014)',
+ limits: [
+ { id: 'publish_per_minute', label: 'Events published per minute', capability: 'events.app.publish', unit: 'per_minute',
+ production: off(q.production.publishPerMinute), sandbox: off(q.sandbox.publishPerMinute), exceeded: '429 events.quota_exceeded' },
+ { id: 'retained_bytes', label: 'Event bytes kept', capability: 'events.app.publish', unit: 'bytes',
+ production: off(q.production.retainedBytes), sandbox: off(q.sandbox.retainedBytes), exceeded: '429 events.quota_exceeded' },
+ { id: 'retention_days', label: 'Days an event is kept', capability: 'events.app.read', unit: 'days',
+ production: config.retentionDays, sandbox: config.apps.sandboxRetentionDays, exceeded: 'pruned' },
+ { id: 'subscriptions', label: 'Webhook subscriptions', capability: 'events.app.subscribe', unit: 'count',
+ production: off(q.production.maxSubscriptions), sandbox: off(q.sandbox.maxSubscriptions), exceeded: '429 events.quota_exceeded' },
+ { id: 'payload_bytes', label: 'Payload size of one event', capability: 'events.app.publish', unit: 'bytes',
+ ...both(config.maxPayloadBytes), exceeded: '413 events.payload_too_large' },
+ { id: 'batch', label: 'Events in one request', capability: 'events.app.publish', unit: 'count',
+ ...both(config.maxBatch), exceeded: '413 events.batch_too_large' },
+ { id: 'realtime_topics', label: 'Topics on one realtime connection', capability: null, unit: 'count',
+ ...both(config.realtime.maxTopics), exceeded: '400 realtime.too_many_topics' },
+ ],
+ };
+}
+
+function mountLimits(app, config) {
+ const body = limitsOf(config);
+ app.get('/limits.json', (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(body);
+ });
+}
+
+module.exports = { limitsOf, mountLimits };
@@ -103,6 +103,21 @@ t('health and ready', async () => {
@@ -103,6 +103,21 @@ t('health and ready', async () => {
assert.ok(r.headers.get('x-openvibe-request-id'));
assert.ok(r.headers.get('x-openvibe-request-id'));
});
});
+t('limits.json: the developer limits, read from config (WS-N task 7)', async () => {
+ const r = await request(h.base, 'GET', '/limits.json');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ const by = Object.fromEntries(r.body.limits.map((l) => [l.id, l]));
+ assert.deepStrictEqual([by.publish_per_minute.production, by.publish_per_minute.sandbox], [120, 30]);
+ assert.deepStrictEqual([by.retention_days.production, by.retention_days.sandbox], [30, 7]);
+ assert.strictEqual(by.subscriptions.capability, 'events.app.subscribe');
+ for (const l of r.body.limits) assert.ok(Number.isInteger(l.production) && Number.isInteger(l.sandbox) && l.exceeded, l.id);
+ const { limitsOf } = require('../server/limits');
+ assert.strictEqual(limitsOf(load({ NODE_ENV: 'test', EVENTS_APP_MAX_SUBSCRIPTIONS: '0', OV_NETWORK_PUBLIC_KEY: publicKey })).limits.find((l) => l.id === 'subscriptions').production, null, '0 (off) is null');
+ const custom = limitsOf(load({ NODE_ENV: 'test', EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '7', OV_NETWORK_PUBLIC_KEY: publicKey }));
+ assert.strictEqual(custom.limits.find((l) => l.id === 'publish_per_minute').sandbox, 7, 'an env override is what the page shows');
+});
+
t('stop', async () => { await h.stop(); });
t('stop', async () => { await h.stop(); });
t('key loader: JWKS {keys:[jwk]} and legacy {public_key}; ready is 503 until it loads', async () => {
t('key loader: JWKS {keys:[jwk]} and legacy {public_key}; ready is 503 until it loads', async () => {