openvibers/openvibe.community Commits

Per-actor rate limits at Community's API routers (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/actor-limits.js makes one limiter per app, used by the paste, comment, forum (spaces, posts, groups), Pulse and relay APIs once each resolves its viewer and checks the route's capability. It counts a person as user:usr_… whether they call with their own token or a service or app names them in X-OV-Subject; a first-party service relaying a signed-out visitor by the address it forwards (as the per-address /api/ limit already does for Live's calls); a service or app acting as itself (moderation, AI output such as Live's AI pastes, Pulse) by its principal; a signed-out browser by address. A first-party service reading for itself is not counted on reads, since its pages speak for all its visitors and the per-address limit already bounds it. Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work, logs one [Limits] line and counts community_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 52d923a941 · 13 files, +343 −66

Every API's reads take COMMUNITY_LIMITS_MINUTE / COMMUNITY_LIMITS_HOUR, 120 and 3000. Writes sit above the per-person content limits that keep deciding for people (a comment every 10 s and 5 a minute, 3 threads and 6 replies a minute, 12 uploads, 60 votes) and cap requests, refused ones included, and every write without a content limit: comment, paste comment and reply create 20 a minute and 300 an hour; thread create, crosspost and space create 10 and 60; paste create 30 and 600; screenshot, fork, attachment upload and censor 20 each (300, 300, 200, 200); the AI summary pass 60 and 1200; edits 30 and 300; deletes and moderation 60 and 600; votes, reactions, likes and copies 120 and 1200; space settings, categories, status, groups, chat room and relay changes 30 and 300; bulk and fork cleanups 10 and 100; thread resolve and Pulse item posts 60 and 1200. The per-address /api/ and anonymous-write limits stay. Health, ready, release.json, metrics and the signed Events deliveries at /internal/events, which carry token cutoffs and account deletions, are never limited.

test/actor-limits.test.js on a fixed clock: the fourth read in a minute is refused with Retry-After 45 while another person passes, a service naming the same person counts against them, relayed visitors count by forwarded address, a service reading for itself is not counted, and the next minute reopens; the 21st comment in a minute is refused and not stored while another person still comments; health, ready, release.json, metrics and /internal/events never answer 429; refusals are counted.

Changes

ddiff --git a/.env.example b/.env.example
iindex 968a0d5..e385dec 100644
---- a/.env.example
++++ b/.env.example
@@@ -63,6 +63,12 @@ COMMUNITY_DB_PATH=./data/community.db
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
#VIEW_HASH_SECRET=
++# Per-actor limits (server/actor-limits.js): reads one caller (a person, a service or app acting as
++# itself, else an address) may make to one API per minute and per hour; past them 429 rate_limited
++# with Retry-After. Writes have their own numbers (README). The per-address /api/ limit stays.
++#COMMUNITY_LIMITS_MINUTE=120
++#COMMUNITY_LIMITS_HOUR=3000
++
# Browser origins allowed to call /api/v1/comments and /api/v1/pulse with a Bearer Network JWT
#API_CORS_ORIGINS=https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games
ddiff --git a/README.md b/README.md
iindex 363d954..ce59949 100644
---- a/README.md
++++ b/README.md
@@@ -524,6 +524,34 @@ through the library's `capabilities.check`. `server/identity/capabilities.js` st
the installed contracts do not know locally, with the library's own matching rule (the exact id
or a `prefix.*` grant).
++### Per-actor limits
++
++Every API router also limits who calls it, once the viewer is resolved and before the route does any
++work (`server/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4). The per-address `/api/`
++limit and the per-person content limits (cooldowns, writes a minute, daily caps) stay. Counted: a
++person as `user:usr_…` (their own token, or named by a service or app in `X-OV-Subject`); a
++first-party service relaying a signed-out visitor by the address it forwards; a service or app acting
++as itself (moderation, AI output, Pulse) by its principal; a signed-out browser by address. A
++first-party service reading for itself is not counted on reads. Past a limit: `429` problem+json
++`rate_limited` with `Retry-After`, one `[Limits]` log line and `community_rate_limited_total{limit,window}`.
++
++| Routes | Per caller |
++| --- | --- |
++| Reads of each API (pastes, comments, forum, Pulse, relay) | `COMMUNITY_LIMITS_MINUTE` / `COMMUNITY_LIMITS_HOUR` (120 a minute, 3000 an hour) |
++| Comment, paste comment and reply create | 20 / 300 |
++| Thread create, crosspost; space create | 10 / 60 |
++| Paste create 30 / 600; screenshot 20 / 300; fork 20 / 300; AI summary 60 / 1200 | as listed |
++| Edits (comments, posts, pastes, thread category and members-only) | 30 / 300 |
++| Deletes and moderation (visibility, comment, post, paste deletes) | 60 / 600 |
++| Votes, reactions, likes and copies | 120 / 1200 |
++| Space settings, categories, status, state, groups, chat room; relay changes | 30 / 300 |
++| Attachment upload 20 / 200; paste censor 20 / 200; bulk and fork cleanups 10 / 100 | as listed |
++| Thread resolve 60 / 1200; Pulse item post and retract 60 / 1200 | as listed |
++
++Never limited: `/api/health`, `/api/ready`, `/release.json`, `/metrics` and the signed Events
++deliveries at `/internal/events` (they carry token cutoffs and account deletions). Pages keep their
++per-address form limits. `test/actor-limits.test.js`.
++
## SEO
Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
ddiff --git a/package-lock.json b/package-lock.json
iindex 3ac35a0..b8e5324 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.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": {
@@@ -1073,9 +1073,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
++ "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"
ddiff --git a/package.json b/package.json
iindex fffd04c..51f41df 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.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..c077fc1
---- /dev/null
++++ b/server/actor-limits.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++/**
++ * Per-actor rate limits at the API routes (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The per-address /api/ limit (app.js) and the per-person content limits (limits.js: cooldowns, writes
++ * a minute) stay. These count requests by who makes them, once req.viewer is resolved:
++ *
++ * a person user:usr_… (their own token, or named by a service or app in X-OV-Subject)
++ * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For),
++ * a signed-out visitor as the per-address limit counts them
++ * a service or app acting as its principal (svc:live, app:app_…): moderation, AI output, Pulse ingest
++ * itself
++ * a signed-out browser ip:<address>
++ *
++ * A first-party service reading for itself (no person, no visitor) is not counted on reads: its pages
++ * speak for all its visitors, and the per-address /api/ limit already bounds it. 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 community_rate_limited_total{limit,window}. Reads get COMMUNITY_LIMITS_MINUTE
++ * / COMMUNITY_LIMITS_HOUR (120 and 3000); writes set their own numbers where they are mounted. Counters
++ * live in this process: a restart forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, and the signed Events deliveries
++ * (/internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall behind;
++ * they also carry token cutoffs and account deletions). Pages keep their per-address form limits.
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++const config = require('./config');
++
++const FIRST_PARTY = /^svc:/;
++const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
++
++/** A first-party service that forwards the address of the visitor it acts for. */
++function relaysVisitor(req) {
++ const v = req.viewer;
++ return !!(v && v.kind === 'service' && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));
++}
++
++function actor(req) {
++ const v = req.viewer;
++ if (!v || v.kind === 'anonymous') return defaultActor(req);
++ if (v.subject) return `user:${v.subject}`;
++ if (v.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;
++ return defaultActor(req);
++}
++
++/** A first-party service reading for itself: no person, no visitor. */
++function serviceItself(req) {
++ const v = req.viewer;
++ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));
++}
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name): the defaults on every GET/HEAD of
++ * a router (its writes set their own limits per route). opts.limits and opts.now are for tests.
++ */
++function createActorLimits({ limits = null, now = () => Date.now(), registry = null, log = console } = {}) {
++ const refused = registry
++ ? registry.counter({ name: 'community_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limiter = createActorLimiter({
++ limits: limits || { minute: config.limits.minute, hour: config.limits.hour },
++ actor,
++ now,
++ onLimited(e) {
++ // The actor is a subject id, a principal or an address, 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 });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(req) ? limit(req, res, next) : next());
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, serviceItself };
ddiff --git a/server/app.js b/server/app.js
iindex 433af5e..cc1ac05 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -59,6 +59,7 @@ const { createRelayEventsWorker } = require('./relay/events-worker');
const { createDiscordGateway } = require('./relay/discord-gateway');
const { createDiscordInbound } = require('./relay/inbound');
const { createRelayApi } = require('./relay/api');
++const { createActorLimits } = require('./actor-limits');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
@@@ -190,9 +191,13 @@ function createApp(opts = {}) {
},
});
++ // Per-actor limits for every API router below (server/actor-limits.js), after each one resolves its
++ // viewer; the per-address /api/ limit stays in front. opts.actorLimits: { limits, now } (tests).
++ const limits = createActorLimits({ registry: metrics.registry, ...(opts.actorLimits || {}) });
++
// ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
-- if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));
++ if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter, limits }));
else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
@@@ -203,16 +208,18 @@ function createApp(opts = {}) {
message: { error: 'Too many requests — try again later' },
});
const cors = v1.cors(config.apiCorsOrigins);
-- app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter }));
-- app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers }));
++ app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
++ app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
const pulseConsumer = require('./pulse/consumer').createPulseConsumer({ db, vipCache: vip && vip.cache, revocations, accountSend: config.oauth && config.oauth.clientSecret ? require('./identity/account-data').createSender({ config }) : null, secrets: String(process.env.COMMUNITY_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean) });
app.locals.pulseConsumer = pulseConsumer;
++ // Never per-actor limited: Events pushes at its own pace (a 429 only makes it retry and fall behind),
++ // and these deliveries carry token cutoffs and account deletions.
app.use('/internal/events', pulseConsumer.router);
-- app.use('/api/v1/spaces', createSpacesApi({ forum, viewers }));
-- app.use('/api/v1/posts', createPostsApi({ forum, viewers }));
-- app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers }));
-- app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound }));
++ app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
++ app.use('/api/v1/posts', createPostsApi({ forum, viewers, limits }));
++ app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
++ app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
ddiff --git a/server/comments/api.js b/server/comments/api.js
iindex 999b03e..81079aa 100644
---- a/server/comments/api.js
++++ b/server/comments/api.js
@@@ -26,24 +26,30 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
const WRITE = 'community.comment.write';
const MOD = 'community.comment.moderate';
--function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter }) {
++function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
++ // Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
++ // (a comment every 10 s, 5 a minute; 60 votes a minute) keep deciding for people; these cap requests,
++ // refused ones included, and every write that has no content limit.
++ router.use(limits.reads('community.comment.read'));
++ const moderate = limits('community.comment.moderate', { minute: 60, hour: 600 });
const read = serviceAnyCap([WRITE, MOD]);
const passAnon = (_req, _res, next) => next();
-- router.post('/threads/resolve', serviceCap(WRITE), resolveLimiter || passAnon, jsonBody,
++ // Opening a thread writes a row the first time: a person opens at most one a second.
++ router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,
run((req) => service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
router.get('/threads/:id', read, run((req) => service.get(req.viewer, req.params.id, req.query)));
-- router.post('/threads/:id/comments', serviceCap(WRITE), anonWriteLimiter || passAnon, jsonBody,
++ router.post('/threads/:id/comments', serviceCap(WRITE), limits('community.comment.create', { minute: 20, hour: 300 }), anonWriteLimiter || passAnon, jsonBody,
run((req) => service.add(req.viewer, req.params.id, req.body || {}), 201));
-- router.put('/threads/:id/visibility', serviceCap(MOD), jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
++ router.put('/threads/:id/visibility', serviceCap(MOD), moderate, jsonBody, run((req) => service.setVisibility(req.viewer, req.params.id, req.body || {})));
router.get('/:commentId', read, run((req) => service.getComment(req.viewer, req.params.commentId)));
-- router.patch('/:commentId', serviceCap(WRITE), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));
-- router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), run((req) => service.remove(req.viewer, req.params.commentId)));
-- router.post('/:commentId/votes', serviceCap(WRITE), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
++ router.patch('/:commentId', serviceCap(WRITE), limits('community.comment.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => service.edit(req.viewer, req.params.commentId, req.body || {})));
++ router.delete('/:commentId', serviceAnyCap([WRITE, MOD]), moderate, run((req) => service.remove(req.viewer, req.params.commentId)));
++ router.post('/:commentId/votes', serviceCap(WRITE), limits('community.comment.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => service.vote(req.viewer, req.params.commentId, req.body || {})));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
ddiff --git a/server/config.js b/server/config.js
iindex 6b85fbd..e0c26ba 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -18,6 +18,13 @@ module.exports = {
// Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.
trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
++ // Per-actor limits at the API routes (server/actor-limits.js, roadmap WS-R task 4): the reads one
++ // caller may make to one API per minute and per hour. Writes set their own numbers per route.
++ limits: {
++ minute: parseInt(process.env.COMMUNITY_LIMITS_MINUTE, 10) || 120,
++ hour: parseInt(process.env.COMMUNITY_LIMITS_HOUR, 10) || 3000,
++ },
++
// Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 8dc5252..3d902bc 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -46,17 +46,23 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
const POST = 'community.post.create';
const MOD = 'community.comment.moderate';
--function createSpacesApi({ forum, viewers }) {
++function createSpacesApi({ forum, viewers, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
const write = serviceCap(POST);
const writeOrMod = serviceAnyCap([POST, MOD]);
const p = (req) => req.params;
++ // Per-actor limits (server/actor-limits.js): reads take the defaults. The person limits in service.js
++ // (threads 3 a minute and a daily cap, replies 6, uploads 12, votes 60) keep deciding for people;
++ // these cap requests, refused ones included, and every write that has no content limit.
++ router.use(limits.reads('community.forum.read'));
++ const configure = limits('community.space.configure', { minute: 30, hour: 300 }); // moderators' settings
++ const editThread = limits('community.thread.edit', { minute: 30, hour: 300 });
router.get('/', run((req) => forum.listSpaces(req.viewer)));
-- router.post('/', serviceCap(MOD), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));
-- router.put('/:space/settings', serviceCap(MOD), jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
++ router.post('/', serviceCap(MOD), limits('community.space.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createSpace(req.viewer, req.body || {}), 201));
++ router.put('/:space/settings', serviceCap(MOD), configure, jsonBody, run((req) => forum.updateSpaceSettings(req.viewer, p(req).space, req.body || {})));
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
@@@ -65,37 +71,40 @@ function createSpacesApi({ forum, viewers }) {
const withFile = (req, res, next) => one(req, res, (err) => (err
? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })
: next()));
-- router.post('/:space/attachments', write, withFile, run((req) => forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
-- router.put('/:space/categories/:category', serviceCap(MOD), jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
-- router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
-- router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
-- router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
-- router.post('/:space/threads/:slug/crosspost', write, jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
-- router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
++ // An upload is stored in OpenVibe.Media: 20 a minute, above the 12 a person may keep.
++ router.post('/:space/attachments', write, limits('community.attachment.upload', { minute: 20, hour: 200 }), withFile, run((req) => forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
++ router.put('/:space/categories/:category', serviceCap(MOD), configure, jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
++ router.delete('/:space/categories/:category', serviceCap(MOD), configure, run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
++ router.put('/:space/threads/:slug/category', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/threads/:slug/status', serviceCap(MOD), configure, jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.post('/:space/threads/:slug/crosspost', write, limits('community.thread.crosspost', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.crosspost(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
++ router.post('/:space/threads', write, limits('community.thread.create', { minute: 10, hour: 60 }), jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
-- router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
-- router.post('/:space/threads/:slug/posts', write, jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
-- router.post('/:space/threads/:slug/votes', write, jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
-- router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
-- router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
-- router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
++ router.delete('/:space/threads/:slug', writeOrMod, editThread, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
++ router.post('/:space/threads/:slug/posts', write, limits('community.post.create', { minute: 20, hour: 300 }), jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
++ router.post('/:space/threads/:slug/votes', write, limits('community.thread.vote', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/threads/:slug/state', serviceCap(MOD), configure, jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/threads/:slug/members-only', writeOrMod, editThread, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
++ router.put('/:space/members-only', serviceCap(MOD), configure, jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
// A chat room (OpenVibe.Chat): people attach with their own token; moderator services may only detach.
-- router.put('/:space/chat-room', jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
-- router.delete('/:space/chat-room', serviceCap(MOD), run((req) => forum.detachChatRoom(req.viewer, p(req).space)));
++ router.put('/:space/chat-room', configure, jsonBody, run((req) => forum.attachChatRoom(req.viewer, p(req).space, req.body || {}), (out) => (out.created ? 201 : 200)));
++ router.delete('/:space/chat-room', serviceCap(MOD), configure, run((req) => forum.detachChatRoom(req.viewer, p(req).space)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
}
--function createPostsApi({ forum, viewers }) {
++function createPostsApi({ forum, viewers, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
const writeOrMod = serviceAnyCap([POST, MOD]);
++ // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
++ router.use(limits.reads('community.forum.read'));
-- router.put('/:id', writeOrMod, jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
-- router.post('/:id/reactions', serviceCap(POST), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));
-- router.delete('/:id', writeOrMod, run((req) => forum.deletePost(req.viewer, req.params.id)));
++ router.put('/:id', writeOrMod, limits('community.post.edit', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.editPost(req.viewer, req.params.id, req.body || {})));
++ router.post('/:id/reactions', serviceCap(POST), limits('community.post.react', { minute: 120, hour: 1200 }), jsonBody, run((req) => forum.react(req.viewer, req.params.id, req.body || {})));
++ router.delete('/:id', writeOrMod, limits('community.post.delete', { minute: 60, hour: 600 }), run((req) => forum.deletePost(req.viewer, req.params.id)));
router.get('/:id/versions', writeOrMod, run((req) => forum.postVersions(req.viewer, req.params.id)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
@@@ -103,12 +112,14 @@ function createPostsApi({ forum, viewers }) {
}
/** /api/v1/space-groups — the board index's groups: GET (everyone), PUT /:group { name, description?, position? } (moderators). */
--function createGroupsApi({ forum, viewers }) {
++function createGroupsApi({ forum, viewers, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
++ // Per-actor limits (server/actor-limits.js), as on /api/v1/spaces.
++ router.use(limits.reads('community.forum.read'));
router.get('/', run(async (req) => ({ groups: (await forum.listSpaces(req.viewer)).groups.map(({ spaces, ...g }) => ({ ...g, spaces: spaces.map((sp) => sp.slug) })) })));
-- router.put('/:group', serviceCap(MOD), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));
++ router.put('/:group', serviceCap(MOD), limits('community.space.configure', { minute: 30, hour: 300 }), jsonBody, run((req) => forum.putGroup(req.viewer, req.params.group, req.body || {})));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
}
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex 203a58a..3ac8d88 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -38,10 +38,16 @@ const { hasCap } = require('../identity/viewer');
const PASTE_CAPS = ['community.paste.create', 'community.paste.write', 'community.paste.moderate'];
--function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = null }) {
++function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = null, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
++ // Per-actor limits (server/actor-limits.js): reads take the defaults. The paste service's own limits
++ // (a person's cooldown and daily cap, comment limits) keep deciding for people; these cap requests,
++ // refused ones included, and the writes without a content limit. Live's AI pastes count as svc:live.
++ router.use(limits.reads('community.paste.read'));
++ const staffWrite = limits('community.paste.admin', { minute: 10, hour: 100 }); // bulk and fork cleanups
++ const react = limits('community.paste.react', { minute: 120, hour: 1200 }); // likes and copy counts
const json = express.json({ limit: '1mb' });
const jsonErrors = (err, _req, res, next) => (err ? res.status(400).json({ error: 'Malformed JSON body' }) : next());
@@@ -106,41 +112,43 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
: next()), run((req) => service.list(req.viewer, req.query)));
-- router.post('/', guard('community.paste.create'), anonWriteLimiter, body,
++ router.post('/', guard('community.paste.create'), limits('community.paste.create', { minute: 30, hour: 600 }), anonWriteLimiter, body,
run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));
router.get('/config', anyCap, run((req) => service.config(req.viewer)));
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
-- router.delete('/admin/forks', staffOnly, run((req) => service.deleteForks(req.viewer)));
-- router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));
++ router.delete('/admin/forks', staffOnly, staffWrite, run((req) => service.deleteForks(req.viewer)));
++ router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run((req) => service.bulk(req.body || {}, req.viewer)));
-- router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
++ // An image paste stores its file in OpenVibe.Media.
++ router.post('/screenshot', guard('community.paste.create'), limits('community.paste.screenshot', { minute: 20, hour: 300 }), anonWriteLimiter, withFile,
run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));
// ── one paste ────────────────────────────────────────────
router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
-- router.put('/:slug', guard('community.paste.write'), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));
-- router.delete('/:slug', guard('community.paste.write'), run((req) => service.remove(req.viewer, slug(req))));
++ router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));
++ router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run((req) => service.remove(req.viewer, slug(req))));
-- router.post('/:slug/censor', staffOnly, withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));
-- router.post('/:slug/ai', serviceModerator, json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));
++ router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));
++ // Live's AI pass writes one paste's summary at a time, behind a model call.
++ router.post('/:slug/ai', serviceModerator, limits('community.paste.ai', { minute: 60, hour: 1200 }), json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));
router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));
router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));
-- router.post('/:slug/fork', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));
-- router.post('/:slug/like', guard('community.paste.write'), run((req) => service.like(req.viewer, slug(req))));
-- router.post('/:slug/copy', guard('community.paste.write'), run((req) => service.copy(req.viewer, slug(req))));
++ router.post('/:slug/fork', guard('community.paste.write'), limits('community.paste.fork', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));
++ router.post('/:slug/like', guard('community.paste.write'), react, run((req) => service.like(req.viewer, slug(req))));
++ router.post('/:slug/copy', guard('community.paste.write'), react, run((req) => service.copy(req.viewer, slug(req))));
router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));
-- router.post('/:slug/comments', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors,
++ router.post('/:slug/comments', guard('community.paste.write'), limits('community.paste.comment', { minute: 20, hour: 300 }), anonWriteLimiter, json, jsonErrors,
run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));
// Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
-- router.delete('/:slug/comments/:commentId', guard('community.paste.write'), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));
++ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), limits('community.paste.comment_delete', { minute: 60, hour: 600 }), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));
// Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
ddiff --git a/server/pulse/api.js b/server/pulse/api.js
iindex 1063d03..51c66b9 100644
---- a/server/pulse/api.js
++++ b/server/pulse/api.js
@@@ -16,17 +16,21 @@ const { run, serviceCap, jsonBody } = require('../http/v1');
const WRITE = 'community.pulse.write';
--function createPulseApi({ pulse, viewers }) {
++function createPulseApi({ pulse, viewers, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
++ // Per-actor limits (server/actor-limits.js): the feed takes the defaults. A service posts or retracts
++ // an item when something public happens on its site (most arrive through Events instead): one a second.
++ router.use(limits.reads('community.pulse.read'));
++ const ingest = limits('community.pulse.write', { minute: 60, hour: 1200 });
const servicesOnly = (req, res, next) => (req.viewer.kind === 'service' ? next()
: contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `a service token with ${WRITE} is required`, ctx: req.ov }));
router.get('/', run((req) => pulse.list(req.query)));
-- router.post('/items', servicesOnly, serviceCap(WRITE), jsonBody, run((req) => pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
-- router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), run((req) => pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
++ router.post('/items', servicesOnly, serviceCap(WRITE), ingest, jsonBody, run((req) => pulse.ingest(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
++ router.delete('/items/:service/:type/:id', servicesOnly, serviceCap(WRITE), ingest, run((req) => pulse.retract(req.viewer, req.params.service, req.params.type, req.params.id)));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
ddiff --git a/server/relay/api.js b/server/relay/api.js
iindex 9612ed6..ba13c9f 100644
---- a/server/relay/api.js
++++ b/server/relay/api.js
@@@ -26,7 +26,7 @@ const { discussionModerator } = require('../identity/capabilities');
const forumStore = require('../forum/store');
const { SNOWFLAKE, DELIVERY_STATUSES } = require('./discord');
--function createRelayApi({ relay, db, viewers, inbound = null }) {
++function createRelayApi({ relay, db, viewers, inbound = null, limits }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
@@@ -37,6 +37,10 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
if (v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'Sign in as staff', ctx: req.ov });
return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'Relay administration is for staff', ctx: req.ov });
});
++ // Per-actor limits (server/actor-limits.js), after the staff check: reads take the defaults, and
++ // retries, drops, dismissals and mapping changes are a person's clicks, 30 a minute at most.
++ router.use(limits.reads('community.relay.read'));
++ const manage = limits('community.relay.manage', { minute: 30, hour: 300 });
const idOf = (req) => (/^\d{1,15}$/.test(req.params.id) ? Number(req.params.id) : fail(404, 'route.not_found', 'Not found'));
/** Discord ids in a mapping body: digits, or null/'' to clear; undefined when absent. */
@@@ -56,23 +60,23 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : null;
return { enabled: relay.enabled, deliveries: relay.listDeliveries({ status, action, limit: intIn(req.query.limit, 50, 1, 200) }) };
}));
-- router.post('/deliveries/:id/retry', run((req) => {
++ router.post('/deliveries/:id/retry', manage, run((req) => {
if (!relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
return { ok: true };
}));
-- router.post('/deliveries/:id/drop', run((req) => {
++ router.post('/deliveries/:id/drop', manage, run((req) => {
if (!relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');
return { ok: true };
}));
router.get('/inbound', run((req) => ({
enabled: !!inbound, failures: inbound ? inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],
})));
-- router.post('/inbound/:id/dismiss', run((req) => {
++ router.post('/inbound/:id/dismiss', manage, run((req) => {
if (!inbound || !inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');
return { ok: true };
}));
router.get('/mappings', run(() => ({ enabled: relay.enabled, mappings: relay.listMappings() })));
-- router.post('/mappings', jsonBody, run((req) => {
++ router.post('/mappings', manage, jsonBody, run((req) => {
const b = req.body || {};
const space = forumStore.getSpace(db, String(b.space || ''));
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
@@@ -82,7 +86,7 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
const ids = discordIds(b);
return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false, ...ids, inbound: b.inbound === undefined ? undefined : !!b.inbound }) };
}, 201));
-- router.put('/mappings/:id', jsonBody, run((req) => {
++ router.put('/mappings/:id', manage, jsonBody, run((req) => {
const b = req.body || {};
const fields = { ...discordIds(b) };
if (b.enabled !== undefined) fields.enabled = !!b.enabled;
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..7295027
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * Per-actor rate limits (server/actor-limits.js, roadmap WS-R task 4): past its limit one caller gets
++ * 429 problem+json `rate_limited` with Retry-After, before the route does any work, while another
++ * caller still passes; the window reopens on the clock. A person is counted as themselves whether they
++ * call directly or a service names them; a service relaying a signed-out visitor is counted by the
++ * visitor's forwarded address; a service reading for itself is not counted; writes have their own,
++ * tighter numbers. Health, ready, release.json, metrics and the Events deliveries are never limited.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { actor, serviceItself } = require('../server/actor-limits');
++
++(async () => {
++ // 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const t = await boot({
++ authority: 'community',
++ appOpts: {
++ actorLimits: { limits: { minute: 3, hour: 100 }, now: () => clock },
++ // The per-person content limits out of the way, so the per-actor one is what answers.
++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000, duplicate: false } },
++ },
++ });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const live = net.signService({ cap: ['community.comment.write', 'community.pulse.write'] });
++
++ const call = (path, { method = 'GET', token, cookie, headers = {}, json } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const PULSE = '/api/v1/pulse';
++
++ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await call(PULSE, { cookie: alexJwt })).status, 200);
++ const r = await call(PULSE, { cookie: alexJwt });
++ 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')));
++ const body = r.json();
++ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(body.detail.includes('community.pulse.read'), body.detail);
++ assert.strictEqual((await call(PULSE, { cookie: samJwt })).status, 200, 'another person still passes');
++ });
++
++ await check('a service naming the person counts against that person', async () => {
++ const r = await call(PULSE, { token: live, headers: { 'X-OV-Subject': alex.subject_id } });
++ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
++ });
++
++ await check('a service relaying signed-out visitors: each forwarded address on its own; reading for itself: not counted', async () => {
++ const visitor = (ip) => call(PULSE, { token: live, headers: { 'X-Forwarded-For': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await visitor('203.0.113.7')).status, 200);
++ assert.strictEqual((await visitor('203.0.113.7')).status, 429);
++ assert.strictEqual((await visitor('203.0.113.8')).status, 200, 'another visitor still passes');
++ for (let i = 0; i < 6; i++) assert.strictEqual((await call(PULSE, { token: live })).status, 200);
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await call(PULSE, { cookie: alexJwt })).status, 200);
++ });
++
++ await check('a write has its own number: 20 comments a minute, the 21st refused before it is stored', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const res = await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: alexJwt, json: { ref: { service: 'live', type: 'stream', id: '42' } } });
++ assert.strictEqual(res.status, 201, res.text);
++ const thread = res.json().thread;
++ const post = (i) => call(`/api/v1/comments/threads/${thread.access_id || thread.id}/comments`, { method: 'POST', cookie: alexJwt, json: { message: `hello ${i}` } });
++ for (let i = 0; i < 20; i++) {
++ const r = await post(i);
++ assert.strictEqual(r.status, 201, `comment ${i + 1}: ${r.text}`);
++ }
++ const r = await post(20);
++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ const stored = t.db.prepare("SELECT COUNT(*) AS n FROM comments c JOIN comment_threads th ON th.id = c.thread_id WHERE th.ref_service = 'live' AND th.ref_type = 'stream' AND th.ref_id = '42'").get().n;
++ assert.strictEqual(stored, 20, 'nothing stored');
++ assert.strictEqual((await call(`/api/v1/comments/threads/${thread.access_id || thread.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'hi' } })).status, 201, 'another person still comments');
++ });
++
++ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await call('/api/health')).status, 200);
++ assert.notStrictEqual((await call('/api/ready')).status, 429);
++ assert.strictEqual((await call('/release.json')).status, 200);
++ assert.strictEqual((await call('/metrics')).status, 200);
++ assert.notStrictEqual((await call('/internal/events', { method: 'POST', json: {} })).status, 429);
++ }
++ });
++
++ await check('refusals are counted in community_rate_limited_total', async () => {
++ const m = (await call('/metrics')).text;
++ assert.ok(/community_rate_limited_total\{limit="community.pulse.read",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
++ assert.ok(/community_rate_limited_total\{limit="community.comment.create",window="minute"\} 1/.test(m));
++ });
++
++ await check('who is counted', () => {
++ const req = (viewer, { xff = null, ip = '127.0.0.1' } = {}) => ({ viewer, ip, get: (h) => (h === 'x-forwarded-for' ? xff : undefined) });
++ assert.strictEqual(actor(req({ kind: 'user', subject: 'usr_a' })), 'user:usr_a');
++ assert.strictEqual(actor(req({ kind: 'service', service: 'svc:live', subject: 'usr_a' })), 'user:usr_a');
++ assert.strictEqual(actor(req({ kind: 'service', service: 'svc:live', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'ip:203.0.113.9');
++ assert.strictEqual(actor(req({ kind: 'service', service: 'svc:live', subject: null })), 'svc:live', 'acting as itself (AI output, moderation)');
++ assert.strictEqual(actor(req({ kind: 'service', service: 'app:app_1', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'app:app_1', 'an app never relays by address');
++ assert.strictEqual(actor(req({ kind: 'anonymous', subject: null }, { ip: '198.51.100.4' })), 'ip:198.51.100.4');
++ assert.strictEqual(serviceItself(req({ kind: 'service', service: 'svc:live', subject: null })), true);
++ assert.strictEqual(serviceItself(req({ kind: 'service', service: 'app:app_1', subject: null })), false);
++ });
++
++ await t.close();
++ done();
++})();