openvibers/openvibe.reviews Commits

Per-actor rate limits on Reviews' /api/v1 and its forms (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/http/actor-limits.js makes one limiter per app and counts requests once req.actor is resolved and a route's capability guard passed, before any work (for a form, before its body is read). A person counts as user:usr_…, whether they use their own token or cookie, a first-party service names them in X-OV-Subject, or an app acts for them through on_behalf_of. A service or app acting as itself, such as OpenVibe.AI's summary proposals or an import service, counts by its principal. A signed-out caller counts by address, on writes only. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one [Limits] line and counts reviews_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 1cff36b8b9 · 12 files, +320 −47

Reads, and POST /resolve (a lookup), take REVIEWS_LIMITS_MINUTE / REVIEWS_LIMITS_HOUR, 120 and 3000, for a person or an app. Signed-out reads keep only the per-address limit, because many readers share a carrier or campus address, and a first-party service reading for itself is not counted on reads (the per-address /api/ limit bounds it). Each write has its own budget, and a form shares it with the API route that does the same thing; a form counts only a signed-in person, since a signed-out post gets the sign-in page:
- Entity create, edit, delete, aliases, links and trust: 30 a minute, 300 an hour.
- Merge and split: 10 and 100, since each moves every signal and recomputes two aggregates.
- Item resolution and POST /signals/import (which asks Sources for the item): 60 and 1200, one a second.
- Sync now, API and editor desk: 2 and 20, since the worker already pulls on a schedule.
- Summary write, review, publish and unpublish: 30 and 300, since each changes what readers, feeds, sitemaps and Search see and emits an event. AI summary proposals: 60 and 1200.
- Correction submit: 5 and 20 per person (the per-address limit allows 20 an hour). Correction resolve: 30 and 300.
- Discussion comments sent to Community in the person's name: 20 and 300.

The per-address limits (API 300 a minute, corrections, forms, sign-in) stay. Health, ready, release.json, metrics, sign-in, the pages people read and the signed Events deliveries at /internal/events are never limited per actor. Like the per-address limits, the per-actor counters are off when createApp gets rateLimits=false, which only tests pass. The test helper now takes rateLimits, limitsNow and a log.

test/actor-limits.test.js runs on a fixed limiter clock. The fourth read in a minute is refused with Retry-After 45 while another person passes, and POST /resolve shares that budget. A service naming the same person counts against them, a service reading for itself is not counted, signed-out reads and lookups from one address all pass, and the next minute reopens. After 30 API entity creates in a minute the new-entity form is refused (the budget is shared) and nothing is stored, while another editor still writes. Health, ready, release.json, metrics and deliveries never answer 429. Refusals are counted and logged without a token. npm test 15/15.

Changes

ddiff --git a/.env.example b/.env.example
iindex c9c8752..d26e480 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,6 +11,12 @@ NODE_ENV=development
#BASE_URL=https://openvibe.reviews
# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
#TRUST_PROXY=2
++# Per-actor limits (server/http/actor-limits.js): API reads one caller (a signed-in person, an app,
++# else an address) may make per minute and per hour; past them 429 rate_limited with Retry-After.
++# Signed-out reads keep the per-address limit only. Edits, summaries and corrections have their own
++# numbers (README). The per-address limits stay.
++#REVIEWS_LIMITS_MINUTE=120
++#REVIEWS_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-reviews/reviews.db (systemd StateDirectory).
REVIEWS_DB_PATH=./data/reviews.db
ddiff --git a/README.md b/README.md
iindex d4bd1e2..8c034d1 100644
---- a/README.md
++++ b/README.md
@@@ -160,6 +160,38 @@ The ids and the service manifest are released in openvibe-contracts v0.20.0 (fro
decides them with the contracts grant rule. `reviews.entity.manage` and
`reviews.summary.propose` are additions to the §15.13 minimum list.
++### Per-actor limits
++
++`/api/v1` and the forms also limit who calls them, once `req.actor` is resolved and a route's
++capability guard passed, before any work (for a form, before its body is read):
++`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. Counted: a person as
++`user:usr_…` (their own token or cookie, named by a first-party service in `X-OV-Subject`, or an app's
++`on_behalf_of`); a service or app acting as itself (OpenVibe.AI's proposals, an import service) by its
++principal; a signed-out caller by address, on writes only. Signed-out reads keep only the per-address
++limit (many readers share a carrier or campus address), and a first-party service reading for itself
++is not counted on reads. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one
++`[Limits]` log line and `reviews_rate_limited_total{limit,window}`. A form and the API route that do
++the same thing share one budget; a form counts only a signed-in person.
++
++| Routes (API and form) | Per caller, a minute / an hour |
++|---|---|
++| Reads, and `POST /resolve` (a lookup) | `REVIEWS_LIMITS_MINUTE` / `REVIEWS_LIMITS_HOUR` (120 / 3000) |
++| Entity create, edit, delete, aliases, links, trust (`/editor/entities/new`, `/e/:slug/edit`) | 30 / 300 |
++| Merge and split | 10 / 100 |
++| Item resolution (`POST /items/:id/resolution`, `/editor/items/:id`) | 60 / 1200 |
++| `POST /signals/import` | 60 / 1200 |
++| Sync now (`POST /sources/sync`, `/editor/sync`) | 2 / 20 |
++| Summary write, review, publish, unpublish | 30 / 300 |
++| Summary proposals (OpenVibe.AI) | 60 / 1200 |
++| Correction submit (`POST …/corrections`, `/e/:slug/correct`) | 5 / 20 |
++| Correction resolve (`PATCH /corrections/:id`, `/editor/corrections/:id`) | 30 / 300 |
++| Discussion comment (`/e/:slug/discuss`, sent to Community) | 20 / 300 |
++
++The per-address limits (API 300 a minute, corrections 20 an hour, forms, sign-in) stay. Never limited
++per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the pages people read,
++and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`; the other tests
++boot with `rateLimits: false`, which turns off both kinds.
++
## Events
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
ddiff --git a/package-lock.json b/package-lock.json
iindex e14be07..6c969e4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
@@@ -1033,9 +1033,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 618ab9b..d334a71 100644
---- a/package.json
++++ b/package.json
@@@ -21,7 +21,7 @@
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex 5d4ccb1..c409d77 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -20,11 +20,12 @@ const { createApi } = require('./http/api');
const { createPages } = require('./http/pages');
const { createMachine } = require('./http/machine');
const { consumerRouter } = require('./http/consumer');
++const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch }) {
++function createApp({ config, svc, viewers, platform, sync, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
@@@ -104,7 +105,10 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
app.use('/api/', limiter(60000, 300));
app.use('/api/v1/entities/:ref/corrections', limiter(60 * 60000, 20));
-- app.use('/api/v1', createApi({ svc, viewers, platform, sync, config, log }));
++ // Per-actor limits (http/actor-limits.js) on /api/v1 and the forms, counted once each router resolved
++ // req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
++ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });
++ app.use('/api/v1', createApi({ svc, viewers, platform, sync, config, log, limits }));
app.use('/api', (req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
@@@ -123,7 +127,7 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
-- const pages = createPages({ svc, platform, sync, viewers, config, log });
++ const pages = createPages({ svc, platform, sync, viewers, config, log, limits });
app.use(pages.router);
app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
ddiff --git a/server/config.js b/server/config.js
iindex 58f0ce6..958bb6c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -25,6 +25,12 @@ function load(env = process.env) {
host: env.HOST || '127.0.0.1',
baseUrl,
trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
++ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller
++ // may make per minute and per hour. Edits, summaries and corrections set tighter numbers there.
++ limits: {
++ minute: Math.max(1, int(env.REVIEWS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.REVIEWS_LIMITS_HOUR, 3000)),
++ },
dbPath: env.REVIEWS_DB_PATH || './data/reviews.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..ff08276
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Per-actor rate limits on /api/v1 and on the forms (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The per-address limits in app.js (every /api/ call, corrections, form posts, sign-in) stay. These
++ * count requests by who makes them, once req.actor is resolved (auth/viewer.js):
++ *
++ * a person user:usr_… (their own token or cookie, or named by a first-party
++ * service in X-OV-Subject, or an app's on_behalf_of)
++ * a service or app acting as its principal (svc:ai, app:app_…): an AI workflow's summary
++ * itself proposals, an import service
++ * a signed-out caller ip:<address> (writes only)
++ *
++ * Reads (GET/HEAD, and POST /resolve, which only looks up) take REVIEWS_LIMITS_MINUTE /
++ * REVIEWS_LIMITS_HOUR, 120 and 3000, for a person or an app. Signed-out reads keep only the
++ * per-address limit (many readers share a carrier or campus address), and a first-party service
++ * reading for itself is not counted on reads (its pages speak for all its visitors; the per-address
++ * /api/ limit bounds it). Every write has its own budget below, shared by the API route and the form
++ * that do the same thing; a form counts only a signed-in person (a signed-out post gets the sign-in
++ * page and does no work). Past a limit the route answers 429 problem+json `rate_limited` with
++ * Retry-After before any work (for a form, before its body is read); the refusal is logged once and
++ * counted in reviews_rate_limited_total{limit,window}. Counters live in this process: a restart
++ * forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the pages people read,
++ * and the signed Events deliveries at /internal/events.
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++
++const FIRST_PARTY = /^svc:/;
++
++function actor(req) {
++ const a = req.actor;
++ if (!a || a.kind === 'anonymous') return defaultActor(req);
++ if (a.subject) return `user:${a.subject}`;
++ if (a.kind === 'service' && a.service) return a.service;
++ return defaultActor(req);
++}
++
++/** Counted on reads: a signed-in person, or a service or app acting for a person or as a third party. */
++function countedRead(req) {
++ const a = req.actor;
++ if (!a || a.kind === 'anonymous') return false;
++ if (a.kind === 'service' && !a.subject && FIRST_PARTY.test(String(a.service))) return false;
++ return true;
++}
++
++/**
++ * The writes, each with its numbers per caller (a minute, an hour). A form and the API route that do
++ * the same thing share one budget.
++ */
++const BUDGETS = {
++ // Creating, editing and deleting an entity, its aliases, links and trust settings: an editor saves
++ // a form every few seconds at most. Each change can move signals and emit an event.
++ 'reviews.entity.manage': { minute: 30, hour: 300 },
++ // A merge or split moves every signal between two entities and recomputes both aggregates.
++ 'reviews.entity.merge': { minute: 10, hour: 100 },
++ // An editor works through the resolution queue one item at a time: one a second at most.
++ 'reviews.item.resolve': { minute: 60, hour: 1200 },
++ // Importing one item asks OpenVibe.Sources for it: a granted service or editor, one a second.
++ 'reviews.signal.import': { minute: 60, hour: 1200 },
++ // A sync pulls the next pages of review items from Sources and drains the import queue: the
++ // worker already does it on a schedule, so a person's button is a rare nudge.
++ 'reviews.sources.sync': { minute: 2, hour: 20 },
++ // Writing, reviewing, publishing and unpublishing a summary changes what readers, feeds, sitemaps
++ // and Search see, and emits an event each.
++ 'reviews.summary.publish': { minute: 30, hour: 300 },
++ // An AI workflow proposes one summary per entity it worked on: one a second at most.
++ 'reviews.summary.propose': { minute: 60, hour: 1200 },
++ // A correction goes to the editors' queue from a person: a few at a time (the per-address limit
++ // allows 20 an hour).
++ 'reviews.correction.submit': { minute: 5, hour: 20 },
++ // Answering corrections: an editor works through the queue.
++ 'reviews.correction.resolve': { minute: 30, hour: 300 },
++ // A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
++ 'reviews.discussion.comment': { minute: 20, hour: 300 },
++};
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted
++ * GET/HEAD), limits.lookup(name) (the defaults on a counted read of any method), limits.budget(name)
++ * (one of BUDGETS) and limits.form(name) (a budget counted for a signed-in person).
++ * enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
++ * per-address limits.
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
++ const refused = registry
++ ? registry.counter({ name: 'reviews_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limiter = createActorLimiter({
++ limits: { minute: config.limits.minute, hour: config.limits.hour },
++ actor: enabled ? actor : () => null,
++ now,
++ onLimited(e) {
++ // The actor is a subject id, a principal or an address, never a token.
++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && countedRead(req) ? limit(req, res, next) : next());
++ };
++ limiter.lookup = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => (countedRead(req) ? limit(req, res, next) : next());
++ };
++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
++ limiter.budget = (name) => {
++ const m = budgets.get(name);
++ if (!m) throw new Error(`limits: no budget named ${name}`);
++ return m;
++ };
++ limiter.form = (name) => {
++ const m = limiter.budget(name);
++ return (req, res, next) => (req.actor && req.actor.subject ? m(req, res, next) : next());
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, countedRead, BUDGETS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5b61951..35c71c3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -44,7 +44,7 @@ const { checkCapability } = require('../auth/capabilities');
const { http } = contracts;
--function createApi({ svc, viewers, platform, sync, config, log = console }) {
++function createApi({ svc, viewers, platform, sync, config, log = console, limits }) {
const router = express.Router();
router.use(http.middleware());
router.use(express.json({ limit: '256kb' }));
@@@ -58,6 +58,11 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
http.sendProblem(res, 403, 'request.cross_site', { detail: 'A write with the session cookie from another site is not accepted', ctx: req.ov });
});
router.use(actorMiddleware(viewers));
++ // Per-actor limits (http/actor-limits.js), once req.actor is resolved: a person's or an app's reads
++ // take the defaults (signed-out reads keep the per-address limit only); each write below names its
++ // budget after its capability guard, before the handler does any work.
++ router.use(limits.reads('reviews.read'));
++ const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
@@@ -74,7 +79,8 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
const out = svc.listEntities({ limit: Number(req.query.limit) || 50, offset: Number(req.query.offset) || 0 });
return { entities: out.entities.map(svc.entityView), total: out.total };
}));
-- router.post('/resolve', guard('reviews.entity.resolve'), R((req) => svc.resolve(body(req))));
++ // A lookup, not a write: counted like a read, in the same budget.
++ router.post('/resolve', guard('reviews.entity.resolve'), limits.lookup('reviews.read'), R((req) => svc.resolve(body(req))));
router.get('/entities/:ref', guard('reviews.entity.resolve'), R((req) => {
const e = svc.entity(req.params.ref);
if (e.state !== 'active') return { entity: svc.entityView(e), canonical: e.state === 'merged' ? svc.entityView(svc.entityById(svc.canonicalOf(e.id))) : null };
@@@ -88,25 +94,25 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
return { entity_id: e.id, aggregate: p.aggregate, aggregate_revision: p.aggregate_revision };
}));
-- router.post('/entities', guard('reviews.entity.manage'), R((req) => {
++ router.post('/entities', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => {
const out = svc.createEntity(body(req), req.actor);
return { entity: svc.entityView(out.entity), settled_items: out.settled_items };
}, 201));
-- router.patch('/entities/:ref', guard('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.updateEntity(req.params.ref, body(req), req.actor)) })));
-- router.delete('/entities/:ref', guard('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.deleteEntity(req.params.ref, body(req), req.actor)) })));
-- router.post('/entities/:ref/aliases', guard('reviews.entity.manage'), R((req) => svc.addAlias(req.params.ref, body(req), req.actor), 201));
-- router.delete('/entities/:ref/aliases/:id', guard('reviews.entity.manage'), R((req) => ({ alias: svc.removeAlias(req.params.ref, req.params.id, req.actor) })));
-- router.post('/entities/:ref/links', guard('reviews.entity.manage'), R((req) => ({ link: svc.addLink(req.params.ref, body(req), req.actor) }), 201));
-- router.delete('/entities/:ref/links/:id', guard('reviews.entity.manage'), R((req) => ({ link: svc.endLink(req.params.ref, req.params.id, body(req), req.actor) })));
-- router.post('/entities/:ref/merge', guard('reviews.entity.merge'), R((req) => {
++ router.patch('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.updateEntity(req.params.ref, body(req), req.actor)) })));
++ router.delete('/entities/:ref', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ entity: svc.entityView(svc.deleteEntity(req.params.ref, body(req), req.actor)) })));
++ router.post('/entities/:ref/aliases', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => svc.addAlias(req.params.ref, body(req), req.actor), 201));
++ router.delete('/entities/:ref/aliases/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ alias: svc.removeAlias(req.params.ref, req.params.id, req.actor) })));
++ router.post('/entities/:ref/links', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ link: svc.addLink(req.params.ref, body(req), req.actor) }), 201));
++ router.delete('/entities/:ref/links/:id', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => ({ link: svc.endLink(req.params.ref, req.params.id, body(req), req.actor) })));
++ router.post('/entities/:ref/merge', guard('reviews.entity.merge'), B('reviews.entity.merge'), R((req) => {
const out = svc.merge(req.params.ref, body(req), req.actor);
return { link: out.link, entity: svc.entityView(out.entity), into: svc.entityView(out.into), signals: out.signals };
}));
-- router.post('/entities/:ref/split', guard('reviews.entity.split'), R((req) => {
++ router.post('/entities/:ref/split', guard('reviews.entity.split'), B('reviews.entity.merge'), R((req) => {
const out = svc.split(req.params.ref, body(req), req.actor);
return { link: out.link, entity: svc.entityView(out.entity), from: out.from ? svc.entityView(out.from) : null, signals: out.signals };
}));
-- router.post('/trust', guard('reviews.entity.manage'), R((req) => svc.setTrust(body(req), req.actor)));
++ router.post('/trust', guard('reviews.entity.manage'), B('reviews.entity.manage'), R((req) => svc.setTrust(body(req), req.actor)));
router.get('/items', guard('reviews.entity.resolve'), R((req) => {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'The resolution queue is for Reviews editors');
@@@ -119,21 +125,21 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
if (row.state !== 'active') throw new svc.ReviewsError(410, 'item.removed', 'Its source removed this item');
return { item: svc.itemView(row) };
}));
-- router.post('/items/:id/resolution', guard('reviews.entity.resolve'), R((req) => {
++ router.post('/items/:id/resolution', guard('reviews.entity.resolve'), B('reviews.item.resolve'), R((req) => {
const b = body(req);
if (b.ignore === true) return { item: svc.itemView(svc.ignoreItem(req.params.id, b, req.actor)) };
const out = svc.confirmResolution(req.params.id, b, req.actor);
return { item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null, settled_items: out.settled_items };
}));
-- router.post('/signals/import', guard('reviews.signal.import'), R(async (req) => {
++ router.post('/signals/import', guard('reviews.signal.import'), B('reviews.signal.import'), R(async (req) => {
const a = req.actor;
if (a.kind === 'anonymous' || (a.kind === 'user' && !svc.access.isEditor(a))) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Importing is for Reviews editors and granted services');
const b = body(req);
const out = await svc.importItem(b.source_item_id || b.item_id, platform.sources, a);
return { outcome: out.outcome, item: svc.itemView(out.item), signal: out.signal ? svc.signalView(out.signal) : null };
}));
-- router.post('/sources/sync', guard('reviews.signal.import'), R(async (req) => {
++ router.post('/sources/sync', guard('reviews.signal.import'), B('reviews.sources.sync'), R(async (req) => {
const a = req.actor;
if (a.kind === 'anonymous' || (a.kind === 'user' && !svc.access.isEditor(a))) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Syncing is for Reviews editors and granted services');
const pulled = await sync.pull();
@@@ -141,19 +147,19 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
return { pull: pulled, queue: drained };
}));
-- router.post('/entities/:ref/summary/revisions', guard('reviews.summary.publish'), R((req) => svc.writeSummary(req.params.ref, body(req), req.actor), 201));
-- router.post('/entities/:ref/summary/proposals', guard('reviews.summary.propose'), R((req) => svc.proposeSummary(req.params.ref, body(req), req.actor), 201));
++ router.post('/entities/:ref/summary/revisions', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => svc.writeSummary(req.params.ref, body(req), req.actor), 201));
++ router.post('/entities/:ref/summary/proposals', guard('reviews.summary.propose'), B('reviews.summary.propose'), R((req) => svc.proposeSummary(req.params.ref, body(req), req.actor), 201));
router.get('/entities/:ref/summary/revisions/:n', guard('reviews.entity.resolve'), R((req) => ({ revision: svc.summaryRevision(svc.entity(req.params.ref), req.params.n, req.actor) })));
-- router.post('/entities/:ref/summary/revisions/:n/review', guard('reviews.summary.publish'), R((req) => svc.reviewSummary(req.params.ref, req.params.n, body(req), req.actor)));
-- router.post('/entities/:ref/summary/publish', guard('reviews.summary.publish'), R((req) => ({ summary: svc.publishSummary(req.params.ref, body(req), req.actor) })));
-- router.post('/entities/:ref/summary/unpublish', guard('reviews.summary.publish'), R((req) => ({ summary: svc.unpublishSummary(req.params.ref, req.actor) })));
++ router.post('/entities/:ref/summary/revisions/:n/review', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => svc.reviewSummary(req.params.ref, req.params.n, body(req), req.actor)));
++ router.post('/entities/:ref/summary/publish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => ({ summary: svc.publishSummary(req.params.ref, body(req), req.actor) })));
++ router.post('/entities/:ref/summary/unpublish', guard('reviews.summary.publish'), B('reviews.summary.publish'), R((req) => ({ summary: svc.unpublishSummary(req.params.ref, req.actor) })));
-- router.post('/entities/:ref/corrections', guard('reviews.correction.submit'), R((req) => ({ correction: svc.submitCorrection(req.params.ref, body(req), req.actor) }), 201));
++ router.post('/entities/:ref/corrections', guard('reviews.correction.submit'), B('reviews.correction.submit'), R((req) => ({ correction: svc.submitCorrection(req.params.ref, body(req), req.actor) }), 201));
router.get('/corrections', guard('reviews.entity.manage'), R((req) => {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
return { corrections: svc.openCorrections() };
}));
-- router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => {
++ router.patch('/corrections/:id', guard('reviews.entity.manage'), B('reviews.correction.resolve'), R((req) => {
const b = body(req);
// Accepting with a correction publishes a summary revision: a service needs that grant too.
if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fdf88cb..4c2fa9f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -84,7 +84,7 @@ function summaryFromForm(b) {
};
}
--function createPages({ svc, platform, sync, viewers, config, log = console }) {
++function createPages({ svc, platform, sync, viewers, config, log = console, limits }) {
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '200kb' });
router.use(actorMiddleware(viewers, { services: false }));
@@@ -96,6 +96,10 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.status(403).type('text/plain').set('Cache-Control', 'private, no-store').send('Cross-site form posts are not accepted.');
});
++ // Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route
++ // that does the same thing, counted for a signed-in person before the form is read.
++ const F = (name) => limits.form(name);
++
const send = (req, res, status, body, o = {}) => {
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
res.status(status).set('Cache-Control', cacheable ? 'public, max-age=60' : 'private, no-store').set('Vary', 'Cookie, Authorization').type('html')
@@@ -207,7 +211,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
send(req, res, 200, views.correctionPage(svc.entityView(e), { values: { target_type: req.query.target_type, target_id: req.query.target_id } }), { title: `Correction: ${e.name}`, robots: 'noindex, nofollow' });
});
-- router.post('/e/:slug/correct', form, wrap(async (req, res) => {
++ router.post('/e/:slug/correct', F('reviews.correction.submit'), form, wrap(async (req, res) => {
const e = locate(req, res, '/correct');
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
@@@ -221,7 +225,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}
}));
-- router.post('/e/:slug/discuss', form, wrap(async (req, res) => {
++ router.post('/e/:slug/discuss', F('reviews.discussion.comment'), form, wrap(async (req, res) => {
const e = locate(req, res, '/discuss');
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
@@@ -247,7 +251,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}), { title: 'Editor desk', robots: 'noindex, nofollow' });
});
-- router.post('/editor/sync', form, wrap(async (req, res) => {
++ router.post('/editor/sync', F('reviews.sources.sync'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const pulled = await sync.pull();
await sync.drain();
@@@ -259,7 +263,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
if (!editorOnly(req, res)) return;
send(req, res, 200, views.newEntityPage({ values: { name: req.query.name || '' } }), { title: 'New entity', robots: 'noindex, nofollow' });
});
-- router.post('/editor/entities/new', form, wrap(async (req, res) => {
++ router.post('/editor/entities/new', F('reviews.entity.manage'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
@@@ -288,7 +292,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
if (!e) return;
editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });
});
-- router.post('/e/:slug/edit', form, wrap(async (req, res) => {
++ router.post('/e/:slug/edit', F('reviews.entity.manage'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
let e = locate(req, res, '/edit');
if (!e) return;
@@@ -314,7 +318,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}
}));
-- router.post('/e/:slug/summary', form, wrap(async (req, res) => {
++ router.post('/e/:slug/summary', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/summary');
if (!e) return;
@@@ -327,7 +331,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}
}));
-- router.post('/e/:slug/summary/:n/review', form, wrap(async (req, res) => {
++ router.post('/e/:slug/summary/:n/review', F('reviews.summary.publish'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const e = locate(req, res, `/summary/${req.params.n}/review`);
if (!e) return;
@@@ -336,7 +340,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(e)}?saved=1`);
}));
-- router.post('/e/:slug/merge', form, wrap(async (req, res) => {
++ router.post('/e/:slug/merge', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/merge');
if (!e) return;
@@@ -345,7 +349,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(out.into)}/history#merges`);
}));
-- router.post('/e/:slug/split', form, wrap(async (req, res) => {
++ router.post('/e/:slug/split', F('reviews.entity.merge'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const e = svc.findEntity(req.params.slug);
if (!e) return notFound(req, res);
@@@ -362,7 +366,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
const candidates = item.candidates.map((c) => c.entity).filter(Boolean);
send(req, res, 200, views.itemPage({ item, candidates }), { title: `Item ${item.id}`, robots: 'noindex, nofollow' });
});
-- router.post('/editor/items/:id', form, wrap(async (req, res) => {
++ router.post('/editor/items/:id', F('reviews.item.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
if (b.action === 'ignore') svc.ignoreItem(req.params.id, { note: b.note || null }, req.actor);
@@@ -373,7 +377,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, '/editor#items');
}));
-- router.post('/editor/corrections/:id', form, wrap(async (req, res) => {
++ router.post('/editor/corrections/:id', F('reviews.correction.resolve'), form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
const b = req.body || {};
svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
ddiff --git a/server/index.js b/server/index.js
iindex 2a8e905..bc7967c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,7 +17,7 @@ const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
--async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true } = {}) {
++async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
config = config || load();
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
@@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
-- const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl });
++ const app = createApp({ config, svc, viewers, platform, sync, keys, db, log, rateLimits, fetchImpl, limitsNow });
// Search holds whatever the current rules say (idempotent: unchanged documents are not re-sent).
try { svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..3209e1b
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller
++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
++ * another caller still passes; the window reopens on the clock. A person is counted as themselves
++ * whether they call directly or a service names them; signed-out reads and a first-party service
++ * reading for itself are left to the per-address limit. A write has its own budget, shared by the API
++ * and the form that do the same thing. Health, ready, release.json, metrics and the Events deliveries
++ * are never limited; refusals are logged (no token) and counted.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const lines = [];
++ const log = { ...H.quiet, warn: (m) => lines.push(String(m)) };
++ const h = await H.boot({ rateLimits: true, limitsNow: () => clock, log, env: { REVIEWS_LIMITS_MINUTE: '3', REVIEWS_LIMITS_HOUR: '100' } });
++ const reader = H.readerToken();
++ const other = H.userToken({ subject: H.subject(), username: 'other' });
++ const chief = H.userToken({ subject: H.subject(), username: 'chief', role: 'admin' }); // staff: an editor too
++ const svc = () => H.serviceToken({ client: 'deals', cap: ['reviews.entity.resolve'] });
++ const read = (o) => H.req(h, 'GET', '/api/v1/entities', o);
++ try {
++ // a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes
++ for (let i = 0; i < 3; i++) assert.strictEqual((await read({ token: reader })).status, 200, `read ${i + 1}`);
++ let r = await read({ token: reader });
++ 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.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(r.json.detail.includes('reviews.read'), r.json.detail);
++ assert.strictEqual((await read({ token: other })).status, 200, 'another person still passes');
++ // POST /resolve only looks up: it shares the read budget
++ r = await H.req(h, 'POST', '/api/v1/resolve', { token: reader, body: { name: 'Widget' } });
++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
++
++ // a first-party service naming the person counts against them; reading for itself is not counted
++ r = await read({ token: svc(), headers: { 'X-OV-Subject': H.READER } });
++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
++ for (let i = 0; i < 6; i++) assert.strictEqual((await read({ token: svc() })).status, 200, `service read ${i + 1}`);
++
++ // signed-out reads keep only the per-address limit (many readers share an address)
++ for (let i = 0; i < 8; i++) {
++ assert.strictEqual((await read({ headers: { 'X-Forwarded-For': '203.0.113.7' } })).status, 200, `signed-out read ${i + 1}`);
++ assert.strictEqual((await H.req(h, 'POST', '/api/v1/resolve', { body: { name: 'Widget' }, headers: { 'X-Forwarded-For': '203.0.113.7' } })).status, 200);
++ }
++
++ // the next minute opens the window again
++ clock += 45 * 1000;
++ assert.strictEqual((await read({ token: reader })).status, 200);
++
++ // entity edits: 30 a minute per editor, shared by the API and the form; nothing is stored past it
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const count = () => h.db.prepare('SELECT COUNT(*) AS n FROM review_entities').get().n;
++ for (let i = 0; i < 30; i++) {
++ r = await H.req(h, 'POST', '/api/v1/entities', { token: H.editorToken(), body: { name: `Limits entity ${i}`, kind: 'product' } });
++ assert.strictEqual(r.status, 201, `entity ${i + 1}: ${r.text}`);
++ }
++ const before = count();
++ r = await H.req(h, 'POST', '/editor/entities/new', { cookie: H.cookieFor(H.editorToken()), form: { name: 'One too many', kind: 'product' } });
++ assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.strictEqual(count(), before, 'nothing stored');
++ r = await H.req(h, 'POST', '/api/v1/entities', { token: chief, body: { name: 'Another editor', kind: 'product' } });
++ assert.strictEqual(r.status, 201, `another editor still writes: ${r.text}`);
++
++ // health, ready, release.json, metrics and the Events deliveries are never limited
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await H.req(h, 'GET', '/api/health')).status, 200);
++ assert.notStrictEqual((await H.req(h, 'GET', '/api/ready')).status, 429);
++ assert.strictEqual((await H.req(h, 'GET', '/release.json')).status, 200);
++ assert.strictEqual((await H.req(h, 'GET', '/metrics')).status, 200);
++ const item = H.steamItem();
++ h.sources.put(item);
++ assert.notStrictEqual((await H.deliver(h, H.sourcesEvent('sources.item.created', item))).status, 429);
++ }
++
++ // refusals are logged (the caller, never a token) and counted in reviews_rate_limited_total
++ assert.ok(lines.includes(`[Limits] reviews.read: user:${H.READER} refused, over 3 per minute`), lines.join('\n'));
++ assert.ok(lines.includes(`[Limits] reviews.entity.manage: user:${H.EDITOR} refused, over 30 per minute`), lines.join('\n'));
++ assert.ok(!lines.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
++ const m = (await H.req(h, 'GET', '/metrics')).text;
++ const found = m.split('\n').filter((l) => l.includes('reviews_rate_limited_total')).join('\n');
++ assert.ok(/reviews_rate_limited_total\{limit="reviews.read",window="minute"\} 3/.test(m), found);
++ assert.ok(/reviews_rate_limited_total\{limit="reviews.entity.manage",window="minute"\} 1/.test(m), found);
++ console.log(' ok actor-limits: reads, services, signed-out reads, shared write budget, never-limited routes, logs and metrics');
++ } finally {
++ await h.stop();
++ }
++ process.exit(0);
++})().catch((err) => { console.error(` FAIL actor-limits\n${err && err.stack}`); process.exit(1); });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 3a28612..314a2fa 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -145,9 +145,11 @@ function fakeCommunity() {
}
/**
-- * A running Reviews. opts.env overrides env vars; sources/community are the stubs above.
++ * A running Reviews. opts.env overrides env vars; sources/community are the stubs above. rateLimits
++ * (default false) turns on the per-address and per-actor limits; limitsNow is the per-actor limiter's
++ * clock; log is Reviews' logger (default quiet).
*/
--async function boot({ env = {}, sources = fakeSources(), community = null, dbPath, now, workers = false } = {}) {
++async function boot({ env = {}, sources = fakeSources(), community = null, dbPath, now, workers = false, rateLimits = false, limitsNow = null, log = quiet } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'reviews-test-'));
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',
@@@ -164,7 +166,7 @@ async function boot({ env = {}, sources = fakeSources(), community = null, dbPat
throw new Error(`unexpected outbound fetch ${url}`);
};
const h = await start({
-- config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now, fetchImpl,
++ config, publicKey, log, listen: true, workers, rateLimits, limitsNow, now, fetchImpl,
tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
const base = `http://127.0.0.1:${h.server.address().port}`;