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}.
Changes
@@@ -11,6 +11,12 @@ NODE_ENV=developmentBASE_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@@@ -315,7 +315,7 @@ call the service live.- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or aneditor 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 sameauto-escaping and stores only the thread id. The comment form needs a signed-in member and theform 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 grantsyet, 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@@@ -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"@@@ -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": {@@@ -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({@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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 });@@@ -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`;});@@@ -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);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 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();++})();@@@ -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}`;
@@ -11,6 +11,12 @@ NODE_ENV=development
@@ -11,6 +11,12 @@ NODE_ENV=development
BASE_URL=http://localhost:4820
BASE_URL=http://localhost:4820
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
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. 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)
# SQLite database (production unit: /var/lib/openvibe-news/news.db)
NEWS_DB_PATH=./data/news.db
NEWS_DB_PATH=./data/news.db
@@ -315,7 +315,7 @@ call the service live.
@@ -315,7 +315,7 @@ call the service live.
- **Fabrication:** no seeded or generated stories, ratings or dates; dates come from sources or an
- **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.
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
- **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
- **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
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.
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.
@@ -323,6 +323,36 @@ call the service live.
refreshed after the first successful read; the Network does not hold News' two Community grants
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.
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
## Development
```bash
```bash
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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"
},
},
"devDependencies": {
"devDependencies": {
@@ -1049,9 +1049,9 @@
@@ -1049,9 +1049,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"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.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"
},
},
"devDependencies": {
"devDependencies": {
@@ -36,6 +36,7 @@ const { createEditorRoutes } = require('./http/editor');
@@ -36,6 +36,7 @@ const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createWebhook } = require('./http/webhook');
const { createWebhook } = require('./http/webhook');
+const { createActorLimits } = require('./http/actor-limits');
const { createNewsReadiness } = require('./observability');
const { createNewsReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
@@ -43,7 +44,8 @@ const { assetVersion } = require('./render/layout');
@@ -43,7 +44,8 @@ const { assetVersion } = require('./render/layout');
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;
-/** 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 = {}) {
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
@@ -78,6 +80,9 @@ function createApp(opts = {}) {
@@ -78,6 +80,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'news', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'news', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
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(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -28,6 +28,12 @@ function load(env = process.env) {
@@ -28,6 +28,12 @@ function load(env = process.env) {
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
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',
dbPath: env.NEWS_DB_PATH || './data/news.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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 };
@@ -72,11 +72,16 @@ function itemDto(it) {
@@ -72,11 +72,16 @@ function itemDto(it) {
}
}
function createApi(ctx) {
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();
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use(viewers.middleware());
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
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 });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
/** Reads for editors: a service passed its capability guard; a browser must be an editor. */
/** Reads for editors: a service passed its capability guard; a browser must be an editor. */
@@ -117,7 +122,7 @@ function createApi(ctx) {
@@ -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.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);
access.requireEditor(config, req.viewer);
const b = req.body || {};
const b = req.body || {};
const r = topics.create({ name: b.name, slug: b.slug, description: b.description });
const r = topics.create({ name: b.name, slug: b.slug, description: b.description });
@@ -149,7 +154,7 @@ function createApi(ctx) {
@@ -149,7 +154,7 @@ function createApi(ctx) {
return { published: reading.storyJson(await reading.storyModel(story)) };
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 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));
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 };
return { story: storyDto(out.story), revision: out.revision ? out.revision.number : null };
@@ -167,52 +172,53 @@ function createApi(ctx) {
@@ -167,52 +172,53 @@ function createApi(ctx) {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
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) };
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);
editorRead(req);
return { diff: stories.diff(mustStory(req), parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
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, {
const out = stories.revise(req.viewer, story, {
headline: b.headline, body: b.body, paragraphs: b.paragraphs, expectedRevision: b.expected_revision ?? b.expectedRevision,
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,
topic: b.topic, noindex: b.noindex, message: b.message, authorship: b.authorship, gaps: b.gaps,
}, tp(req));
}, tp(req));
return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
}, 201);
}, 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));
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) };
return { revision: out.revision.number, created: out.created, story: storyDto(out.story) };
}, 201);
}, 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));
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) };
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));
const out = stories.unpublish(req.viewer, story, tp(req));
return { changed: out.changed, story: storyDto(out.story) };
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));
const out = stories.retract(req.viewer, story, { note: b.note }, tp(req));
return { changed: out.changed, story: storyDto(out.story) };
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 story = mustStory(req);
const b = req.body || {};
const b = req.body || {};
return { review: stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
return { review: stories.review(req.viewer, story, { revision: b.revision, decision: b.decision, note: b.note }, tp(req)) };
}, 201));
}, 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 });
const out = stories.attach(req.viewer, story, { item: b.item || b.item_id });
return { n: out.n, created: out.created, item: itemDto(out.item) };
return { n: out.n, created: out.created, item: itemDto(out.item) };
}, 201);
}, 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 ──────────────────────────
// ── Clusters, items, ingestion ──────────────────────────
@@ -227,18 +233,18 @@ function createApi(ctx) {
@@ -227,18 +233,18 @@ function createApi(ctx) {
if (!c) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
if (!c) throw new ApiError(404, 'cluster.not_found', 'No such cluster');
return { cluster: clusterDto(c, { full: true }) };
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);
access.requireEditor(config, req.viewer);
const out = clusters.reverse(req.viewer, req.params.auditId, { reason: (req.body || {}).reason });
const out = clusters.reverse(req.viewer, req.params.auditId, { reason: (req.body || {}).reason });
return { audit: out.audit, clusters: out.clusters.map((c) => clusterDto(c)) };
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);
access.requireEditor(config, req.viewer);
const b = req.body || {};
const b = req.body || {};
const out = clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
const out = clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
return { cluster: clusterDto(out.cluster, { full: true }), audit: out.audit };
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);
access.requireEditor(config, req.viewer);
const b = req.body || {};
const b = req.body || {};
const out = clusters.split(req.viewer, req.params.id, b.items, { reason: b.reason });
const out = clusters.split(req.viewer, req.params.id, b.items, { reason: b.reason });
@@ -27,7 +27,7 @@ const access = require('../domain/access');
@@ -27,7 +27,7 @@ const access = require('../domain/access');
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
function createEditorRoutes(ctx) {
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 router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
@@ -41,10 +41,15 @@ function createEditorRoutes(ctx) {
@@ -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' });
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();
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) => {
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.');
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();
next();
});
});
+ const B = (name) => limits.budget(name);
const back = (req) => {
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'; }
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) {
@@ -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();
const r = await ingest.pull();
done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
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.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 b = input(req.body);
const r = topics.create({ name: b.name, description: b.description });
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`);
done(res, '/edit/topics', r.created ? `Added ${r.topic.name}` : `${r.topic.name} already exists`);
@@ -111,21 +116,21 @@ function createEditorRoutes(ctx) {
@@ -111,21 +116,21 @@ function createEditorRoutes(ctx) {
topics: topics.list(), csrf: csrf(req), message: flashOf(req),
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);
const b = input(req.body);
clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
clusters.merge(req.viewer, req.params.id, b.other, { reason: b.reason });
done(res, `/clusters/${req.params.id}`, `Merged ${b.other} into this cluster`);
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 items = [].concat((req.body && req.body.items) || []);
const out = clusters.split(req.viewer, req.params.id, items, { reason: one(req.body.reason) });
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}`);
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) });
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('_', ' ')})`);
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 b = input(req.body);
const out = stories.create(req.viewer, { cluster: req.params.id, headline: b.headline, topic: b.topic || null }, tp(req));
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.');
done(res, `/edit/stories/${out.story.id}`, 'Story opened. Write the text; every paragraph cites its sources.');
@@ -162,40 +167,40 @@ function createEditorRoutes(ctx) {
@@ -162,40 +167,40 @@ function createEditorRoutes(ctx) {
return undefined;
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);
const story = mustStory(req, res);
if (!story) return;
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') }));
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);
const story = mustStory(req, res);
if (!story) return;
if (!story) return;
const text = await fn(req, story, input(req.body));
const text = await fn(req, story, input(req.body));
done(res, `/edit/stories/${story.id}`, text);
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));
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';
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 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));
const out = stories.publish(req.viewer, story, { revision: b.revision, correction }, tp(req));
return out.changed ? `Published revision ${out.story.published_revision}` : 'Already published';
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));
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`;
return `AI draft saved as revision ${out.revision.number}: review it before publishing`;
});
});
@@ -198,7 +198,8 @@ function createPublicRoutes(ctx) {
@@ -198,7 +198,8 @@ function createPublicRoutes(ctx) {
return renderStory(req, res, { story });
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);
const story = stories.bySlug(req.params.slug);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
const path = publication.storyPath(story);
const path = publication.storyPath(story);
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 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();
+})();
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
* t.community.threads / .calls // the Community mock (comment threads)
* t.community.threads / .calls // the Community mock (comment threads)
* t.deliver(event) // a signed Events webhook delivery
* t.deliver(event) // a signed Events webhook delivery
* t.clock.advance(ms)
* 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 fs = require('fs');
const os = require('os');
const os = require('os');
@@ -52,7 +55,7 @@ async function boot(opts = {}) {
@@ -52,7 +55,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
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();
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)); });
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}`;
t.base = `http://127.0.0.1:${server.address().port}`;