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}.
Changes
@@@ -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@@@ -160,6 +160,38 @@ The ids and the service manifest are released in openvibe-contracts v0.20.0 (frodecides 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.++## EventsProduced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,@@@ -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"@@@ -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": {@@@ -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 = consoapp.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 = consoapp.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.'));@@@ -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).nnew file mode 100644@@@ -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 };@@@ -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)) {@@@ -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);@@@ -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.feconst 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}`); }nnew file mode 100644@@@ -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); });@@@ -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, dbPatthrow 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}`;
@@ -11,6 +11,12 @@ NODE_ENV=development
@@ -11,6 +11,12 @@ NODE_ENV=development
#BASE_URL=https://openvibe.reviews
#BASE_URL=https://openvibe.reviews
# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
#TRUST_PROXY=2
#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).
# Store (SQLite, WAL). Production: /var/lib/openvibe-reviews/reviews.db (systemd StateDirectory).
REVIEWS_DB_PATH=./data/reviews.db
REVIEWS_DB_PATH=./data/reviews.db
@@ -160,6 +160,38 @@ The ids and the service manifest are released in openvibe-contracts v0.20.0 (fro
@@ -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
decides them with the contracts grant rule. `reviews.entity.manage` and
`reviews.summary.propose` are additions to the §15.13 minimum list.
`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
## Events
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1033,9 +1033,9 @@
@@ -1033,9 +1033,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
+ "version": "0.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -20,11 +20,12 @@ const { createApi } = require('./http/api');
@@ -20,11 +20,12 @@ const { createApi } = require('./http/api');
const { createPages } = require('./http/pages');
const { createPages } = require('./http/pages');
const { createMachine } = require('./http/machine');
const { createMachine } = require('./http/machine');
const { consumerRouter } = require('./http/consumer');
const { consumerRouter } = require('./http/consumer');
+const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -104,7 +105,10 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
@@ -104,7 +105,10 @@ function createApp({ config, svc, viewers, platform, sync, keys, db, log = conso
app.use('/api/', limiter(60000, 300));
app.use('/api/', limiter(60000, 300));
app.use('/api/v1/entities/:ref/corrections', limiter(60 * 60000, 20));
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' }));
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).
// 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
@@ -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/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
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(pages.router);
app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
@@ -25,6 +25,12 @@ function load(env = process.env) {
@@ -25,6 +25,12 @@ function load(env = process.env) {
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
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',
dbPath: env.REVIEWS_DB_PATH || './data/reviews.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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 };
@@ -44,7 +44,7 @@ const { checkCapability } = require('../auth/capabilities');
@@ -44,7 +44,7 @@ const { checkCapability } = require('../auth/capabilities');
const { http } = contracts;
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();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
router.use(express.json({ limit: '256kb' }));
router.use(express.json({ limit: '256kb' }));
@@ -58,6 +58,11 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
@@ -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 });
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));
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 R = (fn, status) => run(fn, status, log);
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
@@ -74,7 +79,8 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
@@ -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 });
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 };
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) => {
router.get('/entities/:ref', guard('reviews.entity.resolve'), R((req) => {
const e = svc.entity(req.params.ref);
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 };
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 }) {
@@ -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 };
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);
const out = svc.createEntity(body(req), req.actor);
return { entity: svc.entityView(out.entity), settled_items: out.settled_items };
return { entity: svc.entityView(out.entity), settled_items: out.settled_items };
}, 201));
}, 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);
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 };
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);
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 };
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) => {
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');
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 }) {
@@ -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');
if (row.state !== 'active') throw new svc.ReviewsError(410, 'item.removed', 'Its source removed this item');
return { item: svc.itemView(row) };
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);
const b = body(req);
if (b.ignore === true) return { item: svc.itemView(svc.ignoreItem(req.params.id, b, req.actor)) };
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);
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 };
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;
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');
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 b = body(req);
const out = await svc.importItem(b.source_item_id || b.item_id, platform.sources, a);
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 };
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;
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');
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();
const pulled = await sync.pull();
@@ -141,19 +147,19 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
@@ -141,19 +147,19 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
return { pull: pulled, queue: drained };
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.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) => {
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');
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
return { corrections: svc.openCorrections() };
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);
const b = body(req);
// Accepting with a correction publishes a summary revision: a service needs that grant too.
// 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)) {
if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
@@ -84,7 +84,7 @@ function summaryFromForm(b) {
@@ -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 router = express.Router();
const form = express.urlencoded({ extended: false, limit: '200kb' });
const form = express.urlencoded({ extended: false, limit: '200kb' });
router.use(actorMiddleware(viewers, { services: false }));
router.use(actorMiddleware(viewers, { services: false }));
@@ -96,6 +96,10 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -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.');
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 send = (req, res, status, body, o = {}) => {
const cacheable = o.cache === 'public' && req.actor.kind === 'anonymous' && status === 200;
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')
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 }) {
@@ -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.');
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' });
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');
const e = locate(req, res, '/correct');
if (!e) return;
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to send a correction to the editors.');
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 }) {
@@ -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');
const e = locate(req, res, '/discuss');
if (!e) return;
if (!e) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to comment.');
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 }) {
@@ -247,7 +251,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}), { title: 'Editor desk', robots: 'noindex, nofollow' });
}), { 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;
if (!editorOnly(req, res)) return;
const pulled = await sync.pull();
const pulled = await sync.pull();
await sync.drain();
await sync.drain();
@@ -259,7 +263,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -259,7 +263,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
send(req, res, 200, views.newEntityPage({ values: { name: req.query.name || '' } }), { title: 'New entity', robots: 'noindex, nofollow' });
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;
if (!editorOnly(req, res)) return;
const b = req.body || {};
const b = req.body || {};
const aliases = ['source', 'url', 'gtin', 'sku', 'external'].map((type) => ({ type, value: String(b[`alias_${type}`] || '').trim() })).filter((a) => a.value);
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 }) {
@@ -288,7 +292,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
if (!e) return;
if (!e) return;
editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });
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;
if (!editorOnly(req, res)) return;
let e = locate(req, res, '/edit');
let e = locate(req, res, '/edit');
if (!e) return;
if (!e) return;
@@ -314,7 +318,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -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;
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/summary');
const e = locate(req, res, '/summary');
if (!e) return;
if (!e) return;
@@ -327,7 +331,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -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;
if (!editorOnly(req, res)) return;
const e = locate(req, res, `/summary/${req.params.n}/review`);
const e = locate(req, res, `/summary/${req.params.n}/review`);
if (!e) return;
if (!e) return;
@@ -336,7 +340,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -336,7 +340,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(e)}?saved=1`);
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;
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/merge');
const e = locate(req, res, '/merge');
if (!e) return;
if (!e) return;
@@ -345,7 +349,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -345,7 +349,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(out.into)}/history#merges`);
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;
if (!editorOnly(req, res)) return;
const e = svc.findEntity(req.params.slug);
const e = svc.findEntity(req.params.slug);
if (!e) return notFound(req, res);
if (!e) return notFound(req, res);
@@ -362,7 +366,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -362,7 +366,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
const candidates = item.candidates.map((c) => c.entity).filter(Boolean);
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' });
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;
if (!editorOnly(req, res)) return;
const b = req.body || {};
const b = req.body || {};
if (b.action === 'ignore') svc.ignoreItem(req.params.id, { note: b.note || null }, req.actor);
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 }) {
@@ -373,7 +377,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, '/editor#items');
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;
if (!editorOnly(req, res)) return;
const b = req.body || {};
const b = req.body || {};
svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
@@ -17,7 +17,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -17,7 +17,7 @@ const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
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();
config = config || load();
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const stores = createStores(db, { now });
@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
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).
// 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}`); }
try { svc.reconcileIndex(); } catch (err) { log.warn(`[Reviews] index reconcile: ${err.message}`); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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); });
@@ -145,9 +145,11 @@ function fakeCommunity() {
@@ -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 dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'reviews-test-'));
const config = load({
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://reviews.test',
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
@@ -164,7 +166,7 @@ async function boot({ env = {}, sources = fakeSources(), community = null, dbPat
throw new Error(`unexpected outbound fetch ${url}`);
throw new Error(`unexpected outbound fetch ${url}`);
};
};
const h = await start({
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() {} },
tokens: { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;