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}.
Changes
@@@ -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@@@ -524,6 +524,34 @@ through the library's `capabilities.check`. `server/identity/capabilities.js` stthe installed contracts do not know locally, with the library's own matching rule (the exact idor 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`.++## SEOEvery page carries a title, description, canonical, robots, Open Graph + Twitter card and@@@ -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"@@@ -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": {nnew file mode 100644@@@ -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 };@@@ -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@@@ -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;@@@ -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(/\/$/, ''),@@@ -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;}@@@ -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 countsconst 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' }));@@@ -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;@@@ -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;nnew file mode 100644@@@ -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();++})();
@@ -63,6 +63,12 @@ COMMUNITY_DB_PATH=./data/community.db
@@ -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)
# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
#VIEW_HASH_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
# 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
#API_CORS_ORIGINS=https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games
@@ -524,6 +524,34 @@ through the library's `capabilities.check`. `server/identity/capabilities.js` st
@@ -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
the installed contracts do not know locally, with the library's own matching rule (the exact id
or a `prefix.*` grant).
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
## SEO
Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1073,9 +1073,9 @@
@@ -1073,9 +1073,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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 };
@@ -59,6 +59,7 @@ const { createRelayEventsWorker } = require('./relay/events-worker');
@@ -59,6 +59,7 @@ const { createRelayEventsWorker } = require('./relay/events-worker');
const { createDiscordGateway } = require('./relay/discord-gateway');
const { createDiscordGateway } = require('./relay/discord-gateway');
const { createDiscordInbound } = require('./relay/inbound');
const { createDiscordInbound } = require('./relay/inbound');
const { createRelayApi } = require('./relay/api');
const { createRelayApi } = require('./relay/api');
+const { createActorLimits } = require('./actor-limits');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
@@ -190,9 +191,13 @@ function createApp(opts = {}) {
@@ -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) ──
// ── /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 }));
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 }));
else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
// ── /api/v1: comments, forum, Pulse, relay admin ─────────
@@ -203,16 +208,18 @@ function createApp(opts = {}) {
@@ -203,16 +208,18 @@ function createApp(opts = {}) {
message: { error: 'Too many requests — try again later' },
message: { error: 'Too many requests — try again later' },
});
});
const cors = v1.cors(config.apiCorsOrigins);
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.
// 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) });
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;
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('/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 }));
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
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
@@ -26,24 +26,30 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
@@ -26,24 +26,30 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
const WRITE = 'community.comment.write';
const WRITE = 'community.comment.write';
const MOD = 'community.comment.moderate';
const MOD = 'community.comment.moderate';
-function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter }) {
+function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter, limits }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.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 read = serviceAnyCap([WRITE, MOD]);
const passAnon = (_req, _res, next) => next();
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)));
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.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));
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.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 }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -18,6 +18,13 @@ module.exports = {
@@ -18,6 +18,13 @@ module.exports = {
// Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.
// 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,
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)
// Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
@@ -46,17 +46,23 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
@@ -46,17 +46,23 @@ const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
const POST = 'community.post.create';
const POST = 'community.post.create';
const MOD = 'community.comment.moderate';
const MOD = 'community.comment.moderate';
-function createSpacesApi({ forum, viewers }) {
+function createSpacesApi({ forum, viewers, limits }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
const write = serviceCap(POST);
const write = serviceCap(POST);
const writeOrMod = serviceAnyCap([POST, MOD]);
const writeOrMod = serviceAnyCap([POST, MOD]);
const p = (req) => req.params;
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.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', 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/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)));
router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
@@ -65,37 +71,40 @@ function createSpacesApi({ forum, viewers }) {
@@ -65,37 +71,40 @@ function createSpacesApi({ forum, viewers }) {
const withFile = (req, res, next) => one(req, res, (err) => (err
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 })
? 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()));
: 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.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.
// 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 }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
}
}
-function createPostsApi({ forum, viewers }) {
+function createPostsApi({ forum, viewers, limits }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
const writeOrMod = serviceAnyCap([POST, MOD]);
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.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 }));
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 }) {
@@ -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). */
/** /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();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.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.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 }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
}
}
@@ -38,10 +38,16 @@ const { hasCap } = require('../identity/viewer');
@@ -38,10 +38,16 @@ const { hasCap } = require('../identity/viewer');
const PASTE_CAPS = ['community.paste.create', 'community.paste.write', 'community.paste.moderate'];
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();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.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 json = express.json({ limit: '1mb' });
const jsonErrors = (err, _req, res, next) => (err ? res.status(400).json({ error: 'Malformed JSON body' }) : next());
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
@@ -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 })
? 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)));
: 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));
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('/config', anyCap, run((req) => service.config(req.viewer)));
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/stats', staffOnly, run(() => service.stats()));
router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
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));
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)));
router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));
// ── one paste ────────────────────────────────────────────
// ── one paste ────────────────────────────────────────────
router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
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/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.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.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));
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.
// 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.
// 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' }));
router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
@@ -16,17 +16,21 @@ const { run, serviceCap, jsonBody } = require('../http/v1');
@@ -16,17 +16,21 @@ const { run, serviceCap, jsonBody } = require('../http/v1');
const WRITE = 'community.pulse.write';
const WRITE = 'community.pulse.write';
-function createPulseApi({ pulse, viewers }) {
+function createPulseApi({ pulse, viewers, limits }) {
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.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()
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 }));
: 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.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 }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -26,7 +26,7 @@ const { discussionModerator } = require('../identity/capabilities');
@@ -26,7 +26,7 @@ const { discussionModerator } = require('../identity/capabilities');
const forumStore = require('../forum/store');
const forumStore = require('../forum/store');
const { SNOWFLAKE, DELIVERY_STATUSES } = require('./discord');
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();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -37,6 +37,10 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
@@ -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 });
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 });
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'));
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. */
/** 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 }) {
@@ -56,23 +60,23 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
const action = ['create', 'edit', 'delete'].includes(req.query.action) ? req.query.action : 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) }) };
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');
if (!relay.retry(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such delivery waiting to be sent');
return { ok: true };
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');
if (!relay.drop(idOf(req))) fail(404, 'relay.delivery_not_found', 'No such pending or failed delivery');
return { ok: true };
return { ok: true };
}));
}));
router.get('/inbound', run((req) => ({
router.get('/inbound', run((req) => ({
enabled: !!inbound, failures: inbound ? inbound.listFailures({ all: req.query.all === '1', limit: intIn(req.query.limit, 50, 1, 200) }) : [],
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');
if (!inbound || !inbound.dismiss(idOf(req))) fail(404, 'relay.inbound_not_found', 'No such inbound failure to dismiss');
return { ok: true };
return { ok: true };
}));
}));
router.get('/mappings', run(() => ({ enabled: relay.enabled, mappings: relay.listMappings() })));
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 b = req.body || {};
const space = forumStore.getSpace(db, String(b.space || ''));
const space = forumStore.getSpace(db, String(b.space || ''));
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
@@ -82,7 +86,7 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
@@ -82,7 +86,7 @@ function createRelayApi({ relay, db, viewers, inbound = null }) {
const ids = discordIds(b);
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 }) };
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));
}, 201));
- router.put('/mappings/:id', jsonBody, run((req) => {
+ router.put('/mappings/:id', manage, jsonBody, run((req) => {
const b = req.body || {};
const b = req.body || {};
const fields = { ...discordIds(b) };
const fields = { ...discordIds(b) };
if (b.enabled !== undefined) fields.enabled = !!b.enabled;
if (b.enabled !== undefined) fields.enabled = !!b.enabled;
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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();
+})();