openvibers/openvibe.events Commits

Per-actor rate limits at Events' capability routes (roadmap WS-R task 4): openvibe-sdk v0.12.0 is now a dependency, and server/actor-limits.js makes one limiter per app that counts each verified principal (svc:live, app:app_…) after its guard and before any work. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one [limits] line and counts events_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 7530c2ff87 · 13 files, +247 −21

Single reads (an event by id, checkpoints, subscription and delivery lists) take EVENTS_LIMITS_MINUTE / EVENTS_LIMITS_HOUR, 120 and 3000. Pull (GET /api/v1/events) and checkpoint writes allow 600 a minute and 20000 an hour, because a consumer pages through a backlog (Community's relay reads up to 20 pages a tick). Publishing allows a service 600 and 20000 requests, since outboxes batch up to 100 events and retry a 429, and a developer app 60 and 1200 besides its project's event quota. Network's publishes are never counted: they carry app revocations, sign-out cutoffs and account deletions, which must never wait behind a limit. Subscription create, enable and disable allow 30 and 300 (a DNS check and a row each; a service ensures a handful at boot), rotating a secret 10 and 100, and delivery replay 6 and 60, since one replay can requeue a subscription's whole retained history. Health, ready, release.json, limits.json, metrics and the realtime stream are never limited. EVENTS_LIMITS=off turns every limit off as a rollback lever; the SSRF suite, which makes dozens of subscribe attempts from one app in one minute of its manual clock, boots with it as it already raises the subscription quota.

test/actor-limits.test.js on the manual clock: the fourth read in a minute is refused with Retry-After 45 while another principal passes and the next minute reopens; the 61st app publish is refused and nothing is stored; the 601st service publish is refused while Network publishes 610 times; health, ready, release.json and metrics never answer 429; refusals are counted and logged without a token; EVENTS_LIMITS=off answers nothing.

Changes

ddiff --git a/.env.example b/.env.example
iindex 2c65ac2..e30b7da 100644
---- a/.env.example
++++ b/.env.example
@@@ -75,6 +75,14 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
#EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS=5
#EVENTS_APP_SANDBOX_RETENTION_DAYS=7
++# Per-actor limits (server/actor-limits.js): requests one principal (svc:…, app:app_…) may make to a
++# read route per minute and per hour; past them 429 rate_limited with Retry-After. Publishing,
++# subscriptions and replay have their own numbers (README). Never on health, ready, metrics.
++# EVENTS_LIMITS=off turns them all off (a rollback lever).
++#EVENTS_LIMITS=on
++#EVENTS_LIMITS_MINUTE=120
++#EVENTS_LIMITS_HOUR=3000
++
# Realtime (SSE) gateway
#REALTIME_MAX_CONNECTIONS=2000
#REALTIME_MAX_TOPICS=20
ddiff --git a/README.md b/README.md
iindex 4567c97..02af6c0 100644
---- a/README.md
++++ b/README.md
@@@ -43,6 +43,22 @@ Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the de
Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a stable `code`.
++### Per-actor limits
++
++Every capability route also limits each principal (`svc:live`, `app:app_…`) by requests, after its guard and before any work (`server/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[limits]` log line and `events_rate_limited_total{limit,window}`. Counters are per process; `EVENTS_LIMITS=off` turns them all off (a rollback lever).
++
++| Route | Per principal |
++|---|---|
++| Single reads: `GET /api/v1/events/:id`, `GET /api/v1/checkpoints`, subscription and delivery lists | `EVENTS_LIMITS_MINUTE` / `EVENTS_LIMITS_HOUR` (120 a minute, 3000 an hour) |
++| `GET /api/v1/events` (pull), `PUT /api/v1/checkpoints` | 600 / 20000 (a consumer pages through a backlog) |
++| `POST /api/v1/events`, a service | 600 / 20000 (outboxes batch and retry a 429); **Network is never counted**: it carries revocations, cutoffs and deletions |
++| `POST /api/v1/events`, a developer app | 60 / 1200, besides the project's event quota |
++| Subscription create, enable, disable | 30 / 300 |
++| `POST /api/v1/subscriptions/:id/rotate-secret` | 10 / 100 |
++| `POST /api/v1/deliveries/replay` | 6 / 60 |
++
++Never limited: `/api/health`, `/api/ready`, `/release.json`, `/limits.json`, `/metrics` and the realtime stream (capped by `REALTIME_MAX_CONNECTIONS` and `REALTIME_MAX_TOPICS`). `test/actor-limits.test.js`.
++
## Publishing
`POST /api/v1/events` takes one `events.event-envelope@1` envelope, or `{ "events": [ … ] }` (up to 100, stored atomically).
ddiff --git a/package-lock.json b/package-lock.json
iindex 1ddd563..6262faa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,6 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
@@@ -982,6 +983,15 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
ddiff --git a/package.json b/package.json
iindex e6f9491..6afe4c9 100644
---- a/package.json
++++ b/package.json
@@@ -18,6 +18,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
ddiff --git a/server/actor-limits.js b/server/actor-limits.js
nnew file mode 100644
iindex 0000000..70eb9e2
---- /dev/null
++++ b/server/actor-limits.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * Per-actor rate limits at the capability routes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * nginx limits by address; these limit by the verified principal that calls: a first-party service
++ * (svc:live) or a developer app (app:app_…). Every limited route sits behind requireCap or
++ * appOrService, so the principal is known before it is counted. Past a limit the route answers 429
++ * problem+json `rate_limited` with Retry-After before it does any work; the refusal is logged once and
++ * counted in events_rate_limited_total{limit,window}. Read routes get EVENTS_LIMITS_MINUTE /
++ * EVENTS_LIMITS_HOUR (120 and 3000); publish, pull, subscriptions and replay set their own numbers
++ * where they are mounted (server/api/*.js). Counters live in this process: a restart forgets them.
++ * EVENTS_LIMITS=off turns every limit off (a rollback lever).
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /limits.json, /metrics, and the realtime
++ * stream (a connection, capped by REALTIME_MAX_CONNECTIONS and REALTIME_MAX_TOPICS).
++ */
++const { createActorLimiter } = require('openvibe-sdk/limits');
++
++/** limits(name, own) middleware for one app; `clock` is the service's (manual in tests). */
++function createLimits({ config, clock = { now: () => Date.now() }, metrics = null, log = console }) {
++ if (!config.limits.enabled) return () => (_req, _res, next) => next(); // EVENTS_LIMITS=off
++ const refused = metrics && metrics.registry
++ ? metrics.registry.counter({ name: 'events_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ return createActorLimiter({
++ limits: { minute: config.limits.minute, hour: config.limits.hour },
++ now: () => clock.now(),
++ onLimited(e) {
++ // The actor is a principal id (svc:…, app:app_…), never a token.
++ log.warn(`[limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++}
++
++module.exports = { createLimits };
ddiff --git a/server/api/publish.js b/server/api/publish.js
iindex bac2e2c..47c3e01 100644
---- a/server/api/publish.js
++++ b/server/api/publish.js
@@@ -37,9 +37,24 @@ function normalize(input, ctx) {
return env;
}
--function publishRouter({ config, store, auth, worker, realtime }) {
++function publishRouter({ config, store, auth, worker, realtime, limits }) {
const router = express.Router();
++ // Per-actor limits (server/actor-limits.js), in requests; a request carries up to 100 events.
++ // First-party outboxes flush about once a second each and page through a backlog in batches, and
++ // a 429 is retried by them (never lost): 600 a minute leaves a restart's backlog room to drain.
++ // A developer app also meets its project's event quota (EVENTS_APP_[SANDBOX_]PUBLISH_PER_MINUTE);
++ // 60 requests a minute stops a loop of refused or tiny requests before any validation runs.
++ // Network is not counted: its publishes carry app revocations, sign-out cutoffs and account
++ // deletions, which must never wait behind a limit.
++ const servicePublish = limits('events.event.publish', { minute: 600, hour: 20000 });
++ const appPublish = limits('events.app.publish', { minute: 60, hour: 1200 });
++ function publishLimit(req, res, next) {
++ if (req.principal.kind === 'app') return appPublish(req, res, next);
++ if (req.principal.service === 'network') return next();
++ return servicePublish(req, res, next);
++ }
++
function checkOne(input, principal, ctx) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { status: 422, code: 'events.invalid_envelope', detail: 'an event envelope must be a JSON object' };
@@@ -81,7 +96,7 @@ function publishRouter({ config, store, auth, worker, realtime }) {
return { env };
}
-- router.post('/api/v1/events', auth.appOrService(CAPS.publish, CAPS.appPublish, { requireService: true }), (req, res) => {
++ router.post('/api/v1/events', auth.appOrService(CAPS.publish, CAPS.appPublish, { requireService: true }), publishLimit, (req, res) => {
const ctx = req.ov;
const body = req.body;
const p = req.principal;
ddiff --git a/server/api/read.js b/server/api/read.js
iindex 70801c8..711c171 100644
---- a/server/api/read.js
++++ b/server/api/read.js
@@@ -50,12 +50,16 @@ const intParam = (v, d, min, max) => {
return Number.isInteger(n) && n >= min && n <= max ? n : NaN;
};
--function readRouter({ store, auth, worker }) {
++function readRouter({ store, auth, worker, limits }) {
const router = express.Router();
const canRead = auth.appOrService(CAPS.read, CAPS.appRead);
const isAdmin = auth.requireCap(CAPS.admin);
++ // Per-actor limits (server/actor-limits.js); single reads take the defaults. A pull consumer polls
++ // every few seconds and pages through a backlog (Community's relay: up to 20 pages a tick), and
++ // may store its checkpoint after each page: 600 a minute each, so a catch-up is never throttled.
++ const pull = limits('events.event.pull', { minute: 600, hour: 20000 });
-- router.get('/api/v1/events', canRead, (req, res) => {
++ router.get('/api/v1/events', canRead, pull, (req, res) => {
const ctx = req.ov;
const patterns = String(req.query.topic || '*').split(',').map(s => s.trim()).filter(Boolean);
if (!patterns.length || patterns.length > 20 || !patterns.every(topics.isValidPattern)) {
@@@ -82,7 +86,7 @@ function readRouter({ store, auth, worker }) {
res.json(out);
});
-- router.get('/api/v1/events/:id', canRead, (req, res) => {
++ router.get('/api/v1/events/:id', canRead, limits('events.event.read'), (req, res) => {
const row = store.getEvent(String(req.params.id));
const visible = row && (req.principal.kind === 'app' ? apps.visibleToApp(row, req.principal) : (row.env || 'production') === 'production');
if (!visible) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });
@@@ -91,7 +95,7 @@ function readRouter({ store, auth, worker }) {
const consumerOf = (req) => req.principal.service || req.principal.sub;
-- router.get('/api/v1/checkpoints', canRead, (req, res) => {
++ router.get('/api/v1/checkpoints', canRead, limits('events.checkpoint.read'), (req, res) => {
const topic = String(req.query.topic || '');
if (!topics.isValidPattern(topic)) return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic is required', ctx: req.ov });
const scopeErr = scopeError(req.principal, [topic]);
@@@ -100,7 +104,7 @@ function readRouter({ store, auth, worker }) {
res.json({ consumer: consumerOf(req), topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });
});
-- router.put('/api/v1/checkpoints', canRead, (req, res) => {
++ router.put('/api/v1/checkpoints', canRead, limits('events.checkpoint.write', { minute: 600, hour: 20000 }), (req, res) => {
const b = req.body || {};
if (!topics.isValidPattern(b.topic) || !Number.isInteger(b.cursor) || b.cursor < 0) {
return http.sendProblem(res, 400, 'events.bad_request', { detail: 'topic (pattern) and cursor (integer >= 0) are required', ctx: req.ov });
@@@ -111,7 +115,7 @@ function readRouter({ store, auth, worker }) {
res.json({ consumer: consumerOf(req), topic: b.topic, ...cp });
});
-- router.get('/api/v1/deliveries', isAdmin, (req, res) => {
++ router.get('/api/v1/deliveries', isAdmin, limits('events.delivery.list'), (req, res) => {
const status = req.query.status ? String(req.query.status) : undefined;
if (status && !['pending', 'delivered', 'failed', 'dead'].includes(status)) {
return http.sendProblem(res, 400, 'events.bad_request', { detail: 'status is pending|delivered|failed|dead', ctx: req.ov });
@@@ -136,7 +140,9 @@ function readRouter({ store, auth, worker }) {
});
});
-- router.post('/api/v1/deliveries/replay', isAdmin, (req, res) => {
++ // A replay requeues up to a whole subscription's retained history: an operator's action, a few
++ // times at most.
++ router.post('/api/v1/deliveries/replay', isAdmin, limits('events.delivery.replay', { minute: 6, hour: 60 }), (req, res) => {
const ctx = req.ov;
const b = req.body || {};
const sub = typeof b.subscription_id === 'string' ? store.getSubscription(b.subscription_id) : null;
ddiff --git a/server/api/subscriptions.js b/server/api/subscriptions.js
iindex d55598e..ba374d6 100644
---- a/server/api/subscriptions.js
++++ b/server/api/subscriptions.js
@@@ -45,12 +45,16 @@ function checkRetryPolicy(p) {
return { ok: true, value: out };
}
--function subscriptionsRouter({ config, store, auth, dnsLookup }) {
++function subscriptionsRouter({ config, store, auth, dnsLookup, limits }) {
const router = express.Router();
// Creation and rotation answer with the signing secret (shown once): nothing here may be cached.
router.use('/api/v1/subscriptions', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });
const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);
++ // Per-actor limits (server/actor-limits.js); reads take the defaults. A service ensures its handful of
++ // subscriptions at boot and an app holds at most EVENTS_APP_MAX_SUBSCRIPTIONS, so creating (a DNS
++ // check of the endpoint and a row), enabling and disabling allow 30 a minute; rotating a secret 10.
++ const change = { minute: 30, hour: 300 };
/** The app's events.app.subscribe grant was withdrawn after this token was issued. */
function subscribeRevoked(req, res) {
@@@ -76,7 +80,7 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
return sub;
}
-- router.post('/api/v1/subscriptions', guard, async (req, res, next) => {
++ router.post('/api/v1/subscriptions', guard, limits('events.subscription.create', change), async (req, res, next) => {
try {
await create(req, res);
} catch (err) {
@@@ -135,11 +139,11 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
return res.status(201).json(subscriptionView(row, { withSecret: true }));
}
-- router.get('/api/v1/subscriptions', guard, (req, res) => {
++ router.get('/api/v1/subscriptions', guard, limits('events.subscription.list'), (req, res) => {
res.json({ subscriptions: store.listSubscriptions(consumerOf(req)).filter(s => inScope(req, s)).map(s => subscriptionView(s)) });
});
-- router.get('/api/v1/subscriptions/:id', guard, (req, res) => {
++ router.get('/api/v1/subscriptions/:id', guard, limits('events.subscription.read'), (req, res) => {
const sub = own(req, res);
if (sub) res.json(subscriptionView(sub));
});
@@@ -147,7 +151,7 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
// POST /api/v1/subscriptions/:id/rotate-secret { secret?, overlap_s? } -> the new secret (shown once). The old
// secret keeps signing next to it for overlap_s (default 1 day, at most 7), so the consumer can switch without
// missing a delivery.
-- router.post('/api/v1/subscriptions/:id/rotate-secret', guard, express.json({ limit: '4kb' }), (req, res) => {
++ router.post('/api/v1/subscriptions/:id/rotate-secret', guard, limits('events.subscription.rotate', { minute: 10, hour: 100 }), express.json({ limit: '4kb' }), (req, res) => {
const sub = own(req, res);
if (!sub) return;
const b = req.body || {};
@@@ -163,8 +167,9 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
res.json({ ...subscriptionView(out), secret, previous_secret_valid_until: new Date(out.previous_secret_until).toISOString() });
});
++ const toggle = limits('events.subscription.toggle', change);
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
-- router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
++ router.post(`/api/v1/subscriptions/:id/${action}`, guard, toggle, (req, res) => {
if (enabled && subscribeRevoked(req, res)) return;
const sub = own(req, res);
if (sub) res.json(subscriptionView(store.setSubscriptionEnabled(sub.id, enabled)));
ddiff --git a/server/app.js b/server/app.js
iindex 997132b..fa18da8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,9 +10,10 @@ const { publishRouter } = require('./api/publish');
const { subscriptionsRouter } = require('./api/subscriptions');
const { readRouter } = require('./api/read');
const { mountLimits } = require('./limits');
++const { createLimits } = require('./actor-limits');
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, clock, log = console }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -69,9 +70,12 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
});
app.get('/api/ready', readiness.handler);
-- app.use(publishRouter({ config, store, auth, worker, realtime }));
-- app.use(subscriptionsRouter({ config, store, auth, dnsLookup }));
-- app.use(readRouter({ store, auth, worker }));
++ // One per-actor limiter for the capability routes below (server/actor-limits.js); health, ready,
++ // release.json, limits.json, metrics and the realtime stream above are never limited.
++ const limits = createLimits({ config, clock, metrics, log });
++ app.use(publishRouter({ config, store, auth, worker, realtime, limits }));
++ app.use(subscriptionsRouter({ config, store, auth, dnsLookup, limits }));
++ app.use(readRouter({ store, auth, worker, limits }));
app.get('/', (_req, res) => {
res.type('text/plain').send([
ddiff --git a/server/config.js b/server/config.js
iindex 80adf8c..21144e3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -113,6 +113,16 @@ function load(env = process.env) {
sandboxRetentionDays: int(env.EVENTS_APP_SANDBOX_RETENTION_DAYS, 7),
},
++ // Per-actor limits at the capability routes (server/actor-limits.js, roadmap WS-R task 4): the
++ // requests one principal (svc:…, app:app_…) may make to a read route per minute and per hour.
++ // Publishing, subscriptions and replay set their own numbers where they are mounted.
++ // EVENTS_LIMITS=off turns every one of them off (a rollback lever).
++ limits: {
++ enabled: env.EVENTS_LIMITS !== 'off',
++ minute: Math.max(1, int(env.EVENTS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.EVENTS_LIMITS_HOUR, 3000)),
++ },
++
// Project usage rollups (server/usage.js, WS-N task 4): each closed hour of a project's
// publishing and webhook deliveries is stored as events.usage.recorded. off: nothing counted.
usage: {
ddiff --git a/server/index.js b/server/index.js
iindex 2effa0b..2421536 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -31,7 +31,7 @@ async function start({
const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, appPost: appPost || createGuardedPost({ lookup: dnsLookup }), log, observe: metrics.observe });
const realtime = createRealtime({ store, auth, config, clock, log });
metrics.bind({ realtime });
-- const app = createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log });
++ const app = createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, clock, log });
// The key loads in the background (retrying while Network boots); /api/ready says when it has.
const keyLoaded = keys.start().catch(() => null);
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..e4c64e6
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// Per-actor rate limits at the capability routes (server/actor-limits.js, roadmap WS-R task 4): past its
++// limit one principal gets 429 problem+json `rate_limited` with Retry-After, before the route does any
++// work, while another principal still passes; the window reopens on the clock; publishing has its own
++// numbers for services and apps, and Network's publishes (revocations, cutoffs, deletions) are never
++// refused; refusals are logged and counted; EVENTS_LIMITS=off turns every limit off.
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const apps = require('../server/apps');
++const { load } = require('../server/config');
++const { createLimits } = require('../server/actor-limits');
++const { boot, request, serviceToken, appToken, envelope, manualClock, suite } = require('./helpers');
++
++const t = suite('actor-limits');
++let h;
++// 15 s into a minute, so the minute window has 45 s left.
++const clock = manualClock(Date.UTC(2026, 8, 27, 12, 0, 15));
++
++const readerA = serviceToken('search', ['events.event.read']);
++const readerB = serviceToken('community', ['events.event.read']);
++const live = serviceToken('live', ['events.event.publish']);
++const network = serviceToken('network', ['events.event.publish']);
++const appId = ids.newId('app');
++const projectId = `prj_${ids.ulid()}`;
++const app = appToken({ appId, projectId, env: 'production', cap: ['events.app.publish'] });
++const appEvent = () => envelope('x', {
++ source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.order.created`,
++ actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal',
++});
++const get = (token, p) => request(h.base, 'GET', p, { token });
++const publish = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });
++
++t('boot (EVENTS_LIMITS_MINUTE=3; the app event quota off, so only the per-actor limit answers)', async () => {
++ h = await boot({ clock, env: { EVENTS_LIMITS_MINUTE: '3', EVENTS_LIMITS_HOUR: '100', EVENTS_APP_PUBLISH_PER_MINUTE: '0' } });
++});
++
++t('a read route: 3 a minute per principal, then 429 rate_limited with Retry-After; another principal passes', async () => {
++ const one = `/api/v1/events/${ids.newId('event')}`;
++ for (let i = 0; i < 3; i++) assert.strictEqual((await get(readerA, one)).status, 404);
++ const r = await get(readerA, one);
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')));
++ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(r.body.detail.includes('events.event.read'), r.body.detail);
++ assert.strictEqual((await get(readerB, one)).status, 404, 'another principal still passes');
++ assert.strictEqual((await get(readerA, '/api/v1/checkpoints?topic=live.*')).status, 200, 'each route counts on its own');
++ assert.strictEqual((await get(null, one)).status, 401, 'no token: 401 from the guard, never counted');
++ clock.advance(45 * 1000);
++ assert.strictEqual((await get(readerA, one)).status, 404, 'the next minute opens the window again');
++});
++
++t('health, ready, release.json and metrics are never limited', async () => {
++ for (let i = 0; i < 10; i++) {
++ assert.strictEqual((await get(null, '/api/health')).status, 200);
++ assert.notStrictEqual((await get(null, '/api/ready')).status, 429);
++ assert.strictEqual((await get(null, '/release.json')).status, 200);
++ assert.strictEqual((await get(null, '/metrics')).status, 200);
++ }
++});
++
++t('publish: an app gets 60 requests a minute; the 61st is refused before anything is stored', async () => {
++ clock.t = Date.UTC(2026, 8, 27, 12, 5, 0);
++ for (let i = 0; i < 60; i++) {
++ const r = await publish(app, appEvent());
++ assert.strictEqual(r.status, 201, r.text);
++ }
++ const before = h.store.lastSeq();
++ const r = await publish(app, appEvent());
++ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.strictEqual(h.store.lastSeq(), before, 'nothing stored');
++ assert.strictEqual((await publish(live, envelope('live'))).status, 201, 'a service still publishes');
++});
++
++t('publish: a service gets 600 requests a minute; Network is never refused', async () => {
++ clock.t = Date.UTC(2026, 8, 27, 12, 10, 0);
++ for (let i = 0; i < 600; i++) assert.strictEqual((await publish(live, envelope('live'))).status, 201);
++ const r = await publish(live, envelope('live'));
++ assert.deepStrictEqual([r.status, r.body.code], [429, 'rate_limited']);
++ for (let i = 0; i < 610; i++) {
++ const n = await publish(network, envelope('network', { event_type: 'network.app.revoked' }));
++ assert.strictEqual(n.status, 201, `Network publish ${i + 1}: ${n.text}`);
++ }
++});
++
++t('refusals are counted in events_rate_limited_total', async () => {
++ const m = (await get(null, '/metrics')).text;
++ assert.ok(/events_rate_limited_total\{limit="events.event.read",window="minute"\} 1/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
++ assert.ok(/events_rate_limited_total\{limit="events.app.publish",window="minute"\} 1/.test(m));
++ assert.ok(/events_rate_limited_total\{limit="events.event.publish",window="minute"\} 1/.test(m));
++ await h.stop();
++});
++
++t('refusals are logged once each: the limit, the principal and the window, never a token', async () => {
++ const warned = [];
++ const limits = createLimits({ config: load({ EVENTS_LIMITS_MINUTE: '1' }), clock: manualClock(Date.UTC(2026, 8, 27, 12, 0, 0)), log: { warn: (...a) => warned.push(a.join(' ')) } });
++ const mw = limits('events.event.read');
++ const req = { principal: { kind: 'service', sub: 'svc:search' }, headers: { authorization: 'Bearer secret-token' } };
++ const res = { headers: {}, statusCode: 200, setHeader(k, v) { this.headers[k.toLowerCase()] = v; }, end(b) { this.body = b; } };
++ let passed = 0;
++ mw(req, res, () => { passed++; });
++ mw(req, res, () => { passed++; });
++ assert.deepStrictEqual([passed, res.statusCode, res.headers['retry-after']], [1, 429, '60']);
++ assert.deepStrictEqual(warned, ['[limits] events.event.read: svc:search refused, over 1 per minute']);
++});
++
++t('EVENTS_LIMITS=off: no limit answers', async () => {
++ const off = await boot({ clock: manualClock(Date.UTC(2026, 8, 27, 12, 0, 15)), env: { EVENTS_LIMITS: 'off', EVENTS_LIMITS_MINUTE: '1' } });
++ for (let i = 0; i < 5; i++) assert.strictEqual((await request(off.base, 'GET', `/api/v1/events/${ids.newId('event')}`, { token: readerA })).status, 404);
++ await off.stop();
++});
++
++t.run();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 2e30b5d..894505d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -113,7 +113,9 @@ t('boot', async () => {
await new Promise((r) => receiver.listen(0, '127.0.0.1', r));
await new Promise((r) => internal.listen(0, '127.0.0.1', r));
receiverPort = receiver.address().port; internalPort = internal.address().port;
-- h = await boot({ appPost: guarded, dnsLookup, env: { EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '50' } });
++ // Dozens of subscribe attempts from one app, in one minute of the manual clock: no quota or
++ // per-actor limit may answer before the endpoint check under test.
++ h = await boot({ appPost: guarded, dnsLookup, env: { EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '50', EVENTS_LIMITS: 'off' } });
});
t('at creation: every internal spelling is refused (422), a public name accepted', async () => {