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

OP OpenVibers committed 26 Sept 2026 · 5889c2c50f · 5 files, +67 −2


Changes

ddiff --git a/README.md b/README.md
iindex 53dbb1e..22eee60 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/deploy/nginx/events.openvibe.network.conf b/deploy/nginx/events.openvibe.network.conf
iindex be1245d..c7d1194 100644
---- a/deploy/nginx/events.openvibe.network.conf
++++ b/deploy/nginx/events.openvibe.network.conf
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 36e7532..997132b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, dnsLo
app.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',
'',
ddiff --git a/server/limits.js b/server/limits.js
nnew file mode 100644
iindex 0000000..d60faad
---- /dev/null
++++ b/server/limits.js
@@@ -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 };
ddiff --git a/test/pull-health.test.js b/test/pull-health.test.js
iindex c08d071..f15ed96 100644
---- a/test/pull-health.test.js
++++ b/test/pull-health.test.js
@@@ -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 () => {