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}.

OP OpenVibers committed 28 Sept 2026 · 27cb1cba83 · 17 files, +397 −55

Reads of each API (/api/chat, /api/dm, /api/tts, /api/sounds, and /api/streams once calls are Chat's) take CHAT_LIMITS_MINUTE / CHAT_LIMITS_HOUR, 120 and 3000, one budget per API. Media files every viewer's player fetches (/api/tts/audio, /api/sounds/file) are left to the per-address limit. Writes and expensive reads have their own numbers, set after requireAuth and above the flood controls that keep deciding (DMs 10 a minute and 5 new conversations an hour, rooms 6 every 10 s, 6 rings a minute), so refused requests count too:
- GIF search and trending: 60 a minute, 600 an hour, since each one is a Tenor or Giphy call on the site's key.
- Chat search, logs and purge preview: 30 and 600. Log exports and a person's own export: 5 and 30. REST sends to global chat: 20 and 300. Purges: 10 and 100.
- DMs: new conversation 10 and 60; send 30 and 900; mark read 60 and 1200; members, rename and blocks 20 and 200; deleting their own messages 60 and 600.
- Rooms: create 10 and 60; post 60 and 1200; delete a line 60 and 600; settings, join, leave and members 30 and 300; mark read 60 and 1200; attachments 10 and 100.
- Sounds: an upload is checked before multer reads it, 10 and 60; delete and edit 30 and 300.
- TTS: admin settings 30 and 300; the admin test, which synthesizes speech with the site's paid provider, 10 and 100; queue skip and clear 60 and 600; the player's reports 60 and 1800.
- Calls: voice channel changes and the stream call switch 20 and 200; ringing 20 and 200; answering 30 and 300.
- Saving chat settings to the Network user modules: 30 and 300.

The per-address /api/ limit and IP bans stay. Health, ready, release.json, metrics, /internal/* (including the signed Events deliveries that carry account deletions and merges), the sockets and the openvibe.chat pages are never limited.

test/actor-limits.test.js runs on a fixed limiter clock. The fourth DM read in a minute is refused with Retry-After 45 while another person passes. The person's API token counts against them, while /api/chat keeps its own budget. Signed-out callers count by forwarded address, and sound files never answer 429. The next minute reopens. The 21st REST send in a minute is refused and not stored while another person still sends. Health, ready, release.json, metrics and /internal/* never answer 429, and refusals are counted. security-private.test.js boots with high limits, because its crawl GETs every route as the same few people and reads privacy, not limits.

Changes

ddiff --git a/.env.example b/.env.example
iindex 543fd19..a50f703 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 2944772..c703cc8 100644
---- a/README.md
++++ b/README.md
@@@ -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
```
ddiff --git a/package-lock.json b/package-lock.json
iindex b61c619..d47efc6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex f7e8501..b1fdf8f 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/app.js b/server/app.js
iindex fc19287..eaf53be 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, callServe
async 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, callServe
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).
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 }));
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 1e5f602..f647adc 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -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,
ddiff --git a/server/calls/routes.js b/server/calls/routes.js
iindex 1cf4d4e..6213da7 100644
---- a/server/calls/routes.js
++++ b/server/calls/routes.js
@@@ -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);
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex 5e4b3ac..5a345c9 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -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 authentication
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) ────────
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);
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
iindex 5ed9af2..fbf6ad8 100644
---- a/server/chat/routes.js
++++ b/server/chat/routes.js
@@@ -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 = [];
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex 86f532d..39eb03f 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -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);
ddiff --git a/server/chat/tts-routes.js b/server/chat/tts-routes.js
iindex 6dfc226..c795502 100644
---- a/server/chat/tts-routes.js
++++ b/server/chat/tts-routes.js
@@@ -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;
ddiff --git a/server/config.js b/server/config.js
iindex 5a28c92..4fdd636 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/net/actor-limits.js b/server/net/actor-limits.js
nnew file mode 100644
iindex 0000000..c6e769d
---- /dev/null
++++ b/server/net/actor-limits.js
@@@ -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 };
ddiff --git a/server/prefs/routes.js b/server/prefs/routes.js
iindex 1840f37..334a6df 100644
---- a/server/prefs/routes.js
++++ b/server/prefs/routes.js
@@@ -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) {
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 6e26110..58f739a 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -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); }
});
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..36190ef
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -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(); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex bf45d71..74ee96a 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -30,7 +30,9 @@ const SECRET = { roomName: 'Hidden Den', roomWords: 'whisper-in-the-den', callWo
const 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') });