openvibers/openvibe.chat Commits
Per-actor rate limits on Chat's REST API (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/net/actor-limits.js is one limiter for the app. It counts a person as user:usr_… (user:<id> before their subject is known) whether they call with their Network token or an hbt_ API token, so a bot counts against its owner, and anyone else by their own address. The caller's token is resolved once per request (auth.js requestUser, now shared by requireAuth, optionalAuth and the ban exemption), so reads count against the person rather than the address. Browsers call these routes themselves; Live's service calls (the bridge at /internal/live and the stream hooks at /internal/calls) carry every viewer's chat, so they are never limited and never count as svc:live. The WebSocket keeps its own flood control. Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work, logs one [Limits] line and counts chat_rate_limited_total{limit,window}.
Changes
@@@ -16,6 +16,12 @@ TRUST_PROXY=2# Exact origins only, no wildcard: list any *.openvibe.tools satellite that embeds chat.#ALLOWED_ORIGINS=https://example.openvibe.tools++# Per-actor limits on the REST API (server/net/actor-limits.js): reads one caller (a person, else an++# address) may make to one API per minute and per hour; past them 429 rate_limited with Retry-After.++# Writes and expensive reads have their own numbers (README). The per-address /api/ limit stays.++#CHAT_LIMITS_MINUTE=120++#CHAT_LIMITS_HOUR=3000++# Chat's own database (production: systemd StateDirectory=openvibe-chat)CHAT_DB_PATH=./data/chat.db# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache@@@ -347,6 +347,38 @@ Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,rollback — is `docs/cutover.md`.++### Per-actor limits++++The REST API also limits who calls it (`server/net/actor-limits.js`, openvibe-sdk/limits, roadmap++WS-R task 4). A person counts as `user:usr_…` whether they call with their Network token or an `hbt_`++API token (a bot counts as its owner); anyone else by their address. Browsers call these routes++themselves, so no service speaks for many visitors here; Live's service calls (the bridge at++`/internal/live`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never++limited. The per-address `/api/` limit and the chat flood controls (the socket's, DMs 10 a minute and++5 new conversations an hour, rooms 6 every 10 s, 6 rings a minute) stay. Past a limit: `429`++problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and++`chat_rate_limited_total{limit,window}`.++++| Routes | Per caller |++| --- | --- |++| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |++| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |++| Chat search, staff and streamer logs, purge preview | 30 / 600 |++| Log export, `GET /api/chat/me/export` | 5 / 30 |++| `POST /api/chat/send` (bots to global chat) | 20 / 300 |++| `DELETE /api/chat/admin/purge` | 10 / 100 |++| DM: new conversation 10 / 60; send 30 / 900; mark read 60 / 1200; members, rename, blocks 20 / 200; delete own message 60 / 600 | as listed |++| Rooms: create 10 / 60; post 60 / 1200; delete a line 60 / 600; settings, join, leave, members 30 / 300; mark read 60 / 1200; attachments 10 / 100 | as listed |++| Sounds: upload (channel or alert) 10 / 60; delete, command edit, alert clear 30 / 300 | as listed |++| TTS: admin settings 30 / 300; admin test (synthesizes) 10 / 100; queue skip and clear 60 / 600; the player's reports 60 / 1800 | as listed |++| Calls: voice channel create and delete, stream call switch 20 / 200; ring 20 / 200; answer 30 / 300 | as listed |++| Chat settings save (`PUT /api/chat/preferences` and the other modules) | 30 / 300 |++++Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the call++hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and++`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,++`/api/sounds/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.++### Layout```@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","multer": "^2.0.2","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","ws": "^8.18.0"},@@@ -1078,9 +1078,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"@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","multer": "^2.0.2","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","ws": "^8.18.0"},@@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');const config = require('./config');const ctx = require('./live-context');const db = require('./db/database');--const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');++const { extractWsToken, authenticateWs, requestUser } = require('./auth/auth');const { trustProxy } = require('./net/client-ip');function normalizeOrigin(origin) {@@@ -187,7 +187,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeasync function isBanExemptAdmin(req) {if (req._ovBanUser === undefined) {let user = null;-- try { const t = extractToken(req); user = t ? await require('./auth/network-session').authenticate(t) : null; } catch { user = null; }++ try { user = await requestUser(req); } catch { user = null; }req._ovBanUser = user;}const u = req._ovBanUser;@@@ -205,6 +205,16 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServenext();});++ // Per-actor limits (server/net/actor-limits.js): every read of each API is counted against the++ // person its token resolves to (else the address), one budget per API; writes and expensive reads++ // set tighter numbers at their routes. The per-address limit above stays. Media files (TTS clips,++ // channel sounds) that every viewer's player fetches are left to the per-address limit.++ const limits = require('./net/actor-limits').limits.attach(metrics.registry);++ app.use('/api/chat', limits.identify, limits.reads('chat.read'));++ app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));++ app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));++ app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));++// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…@@@ -218,7 +228,11 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).const callRoutes = require('./calls/routes');-- app.use('/api/streams', (req, res, next) => (config.calls.enabled ? callRoutes(req, res, next) : next()));++ const callReads = limits.reads('chat.calls.read');++ app.use('/api/streams', (req, res, next) => {++ if (!config.calls.enabled) return next();++ limits.identify(req, res, () => callReads(req, res, () => callRoutes(req, res, next)));++ });// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).app.use(require('./web/pages').createWebRoutes({ config }));@@@ -104,6 +104,19 @@ async function authenticateWs(token) {return user;}++/**++ * The account behind this request's token (null without one, or when it does not resolve), resolved++ * once per request: requireAuth, optionalAuth, the ban exemption and the per-actor limits++ * (net/actor-limits.js) share the answer.++ */++function requestUser(req) {++ if (!req._ovAuthUser) {++ const token = extractToken(req);++ req._ovAuthUser = token ? session.authenticate(token).catch(() => null) : Promise.resolve(null);++ }++ return req._ovAuthUser;++}++/*** Express middleware — requires a valid openvibe.network JWT or API token.*/@@@ -113,7 +126,7 @@ async function requireAuth(req, res, next) {return res.status(401).json({ error: 'Authentication required' });}let user = null;-- try { user = await session.authenticate(token); } catch { user = null; }++ try { user = await requestUser(req); } catch { user = null; }if (!user) {return session.failureReason(token) === 'unresolved'? res.status(401).json({ error: 'Unable to resolve account' })@@@ -146,7 +159,7 @@ async function optionalAuth(req, res, next) {const token = extractToken(req);if (token) {let user = null;-- try { user = await session.authenticate(token); } catch { user = null; }++ try { user = await requestUser(req); } catch { user = null; }if (user && !user.is_banned) {if (user.auth_source === 'api_token') {if (apiTokenAllows(req, user.scopes)) {@@@ -178,6 +191,7 @@ function requireAdmin(req, res, next) {module.exports = {apiTokenAllows,extractToken,++ requestUser,extractWsToken,authenticateWs,requireAuth,@@@ -26,10 +26,20 @@ const ctx = require('../live-context');const dm = require('../chat/dm');const chatServer = require('../chat/chat-server');const { requireAuth, optionalAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const callServer = require('./call-server');const lifecycle = require('./lifecycle');const router = express.Router();++++// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js.++// Creating and closing voice channels, and a streamer switching a stream's call: pushed to every++// socket, done by hand a few at a time.++const channelLimit = limits('chat.call.manage', { minute: 20, hour: 200 });++// Ringing someone: above the six rings a minute below, which keeps deciding; refusals count.++const ringLimit = limits('chat.call.ring', { minute: 20, hour: 200 });++// Answering a ring: once per ring received.++const answerLimit = limits('chat.call.answer', { minute: 30, hour: 300 });const MODES = ['mic', 'mic+cam', 'cam+mic'];/* ── Voice Channels (global, non-stream) ───────────────────── */@@@ -55,7 +65,7 @@ router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {}});--router.post('/voice-channels', requireAuth, (req, res) => {++router.post('/voice-channels', requireAuth, channelLimit, (req, res) => {try {const { name, mode, maxParticipants } = req.body || {};const ch = callServer.createChannel({ name, mode, createdBy: req.user.id, maxParticipants });@@@ -67,7 +77,7 @@ router.post('/voice-channels', requireAuth, (req, res) => {}});--router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {++router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res) => {try {const ok = callServer.deleteChannel(req.params.channelId, req.user);if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });@@@ -105,7 +115,7 @@ function tellCallerNoAnswer(row, channelName, target) {}const _callUserRate = new Map(); // userId → [timestamps]--router.post('/voice-channels/call-user', requireAuth, async (req, res) => {++router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res) => {let call = null;try {// Six calls a minute per account: a ring is a notification on someone else's screen.@@@ -168,7 +178,7 @@ router.post('/voice-channels/call-user', requireAuth, async (req, res) => {}});--router.post('/voice-channels/call-user/respond', requireAuth, (req, res) => {++router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req, res) => {try {const callerUserId = Number(req.body?.caller_user_id || 0);const channelId = String(req.body?.channel_id || '').trim();@@@ -218,7 +228,7 @@ function callStatus(streamId, callMode) {};}--router.put('/:id/call', requireAuth, async (req, res) => {++router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {try {// Ownership and live state from Live now, not a cached copy.const stream = await ctx.refreshStream(req.params.id);@@@ -16,6 +16,7 @@const express = require('express');const router = express.Router();const { requireAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const dm = require('./dm');const ctx = require('../live-context');const prefStores = require('../prefs/stores');@@@ -38,6 +39,20 @@ function publicMessage(m) {// All DM routes require authenticationrouter.use(requireAuth);++// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js; each++// write below has its own number. The per-person DM limits further down (10 messages a minute, 5 new++// conversations an hour) keep deciding what is sent; these cap the requests, refused ones included.++// Starting a conversation checks every invitee with Live and the Network: 10 a minute, 60 an hour.++const startLimit = limits('chat.dm.conversation.create', { minute: 10, hour: 60 });++// Sending: three times the message limit, so a person retrying a refused send is not locked out.++const sendLimit = limits('chat.dm.message.send', { minute: 30, hour: 900 });++// Marking read clears the person's DM notifications on the Network: once per conversation opened.++const readLimit = limits('chat.dm.read_mark', { minute: 60, hour: 1200 });++// Group membership, renames and blocks: a person does a few at a time.++const manageLimit = limits('chat.dm.manage', { minute: 20, hour: 200 });++// Deleting their own messages: one at a time, by hand.++const deleteLimit = limits('chat.dm.message.delete', { minute: 60, hour: 600 });++// ── Per-user rate limiting (in-memory sliding window) ────────const _rateBuckets = new Map();const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s@@@ -78,7 +93,7 @@ router.get('/conversations', (req, res) => {});// Create or get a conversation--router.post('/conversations', async (req, res) => {++router.post('/conversations', startLimit, async (req, res) => {try {const { user_ids, name } = req.body;if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {@@@ -177,7 +192,7 @@ router.get('/conversations/:id/messages', (req, res) => {});// Send a message--router.post('/conversations/:id/messages', (req, res) => {++router.post('/conversations/:id/messages', sendLimit, (req, res) => {try {const convId = parseInt(req.params.id);if (!dm.isParticipant(convId, req.user.id)) {@@@ -246,7 +261,7 @@ router.post('/conversations/:id/messages', (req, res) => {});// Mark conversation read--router.post('/conversations/:id/read', (req, res) => {++router.post('/conversations/:id/read', readLimit, (req, res) => {try {const convId = parseInt(req.params.id);if (!dm.isParticipant(convId, req.user.id)) {@@@ -267,7 +282,7 @@ router.post('/conversations/:id/read', (req, res) => {});// Add participant to group--router.post('/conversations/:id/participants', async (req, res) => {++router.post('/conversations/:id/participants', manageLimit, async (req, res) => {try {const convId = parseInt(req.params.id);if (!dm.isParticipant(convId, req.user.id)) {@@@ -324,7 +339,7 @@ router.post('/conversations/:id/participants', async (req, res) => {});// Remove participant from group--router.delete('/conversations/:id/participants/:userId', (req, res) => {++router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res) => {try {const convId = parseInt(req.params.id);const targetUserId = parseInt(req.params.userId);@@@ -345,7 +360,7 @@ router.delete('/conversations/:id/participants/:userId', (req, res) => {});// Rename group conversation--router.patch('/conversations/:id', (req, res) => {++router.patch('/conversations/:id', manageLimit, (req, res) => {try {const convId = parseInt(req.params.id);if (!dm.isParticipant(convId, req.user.id)) {@@@ -388,7 +403,7 @@ router.get('/users/search', (req, res) => {// ── Block & message management ───────────────────────────────// Block a user--router.post('/blocks/:userId', (req, res) => {++router.post('/blocks/:userId', manageLimit, (req, res) => {try {const targetId = parseInt(req.params.userId);if (!targetId || targetId === req.user.id) {@@@ -403,7 +418,7 @@ router.post('/blocks/:userId', (req, res) => {});// Unblock a user--router.delete('/blocks/:userId', (req, res) => {++router.delete('/blocks/:userId', manageLimit, (req, res) => {try {const targetId = parseInt(req.params.userId);dm.unblockUser(req.user.id, targetId);@@@ -435,7 +450,7 @@ router.get('/blocks/check/:userId', (req, res) => {});// Delete own message--router.delete('/conversations/:id/messages/:msgId', (req, res) => {++router.delete('/conversations/:id/messages/:msgId', deleteLimit, (req, res) => {try {const convId = parseInt(req.params.id);const msgId = parseInt(req.params.msgId);@@@ -18,6 +18,7 @@ const { optionalAuth, requireAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const historyStore = require('./history-store');const networkBlocks = require('./network-blocks');++const { limits } = require('../net/actor-limits');/*** A signed-in reader does not get the lines of people they blocked on the network (one-way; only@@@ -34,6 +35,15 @@ function withoutBlocked(req, res, rows) {const router = express.Router();++// Per-actor limits (net/actor-limits.js): reads take the defaults at app.js; these are tighter.++// GIF search and trending: each one is a call to Tenor or Giphy on the site's key, whose quota every++// viewer shares. The picker waits 300 ms after each keystroke, so a person searching sends a few a minute.++const gifLimit = limits('chat.gif.search', { minute: 60, hour: 600 });++// Searching or exporting chat logs scans chat_messages: a person or staff member browsing pages asks++// a few a minute; an export writes up to the whole range at once.++const searchLimit = limits('chat.search', { minute: 30, hour: 600 });++const exportLimit = limits('chat.export', { minute: 5, hour: 30 });++// ── Staff reading other people's logs is audited (WS-I task 7) ───────────────// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).@@@ -172,7 +182,7 @@ router.get('/gif/providers', optionalAuth, async (req, res) => {});});--router.get('/gif/trending', optionalAuth, async (req, res) => {++router.get('/gif/trending', optionalAuth, gifLimit, async (req, res) => {try {const provider = normalizeGifProvider(req.query.provider);await ctx.ensureSettings();@@@ -199,7 +209,7 @@ router.get('/gif/trending', optionalAuth, async (req, res) => {}});--router.get('/gif/search', optionalAuth, async (req, res) => {++router.get('/gif/search', optionalAuth, gifLimit, async (req, res) => {try {const provider = normalizeGifProvider(req.query.provider);const query = String(req.query.q || '').trim();@@@ -264,7 +274,9 @@ function hydrateReplies(messages) {}// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──--router.post('/send', requireAuth, async (req, res) => {++// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300++// an hour), refusals included; the word and spam filters below still decide each line.++router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {try {const text = (req.body.message || '').trim();// 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).@@@ -369,7 +381,7 @@ router.post('/send', requireAuth, async (req, res) => {});// ── Search Chat Messages (admin or self) ─────────────────────--router.get('/search', requireAuth, (req, res) => {++router.get('/search', requireAuth, searchLimit, (req, res) => {try {const limit = Math.min(parseInt(req.query.limit || '50'), 200);const offset = parseInt(req.query.offset || '0');@@@ -686,8 +698,12 @@ router.get('/:streamId/users', (req, res) => {// ── Chat Log Management ─────────────────────────────────────++// Purges: the preview counts a time range, as a search does (the same budget); the purge soft-deletes++// every line in it and tells each room, so a streamer or staff member cleaning up runs a few (10 a++// minute, 100 an hour).++// Preview count of messages in a time range--router.post('/admin/purge/preview', requireAuth, async (req, res) => {++router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) => {try {const { streamId, from, to } = req.body;if (!from || !to) return res.status(400).json({ error: 'from and to are required' });@@@ -719,7 +735,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {});// Delete messages in a time range (soft delete)--router.delete('/admin/purge', requireAuth, async (req, res) => {++router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, hour: 100 }), async (req, res) => {try {const { streamId, from, to } = req.body;if (!from || !to) return res.status(400).json({ error: 'from and to are required' });@@@ -774,7 +790,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {});// Get paginated chat logs with filters--router.get('/admin/logs', requireAuth, async (req, res) => {++router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {try {const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;@@@ -818,7 +834,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {});// Export chat logs as CSV or JSON--router.get('/admin/logs/export', requireAuth, async (req, res) => {++router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {try {const { streamId, username, search, from, to, messageType, format } = req.query;@@@ -866,7 +882,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or// CSV. Up to 100,000 lines; `truncated` says when there were more.--router.get('/me/export', requireAuth, (req, res) => {++router.get('/me/export', requireAuth, exportLimit, (req, res) => {try {const MAX = 100000, PAGE = 5000;const rows = [];@@@ -22,7 +22,15 @@ const multer = require('multer');const db = require('../db/database');const ctx = require('../live-context');const { requireAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const permissions = require('../auth/permissions');++++// Per-actor limits (net/actor-limits.js), after requireAuth and before the upload is read: reads take++// the defaults at app.js (sound files are left to the per-address limit). An upload is up to++// MAX_SOUND_SIZE_KB on disk plus an ffprobe and an ffmpeg run: 10 a minute, 60 an hour (channel and++// uploader caps still decide what is kept). Deletes and command edits tell every room of the channel.++const uploadLimit = limits('chat.sound.upload', { minute: 10, hour: 60 });++const editLimit = limits('chat.sound.update', { minute: 30, hour: 300 });const config = require('../config');/** The channel of a user plus its moderation policy, loaded before a permission check. */@@@ -162,7 +170,7 @@ router.get('/all/:streamId', async (req, res) => {});// ── Upload a channel sound ───────────────────────────────────--router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {++router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (req, res) => {const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };try {if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });@@@ -304,7 +312,7 @@ router.use((err, req, res, next) => {});// ── Delete a sound ───────────────────────────────────────────--router.delete('/:id', requireAuth, async (req, res) => {++router.delete('/:id', requireAuth, editLimit, async (req, res) => {try {const sound = db.getChannelSoundById(parseInt(req.params.id));if (!sound) return res.status(404).json({ error: 'Sound not found' });@@@ -331,7 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {// A command may hold several sounds, so edits apply to the whole group.// Allowed: channel owner, channel mods, admins, or the creator of every// sound in the group (mirrors the add-to-command rule).--router.patch('/command', requireAuth, async (req, res) => {++router.patch('/command', requireAuth, editLimit, async (req, res) => {try {let channelOwnerId = parseInt(req.body.channel_id) || null;if (!channelOwnerId && req.body.stream_id) {@@@ -422,7 +430,7 @@ router.get('/alert/mine', requireAuth, async (req, res) => {});// Upload/replace an alert sound. kind = 'donation' | 'goal'.--router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req, res) => {++router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'), async (req, res) => {const kind = _alertKind(req.params.kind);try {if (!req.file) return res.status(400).json({ error: 'No sound file' });@@@ -461,7 +469,7 @@ router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req});// Clear an alert sound.--router.delete('/alert/:kind', requireAuth, async (req, res) => {++router.delete('/alert/:kind', requireAuth, editLimit, async (req, res) => {const kind = _alertKind(req.params.kind);try {const channel = await channelWithPolicy(req.user.id);@@@ -28,6 +28,7 @@ const fs = require('fs');const path = require('path');const crypto = require('crypto');const { requireAuth, requireAdmin } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const permissions = require('../auth/permissions');const { isOwner } = permissions;const ttsEngine = require('./tts-engine');@@@ -127,7 +128,8 @@ router.get('/admin/settings', requireAuth, requireAdmin, async (req, res) => {});// ── Admin: Update TTS config ──────────────────────────────────--router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {++// Settings are Live's (ctx.setSettings writes them there): an admin saves a form now and then.++router.put('/admin/settings', requireAuth, requireAdmin, limits('chat.tts.settings.update', { minute: 30, hour: 300 }), async (req, res) => {try {const allowed = ['tts_enabled', 'tts_provider',@@@ -159,7 +161,8 @@ router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {});// ── Admin: Test voice synthesis ───────────────────────────────--router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {++// Each test synthesizes speech with the site's provider credentials (a paid call): 10 a minute, 100 an hour.++router.post('/admin/test', requireAuth, requireAdmin, limits('chat.tts.synthesize', { minute: 10, hour: 100 }), async (req, res) => {try {const { voiceId, text } = req.body;const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);@@@ -222,7 +225,11 @@ router.get('/queue', requireAuth, async (req, res) => {} catch (e) { res.status(500).json({ error: e.message }); }});--router.post('/queue/skip', requireAuth, async (req, res) => {++// Skipping and clearing: a streamer or moderator by hand, a few at a time.++const queueModerate = limits('chat.tts.queue.moderate', { minute: 60, hour: 600 });++// The streamer's player reports each request as it ends; each plays for seconds, so well under one a second.++const queueReport = limits('chat.tts.queue.report', { minute: 60, hour: 1800 });++router.post('/queue/skip', requireAuth, queueModerate, async (req, res) => {try {const r = await queueRoom(req, res);if (!r) return;@@@ -234,7 +241,7 @@ router.post('/queue/skip', requireAuth, async (req, res) => {} catch (e) { res.status(500).json({ error: e.message }); }});--router.post('/queue/clear', requireAuth, async (req, res) => {++router.post('/queue/clear', requireAuth, queueModerate, async (req, res) => {try {const r = await queueRoom(req, res);if (!r) return;@@@ -244,7 +251,7 @@ router.post('/queue/clear', requireAuth, async (req, res) => {} catch (e) { res.status(500).json({ error: e.message }); }});--router.post('/queue/:id/report', requireAuth, async (req, res) => {++router.post('/queue/:id/report', requireAuth, queueReport, async (req, res) => {try {const r = await queueRoom(req, res);if (!r) return;@@@ -30,6 +30,14 @@ module.exports = {// Exact origins only: a *.openvibe.tools satellite that embeds chat must be listed here.extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),++ // Per-actor limits on the REST API (server/net/actor-limits.js, roadmap WS-R task 4): the reads one++ // caller (a person, else an address) may make to one API per minute and per hour. Writes and++ // expensive reads set their own, tighter numbers where they are mounted.++ limits: {++ minute: Math.max(1, int(process.env.CHAT_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(process.env.CHAT_LIMITS_HOUR, 3000)),++ },++// Chat's own SQLite database (systemd StateDirectory openvibe-chat).dbPath: process.env.CHAT_DB_PATH || './data/chat.db',nnew file mode 100644@@@ -0,0 +1,87 @@++/**++ * OpenVibe.Chat — per-actor rate limits on the REST API (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * The per-address /api/ limit (app.js) and the chat flood controls (the socket's, the DM and room++ * message limits, the call ring limit) stay. These count requests by who makes them:++ *++ * a person user:usr_… (user:<id> before their Network subject is known), whether they call with++ * their Network token or an hbt_ API token (a bot counts as the person who made it)++ * anyone else ip:<address>, the visitor's own address (Cloudflare → nginx → Chat, net/client-ip.js)++ *++ * Browsers call these routes themselves (nginx sends Live's chat prefixes here), so no service ever++ * speaks for many visitors on them. Live's service calls (the bridge at /internal/live, the stream++ * hooks at /internal/calls) carry every viewer's chat and are never limited here: a refusal there++ * would drop the whole site's chat, and the WebSocket keeps its own flood control.++ *++ * Reads: every GET/HEAD under /api/chat, /api/dm, /api/tts, /api/sounds (and /api/streams once calls++ * are Chat's) takes CHAT_LIMITS_MINUTE / CHAT_LIMITS_HOUR, 120 and 3000, one budget per API, counted++ * once the caller's token is resolved (identify below). Writes and expensive reads set their own,++ * tighter numbers where they are mounted, after requireAuth. 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 chat_rate_limited_total{limit,window}. Counters live in this process: a restart++ * forgets them.++ *++ * Never limited: /health, /ready, /release.json, /metrics, /internal/* (the bridge, the call hooks and++ * the signed Events deliveries, which carry account deletions and merges), the WebSocket, and the++ * pages of openvibe.chat.++ */++'use strict';++++const { createActorLimiter } = require('openvibe-sdk/limits');++const config = require('../config');++const { requestUser } = require('../auth/auth');++++/** The limiter's clock (tests set it). */++const clock = { now: () => Date.now() };++let refused = null;++let attachedTo = null;++++function personOf(u) {++ if (!u || (!u.subject_id && u.id == null)) return null;++ return u.subject_id ? `user:${u.subject_id}` : `user:${u.id}`;++}++++/** Who a request counts against: the person its token resolved to, else its address. */++function actor(req) {++ return personOf(req.user) || personOf(req.ovActorUser) || `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;++}++++const limits = createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor,++ now: () => clock.now(),++ onLimited(e) {++ // The actor is a subject id, a user id or an address, never a token.++ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++});++++/**++ * Resolve the caller's token once (the same resolution requireAuth and optionalAuth then reuse), so++ * a read counts against the person and not their address. A bad or missing token counts by address.++ */++limits.identify = async function identify(req, res, next) {++ try { req.ovActorUser = await requestUser(req); } catch { req.ovActorUser = null; }++ next();++};++++/**++ * The defaults on every GET/HEAD of one API. `skip(req)`: reads left to the per-address limit (media++ * files every viewer's player fetches, cached by the browser).++ */++limits.reads = function reads(name, { skip = null } = {}) {++ const limit = limits(name);++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !(skip && skip(req)) ? limit(req, res, next) : next());++};++++/** Count refusals in this app's registry (chat_rate_limited_total{limit,window}). */++limits.attach = function attach(registry) {++ if (registry && registry !== attachedTo) {++ attachedTo = registry;++ refused = registry.counter({ name: 'chat_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });++ }++ return limits;++};++++module.exports = { limits, actor, clock };@@@ -18,8 +18,13 @@const express = require('express');const { requireAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const { PrefsError } = require('./module-store');++// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js. A save++// is a write to the person's Network user module: a settings page saves on change, a few a minute.++const saveLimit = limits('chat.preferences.update', { minute: 30, hour: 300 });++function fail(res, err) {if (err instanceof PrefsError) {return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });@@@ -35,7 +40,7 @@ function routesFor(store, key) {router.get('/', requireAuth, async (req, res) => {try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }});-- router.put('/', requireAuth, async (req, res) => {++ router.put('/', requireAuth, saveLimit, async (req, res) => {if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });let expectedRevision;if (req.headers['if-match'] !== undefined) {@@@ -24,11 +24,26 @@*/const express = require('express');const { requireAuth, optionalAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');const rooms = require('./rooms');const ctx = require('../live-context');const router = express.Router();++// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js.++// Creating a room (owners keep at most MAX_ROOMS_PER_OWNER): 10 a minute, 60 an hour.++const createLimit = limits('chat.room.create', { minute: 10, hour: 60 });++// Posting: above the per-room limit (6 every 10 s, and slow mode), which keeps deciding; refusals count.++const postLimit = limits('chat.room.message.send', { minute: 60, hour: 1200 });++// Deleting a line (the author, a moderator): one at a time, by hand.++const deleteLimit = limits('chat.room.message.delete', { minute: 60, hour: 600 });++// Settings, membership and roles reach every socket and a running call: a few at a time.++const manageLimit = limits('chat.room.manage', { minute: 30, hour: 300 });++// Marking read: once per room opened or caught up.++const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });++// Attaching links the room to a Community space: an owner sets up a few.++const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });++function send(res, err) {if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });console.error('[Rooms]', err && err.message);@@@ -66,7 +81,7 @@ router.get('/', optionalAuth, (req, res) => {try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }});--router.post('/', requireAuth, (req, res) => {++router.post('/', requireAuth, createLimit, (req, res) => {try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }});@@@ -80,7 +95,7 @@ router.get('/:slug', optionalAuth, (req, res) => {});});--router.patch('/:slug', requireAuth, (req, res) => {++router.patch('/:slug', requireAuth, manageLimit, (req, res) => {const room = readable(req, res); if (!room) return;try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }});@@@ -93,7 +108,7 @@ router.get('/:slug/messages', optionalAuth, (req, res) => {} catch (err) { send(res, err); }});--router.post('/:slug/messages', requireAuth, (req, res) => {++router.post('/:slug/messages', requireAuth, postLimit, (req, res) => {const room = readable(req, res); if (!room) return;try {const message = rooms.post(room, req.user, req.body && req.body.message);@@@ -102,7 +117,7 @@ router.post('/:slug/messages', requireAuth, (req, res) => {} catch (err) { send(res, err); }});--router.delete('/:slug/messages/:id', requireAuth, (req, res) => {++router.delete('/:slug/messages/:id', requireAuth, deleteLimit, (req, res) => {const room = readable(req, res); if (!room) return;try {const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));@@@ -111,13 +126,13 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {} catch (err) { send(res, err); }});--router.post('/:slug/join', requireAuth, (req, res) => {++router.post('/:slug/join', requireAuth, manageLimit, (req, res) => {const room = rooms.bySlug(req.params.slug);if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }});--router.post('/:slug/leave', requireAuth, (req, res) => {++router.post('/:slug/leave', requireAuth, manageLimit, (req, res) => {const room = readable(req, res); if (!room) return;try {rooms.leave(room, req.user);@@@ -127,7 +142,7 @@ router.post('/:slug/leave', requireAuth, (req, res) => {} catch (err) { send(res, err); }});--router.post('/:slug/read', requireAuth, (req, res) => {++router.post('/:slug/read', requireAuth, readLimit, (req, res) => {const room = readable(req, res); if (!room) return;try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }});@@@ -138,7 +153,7 @@ router.get('/:slug/members', optionalAuth, (req, res) => {res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });});--router.post('/:slug/members', requireAuth, async (req, res) => {++router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {const room = readable(req, res); if (!room) return;try {const name = String((req.body && req.body.username) || '').trim();@@@ -158,7 +173,7 @@ router.get('/:slug/attachments', requireAuth, (req, res) => {res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });});--router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {++router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, (req, res) => {const room = readable(req, res); if (!room) return;try {const b = req.body || {};@@@ -167,7 +182,7 @@ router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {} catch (err) { send(res, err); }});--router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, (req, res) => {++router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, (req, res) => {const room = readable(req, res); if (!room) return;try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }});nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * Per-actor rate limits on the REST API (server/net/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; each API has its own read budget and the window reopens++ * on the clock. A person's API token counts against the person; signed-out callers count by address;++ * media files are left to the per-address limit. Writes have their own, tighter numbers. Health,++ * ready, release.json, metrics and /internal/* are never limited; refusals are logged and counted.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('actor-limits');++let h, alice, bob, limitsMod;++// The limiter's clock: 15 s into a minute, so the minute window has 45 s left.++let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++++t('boot (reads: 3 a minute per caller)', async () => {++ h = await boot({ env: { CHAT_LIMITS_MINUTE: '3', CHAT_LIMITS_HOUR: '100' } });++ limitsMod = require('../server/net/actor-limits');++ limitsMod.clock.now = () => clock;++ alice = h.addUser('alice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1ZAAA' });++ bob = h.addUser('bob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1ZBBB' });++ await h.ctx.sync();++});++++t('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 h.http('GET', '/api/dm/unread', { token: alice.token })).status, 200);++ const r = await h.http('GET', '/api/dm/unread', { token: alice.token });++ 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')), r.headers.get('content-type'));++ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.body.detail.includes('chat.dm.read'), r.body.detail);++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: bob.token })).status, 200, 'another person still passes');++});++++t('the person\'s API token counts against the person; another API has its own budget', async () => {++ h.live.tokens.set('hbt_alice_bot', { userId: alice.id, apiScopes: ['read', 'chat'] });++ const bot = await h.http('GET', '/api/dm/unread', { token: 'hbt_alice_bot' });++ assert.deepStrictEqual([bot.status, bot.body.code], [429, 'rate_limited']);++ assert.strictEqual((await h.http('GET', '/api/chat/online?users=alice', { token: alice.token })).status, 200, '/api/chat is another budget');++});++++t('signed-out callers count by address; media files are left to the per-address limit', async () => {++ const from = (ip) => h.http('GET', '/api/chat/online?users=alice', { headers: { 'X-Forwarded-For': ip } });++ for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);++ assert.strictEqual((await from('203.0.113.7')).status, 429);++ assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await h.http('GET', '/api/sounds/file/none.mp3', { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 404);++ }++});++++t('the next minute opens the window again', async () => {++ clock += 45 * 1000;++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: alice.token })).status, 200);++});++++t('a write has its own number: 20 REST sends a minute, the 21st refused before it is stored', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ const stored = () => h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'").n;++ for (let i = 0; i < 20; i++) {++ const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: `limits ${i} from alice` } });++ assert.strictEqual(r.status, 200, `send ${i + 1}: ${r.text}`);++ }++ const before = stored();++ const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'limits one more' } });++ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);++ assert.ok(r.body.detail.includes('chat.message.send'), r.body.detail);++ assert.strictEqual(stored(), before, 'nothing stored');++ assert.strictEqual((await h.http('POST', '/api/chat/send', { token: bob.token, body: { message: 'limits hi from bob' } })).status, 200, 'another person still sends');++ assert.strictEqual((await h.http('POST', '/api/chat/send', { token: 'nope', body: { message: 'x' } })).status, 401, 'a bad token: 401 from auth');++});++++t('health, ready, release.json, metrics and /internal/* are never limited', async () => {++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await h.http('GET', '/health')).status, 200);++ assert.notStrictEqual((await h.http('GET', '/ready')).status, 429);++ assert.strictEqual((await h.http('GET', '/release.json')).status, 200);++ assert.strictEqual((await h.http('GET', '/metrics')).status, 200);++ assert.notStrictEqual((await h.http('POST', '/internal/events', { body: {} })).status, 429);++ assert.notStrictEqual((await h.http('POST', '/internal/live/calls', { body: { ops: [] } })).status, 429);++ }++});++++t('refusals are counted in chat_rate_limited_total', async () => {++ const m = (await h.http('GET', '/metrics')).text;++ const lines = m.split('\n').filter((l) => l.includes('chat_rate_limited_total')).join('\n');++ assert.ok(/chat_rate_limited_total\{limit="chat.dm.read",window="minute"\} 2/.test(m), lines);++ assert.ok(/chat_rate_limited_total\{limit="chat.read",window="minute"\} 1/.test(m), lines);++ assert.ok(/chat_rate_limited_total\{limit="chat.message.send",window="minute"\} 1/.test(m), lines);++});++++t('who is counted', () => {++ const { actor } = limitsMod;++ assert.strictEqual(actor({ user: { id: 7, subject_id: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');++ assert.strictEqual(actor({ user: { id: 7, subject_id: null }, ip: '203.0.113.1' }), 'user:7', 'before the subject is known');++ assert.strictEqual(actor({ ovActorUser: { id: 7, subject_id: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a', 'resolved before requireAuth');++ assert.strictEqual(actor({ ip: '203.0.113.1' }), 'ip:203.0.113.1');++});++++t.run(async () => { if (h) await h.close(); });@@@ -30,7 +30,9 @@ const SECRET = { roomName: 'Hidden Den', roomWords: 'whisper-in-the-den', callWoconst seeded = {};t('boot and seed: a private room, a private call room, a DM', async () => {-- h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ // The crawl GETs every route as the same few people, far past one person's per-actor read budget++ // (server/net/actor-limits.js); this suite reads privacy, test/actor-limits.test.js the limits.++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat', CHAT_LIMITS_MINUTE: '100000', CHAT_LIMITS_HOUR: '100000' } });ann = h.addUser('ann', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });cat = h.addUser('cat', { subject: ids.newId('user') });
@@ -16,6 +16,12 @@ TRUST_PROXY=2
@@ -16,6 +16,12 @@ TRUST_PROXY=2
# Exact origins only, no wildcard: list any *.openvibe.tools satellite that embeds chat.
# Exact origins only, no wildcard: list any *.openvibe.tools satellite that embeds chat.
#ALLOWED_ORIGINS=https://example.openvibe.tools
#ALLOWED_ORIGINS=https://example.openvibe.tools
+# Per-actor limits on the REST API (server/net/actor-limits.js): reads one caller (a person, else an
+# address) may make to one API per minute and per hour; past them 429 rate_limited with Retry-After.
+# Writes and expensive reads have their own numbers (README). The per-address /api/ limit stays.
+#CHAT_LIMITS_MINUTE=120
+#CHAT_LIMITS_HOUR=3000
+
# Chat's own database (production: systemd StateDirectory=openvibe-chat)
# Chat's own database (production: systemd StateDirectory=openvibe-chat)
CHAT_DB_PATH=./data/chat.db
CHAT_DB_PATH=./data/chat.db
# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache
# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache
@@ -347,6 +347,38 @@ Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-
@@ -347,6 +347,38 @@ Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-
`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
rollback — is `docs/cutover.md`.
rollback — is `docs/cutover.md`.
+### Per-actor limits
+
+The REST API also limits who calls it (`server/net/actor-limits.js`, openvibe-sdk/limits, roadmap
+WS-R task 4). A person counts as `user:usr_…` whether they call with their Network token or an `hbt_`
+API token (a bot counts as its owner); anyone else by their address. Browsers call these routes
+themselves, so no service speaks for many visitors here; Live's service calls (the bridge at
+`/internal/live`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never
+limited. The per-address `/api/` limit and the chat flood controls (the socket's, DMs 10 a minute and
+5 new conversations an hour, rooms 6 every 10 s, 6 rings a minute) stay. Past a limit: `429`
+problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
+`chat_rate_limited_total{limit,window}`.
+
+| Routes | Per caller |
+| --- | --- |
+| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
+| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |
+| Chat search, staff and streamer logs, purge preview | 30 / 600 |
+| Log export, `GET /api/chat/me/export` | 5 / 30 |
+| `POST /api/chat/send` (bots to global chat) | 20 / 300 |
+| `DELETE /api/chat/admin/purge` | 10 / 100 |
+| DM: new conversation 10 / 60; send 30 / 900; mark read 60 / 1200; members, rename, blocks 20 / 200; delete own message 60 / 600 | as listed |
+| Rooms: create 10 / 60; post 60 / 1200; delete a line 60 / 600; settings, join, leave, members 30 / 300; mark read 60 / 1200; attachments 10 / 100 | as listed |
+| Sounds: upload (channel or alert) 10 / 60; delete, command edit, alert clear 30 / 300 | as listed |
+| TTS: admin settings 30 / 300; admin test (synthesizes) 10 / 100; queue skip and clear 60 / 600; the player's reports 60 / 1800 | as listed |
+| Calls: voice channel create and delete, stream call switch 20 / 200; ring 20 / 200; answer 30 / 300 | as listed |
+| Chat settings save (`PUT /api/chat/preferences` and the other modules) | 30 / 300 |
+
+Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the bridge, the call
+hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and
+`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,
+`/api/sounds/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
+
### Layout
### Layout
```
```
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"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",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1078,9 +1078,9 @@
@@ -1078,9 +1078,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"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"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",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');
@@ -17,7 +17,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const ctx = require('./live-context');
const ctx = require('./live-context');
const db = require('./db/database');
const db = require('./db/database');
-const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');
+const { extractWsToken, authenticateWs, requestUser } = require('./auth/auth');
const { trustProxy } = require('./net/client-ip');
const { trustProxy } = require('./net/client-ip');
function normalizeOrigin(origin) {
function normalizeOrigin(origin) {
@@ -187,7 +187,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -187,7 +187,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
async function isBanExemptAdmin(req) {
async function isBanExemptAdmin(req) {
if (req._ovBanUser === undefined) {
if (req._ovBanUser === undefined) {
let user = null;
let user = null;
- try { const t = extractToken(req); user = t ? await require('./auth/network-session').authenticate(t) : null; } catch { user = null; }
+ try { user = await requestUser(req); } catch { user = null; }
req._ovBanUser = user;
req._ovBanUser = user;
}
}
const u = req._ovBanUser;
const u = req._ovBanUser;
@@ -205,6 +205,16 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -205,6 +205,16 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
next();
next();
});
});
+ // Per-actor limits (server/net/actor-limits.js): every read of each API is counted against the
+ // person its token resolves to (else the address), one budget per API; writes and expensive reads
+ // set tighter numbers at their routes. The per-address limit above stays. Media files (TTS clips,
+ // channel sounds) that every viewer's player fetches are left to the per-address limit.
+ const limits = require('./net/actor-limits').limits.attach(metrics.registry);
+ app.use('/api/chat', limits.identify, limits.reads('chat.read'));
+ app.use('/api/dm', limits.identify, limits.reads('chat.dm.read'));
+ app.use('/api/tts', limits.identify, limits.reads('chat.tts.read', { skip: (req) => req.path.startsWith('/audio/') }));
+ app.use('/api/sounds', limits.identify, limits.reads('chat.sounds.read', { skip: (req) => req.path.startsWith('/file/') }));
+
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
// The person's chat settings: the Network user modules Chat owns (server/prefs/stores.js).
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
@@ -218,7 +228,11 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -218,7 +228,11 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.
// Calls on Live's paths (server/calls/routes.js): /api/streams/voice-channels… and /api/streams/:id/call.
// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).
// Nothing answers here until CHAT_CALLS is on (docs/calls-cutover.md).
const callRoutes = require('./calls/routes');
const callRoutes = require('./calls/routes');
- app.use('/api/streams', (req, res, next) => (config.calls.enabled ? callRoutes(req, res, next) : next()));
+ const callReads = limits.reads('chat.calls.read');
+ app.use('/api/streams', (req, res, next) => {
+ if (!config.calls.enabled) return next();
+ limits.identify(req, res, () => callReads(req, res, () => callRoutes(req, res, next)));
+ });
// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
// openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
app.use(require('./web/pages').createWebRoutes({ config }));
app.use(require('./web/pages').createWebRoutes({ config }));
@@ -104,6 +104,19 @@ async function authenticateWs(token) {
@@ -104,6 +104,19 @@ async function authenticateWs(token) {
return user;
return user;
}
}
+/**
+ * The account behind this request's token (null without one, or when it does not resolve), resolved
+ * once per request: requireAuth, optionalAuth, the ban exemption and the per-actor limits
+ * (net/actor-limits.js) share the answer.
+ */
+function requestUser(req) {
+ if (!req._ovAuthUser) {
+ const token = extractToken(req);
+ req._ovAuthUser = token ? session.authenticate(token).catch(() => null) : Promise.resolve(null);
+ }
+ return req._ovAuthUser;
+}
+
/**
/**
* Express middleware — requires a valid openvibe.network JWT or API token.
* Express middleware — requires a valid openvibe.network JWT or API token.
*/
*/
@@ -113,7 +126,7 @@ async function requireAuth(req, res, next) {
@@ -113,7 +126,7 @@ async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
return res.status(401).json({ error: 'Authentication required' });
}
}
let user = null;
let user = null;
- try { user = await session.authenticate(token); } catch { user = null; }
+ try { user = await requestUser(req); } catch { user = null; }
if (!user) {
if (!user) {
return session.failureReason(token) === 'unresolved'
return session.failureReason(token) === 'unresolved'
? res.status(401).json({ error: 'Unable to resolve account' })
? res.status(401).json({ error: 'Unable to resolve account' })
@@ -146,7 +159,7 @@ async function optionalAuth(req, res, next) {
@@ -146,7 +159,7 @@ async function optionalAuth(req, res, next) {
const token = extractToken(req);
const token = extractToken(req);
if (token) {
if (token) {
let user = null;
let user = null;
- try { user = await session.authenticate(token); } catch { user = null; }
+ try { user = await requestUser(req); } catch { user = null; }
if (user && !user.is_banned) {
if (user && !user.is_banned) {
if (user.auth_source === 'api_token') {
if (user.auth_source === 'api_token') {
if (apiTokenAllows(req, user.scopes)) {
if (apiTokenAllows(req, user.scopes)) {
@@ -178,6 +191,7 @@ function requireAdmin(req, res, next) {
@@ -178,6 +191,7 @@ function requireAdmin(req, res, next) {
module.exports = {
module.exports = {
apiTokenAllows,
apiTokenAllows,
extractToken,
extractToken,
+ requestUser,
extractWsToken,
extractWsToken,
authenticateWs,
authenticateWs,
requireAuth,
requireAuth,
@@ -26,10 +26,20 @@ const ctx = require('../live-context');
@@ -26,10 +26,20 @@ const ctx = require('../live-context');
const dm = require('../chat/dm');
const dm = require('../chat/dm');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const callServer = require('./call-server');
const callServer = require('./call-server');
const lifecycle = require('./lifecycle');
const lifecycle = require('./lifecycle');
const router = express.Router();
const router = express.Router();
+
+// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js.
+// Creating and closing voice channels, and a streamer switching a stream's call: pushed to every
+// socket, done by hand a few at a time.
+const channelLimit = limits('chat.call.manage', { minute: 20, hour: 200 });
+// Ringing someone: above the six rings a minute below, which keeps deciding; refusals count.
+const ringLimit = limits('chat.call.ring', { minute: 20, hour: 200 });
+// Answering a ring: once per ring received.
+const answerLimit = limits('chat.call.answer', { minute: 30, hour: 300 });
const MODES = ['mic', 'mic+cam', 'cam+mic'];
const MODES = ['mic', 'mic+cam', 'cam+mic'];
/* ── Voice Channels (global, non-stream) ───────────────────── */
/* ── Voice Channels (global, non-stream) ───────────────────── */
@@ -55,7 +65,7 @@ router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {
@@ -55,7 +65,7 @@ router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {
}
}
});
});
-router.post('/voice-channels', requireAuth, (req, res) => {
+router.post('/voice-channels', requireAuth, channelLimit, (req, res) => {
try {
try {
const { name, mode, maxParticipants } = req.body || {};
const { name, mode, maxParticipants } = req.body || {};
const ch = callServer.createChannel({ name, mode, createdBy: req.user.id, maxParticipants });
const ch = callServer.createChannel({ name, mode, createdBy: req.user.id, maxParticipants });
@@ -67,7 +77,7 @@ router.post('/voice-channels', requireAuth, (req, res) => {
@@ -67,7 +77,7 @@ router.post('/voice-channels', requireAuth, (req, res) => {
}
}
});
});
-router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
+router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res) => {
try {
try {
const ok = callServer.deleteChannel(req.params.channelId, req.user);
const ok = callServer.deleteChannel(req.params.channelId, req.user);
if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });
if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });
@@ -105,7 +115,7 @@ function tellCallerNoAnswer(row, channelName, target) {
@@ -105,7 +115,7 @@ function tellCallerNoAnswer(row, channelName, target) {
}
}
const _callUserRate = new Map(); // userId → [timestamps]
const _callUserRate = new Map(); // userId → [timestamps]
-router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
+router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res) => {
let call = null;
let call = null;
try {
try {
// Six calls a minute per account: a ring is a notification on someone else's screen.
// Six calls a minute per account: a ring is a notification on someone else's screen.
@@ -168,7 +178,7 @@ router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
@@ -168,7 +178,7 @@ router.post('/voice-channels/call-user', requireAuth, async (req, res) => {
}
}
});
});
-router.post('/voice-channels/call-user/respond', requireAuth, (req, res) => {
+router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req, res) => {
try {
try {
const callerUserId = Number(req.body?.caller_user_id || 0);
const callerUserId = Number(req.body?.caller_user_id || 0);
const channelId = String(req.body?.channel_id || '').trim();
const channelId = String(req.body?.channel_id || '').trim();
@@ -218,7 +228,7 @@ function callStatus(streamId, callMode) {
@@ -218,7 +228,7 @@ function callStatus(streamId, callMode) {
};
};
}
}
-router.put('/:id/call', requireAuth, async (req, res) => {
+router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {
try {
try {
// Ownership and live state from Live now, not a cached copy.
// Ownership and live state from Live now, not a cached copy.
const stream = await ctx.refreshStream(req.params.id);
const stream = await ctx.refreshStream(req.params.id);
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const dm = require('./dm');
const dm = require('./dm');
const ctx = require('../live-context');
const ctx = require('../live-context');
const prefStores = require('../prefs/stores');
const prefStores = require('../prefs/stores');
@@ -38,6 +39,20 @@ function publicMessage(m) {
@@ -38,6 +39,20 @@ function publicMessage(m) {
// All DM routes require authentication
// All DM routes require authentication
router.use(requireAuth);
router.use(requireAuth);
+// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js; each
+// write below has its own number. The per-person DM limits further down (10 messages a minute, 5 new
+// conversations an hour) keep deciding what is sent; these cap the requests, refused ones included.
+// Starting a conversation checks every invitee with Live and the Network: 10 a minute, 60 an hour.
+const startLimit = limits('chat.dm.conversation.create', { minute: 10, hour: 60 });
+// Sending: three times the message limit, so a person retrying a refused send is not locked out.
+const sendLimit = limits('chat.dm.message.send', { minute: 30, hour: 900 });
+// Marking read clears the person's DM notifications on the Network: once per conversation opened.
+const readLimit = limits('chat.dm.read_mark', { minute: 60, hour: 1200 });
+// Group membership, renames and blocks: a person does a few at a time.
+const manageLimit = limits('chat.dm.manage', { minute: 20, hour: 200 });
+// Deleting their own messages: one at a time, by hand.
+const deleteLimit = limits('chat.dm.message.delete', { minute: 60, hour: 600 });
+
// ── Per-user rate limiting (in-memory sliding window) ────────
// ── Per-user rate limiting (in-memory sliding window) ────────
const _rateBuckets = new Map();
const _rateBuckets = new Map();
const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s
const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s
@@ -78,7 +93,7 @@ router.get('/conversations', (req, res) => {
@@ -78,7 +93,7 @@ router.get('/conversations', (req, res) => {
});
});
// Create or get a conversation
// Create or get a conversation
-router.post('/conversations', async (req, res) => {
+router.post('/conversations', startLimit, async (req, res) => {
try {
try {
const { user_ids, name } = req.body;
const { user_ids, name } = req.body;
if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {
if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {
@@ -177,7 +192,7 @@ router.get('/conversations/:id/messages', (req, res) => {
@@ -177,7 +192,7 @@ router.get('/conversations/:id/messages', (req, res) => {
});
});
// Send a message
// Send a message
-router.post('/conversations/:id/messages', (req, res) => {
+router.post('/conversations/:id/messages', sendLimit, (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
if (!dm.isParticipant(convId, req.user.id)) {
if (!dm.isParticipant(convId, req.user.id)) {
@@ -246,7 +261,7 @@ router.post('/conversations/:id/messages', (req, res) => {
@@ -246,7 +261,7 @@ router.post('/conversations/:id/messages', (req, res) => {
});
});
// Mark conversation read
// Mark conversation read
-router.post('/conversations/:id/read', (req, res) => {
+router.post('/conversations/:id/read', readLimit, (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
if (!dm.isParticipant(convId, req.user.id)) {
if (!dm.isParticipant(convId, req.user.id)) {
@@ -267,7 +282,7 @@ router.post('/conversations/:id/read', (req, res) => {
@@ -267,7 +282,7 @@ router.post('/conversations/:id/read', (req, res) => {
});
});
// Add participant to group
// Add participant to group
-router.post('/conversations/:id/participants', async (req, res) => {
+router.post('/conversations/:id/participants', manageLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
if (!dm.isParticipant(convId, req.user.id)) {
if (!dm.isParticipant(convId, req.user.id)) {
@@ -324,7 +339,7 @@ router.post('/conversations/:id/participants', async (req, res) => {
@@ -324,7 +339,7 @@ router.post('/conversations/:id/participants', async (req, res) => {
});
});
// Remove participant from group
// Remove participant from group
-router.delete('/conversations/:id/participants/:userId', (req, res) => {
+router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
const targetUserId = parseInt(req.params.userId);
const targetUserId = parseInt(req.params.userId);
@@ -345,7 +360,7 @@ router.delete('/conversations/:id/participants/:userId', (req, res) => {
@@ -345,7 +360,7 @@ router.delete('/conversations/:id/participants/:userId', (req, res) => {
});
});
// Rename group conversation
// Rename group conversation
-router.patch('/conversations/:id', (req, res) => {
+router.patch('/conversations/:id', manageLimit, (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
if (!dm.isParticipant(convId, req.user.id)) {
if (!dm.isParticipant(convId, req.user.id)) {
@@ -388,7 +403,7 @@ router.get('/users/search', (req, res) => {
@@ -388,7 +403,7 @@ router.get('/users/search', (req, res) => {
// ── Block & message management ───────────────────────────────
// ── Block & message management ───────────────────────────────
// Block a user
// Block a user
-router.post('/blocks/:userId', (req, res) => {
+router.post('/blocks/:userId', manageLimit, (req, res) => {
try {
try {
const targetId = parseInt(req.params.userId);
const targetId = parseInt(req.params.userId);
if (!targetId || targetId === req.user.id) {
if (!targetId || targetId === req.user.id) {
@@ -403,7 +418,7 @@ router.post('/blocks/:userId', (req, res) => {
@@ -403,7 +418,7 @@ router.post('/blocks/:userId', (req, res) => {
});
});
// Unblock a user
// Unblock a user
-router.delete('/blocks/:userId', (req, res) => {
+router.delete('/blocks/:userId', manageLimit, (req, res) => {
try {
try {
const targetId = parseInt(req.params.userId);
const targetId = parseInt(req.params.userId);
dm.unblockUser(req.user.id, targetId);
dm.unblockUser(req.user.id, targetId);
@@ -435,7 +450,7 @@ router.get('/blocks/check/:userId', (req, res) => {
@@ -435,7 +450,7 @@ router.get('/blocks/check/:userId', (req, res) => {
});
});
// Delete own message
// Delete own message
-router.delete('/conversations/:id/messages/:msgId', (req, res) => {
+router.delete('/conversations/:id/messages/:msgId', deleteLimit, (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
const msgId = parseInt(req.params.msgId);
const msgId = parseInt(req.params.msgId);
@@ -18,6 +18,7 @@ const { optionalAuth, requireAuth } = require('../auth/auth');
@@ -18,6 +18,7 @@ const { optionalAuth, requireAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const historyStore = require('./history-store');
const historyStore = require('./history-store');
const networkBlocks = require('./network-blocks');
const networkBlocks = require('./network-blocks');
+const { limits } = require('../net/actor-limits');
/**
/**
* A signed-in reader does not get the lines of people they blocked on the network (one-way; only
* A signed-in reader does not get the lines of people they blocked on the network (one-way; only
@@ -34,6 +35,15 @@ function withoutBlocked(req, res, rows) {
@@ -34,6 +35,15 @@ function withoutBlocked(req, res, rows) {
const router = express.Router();
const router = express.Router();
+// Per-actor limits (net/actor-limits.js): reads take the defaults at app.js; these are tighter.
+// GIF search and trending: each one is a call to Tenor or Giphy on the site's key, whose quota every
+// viewer shares. The picker waits 300 ms after each keystroke, so a person searching sends a few a minute.
+const gifLimit = limits('chat.gif.search', { minute: 60, hour: 600 });
+// Searching or exporting chat logs scans chat_messages: a person or staff member browsing pages asks
+// a few a minute; an export writes up to the whole range at once.
+const searchLimit = limits('chat.search', { minute: 30, hour: 600 });
+const exportLimit = limits('chat.export', { minute: 5, hour: 30 });
+
// ── Staff reading other people's logs is audited (WS-I task 7) ───────────────
// ── Staff reading other people's logs is audited (WS-I task 7) ───────────────
// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other
// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other
// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).
// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).
@@ -172,7 +182,7 @@ router.get('/gif/providers', optionalAuth, async (req, res) => {
@@ -172,7 +182,7 @@ router.get('/gif/providers', optionalAuth, async (req, res) => {
});
});
});
});
-router.get('/gif/trending', optionalAuth, async (req, res) => {
+router.get('/gif/trending', optionalAuth, gifLimit, async (req, res) => {
try {
try {
const provider = normalizeGifProvider(req.query.provider);
const provider = normalizeGifProvider(req.query.provider);
await ctx.ensureSettings();
await ctx.ensureSettings();
@@ -199,7 +209,7 @@ router.get('/gif/trending', optionalAuth, async (req, res) => {
@@ -199,7 +209,7 @@ router.get('/gif/trending', optionalAuth, async (req, res) => {
}
}
});
});
-router.get('/gif/search', optionalAuth, async (req, res) => {
+router.get('/gif/search', optionalAuth, gifLimit, async (req, res) => {
try {
try {
const provider = normalizeGifProvider(req.query.provider);
const provider = normalizeGifProvider(req.query.provider);
const query = String(req.query.q || '').trim();
const query = String(req.query.q || '').trim();
@@ -264,7 +274,9 @@ function hydrateReplies(messages) {
@@ -264,7 +274,9 @@ function hydrateReplies(messages) {
}
}
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
// ── Post to global chat over REST (bots; the browser never falls back to it: C-06) ──
-router.post('/send', requireAuth, async (req, res) => {
+// Every line reaches every global chat socket: a bot posts at most one every 3 s (20 a minute, 300
+// an hour), refusals included; the word and spam filters below still decide each line.
+router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour: 300 }), async (req, res) => {
try {
try {
const text = (req.body.message || '').trim();
const text = (req.body.message || '').trim();
// 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).
// 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).
@@ -369,7 +381,7 @@ router.post('/send', requireAuth, async (req, res) => {
@@ -369,7 +381,7 @@ router.post('/send', requireAuth, async (req, res) => {
});
});
// ── Search Chat Messages (admin or self) ─────────────────────
// ── Search Chat Messages (admin or self) ─────────────────────
-router.get('/search', requireAuth, (req, res) => {
+router.get('/search', requireAuth, searchLimit, (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const offset = parseInt(req.query.offset || '0');
const offset = parseInt(req.query.offset || '0');
@@ -686,8 +698,12 @@ router.get('/:streamId/users', (req, res) => {
@@ -686,8 +698,12 @@ router.get('/:streamId/users', (req, res) => {
// ── Chat Log Management ─────────────────────────────────────
// ── Chat Log Management ─────────────────────────────────────
+// Purges: the preview counts a time range, as a search does (the same budget); the purge soft-deletes
+// every line in it and tells each room, so a streamer or staff member cleaning up runs a few (10 a
+// minute, 100 an hour).
+
// Preview count of messages in a time range
// Preview count of messages in a time range
-router.post('/admin/purge/preview', requireAuth, async (req, res) => {
+router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) => {
try {
try {
const { streamId, from, to } = req.body;
const { streamId, from, to } = req.body;
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
@@ -719,7 +735,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {
@@ -719,7 +735,7 @@ router.post('/admin/purge/preview', requireAuth, async (req, res) => {
});
});
// Delete messages in a time range (soft delete)
// Delete messages in a time range (soft delete)
-router.delete('/admin/purge', requireAuth, async (req, res) => {
+router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, hour: 100 }), async (req, res) => {
try {
try {
const { streamId, from, to } = req.body;
const { streamId, from, to } = req.body;
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
@@ -774,7 +790,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
@@ -774,7 +790,7 @@ router.delete('/admin/purge', requireAuth, async (req, res) => {
});
});
// Get paginated chat logs with filters
// Get paginated chat logs with filters
-router.get('/admin/logs', requireAuth, async (req, res) => {
+router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
try {
try {
const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;
const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;
@@ -818,7 +834,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
@@ -818,7 +834,7 @@ router.get('/admin/logs', requireAuth, async (req, res) => {
});
});
// Export chat logs as CSV or JSON
// Export chat logs as CSV or JSON
-router.get('/admin/logs/export', requireAuth, async (req, res) => {
+router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {
try {
try {
const { streamId, username, search, from, to, messageType, format } = req.query;
const { streamId, username, search, from, to, messageType, format } = req.query;
@@ -866,7 +882,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {
@@ -866,7 +882,7 @@ router.get('/admin/logs/export', requireAuth, async (req, res) => {
// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────
// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────
// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or
// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or
// CSV. Up to 100,000 lines; `truncated` says when there were more.
// CSV. Up to 100,000 lines; `truncated` says when there were more.
-router.get('/me/export', requireAuth, (req, res) => {
+router.get('/me/export', requireAuth, exportLimit, (req, res) => {
try {
try {
const MAX = 100000, PAGE = 5000;
const MAX = 100000, PAGE = 5000;
const rows = [];
const rows = [];
@@ -22,7 +22,15 @@ const multer = require('multer');
@@ -22,7 +22,15 @@ const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
+
+// Per-actor limits (net/actor-limits.js), after requireAuth and before the upload is read: reads take
+// the defaults at app.js (sound files are left to the per-address limit). An upload is up to
+// MAX_SOUND_SIZE_KB on disk plus an ffprobe and an ffmpeg run: 10 a minute, 60 an hour (channel and
+// uploader caps still decide what is kept). Deletes and command edits tell every room of the channel.
+const uploadLimit = limits('chat.sound.upload', { minute: 10, hour: 60 });
+const editLimit = limits('chat.sound.update', { minute: 30, hour: 300 });
const config = require('../config');
const config = require('../config');
/** The channel of a user plus its moderation policy, loaded before a permission check. */
/** The channel of a user plus its moderation policy, loaded before a permission check. */
@@ -162,7 +170,7 @@ router.get('/all/:streamId', async (req, res) => {
@@ -162,7 +170,7 @@ router.get('/all/:streamId', async (req, res) => {
});
});
// ── Upload a channel sound ───────────────────────────────────
// ── Upload a channel sound ───────────────────────────────────
-router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
+router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (req, res) => {
const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };
const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };
try {
try {
if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });
if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });
@@ -304,7 +312,7 @@ router.use((err, req, res, next) => {
@@ -304,7 +312,7 @@ router.use((err, req, res, next) => {
});
});
// ── Delete a sound ───────────────────────────────────────────
// ── Delete a sound ───────────────────────────────────────────
-router.delete('/:id', requireAuth, async (req, res) => {
+router.delete('/:id', requireAuth, editLimit, async (req, res) => {
try {
try {
const sound = db.getChannelSoundById(parseInt(req.params.id));
const sound = db.getChannelSoundById(parseInt(req.params.id));
if (!sound) return res.status(404).json({ error: 'Sound not found' });
if (!sound) return res.status(404).json({ error: 'Sound not found' });
@@ -331,7 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -331,7 +339,7 @@ router.delete('/:id', requireAuth, async (req, res) => {
// A command may hold several sounds, so edits apply to the whole group.
// A command may hold several sounds, so edits apply to the whole group.
// Allowed: channel owner, channel mods, admins, or the creator of every
// Allowed: channel owner, channel mods, admins, or the creator of every
// sound in the group (mirrors the add-to-command rule).
// sound in the group (mirrors the add-to-command rule).
-router.patch('/command', requireAuth, async (req, res) => {
+router.patch('/command', requireAuth, editLimit, async (req, res) => {
try {
try {
let channelOwnerId = parseInt(req.body.channel_id) || null;
let channelOwnerId = parseInt(req.body.channel_id) || null;
if (!channelOwnerId && req.body.stream_id) {
if (!channelOwnerId && req.body.stream_id) {
@@ -422,7 +430,7 @@ router.get('/alert/mine', requireAuth, async (req, res) => {
@@ -422,7 +430,7 @@ router.get('/alert/mine', requireAuth, async (req, res) => {
});
});
// Upload/replace an alert sound. kind = 'donation' | 'goal'.
// Upload/replace an alert sound. kind = 'donation' | 'goal'.
-router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req, res) => {
+router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'), async (req, res) => {
const kind = _alertKind(req.params.kind);
const kind = _alertKind(req.params.kind);
try {
try {
if (!req.file) return res.status(400).json({ error: 'No sound file' });
if (!req.file) return res.status(400).json({ error: 'No sound file' });
@@ -461,7 +469,7 @@ router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req
@@ -461,7 +469,7 @@ router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req
});
});
// Clear an alert sound.
// Clear an alert sound.
-router.delete('/alert/:kind', requireAuth, async (req, res) => {
+router.delete('/alert/:kind', requireAuth, editLimit, async (req, res) => {
const kind = _alertKind(req.params.kind);
const kind = _alertKind(req.params.kind);
try {
try {
const channel = await channelWithPolicy(req.user.id);
const channel = await channelWithPolicy(req.user.id);
@@ -28,6 +28,7 @@ const fs = require('fs');
@@ -28,6 +28,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const { requireAuth, requireAdmin } = require('../auth/auth');
const { requireAuth, requireAdmin } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const { isOwner } = permissions;
const { isOwner } = permissions;
const ttsEngine = require('./tts-engine');
const ttsEngine = require('./tts-engine');
@@ -127,7 +128,8 @@ router.get('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
@@ -127,7 +128,8 @@ router.get('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
});
});
// ── Admin: Update TTS config ──────────────────────────────────
// ── Admin: Update TTS config ──────────────────────────────────
-router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
+// Settings are Live's (ctx.setSettings writes them there): an admin saves a form now and then.
+router.put('/admin/settings', requireAuth, requireAdmin, limits('chat.tts.settings.update', { minute: 30, hour: 300 }), async (req, res) => {
try {
try {
const allowed = [
const allowed = [
'tts_enabled', 'tts_provider',
'tts_enabled', 'tts_provider',
@@ -159,7 +161,8 @@ router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
@@ -159,7 +161,8 @@ router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
});
});
// ── Admin: Test voice synthesis ───────────────────────────────
// ── Admin: Test voice synthesis ───────────────────────────────
-router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {
+// Each test synthesizes speech with the site's provider credentials (a paid call): 10 a minute, 100 an hour.
+router.post('/admin/test', requireAuth, requireAdmin, limits('chat.tts.synthesize', { minute: 10, hour: 100 }), async (req, res) => {
try {
try {
const { voiceId, text } = req.body;
const { voiceId, text } = req.body;
const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
@@ -222,7 +225,11 @@ router.get('/queue', requireAuth, async (req, res) => {
@@ -222,7 +225,11 @@ router.get('/queue', requireAuth, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
-router.post('/queue/skip', requireAuth, async (req, res) => {
+// Skipping and clearing: a streamer or moderator by hand, a few at a time.
+const queueModerate = limits('chat.tts.queue.moderate', { minute: 60, hour: 600 });
+// The streamer's player reports each request as it ends; each plays for seconds, so well under one a second.
+const queueReport = limits('chat.tts.queue.report', { minute: 60, hour: 1800 });
+router.post('/queue/skip', requireAuth, queueModerate, async (req, res) => {
try {
try {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
@@ -234,7 +241,7 @@ router.post('/queue/skip', requireAuth, async (req, res) => {
@@ -234,7 +241,7 @@ router.post('/queue/skip', requireAuth, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
-router.post('/queue/clear', requireAuth, async (req, res) => {
+router.post('/queue/clear', requireAuth, queueModerate, async (req, res) => {
try {
try {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
@@ -244,7 +251,7 @@ router.post('/queue/clear', requireAuth, async (req, res) => {
@@ -244,7 +251,7 @@ router.post('/queue/clear', requireAuth, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
-router.post('/queue/:id/report', requireAuth, async (req, res) => {
+router.post('/queue/:id/report', requireAuth, queueReport, async (req, res) => {
try {
try {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
@@ -30,6 +30,14 @@ module.exports = {
@@ -30,6 +30,14 @@ module.exports = {
// Exact origins only: a *.openvibe.tools satellite that embeds chat must be listed here.
// Exact origins only: a *.openvibe.tools satellite that embeds chat must be listed here.
extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),
extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),
+ // Per-actor limits on the REST API (server/net/actor-limits.js, roadmap WS-R task 4): the reads one
+ // caller (a person, else an address) may make to one API per minute and per hour. Writes and
+ // expensive reads set their own, tighter numbers where they are mounted.
+ limits: {
+ minute: Math.max(1, int(process.env.CHAT_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(process.env.CHAT_LIMITS_HOUR, 3000)),
+ },
+
// Chat's own SQLite database (systemd StateDirectory openvibe-chat).
// Chat's own SQLite database (systemd StateDirectory openvibe-chat).
dbPath: process.env.CHAT_DB_PATH || './data/chat.db',
dbPath: process.env.CHAT_DB_PATH || './data/chat.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+/**
+ * OpenVibe.Chat — per-actor rate limits on the REST API (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * The per-address /api/ limit (app.js) and the chat flood controls (the socket's, the DM and room
+ * message limits, the call ring limit) stay. These count requests by who makes them:
+ *
+ * a person user:usr_… (user:<id> before their Network subject is known), whether they call with
+ * their Network token or an hbt_ API token (a bot counts as the person who made it)
+ * anyone else ip:<address>, the visitor's own address (Cloudflare → nginx → Chat, net/client-ip.js)
+ *
+ * Browsers call these routes themselves (nginx sends Live's chat prefixes here), so no service ever
+ * speaks for many visitors on them. Live's service calls (the bridge at /internal/live, the stream
+ * hooks at /internal/calls) carry every viewer's chat and are never limited here: a refusal there
+ * would drop the whole site's chat, and the WebSocket keeps its own flood control.
+ *
+ * Reads: every GET/HEAD under /api/chat, /api/dm, /api/tts, /api/sounds (and /api/streams once calls
+ * are Chat's) takes CHAT_LIMITS_MINUTE / CHAT_LIMITS_HOUR, 120 and 3000, one budget per API, counted
+ * once the caller's token is resolved (identify below). Writes and expensive reads set their own,
+ * tighter numbers where they are mounted, after requireAuth. 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 chat_rate_limited_total{limit,window}. Counters live in this process: a restart
+ * forgets them.
+ *
+ * Never limited: /health, /ready, /release.json, /metrics, /internal/* (the bridge, the call hooks and
+ * the signed Events deliveries, which carry account deletions and merges), the WebSocket, and the
+ * pages of openvibe.chat.
+ */
+'use strict';
+
+const { createActorLimiter } = require('openvibe-sdk/limits');
+const config = require('../config');
+const { requestUser } = require('../auth/auth');
+
+/** The limiter's clock (tests set it). */
+const clock = { now: () => Date.now() };
+let refused = null;
+let attachedTo = null;
+
+function personOf(u) {
+ if (!u || (!u.subject_id && u.id == null)) return null;
+ return u.subject_id ? `user:${u.subject_id}` : `user:${u.id}`;
+}
+
+/** Who a request counts against: the person its token resolved to, else its address. */
+function actor(req) {
+ return personOf(req.user) || personOf(req.ovActorUser) || `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
+}
+
+const limits = createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor,
+ now: () => clock.now(),
+ onLimited(e) {
+ // The actor is a subject id, a user id or an address, never a token.
+ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+});
+
+/**
+ * Resolve the caller's token once (the same resolution requireAuth and optionalAuth then reuse), so
+ * a read counts against the person and not their address. A bad or missing token counts by address.
+ */
+limits.identify = async function identify(req, res, next) {
+ try { req.ovActorUser = await requestUser(req); } catch { req.ovActorUser = null; }
+ next();
+};
+
+/**
+ * The defaults on every GET/HEAD of one API. `skip(req)`: reads left to the per-address limit (media
+ * files every viewer's player fetches, cached by the browser).
+ */
+limits.reads = function reads(name, { skip = null } = {}) {
+ const limit = limits(name);
+ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !(skip && skip(req)) ? limit(req, res, next) : next());
+};
+
+/** Count refusals in this app's registry (chat_rate_limited_total{limit,window}). */
+limits.attach = function attach(registry) {
+ if (registry && registry !== attachedTo) {
+ attachedTo = registry;
+ refused = registry.counter({ name: 'chat_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
+ }
+ return limits;
+};
+
+module.exports = { limits, actor, clock };
@@ -18,8 +18,13 @@
@@ -18,8 +18,13 @@
const express = require('express');
const express = require('express');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const { PrefsError } = require('./module-store');
const { PrefsError } = require('./module-store');
+// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js. A save
+// is a write to the person's Network user module: a settings page saves on change, a few a minute.
+const saveLimit = limits('chat.preferences.update', { minute: 30, hour: 300 });
+
function fail(res, err) {
function fail(res, err) {
if (err instanceof PrefsError) {
if (err instanceof PrefsError) {
return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
return res.status(err.status).json({ error: err.message, code: err.code, ...(err.errors ? { errors: err.errors } : {}) });
@@ -35,7 +40,7 @@ function routesFor(store, key) {
@@ -35,7 +40,7 @@ function routesFor(store, key) {
router.get('/', requireAuth, async (req, res) => {
router.get('/', requireAuth, async (req, res) => {
try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }
try { reply(res, await store.get(req.user.subject_id)); } catch (err) { fail(res, err); }
});
});
- router.put('/', requireAuth, async (req, res) => {
+ router.put('/', requireAuth, saveLimit, async (req, res) => {
if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });
if (req.authSource === 'api_token') return res.status(403).json({ error: 'API tokens cannot change chat settings', code: 'prefs.token_denied' });
let expectedRevision;
let expectedRevision;
if (req.headers['if-match'] !== undefined) {
if (req.headers['if-match'] !== undefined) {
@@ -24,11 +24,26 @@
@@ -24,11 +24,26 @@
*/
*/
const express = require('express');
const express = require('express');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
const ctx = require('../live-context');
const ctx = require('../live-context');
const router = express.Router();
const router = express.Router();
+// Per-actor limits (net/actor-limits.js), after requireAuth: reads take the defaults at app.js.
+// Creating a room (owners keep at most MAX_ROOMS_PER_OWNER): 10 a minute, 60 an hour.
+const createLimit = limits('chat.room.create', { minute: 10, hour: 60 });
+// Posting: above the per-room limit (6 every 10 s, and slow mode), which keeps deciding; refusals count.
+const postLimit = limits('chat.room.message.send', { minute: 60, hour: 1200 });
+// Deleting a line (the author, a moderator): one at a time, by hand.
+const deleteLimit = limits('chat.room.message.delete', { minute: 60, hour: 600 });
+// Settings, membership and roles reach every socket and a running call: a few at a time.
+const manageLimit = limits('chat.room.manage', { minute: 30, hour: 300 });
+// Marking read: once per room opened or caught up.
+const readLimit = limits('chat.room.read_mark', { minute: 60, hour: 1200 });
+// Attaching links the room to a Community space: an owner sets up a few.
+const attachLimit = limits('chat.room.attach', { minute: 10, hour: 100 });
+
function send(res, err) {
function send(res, err) {
if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Rooms]', err && err.message);
console.error('[Rooms]', err && err.message);
@@ -66,7 +81,7 @@ router.get('/', optionalAuth, (req, res) => {
@@ -66,7 +81,7 @@ router.get('/', optionalAuth, (req, res) => {
try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
});
});
-router.post('/', requireAuth, (req, res) => {
+router.post('/', requireAuth, createLimit, (req, res) => {
try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
});
});
@@ -80,7 +95,7 @@ router.get('/:slug', optionalAuth, (req, res) => {
@@ -80,7 +95,7 @@ router.get('/:slug', optionalAuth, (req, res) => {
});
});
});
});
-router.patch('/:slug', requireAuth, (req, res) => {
+router.patch('/:slug', requireAuth, manageLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
});
});
@@ -93,7 +108,7 @@ router.get('/:slug/messages', optionalAuth, (req, res) => {
@@ -93,7 +108,7 @@ router.get('/:slug/messages', optionalAuth, (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/messages', requireAuth, (req, res) => {
+router.post('/:slug/messages', requireAuth, postLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try {
try {
const message = rooms.post(room, req.user, req.body && req.body.message);
const message = rooms.post(room, req.user, req.body && req.body.message);
@@ -102,7 +117,7 @@ router.post('/:slug/messages', requireAuth, (req, res) => {
@@ -102,7 +117,7 @@ router.post('/:slug/messages', requireAuth, (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
+router.delete('/:slug/messages/:id', requireAuth, deleteLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try {
try {
const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));
const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));
@@ -111,13 +126,13 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
@@ -111,13 +126,13 @@ router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/join', requireAuth, (req, res) => {
+router.post('/:slug/join', requireAuth, manageLimit, (req, res) => {
const room = rooms.bySlug(req.params.slug);
const room = rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
});
});
-router.post('/:slug/leave', requireAuth, (req, res) => {
+router.post('/:slug/leave', requireAuth, manageLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try {
try {
rooms.leave(room, req.user);
rooms.leave(room, req.user);
@@ -127,7 +142,7 @@ router.post('/:slug/leave', requireAuth, (req, res) => {
@@ -127,7 +142,7 @@ router.post('/:slug/leave', requireAuth, (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/read', requireAuth, (req, res) => {
+router.post('/:slug/read', requireAuth, readLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }
try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }
});
});
@@ -138,7 +153,7 @@ router.get('/:slug/members', optionalAuth, (req, res) => {
@@ -138,7 +153,7 @@ router.get('/:slug/members', optionalAuth, (req, res) => {
res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });
res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });
});
});
-router.post('/:slug/members', requireAuth, async (req, res) => {
+router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try {
try {
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
@@ -158,7 +173,7 @@ router.get('/:slug/attachments', requireAuth, (req, res) => {
@@ -158,7 +173,7 @@ router.get('/:slug/attachments', requireAuth, (req, res) => {
res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });
res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });
});
});
-router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {
+router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try {
try {
const b = req.body || {};
const b = req.body || {};
@@ -167,7 +182,7 @@ router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {
@@ -167,7 +182,7 @@ router.post('/:slug/attachments', requireAuth, personOnly, (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, (req, res) => {
+router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, (req, res) => {
const room = readable(req, res); if (!room) return;
const room = readable(req, res); if (!room) return;
try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Per-actor rate limits on the REST API (server/net/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; each API has its own read budget and the window reopens
+ * on the clock. A person's API token counts against the person; signed-out callers count by address;
+ * media files are left to the per-address limit. Writes have their own, tighter numbers. Health,
+ * ready, release.json, metrics and /internal/* are never limited; refusals are logged and counted.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('actor-limits');
+let h, alice, bob, limitsMod;
+// The limiter's clock: 15 s into a minute, so the minute window has 45 s left.
+let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+
+t('boot (reads: 3 a minute per caller)', async () => {
+ h = await boot({ env: { CHAT_LIMITS_MINUTE: '3', CHAT_LIMITS_HOUR: '100' } });
+ limitsMod = require('../server/net/actor-limits');
+ limitsMod.clock.now = () => clock;
+ alice = h.addUser('alice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1ZAAA' });
+ bob = h.addUser('bob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1ZBBB' });
+ await h.ctx.sync();
+});
+
+t('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 h.http('GET', '/api/dm/unread', { token: alice.token })).status, 200);
+ const r = await h.http('GET', '/api/dm/unread', { token: alice.token });
+ 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')), r.headers.get('content-type'));
+ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.body.detail.includes('chat.dm.read'), r.body.detail);
+ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: bob.token })).status, 200, 'another person still passes');
+});
+
+t('the person\'s API token counts against the person; another API has its own budget', async () => {
+ h.live.tokens.set('hbt_alice_bot', { userId: alice.id, apiScopes: ['read', 'chat'] });
+ const bot = await h.http('GET', '/api/dm/unread', { token: 'hbt_alice_bot' });
+ assert.deepStrictEqual([bot.status, bot.body.code], [429, 'rate_limited']);
+ assert.strictEqual((await h.http('GET', '/api/chat/online?users=alice', { token: alice.token })).status, 200, '/api/chat is another budget');
+});
+
+t('signed-out callers count by address; media files are left to the per-address limit', async () => {
+ const from = (ip) => h.http('GET', '/api/chat/online?users=alice', { headers: { 'X-Forwarded-For': ip } });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
+ assert.strictEqual((await from('203.0.113.7')).status, 429);
+ assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await h.http('GET', '/api/sounds/file/none.mp3', { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 404);
+ }
+});
+
+t('the next minute opens the window again', async () => {
+ clock += 45 * 1000;
+ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: alice.token })).status, 200);
+});
+
+t('a write has its own number: 20 REST sends a minute, the 21st refused before it is stored', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ const stored = () => h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'").n;
+ for (let i = 0; i < 20; i++) {
+ const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: `limits ${i} from alice` } });
+ assert.strictEqual(r.status, 200, `send ${i + 1}: ${r.text}`);
+ }
+ const before = stored();
+ const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'limits one more' } });
+ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
+ assert.ok(r.body.detail.includes('chat.message.send'), r.body.detail);
+ assert.strictEqual(stored(), before, 'nothing stored');
+ assert.strictEqual((await h.http('POST', '/api/chat/send', { token: bob.token, body: { message: 'limits hi from bob' } })).status, 200, 'another person still sends');
+ assert.strictEqual((await h.http('POST', '/api/chat/send', { token: 'nope', body: { message: 'x' } })).status, 401, 'a bad token: 401 from auth');
+});
+
+t('health, ready, release.json, metrics and /internal/* are never limited', async () => {
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await h.http('GET', '/health')).status, 200);
+ assert.notStrictEqual((await h.http('GET', '/ready')).status, 429);
+ assert.strictEqual((await h.http('GET', '/release.json')).status, 200);
+ assert.strictEqual((await h.http('GET', '/metrics')).status, 200);
+ assert.notStrictEqual((await h.http('POST', '/internal/events', { body: {} })).status, 429);
+ assert.notStrictEqual((await h.http('POST', '/internal/live/calls', { body: { ops: [] } })).status, 429);
+ }
+});
+
+t('refusals are counted in chat_rate_limited_total', async () => {
+ const m = (await h.http('GET', '/metrics')).text;
+ const lines = m.split('\n').filter((l) => l.includes('chat_rate_limited_total')).join('\n');
+ assert.ok(/chat_rate_limited_total\{limit="chat.dm.read",window="minute"\} 2/.test(m), lines);
+ assert.ok(/chat_rate_limited_total\{limit="chat.read",window="minute"\} 1/.test(m), lines);
+ assert.ok(/chat_rate_limited_total\{limit="chat.message.send",window="minute"\} 1/.test(m), lines);
+});
+
+t('who is counted', () => {
+ const { actor } = limitsMod;
+ assert.strictEqual(actor({ user: { id: 7, subject_id: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
+ assert.strictEqual(actor({ user: { id: 7, subject_id: null }, ip: '203.0.113.1' }), 'user:7', 'before the subject is known');
+ assert.strictEqual(actor({ ovActorUser: { id: 7, subject_id: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a', 'resolved before requireAuth');
+ assert.strictEqual(actor({ ip: '203.0.113.1' }), 'ip:203.0.113.1');
+});
+
+t.run(async () => { if (h) await h.close(); });
@@ -30,7 +30,9 @@ const SECRET = { roomName: 'Hidden Den', roomWords: 'whisper-in-the-den', callWo
@@ -30,7 +30,9 @@ const SECRET = { roomName: 'Hidden Den', roomWords: 'whisper-in-the-den', callWo
const seeded = {};
const seeded = {};
t('boot and seed: a private room, a private call room, a DM', async () => {
t('boot and seed: a private room, a private call room, a DM', async () => {
- h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ // The crawl GETs every route as the same few people, far past one person's per-actor read budget
+ // (server/net/actor-limits.js); this suite reads privacy, test/actor-limits.test.js the limits.
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat', CHAT_LIMITS_MINUTE: '100000', CHAT_LIMITS_HOUR: '100000' } });
ann = h.addUser('ann', { subject: ids.newId('user') });
ann = h.addUser('ann', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });