openvibers/openvibe.news Commits

Per-actor rate limits on News' /api/v1, the editor desk and the comment form (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.viewer is resolved and a route's capability guard passed, before any work (for a write, 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 counts by its principal; anyone else by address. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts news_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 8ab47a6a78 · 12 files, +331 −54

Signed-in API reads and an editor's desk pages take NEWS_LIMITS_MINUTE / NEWS_LIMITS_HOUR, 120 and 3000. Signed-out reads keep only the per-address limit, because many readers share a carrier or campus address, and a first-party service reading for itself is not counted on reads (the per-address /api/v1 limit bounds it). A desk form shares its budget with the API route that does the same thing:
- Topic create: 10 a minute, 60 an hour.
- Story create 30 and 300; revisions 30 and 600, since each stores the whole text and checks its citations.
- AI drafts: 5 and 60, since each asks OpenVibe.AI about every source.
- Publish, unpublish, retract and reviews: 30 and 300, since each changes what readers, feeds, sitemaps and Search see and emits an event.
- Attaching and detaching a source, which asks Sources: 30 and 300. Perspectives, timeline entries and flags: 60 and 600.
- Cluster merge, split and reverse: 30 and 300.
- The desk's pull-now button: 2 and 20, since the worker already pulls on a schedule.
- A comment sent to Community in the person's name: 20 and 300, signed-in only (a signed-out post is sent to sign in).
- Diffs, API and desk: 30 and 600.

The per-address limits stay. Health, ready, release.json, metrics, sign-in, the public pages and feeds, and the signed Events deliveries at /internal/events are never limited per actor. The test helper takes a log and limitsNow.

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 4891205..0ea6746 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,6 +11,12 @@ NODE_ENV=development
BASE_URL=http://localhost:4820
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 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. Writes, AI drafts and pulls have their own numbers
++# (README). The per-address limits stay.
++#NEWS_LIMITS_MINUTE=120
++#NEWS_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-news/news.db)
NEWS_DB_PATH=./data/news.db
ddiff --git a/README.md b/README.md
iindex 93c8154..61910e0 100644
---- a/README.md
++++ b/README.md
@@@ -315,7 +315,7 @@ call the service live.
- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or an
editor citing one; empty feeds use a real time; JSON-LD omits unknown fields.
- **Abuse:** rate limits on `/auth`, `/edit`, `/clusters`, `/api/v1` and the comment form, in
-- Express and in the nginx reference.
++ Express and in the nginx reference, and per-actor limits (below).
- **Comments:** Community owns the text and its moderation; News renders it through the same
auto-escaping and stores only the thread id. The comment form needs a signed-in member and the
form token, is rate-limited, and is refused for a story that is not open for discussion.
@@@ -323,6 +323,36 @@ call the service live.
refreshed after the first successful read; the Network does not hold News' two Community grants
yet, so story pages say comments are unavailable until they are added.
++### Per-actor limits
++
++`/api/v1`, the editor desk and the comment form also limit who calls them, once `req.viewer` is
++resolved and a route's capability guard passed, before any work (for a write, 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, a first-party service naming them in `X-OV-Subject`, or an
++app's `on_behalf_of`); a service or app acting as itself by its principal; anyone else by address.
++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 log line and `news_rate_limited_total{limit,window}`.
++A desk form and the API route that do the same thing share one budget.
++
++| Routes (API and desk form) | Per caller, a minute / an hour |
++|---|---|
++| Signed-in API reads, desk pages | `NEWS_LIMITS_MINUTE` / `NEWS_LIMITS_HOUR` (120 / 3000) |
++| Topic create | 10 / 60 |
++| Story create (`POST /stories`, `/clusters/:id/stories`) | 30 / 300 |
++| Revisions | 30 / 600 |
++| AI drafts (`POST /stories/:id/ai-drafts`, the desk's AI button) | 5 / 60 |
++| Publish, unpublish, retract, reviews | 30 / 300 |
++| Attach and detach a source | 30 / 300 |
++| Perspectives, timeline, flags | 60 / 600 |
++| Cluster merge, split, reverse | 30 / 300 |
++| Pull now (`POST /edit/pull`) | 2 / 20 |
++| Comment (`POST /stories/:slug/comments`, signed-in) | 20 / 300 |
++| Diffs (API and desk) | 30 / 600 |
++
++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the public
++pages and feeds, and the signed Events deliveries at `/internal/events`. `test/actor-limits.test.js`.
++
## Development
```bash
ddiff --git a/package-lock.json b/package-lock.json
iindex a324f39..5eb62e8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.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"
},
"devDependencies": {
@@@ -1049,9 +1049,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
++ "version": "0.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 4273e3d..beb374d 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"helmet": "^8.1.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"
},
"devDependencies": {
ddiff --git a/server/app.js b/server/app.js
iindex 8cf2afa..a0bc787 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -36,6 +36,7 @@ const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createWebhook } = require('./http/webhook');
++const { createActorLimits } = require('./http/actor-limits');
const { createNewsReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
@@@ -43,7 +44,8 @@ const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log */
++/** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
++ * limitsNow (the per-actor limiter's clock, tests; default the wall clock) */
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
@@@ -78,6 +80,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'news', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
++ // Per-actor limits (http/actor-limits.js) on /api/v1, the desk and the comment form, counted once
++ // each router resolved req.viewer; the per-address limits below stay.
++ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
app.use(contracts.http.middleware());
app.use(helmet({
ddiff --git a/server/config.js b/server/config.js
iindex 7674035..1177a21 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -28,6 +28,12 @@ function load(env = process.env) {
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
baseUrl,
trustProxy: env.TRUST_PROXY != null ? 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. Writes, AI drafts and pulls set tighter numbers there.
++ limits: {
++ minute: Math.max(1, int(env.NEWS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.NEWS_LIMITS_HOUR, 3000)),
++ },
dbPath: env.NEWS_DB_PATH || './data/news.db',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..2a47145
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,123 @@
++'use strict';
++/**
++ * Per-actor rate limits on /api/v1, the editor desk and the comment form (roadmap WS-R task 4;
++ * openvibe-sdk/limits).
++ *
++ * The per-address limits in app.js (/auth, /api/v1, /edit, /clusters, the comment form) stay. These
++ * count requests by who makes them, once req.viewer is resolved (auth/viewer.js) and a route's
++ * capability guard passed:
++ *
++ * a person user:usr_… (their own token or cookie, a first-party service naming
++ * them in X-OV-Subject, or an app's on_behalf_of)
++ * a service or app acting as its principal (svc:ai, app:app_…)
++ * itself
++ * anyone else ip:<address>
++ *
++ * Reads: a signed-in person's or an app's GET/HEAD under /api/v1 takes NEWS_LIMITS_MINUTE /
++ * NEWS_LIMITS_HOUR (120 and 3000), and so does an editor's desk page. 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 (the per-address /api/v1 limit bounds it). Every write
++ * has its own budget below, shared by the API route and the desk form that do the same thing. Past a
++ * limit the route answers 429 problem+json `rate_limited` with Retry-After before any work; the refusal
++ * is logged once and counted in news_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 public pages and feeds
++ * (nginx and the per-address limits bound them), and the signed Events deliveries at /internal/events.
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++
++const FIRST_PARTY = /^svc:/;
++
++function actor(req) {
++ const v = req.viewer;
++ if (!v || v.kind === 'anonymous') return defaultActor(req);
++ if (v.subject) return `user:${v.subject}`;
++ if (v.kind === 'service' && v.service) return v.service;
++ if (v.kind === 'user' && v.user && v.user.id != null) return `user:${v.user.id}`;
++ 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 v = req.viewer;
++ if (!v || v.kind === 'anonymous') return false;
++ if (v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service))) return false;
++ return true;
++}
++
++/**
++ * The writes and expensive reads, each with its numbers per caller (a minute, an hour). A desk form
++ * and the API route that do the same thing share one budget.
++ */
++const BUDGETS = {
++ // A topic is a new section with its own page and feeds: an editor adds a few.
++ 'news.topic.manage': { minute: 10, hour: 60 },
++ // A new story stores its first text and emits news.story.created; a revision stores the whole
++ // text again and checks every citation. An editor saves every few seconds at most: 30 a minute,
++ // 300 new stories or 600 revisions an hour.
++ 'news.story.create': { minute: 30, hour: 300 },
++ 'news.story.revise': { minute: 30, hour: 600 },
++ // An AI draft asks OpenVibe.AI to summarize or compare every source (up to NEWS_AI_WAIT_MS each):
++ // a few a minute.
++ 'news.story.ai_draft': { minute: 5, hour: 60 },
++ // Publishing, unpublishing, retracting and reviews change what readers, feeds, sitemaps and
++ // Search see, and emit an event each.
++ 'news.story.publish': { minute: 30, hour: 300 },
++ // Attaching a source looks the item up in OpenVibe.Sources.
++ 'news.source.attach': { minute: 30, hour: 300 },
++ // Perspectives, timeline entries and editorial flags: small edits, a few in a row.
++ 'news.story.annotate': { minute: 60, hour: 600 },
++ // Merge, split and reverse rewrite a cluster's membership and its audit.
++ 'news.cluster.manage': { minute: 30, hour: 300 },
++ // A pull reads up to NEWS_PULL_MAX_PAGES pages of Sources items and clusters them: the worker
++ // already pulls on a schedule, so a person's button is a rare nudge.
++ 'news.ingest.pull': { minute: 2, hour: 20 },
++ // A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
++ 'news.discussion.comment': { minute: 20, hour: 300 },
++ // A word diff of two long revisions costs hundreds of ms of CPU: a person's pace.
++ 'news.story.diff': { minute: 30, hour: 600 },
++};
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted
++ * GET/HEAD) and limits.budget(name) (one of BUDGETS).
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'news_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,
++ now,
++ onLimited(e) {
++ // The actor is a subject id, a principal or an address, never a token.
++ log.warn(`[News] limit ${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());
++ };
++ 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;
++ };
++ /** A budget on an expensive read, counted like limits.reads (signed-out reads keep the per-address limit). */
++ limiter.readBudget = (name) => {
++ const m = limiter.budget(name);
++ return (req, res, next) => (countedRead(req) ? m(req, res, next) : next());
++ };
++ /** A budget counted only for a signed-in person (a signed-out form post is sent to sign in, no work). */
++ limiter.signedIn = (name) => {
++ const m = limiter.budget(name);
++ return (req, res, next) => (req.viewer && req.viewer.kind === 'user' ? m(req, res, next) : next());
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, countedRead, BUDGETS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 435ef89..a71d490 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -72,11 +72,16 @@ function itemDto(it) {
}
function createApi(ctx) {
-- const { config, stories, clusters, publication, reading, topics, ingest, viewers } = ctx;
++ const { config, stories, clusters, publication, reading, topics, ingest, viewers, limits } = ctx;
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
++ // Per-actor limits (http/actor-limits.js), once req.viewer is resolved: a signed-in 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 its body is read.
++ router.use(limits.reads('news.read'));
++ const B = (name) => limits.budget(name);
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
/** Reads for editors: a service passed its capability guard; a browser must be an editor. */
@@@ -117,7 +122,7 @@ function createApi(ctx) {
router.get('/topics', run(() => ({ topics: topics.list().map((t) => ({ id: t.id, slug: t.slug, name: t.name, description: t.description, url: publication.abs(publication.topicPath(t)) })) })));
-- router.post('/topics', guard('news.topic.manage'), jsonBody, run((req) => {
++ router.post('/topics', guard('news.topic.manage'), B('news.topic.manage'), jsonBody, run((req) => {
access.requireEditor(config, req.viewer);
const b = req.body || {};
const r = topics.create({ name: b.name, slug: b.slug, description: b.description });
@@@ -149,7 +154,7 @@ function createApi(ctx) {
return { published: reading.storyJson(await reading.storyModel(story)) };
}));
-- router.post('/stories', guard('news.story.create'), jsonBody, run((req) => {
++ router.post('/stories', guard('news.story.create'), B('news.story.create'), jsonBody, run((req) => {
const b = req.body || {};
const out = stories.create(req.viewer, { cluster: b.cluster || b.cluster_id, headline: b.headline, topic: b.topic, slug: b.slug, body: b.body, paragraphs: b.paragraphs }, tp(req));
return { story: storyDto(out.story), revision: out.revision ? out.revision.number : null };
@@@ -167,52 +172,53 @@ function createApi(ctx) {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
return { revision: rev, citations: ctx.store.citations.forRevision(story.id, rev.number), problems: stories.problems(story, rev) };
}));
-- router.get('/stories/:id/diff', guard('news.story.read'), run((req) => {
++ router.get('/stories/:id/diff', guard('news.story.read'), limits.readBudget('news.story.diff'), run((req) => {
editorRead(req);
return { diff: stories.diff(mustStory(req), parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
}));
-- const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => fn(req, mustStory(req), req.body || {}), status));
++ // guards: the capability guard, then the route's per-actor budget (both before the body is read).
++ const write = (method, path, guards, fn, status = 200) => router[method](path, guards, jsonBody, run(async (req) => fn(req, mustStory(req), req.body || {}), status));
-- write('post', '/stories/:id/revisions', guard('news.story.revise'), (req, story, b) => {
++ write('post', '/stories/:id/revisions', [guard('news.story.revise'), B('news.story.revise')], (req, story, b) => {
const out = stories.revise(req.viewer, story, {
headline: b.headline, body: b.body, paragraphs: b.paragraphs, expectedRevision: b.expected_revision ?? b.expectedRevision,
topic: b.topic, noindex: b.noindex, message: b.message, authorship: b.authorship, gaps: b.gaps,
}, tp(req));
return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
}, 201);
-- write('post', '/stories/:id/ai-drafts', guard('news.story.revise'), async (req, story, b) => {
++ write('post', '/stories/:id/ai-drafts', [guard('news.story.revise'), B('news.story.ai_draft')], async (req, story, b) => {
const out = await stories.aiDraft(req.viewer, story, String(b.workflow || 'news.summarize_story'), tp(req));
return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
}, 201);
-- write('post', '/stories/:id/publish', guard('news.story.publish'), (req, story, b) => {
++ write('post', '/stories/:id/publish', [guard('news.story.publish'), B('news.story.publish')], (req, story, b) => {
const out = stories.publish(req.viewer, story, { revision: b.revision, correction: b.correction }, tp(req));
return { changed: out.changed, resolved_flags: out.resolved || [], story: storyDto(out.story) };
});
-- write('post', '/stories/:id/unpublish', guard('news.story.publish'), (req, story) => {
++ write('post', '/stories/:id/unpublish', [guard('news.story.publish'), B('news.story.publish')], (req, story) => {
const out = stories.unpublish(req.viewer, story, tp(req));
return { changed: out.changed, story: storyDto(out.story) };
});
-- write('post', '/stories/:id/retract', guard('news.story.retract'), (req, story, b) => {
++ write('post', '/stories/:id/retract', [guard('news.story.retract'), B('news.story.publish')], (req, story, b) => {
const out = stories.retract(req.viewer, story, { note: b.note }, tp(req));
return { changed: out.changed, story: storyDto(out.story) };
});
-- router.post('/stories/:id/reviews', jsonBody, run((req) => {
++ router.post('/stories/:id/reviews', B('news.story.publish'), jsonBody, run((req) => {
const story = mustStory(req);
const b = req.body || {};
return { review: stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
}, 201));
-- write('post', '/stories/:id/flags', guard('news.story.revise'), (req, story, b) => ({ flag: stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }) }), 201);
-- write('post', '/stories/:id/sources', guard('news.source.attach'), (req, story, b) => {
++ write('post', '/stories/:id/flags', [guard('news.story.revise'), B('news.story.annotate')], (req, story, b) => ({ flag: stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }) }), 201);
++ write('post', '/stories/:id/sources', [guard('news.source.attach'), B('news.source.attach')], (req, story, b) => {
const out = stories.attach(req.viewer, story, { item: b.item || b.item_id });
return { n: out.n, created: out.created, item: itemDto(out.item) };
}, 201);
-- write('delete', '/stories/:id/sources/:item', guard('news.source.attach'), (req, story) => stories.detach(req.viewer, story, req.params.item));
-- write('post', '/stories/:id/perspectives', guard('news.perspective.update'), (req, story, b) => ({ perspective: stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }) }), 201);
-- write('delete', '/stories/:id/perspectives/:pid', guard('news.perspective.update'), (req, story) => stories.removePerspective(req.viewer, story, req.params.pid));
-- write('put', '/stories/:id/sources/:item/perspective', guard('news.perspective.update'), (req, story, b) => stories.assignPerspective(req.viewer, story, req.params.item, b.perspective || null));
-- write('post', '/stories/:id/timeline', guard('news.timeline.update'), (req, story, b) => ({ entry: stories.addTimeline(req.viewer, story, { occurredOn: b.occurred_on ?? b.occurredOn, text: b.text, source: b.source }) }), 201);
-- write('delete', '/stories/:id/timeline/:eid', guard('news.timeline.update'), (req, story) => stories.removeTimeline(req.viewer, story, req.params.eid));
++ write('delete', '/stories/:id/sources/:item', [guard('news.source.attach'), B('news.source.attach')], (req, story) => stories.detach(req.viewer, story, req.params.item));
++ write('post', '/stories/:id/perspectives', [guard('news.perspective.update'), B('news.story.annotate')], (req, story, b) => ({ perspective: stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }) }), 201);
++ write('delete', '/stories/:id/perspectives/:pid', [guard('news.perspective.update'), B('news.story.annotate')], (req, story) => stories.removePerspective(req.viewer, story, req.params.pid));
++ write('put', '/stories/:id/sources/:item/perspective', [guard('news.perspective.update'), B('news.story.annotate')], (req, story, b) => stories.assignPerspective(req.viewer, story, req.params.item, b.perspective || null));
++ write('post', '/stories/:id/timeline', [guard('news.timeline.update'), B('news.story.annotate')], (req, story, b) => ({ entry: stories.addTimeline(req.viewer, story, { occurredOn: b.occurred_on ?? b.occurredOn, text: b.text, source: b.source }) }), 201);
++ write('delete', '/stories/:id/timeline/:eid', [guard('news.timeline.update'), B('news.story.annotate')], (req, story) => stories.removeTimeline(req.viewer, story, req.params.eid));
// ── Clusters, items, ingestion ──────────────────────────
@@@ -227,18 +233,18 @@ function createApi(ctx) {
if (!c) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
return { cluster: clusterDto(c, { full: true }) };
}));
-- router.post('/clusters/audit/:auditId/reverse', guard('news.cluster.manage'), jsonBody, run((req) => {
++ router.post('/clusters/audit/:auditId/reverse', guard('news.cluster.manage'), B('news.cluster.manage'), jsonBody, run((req) => {
access.requireEditor(config, req.viewer);
const out = clusters.reverse(req.viewer, req.params.auditId, { reason: (req.body || {}).reason });
return { audit: out.audit, clusters: out.clusters.map((c) => clusterDto(c)) };
}));
-- router.post('/clusters/:id/merge', guard('news.cluster.manage'), jsonBody, run((req) => {
++ router.post('/clusters/:id/merge', guard('news.cluster.manage'), B('news.cluster.manage'), jsonBody, run((req) => {
access.requireEditor(config, req.viewer);
const b = req.body || {};
const out = clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
return { cluster: clusterDto(out.cluster, { full: true }), audit: out.audit };
}));
-- router.post('/clusters/:id/split', guard('news.cluster.manage'), jsonBody, run((req) => {
++ router.post('/clusters/:id/split', guard('news.cluster.manage'), B('news.cluster.manage'), jsonBody, run((req) => {
access.requireEditor(config, req.viewer);
const b = req.body || {};
const out = clusters.split(req.viewer, req.params.id, b.items, { reason: b.reason });
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 4eec4cf..00f4cc0 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -27,7 +27,7 @@ const access = require('../domain/access');
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
function createEditorRoutes(ctx) {
-- const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai } = ctx;
++ const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits } = ctx;
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
@@@ -41,10 +41,15 @@ function createEditorRoutes(ctx) {
if (!access.isEditor(config, req.viewer)) return publicRoutes.messagePage(req, res, 403, 'Editors only', 'The editor desk is for OpenVibe.News editors.', { href: '/', label: 'OpenVibe.News' });
next();
});
++ // Per-actor limits (http/actor-limits.js): an editor's desk pages take the read defaults; each form
++ // below names its budget (shared with the API route that does the same thing), checked once the
++ // form token is, before any work.
++ router.use(['/edit', '/clusters'], limits.reads('news.desk.read'));
router.post(['/edit/*', '/clusters/*'], form, (req, res, next) => {
if (!checkCsrf(config, req.viewer, req.body && one(req.body._csrf))) return publicRoutes.messagePage(req, res, 403, 'Form expired', 'Reload the page and submit it again.');
next();
});
++ const B = (name) => limits.budget(name);
const back = (req) => {
try { const u = new URL(req.get('referer') || '', config.baseUrl); return u.origin === new URL(config.baseUrl).origin && /^\/(edit|clusters)/.test(u.pathname) ? u.pathname : '/edit'; } catch { return '/edit'; }
@@@ -82,13 +87,13 @@ function createEditorRoutes(ctx) {
}));
}));
-- router.post('/edit/pull', wrap(async (req, res) => {
++ router.post('/edit/pull', B('news.ingest.pull'), wrap(async (req, res) => {
const r = await ingest.pull();
done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
}));
router.get('/edit/topics', wrap(async (req, res) => page(req, res, 'Topics', editor.topicsPage({ topics: topics.list(), csrf: csrf(req), message: flashOf(req) }))));
-- router.post('/edit/topics', wrap(async (req, res) => {
++ router.post('/edit/topics', B('news.topic.manage'), wrap(async (req, res) => {
const b = input(req.body);
const r = topics.create({ name: b.name, description: b.description });
done(res, '/edit/topics', r.created ? `Added ${r.topic.name}` : `${r.topic.name} already exists`);
@@@ -111,21 +116,21 @@ function createEditorRoutes(ctx) {
topics: topics.list(), csrf: csrf(req), message: flashOf(req),
}));
}));
-- router.post('/clusters/:id/merge', wrap(async (req, res) => {
++ router.post('/clusters/:id/merge', B('news.cluster.manage'), wrap(async (req, res) => {
const b = input(req.body);
clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
done(res, `/clusters/${req.params.id}`, `Merged ${b.other} into this cluster`);
}));
-- router.post('/clusters/:id/split', wrap(async (req, res) => {
++ router.post('/clusters/:id/split', B('news.cluster.manage'), wrap(async (req, res) => {
const items = [].concat((req.body && req.body.items) || []);
const out = clusters.split(req.viewer, req.params.id, items, { reason: one(req.body.reason) });
done(res, `/clusters/${out.created.id}`, `Split ${items.length} items off ${req.params.id}`);
}));
-- router.post('/clusters/audit/:aid/reverse', wrap(async (req, res) => {
++ router.post('/clusters/audit/:aid/reverse', B('news.cluster.manage'), wrap(async (req, res) => {
const out = clusters.reverse(req.viewer, req.params.aid, { reason: one(req.body.reason) });
done(res, `/clusters/${out.clusters[0].id}`, `Reversed (${out.audit.action.replace('_', ' ')})`);
}));
-- router.post('/clusters/:id/stories', wrap(async (req, res) => {
++ router.post('/clusters/:id/stories', B('news.story.create'), wrap(async (req, res) => {
const b = input(req.body);
const out = stories.create(req.viewer, { cluster: req.params.id, headline: b.headline, topic: b.topic || null }, tp(req));
done(res, `/edit/stories/${out.story.id}`, 'Story opened. Write the text; every paragraph cites its sources.');
@@@ -162,40 +167,40 @@ function createEditorRoutes(ctx) {
return undefined;
}));
-- router.get('/edit/stories/:id/diff', wrap(async (req, res) => {
++ router.get('/edit/stories/:id/diff', B('news.story.diff'), wrap(async (req, res) => {
const story = mustStory(req, res);
if (!story) return;
page(req, res, 'Diff', editor.diffPage({ story, diff: stories.diff(story, parseInt(req.query.from, 10), parseInt(req.query.to, 10), 'word') }));
}));
-- const act = (path, fn) => router.post(`/edit/stories/:id/${path}`, wrap(async (req, res) => {
++ const act = (path, budget, fn) => router.post(`/edit/stories/:id/${path}`, B(budget), wrap(async (req, res) => {
const story = mustStory(req, res);
if (!story) return;
const text = await fn(req, story, input(req.body));
done(res, `/edit/stories/${story.id}`, text);
}));
-- act('revise', (req, story, b) => {
++ act('revise', 'news.story.revise', (req, story, b) => {
const out = stories.revise(req.viewer, story, { headline: b.headline, body: b.body, expectedRevision: b.expectedRevision, topic: b.topic, noindex: b.noindex, message: b.message }, tp(req));
return out.created ? `Saved revision ${out.revision.number}` : 'Nothing changed';
});
-- act('publish', (req, story, b) => {
++ act('publish', 'news.story.publish', (req, story, b) => {
const correction = b.correctionKind || b.correctionNote ? { kind: b.correctionKind || 'correction', note: b.correctionNote } : undefined;
const out = stories.publish(req.viewer, story, { revision: b.revision, correction }, tp(req));
return out.changed ? `Published revision ${out.story.published_revision}` : 'Already published';
});
-- act('unpublish', (req, story) => (stories.unpublish(req.viewer, story, tp(req)).changed ? 'Unpublished' : 'Not published'));
-- act('retract', (req, story, b) => (stories.retract(req.viewer, story, { note: b.note }, tp(req)).changed ? 'Retracted' : 'Already retracted'));
-- act('review', (req, story, b) => { stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)); return `Review recorded for revision ${b.revision}`; });
-- act('flag', (req, story, b) => { stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }); return 'Note added; it is published with the next publication'; });
-- act('attach', (req, story, b) => `Attached as [${stories.attach(req.viewer, story, { item: b.item }).n}]`);
-- act('detach', (req, story, b) => `Detached [${stories.detach(req.viewer, story, b.item).n}]`);
-- act('assign', (req, story, b) => { stories.assignPerspective(req.viewer, story, b.item, b.perspective || null); return 'Perspective set'; });
-- act('perspective', (req, story, b) => { stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }); return 'Perspective added'; });
-- act('perspective/remove', (req, story, b) => { stories.removePerspective(req.viewer, story, b.perspective); return 'Perspective removed'; });
-- act('timeline', (req, story, b) => { stories.addTimeline(req.viewer, story, { occurredOn: b.occurredOn, text: b.text, source: b.source }); return 'Timeline entry added'; });
-- act('timeline/remove', (req, story, b) => { stories.removeTimeline(req.viewer, story, b.entry); return 'Timeline entry removed'; });
-- act('ai', async (req, story, b) => {
++ act('unpublish', 'news.story.publish', (req, story) => (stories.unpublish(req.viewer, story, tp(req)).changed ? 'Unpublished' : 'Not published'));
++ act('retract', 'news.story.publish', (req, story, b) => (stories.retract(req.viewer, story, { note: b.note }, tp(req)).changed ? 'Retracted' : 'Already retracted'));
++ act('review', 'news.story.publish', (req, story, b) => { stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)); return `Review recorded for revision ${b.revision}`; });
++ act('flag', 'news.story.annotate', (req, story, b) => { stories.addFlag(req.viewer, story, { kind: b.kind, note: b.note }); return 'Note added; it is published with the next publication'; });
++ act('attach', 'news.source.attach', (req, story, b) => `Attached as [${stories.attach(req.viewer, story, { item: b.item }).n}]`);
++ act('detach', 'news.source.attach', (req, story, b) => `Detached [${stories.detach(req.viewer, story, b.item).n}]`);
++ act('assign', 'news.story.annotate', (req, story, b) => { stories.assignPerspective(req.viewer, story, b.item, b.perspective || null); return 'Perspective set'; });
++ act('perspective', 'news.story.annotate', (req, story, b) => { stories.addPerspective(req.viewer, story, { label: b.label, description: b.description }); return 'Perspective added'; });
++ act('perspective/remove', 'news.story.annotate', (req, story, b) => { stories.removePerspective(req.viewer, story, b.perspective); return 'Perspective removed'; });
++ act('timeline', 'news.story.annotate', (req, story, b) => { stories.addTimeline(req.viewer, story, { occurredOn: b.occurredOn, text: b.text, source: b.source }); return 'Timeline entry added'; });
++ act('timeline/remove', 'news.story.annotate', (req, story, b) => { stories.removeTimeline(req.viewer, story, b.entry); return 'Timeline entry removed'; });
++ act('ai', 'news.story.ai_draft', async (req, story, b) => {
const out = await stories.aiDraft(req.viewer, story, b.workflow, tp(req));
return `AI draft saved as revision ${out.revision.number}: review it before publishing`;
});
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 379e882..cb9c84e 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -198,7 +198,8 @@ function createPublicRoutes(ctx) {
return renderStory(req, res, { story });
}));
-- router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
++ // Per address (20 a minute), then per signed-in person (http/actor-limits.js) before the form is read.
++ router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), ctx.limits.signedIn('news.discussion.comment'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
const story = stories.bySlug(req.params.slug);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
const path = publication.storyPath(story);
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..9985525
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller
++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while another
++ * caller still passes; the window reopens on the clock. A first-party service naming a person counts
++ * against that person; signed-out reads and a first-party service reading for itself are left to the
++ * per-address limit. A desk form shares its budget with the API route that does 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 { boot, check, done } = require('./helpers/boot');
++
++(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 logs = [];
++ const log = { log() {}, error() {}, warn: (...a) => logs.push(a.join(' ')) };
++ const t = await boot({ env: { NEWS_LIMITS_MINUTE: '3', NEWS_LIMITS_HOUR: '100' }, limitsNow: () => clock, log });
++ const reader = t.network.addUser('reader');
++ const admin = t.network.addUser('chief', { role: 'admin' }); // staff: an editor too
++ const svc = t.network.serviceToken('tools', ['news.topic.manage', 'news.story.read']);
++
++ await check('reads: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.api('/topics')).status, 200, `read ${i + 1}`);
++ const r = await t.api('/topics');
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
++ const p = r.json();
++ assert.deepStrictEqual([p.code, p.status, p.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(p.detail.includes('news.read'), p.detail);
++ assert.strictEqual((await t.get('/api/v1/topics', { as: reader })).status, 200, 'another person still passes');
++ });
++
++ await check('a first-party service naming the person counts against them; reading for itself is not counted', async () => {
++ const named = await t.get('/api/v1/topics', { as: svc, headers: { 'x-ov-subject': t.editor.subject } });
++ assert.strictEqual(named.status, 429, named.text);
++ for (let i = 0; i < 6; i++) assert.strictEqual((await t.get('/api/v1/topics', { as: svc })).status, 200, `service read ${i + 1}`);
++ });
++
++ await check('signed-out reads keep only the per-address limit (many readers share an address)', async () => {
++ for (let i = 0; i < 8; i++) assert.strictEqual((await t.get('/api/v1/stories')).status, 200, `signed-out read ${i + 1}`);
++ for (let i = 0; i < 8; i++) assert.strictEqual((await t.get('/api/v1/topics', { headers: { 'X-Forwarded-For': '203.0.113.7' } })).status, 200);
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await t.api('/topics')).status, 200);
++ });
++
++ await check('writes: 10 topics a minute per editor, shared by the API and the desk form; nothing stored past it', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const count = () => t.db().prepare('SELECT COUNT(*) AS n FROM news_topics').get().n;
++ for (let i = 0; i < 10; i++) {
++ const r = await t.api('/topics', { json: { name: `Limits topic ${i}` } });
++ assert.strictEqual(r.status, 201, `topic ${i + 1}: ${r.text}`);
++ }
++ const before = count();
++ const form = await t.get('/edit/topics', { as: t.editor, form: { _csrf: t.csrf(t.editor), name: 'One too many' } });
++ assert.deepStrictEqual([form.status, form.json().code, form.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ const api = await t.api('/topics', { json: { name: 'One too many' } });
++ assert.strictEqual(api.status, 429);
++ assert.strictEqual(count(), before, 'nothing stored');
++ const other = await t.get('/api/v1/topics', { as: admin, json: { name: 'Another editor' } });
++ assert.strictEqual(other.status, 201, `another editor still writes: ${other.text}`);
++ });
++
++ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ const d = await t.deliver({ event_type: 'test.nothing', source: 'sources', data: {} });
++ assert.notStrictEqual(d.status, 429, d.text);
++ }
++ });
++
++ await check('refusals are logged (the caller, never a token) and counted in news_rate_limited_total', async () => {
++ assert.ok(logs.includes(`[News] limit news.read: user:${t.editor.subject} refused, over 3 per minute`), logs.join('\n'));
++ assert.ok(logs.includes(`[News] limit news.topic.manage: user:${t.editor.subject} refused, over 10 per minute`), logs.join('\n'));
++ assert.ok(!logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
++ const m = (await t.get('/metrics')).text;
++ const lines = m.split('\n').filter((l) => l.includes('news_rate_limited_total')).join('\n');
++ assert.ok(/news_rate_limited_total\{limit="news.read",window="minute"\} 2/.test(m), lines);
++ assert.ok(/news_rate_limited_total\{limit="news.topic.manage",window="minute"\} 2/.test(m), lines);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 1f02ec1..690fb60 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -9,6 +9,9 @@
* t.community.threads / .calls // the Community mock (comment threads)
* t.deliver(event) // a signed Events webhook delivery
* t.clock.advance(ms)
++ *
++ * opts: env (overrides), ai (true: News talks to the AI mock), clock, log (News' logger; default quiet),
++ * limitsNow (the per-actor limiter's clock; default the wall clock).
*/
const fs = require('fs');
const os = require('os');
@@@ -52,7 +55,7 @@ async function boot(opts = {}) {
let built = null;
async function start() {
const config = configLib.load(env);
-- built = createApp({ config, now: clock.now, log: quiet });
++ built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;