openvibers/openvibe.blog Commits
Per-actor rate limits on Blog's /api/v1, the editor and comment posts (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. A person counts as user:usr_…, whether they use their own token or cookie, a service names them in X-OV-Subject, or an app acts for them through on_behalf_of. A first-party service relaying a signed-out visitor counts by the address it forwards. A service or app acting as itself counts by its principal. A signed-out caller counts by address. A first-party service reading for itself is not counted on reads. Neither is GET /api/v1/changelog: OpenVibe.Network reads it for every site's "shipped" widget from loopback without a token, so counting it would treat the whole network as one caller (Network caches each answer for 60 s, and the per-address limit still applies). Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work, including before the body is read, logs one [Limits] line and counts blog_rate_limited_total{limit,window}.
Changes
@@@ -11,6 +11,11 @@ NODE_ENV=developmentBASE_URL=http://localhost:4810# 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 person, a service or app++# acting as itself, else an address) may make per minute and per hour; past them 429 rate_limited with++# Retry-After. Writes have their own numbers (README). The per-address limits stay.++#BLOG_LIMITS_MINUTE=120++#BLOG_LIMITS_HOUR=3000# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)BLOG_DB_PATH=./data/blog.db@@@ -347,7 +347,7 @@ its commit or tag, date and GitHub URL, and is attached as a citation.- **AI:** output is never attributed to a person. It needs a person's (`usr_`) review beforepublication or indexing, and only a signed-in person can record a review.- **Abuse:** rate limits on `/auth`, `/write` and `/api/v1`, both in Express and in the nginx-- reference.++ reference, and per-actor limits (below).- **Known gaps:**- Uploads go through OpenVibe.Media. The editor attaches existing `med_…` ids and doesn'tupload.@@@ -355,6 +355,37 @@ its commit or tag, date and GitHub URL, and is attached as a citation.interval).- The Community thread visibility sync is best effort and needs `community.comment.moderate`.++### Per-actor limits++++`/api/v1`, the editor and comment posts also limit who calls them, once `req.viewer` is resolved and++before any work (before the body is read): `server/http/actor-limits.js`, openvibe-sdk/limits,++roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, named by a service++in `X-OV-Subject`, or an app's `on_behalf_of`); a first-party service relaying a signed-out visitor by++the address it forwards; a service or app acting as itself by its principal; a signed-out caller by++address. A first-party service reading for itself is not counted on reads, and neither is++`GET /api/v1/changelog`, which OpenVibe.Network reads for every site's "shipped" widget from loopback++without a token (it would count as one caller for the whole network). Past a limit: `429`++problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and++`blog_rate_limited_total{limit,window}`. An editor form and the API route that do the same thing share++one budget.++++| Routes (API and editor) | Per caller, a minute / an hour |++|---|---|++| API reads | `BLOG_LIMITS_MINUTE` / `BLOG_LIMITS_HOUR` (120 / 3000) |++| Blog create (`POST /blogs`, `/write/start`) | 10 / 60 |++| Blog settings and theme; members | 30 / 300 each |++| Post create (`POST /blogs/:handle/posts`, `/write/@:handle/new`) | 30 / 300 |++| Post edit and revert (`PATCH /posts/:id`, `/revert`, `/write/posts/:id`) | 30 / 600 |++| AI draft (`…/posts/ai-draft`, `/write/@:handle/ai-draft`: an OpenVibe.AI run) | 5 / 30 |++| Publish, schedule, unschedule, unpublish, reviews | 30 / 300 |++| Delete | 30 / 300 |++| Media attach and remove | 20 / 200 |++| API diffs | 30 / 600 |++| Comments (`POST /@:handle/:slug/comments`, sent to Community) | 20 / 300 |++++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the++pages and feeds people read. `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.53.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"@@@ -23,7 +23,7 @@"helmet": "^8.1.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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": {@@@ -34,6 +34,7 @@ const { createPublicRoutes } = require('./http/public');const { createEditorRoutes } = require('./http/editor');const { createApi } = require('./http/api');const { createDiscoveryRoutes } = require('./http/discovery');++const { createActorLimits } = require('./http/actor-limits');const { createBlogReadiness } = require('./observability');const { createWorker } = require('./worker');const { assetVersion } = require('./render/layout');@@@ -43,7 +44,8 @@ const VERSION = require('../package.json').version;/*** opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),-- * entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log++ * entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,++ * limitsNow (the per-actor limiter's clock, tests)*/function createApp(opts = {}) {const config = opts.config || configLib.load();@@@ -78,6 +80,9 @@ function createApp(opts = {}) {const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', release: release.release });app.locals.metrics = metrics.registry;app.locals.ctx = ctx;++ // Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, 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 set tighter numbers there.++ limits: {++ minute: Math.max(1, int(env.BLOG_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.BLOG_LIMITS_HOUR, 3000)),++ },dbPath: env.BLOG_DB_PATH || './data/blog.db',nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++++/**++ * Per-actor rate limits on /api/v1, the editor and comment posts (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * The per-address limits in app.js (/api/v1 240 a minute, /write 120 a minute, sign-in) stay. These++ * count requests by who makes them, once req.viewer is resolved (auth/viewer.js):++ *++ * a person user:usr_… (their own token or cookie, named by a service in++ * X-OV-Subject, or an app's on_behalf_of)++ * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For)++ * a signed-out visitor++ * a service or app acting as its principal (svc:ai, app:app_…)++ * itself++ * a signed-out caller ip:<address>++ *++ * A first-party service reading for itself (no person, no visitor) is not counted on reads: its++ * pages speak for all its visitors, and the per-address limit already bounds it. GET++ * /api/v1/changelog is not counted either: OpenVibe.Network reads it for every site's "shipped"++ * widget, from loopback and without a token, so it would count as one caller for the whole network++ * (Network caches each answer for 60 s; the per-address limit still applies).++ *++ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any++ * work (before the body is read); the refusal is logged once and counted in++ * blog_rate_limited_total{limit,window}. API reads get BLOG_LIMITS_MINUTE / BLOG_LIMITS_HOUR (120 and++ * 3000); every write has its own number below, shared by the API route and the editor form that do++ * the same thing. Counters live in this process: a restart forgets them.++ *++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds++ * people read.++ */++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++++const FIRST_PARTY = /^svc:/;++const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;++++/** A first-party service that forwards the address of the signed-out visitor it acts for. */++function relaysVisitor(req) {++ const v = req.viewer;++ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));++}++++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') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;++ return defaultActor(req);++}++++/** A first-party service reading for itself: no person, no visitor. */++function serviceItself(req) {++ const v = req.viewer;++ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));++}++++/**++ * The writes and expensive reads, each with its numbers per caller (a minute, an hour). An editor++ * form and the API route that do the same thing share one budget.++ */++const BUDGETS = {++ // A member has one blog (get-or-create): a few tries at most.++ 'blog.blog.create': { minute: 10, hour: 60 },++ // Title, description, feeds, theme and members: an owner saves a form now and then.++ 'blog.blog.configure': { minute: 30, hour: 300 },++ 'blog.member.manage': { minute: 30, hour: 300 },++ // A new draft, or a new revision of one (the whole text again): a writer saves every few seconds at++ // most, 30 a minute, and 300 new posts or 600 revisions an hour. A revert writes a revision too.++ 'blog.post.create': { minute: 30, hour: 300 },++ 'blog.post.update': { minute: 30, hour: 600 },++ // A draft written by OpenVibe.AI is a model run on the network's budget: 5 a minute, 30 an hour.++ 'blog.post.ai_draft': { minute: 5, hour: 30 },++ // Publishing, scheduling, unpublishing and reviews change what readers, feeds, sitemaps and Search++ // see, and each emits events.++ 'blog.post.publish': { minute: 30, hour: 300 },++ 'blog.post.delete': { minute: 30, hour: 300 },++ // Attaching media asks OpenVibe.Media about the object.++ 'blog.post.media': { minute: 20, hour: 200 },++ // A word diff of two long revisions costs CPU: a writer's pace, not a crawler's.++ 'blog.post.diff': { minute: 30, hour: 600 },++ // A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).++ 'blog.comment.create': { minute: 20, hour: 300 },++};++++/**++ * limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every++ * GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).++ */++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'blog_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(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name, { skip = null } = {}) => {++ const limit = limiter(name);++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(req) && !(skip && skip(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;++ };++ return limiter;++}++++module.exports = { createActorLimits, actor, serviceItself, BUDGETS };@@@ -53,10 +53,15 @@ function cors(origins) {}function createApi(ctx) {-- const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people } = ctx;++ const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people, limits } = ctx;const router = express.Router();router.use(cors(config.apiCorsOrigins));router.use(viewers.middleware());++ // Per-actor limits (http/actor-limits.js), once req.viewer is resolved: every read takes the defaults++ // (not the changelog, which Network reads for every site from loopback); each write names its budget++ // after its capability guard and before its body is read.++ router.use(limits.reads('blog.read', { skip: (req) => req.path === '/changelog' }));++ const B = (name) => limits.budget(name);router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });@@@ -124,14 +129,14 @@ function createApi(ctx) {return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };}));-- router.post('/blogs', guard('blog.blog.create'), jsonBody, run((req) => {++ router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run((req) => {const b = req.body || {};const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });return { blog: blogDto(blog, { full: true }), created };}, (out) => (out.created ? 201 : 200)));-- router.patch('/blogs/:handle', guard('blog.blog.configure'), jsonBody, run((req) => {++ router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run((req) => {const blog = mustBlog(req);if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');const b = req.body || {};@@@ -143,7 +148,7 @@ function createApi(ctx) {return { blog: blogDto(updated, { full: true }) };}));-- router.put('/blogs/:handle/theme', guard('blog.theme.set'), jsonBody, run((req) => {++ router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run((req) => {const blog = mustBlog(req);if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };@@@ -157,13 +162,13 @@ function createApi(ctx) {return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };}));-- router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), jsonBody, run((req) => {++ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run((req) => {const blog = mustBlog(req);if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };}));-- router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), run((req) => {++ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run((req) => {const blog = mustBlog(req);if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');return { removed: blogs.removeMember(blog, req.params.subject) };@@@ -203,7 +208,7 @@ function createApi(ctx) {}));// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).-- router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), jsonBody, run(async (req) => {++ router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {const blog = mustBlog(req);if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));@@@ -211,7 +216,7 @@ function createApi(ctx) {return { post: postDto(post, { full: true }), revision: revision.number };}, 201));-- router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {++ router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run((req) => {const blog = mustBlog(req);const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));after(null, post.id, req);@@@ -239,7 +244,7 @@ function createApi(ctx) {return { post: postDto(post) };}));-- const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => {++ const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {const { post } = mustPost(req);const before = { ...post };const out = await fn(req, post, req.body || {});@@@ -247,46 +252,46 @@ function createApi(ctx) {return out;}, status));-- write('patch', '/posts/:id', guard('blog.post.update'), (req, post, b) => {++ write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };const out = posts.update(req.viewer, post, input, tp(req));return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };});-- write('post', '/posts/:id/publish', guard('blog.post.publish'), (req, post, b) => {++ write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), (req, post, b) => {const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));return { post: postDto(out.post, { full: true }), changed: out.changed };});-- write('post', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post, b) => {++ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post, b) => {const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };}, 201);-- write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post) => {++ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post) => {const out = posts.cancelSchedule(req.viewer, post);return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };});-- write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), (req, post) => {++ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), (req, post) => {const out = posts.unpublish(req.viewer, post, tp(req));return { post: postDto(out.post, { full: true }), changed: out.changed };});-- write('delete', '/posts/:id', guard('blog.post.delete'), (req, post) => {++ write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), (req, post) => {posts.remove(req.viewer, post, tp(req));return { deleted: true, id: post.id };});-- write('post', '/posts/:id/revert', guard('blog.post.update'), (req, post, b) => {++ write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });return { revision: out.revision.number, post: postDto(out.post, { full: true }) };}, 201);-- router.post('/posts/:id/reviews', jsonBody, run(async (req) => {++ router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {const { post } = mustPost(req);const b = req.body || {};const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));after({ ...post }, post.id, req);return { review };}, 201));-- write('post', '/posts/:id/attachments', guard('blog.post.update'), (req, post, b) => ({++ write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), (req, post, b) => ({attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),}), 201);-- write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));++ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));function readable(req) {const { post, blog } = mustPost(req);@@@ -303,7 +308,7 @@ function createApi(ctx) {if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');return { revision: rev, citations: posts.citations(post, rev.number) };}));-- router.get('/posts/:id/diff', guard('blog.post.read'), run((req) => {++ router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run((req) => {const post = readable(req);return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };}));@@@ -34,7 +34,7 @@ function formInput(body) {}function createEditorRoutes(ctx) {-- const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes } = ctx;++ const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes, limits } = ctx;const router = express.Router();const form = express.urlencoded({ extended: false, limit: '600kb' });router.use(viewers.middleware({ services: false }));@@@ -49,6 +49,18 @@ function createEditorRoutes(ctx) {if (!req.viewer.subject) return publicRoutes.messagePage(req, res, 403, 'Account not ready', 'Your OpenVibe sign-in did not include an account id. Sign out and in again.');next();});++ // Per-actor limits (http/actor-limits.js), for a signed-in member and before the form is read: each++ // form shares its budget with the API route that does the same thing.++ const B = (name) => limits.budget(name);++ router.post('/start', B('blog.blog.create'));++ router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));++ router.post('/@:handle/new', B('blog.post.create'));++ router.post('/@:handle/settings', B('blog.blog.configure'));++ router.post(['/@:handle/members', '/@:handle/members/remove'], B('blog.member.manage'));++ router.post(['/posts/:id', '/posts/:id/revert'], B('blog.post.update'));++ router.post(['/posts/:id/publish', '/posts/:id/schedule', '/posts/:id/unschedule', '/posts/:id/unpublish', '/posts/:id/review'], B('blog.post.publish'));++ router.post('/posts/:id/delete', B('blog.post.delete'));++ router.post(['/posts/:id/media', '/posts/:id/media/:aid/remove'], B('blog.post.media'));router.post('*', 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();@@@ -29,7 +29,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');const PER_PAGE = 15;function createPublicRoutes(ctx) {-- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers } = ctx;++ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits } = ctx;const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -378,7 +378,9 @@ function createPublicRoutes(ctx) {return renderPost(req, res, { blog, post, rev });}));-- router.post('/@:handle/:slug/comments', express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {++ // Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in++ // the person's name.++ router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {const blog = activeBlog(req);const post = blog && posts.bySlug(blog, req.params.slug);if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while++ * another caller still passes; the window reopens on the clock. A person is counted as themselves++ * whether they call directly or a service names them; a service relaying signed-out visitors is++ * counted by each forwarded address; a service reading for itself, and Network reading the changelog++ * for every site, are not counted. Writes have their own budget, shared by the API and the editor++ * form. Health, ready, release.json and metrics are never limited; refusals are logged and counted.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { actor, serviceItself } = require('../server/http/actor-limits');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++ const lines = [];++ const log = { log() {}, error() {}, warn: (m) => lines.push(String(m)) };++ const t = await boot({ env: { BLOG_LIMITS_MINUTE: '3', BLOG_LIMITS_HOUR: '100' }, limitsNow: () => clock, log });++ const fay = t.network.addUser('fay');++ const gus = t.network.addUser('gus');++ assert.strictEqual((await t.get('/api/v1/blogs', { as: fay, json: {} })).status, 201);++ assert.strictEqual((await t.get('/api/v1/blogs', { as: gus, json: {} })).status, 201);++ const svc = t.network.serviceToken('ai', ['blog.post.read', 'blog.post.create']);++++ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: fay })).status, 200);++ const r = await t.get('/api/v1/blogs/fay', { as: fay });++ assert.strictEqual(r.status, 429, r.text);++ assert.strictEqual(r.headers.get('retry-after'), '45');++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));++ const body = r.json();++ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(body.detail.includes('blog.read'), body.detail);++ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: gus })).status, 200, 'another person still passes');++ });++++ await check('a service naming the person counts against that person', async () => {++ const r = await t.get('/api/v1/blogs/fay', { as: svc, headers: { 'X-OV-Subject': fay.subject } });++ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);++ });++++ await check('relayed visitors count by forwarded address; a service reading for itself and the changelog are not counted', async () => {++ const visitor = (ip) => t.get('/api/v1/blogs/fay', { as: svc, headers: { 'X-Forwarded-For': ip } });++ for (let i = 0; i < 3; i++) assert.strictEqual((await visitor('203.0.113.7')).status, 200);++ assert.strictEqual((await visitor('203.0.113.7')).status, 429);++ assert.strictEqual((await visitor('203.0.113.8')).status, 200, 'another visitor still passes');++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: svc })).status, 200);++ assert.strictEqual((await t.get('/api/v1/changelog')).status, 200, 'Network reads it for every site');++ }++ });++++ await check('the next minute opens the window again', async () => {++ clock += 45 * 1000;++ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: fay })).status, 200);++ });++++ await check('a write has its own budget (30 revisions a minute), shared by the API and the editor form; nothing stored past it', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ let r = await t.get('/api/v1/blogs/fay/posts', { as: fay, json: { title: 'Counted', body: LONG } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json().post.id;++ let rev = r.json().revision;++ for (let i = 0; i < 29; i++) {++ r = await t.get(`/api/v1/posts/${id}`, { as: fay, method: 'PATCH', json: { expected_revision: rev, body: `${LONG} edit ${i}` } });++ assert.strictEqual(r.status, 200, `edit ${i + 1}: ${r.text}`);++ rev = r.json().revision;++ }++ r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });++ assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);++ rev = r.json().revision;++ const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id).n;++ const before = stored();++ r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');++ assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);++ assert.strictEqual(stored(), before, 'nothing stored');++ r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });++ assert.strictEqual(r.status, 201, 'another person still writes');++ r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });++ assert.strictEqual(r.status, 200, r.text);++ });++++ await check('health, ready, release.json and metrics 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);++ }++ });++++ await check('refusals are counted in blog_rate_limited_total and logged without a token', async () => {++ const m = (await t.get('/metrics')).text;++ const counted = m.split('\n').filter((l) => l.includes('blog_rate_limited_total')).join('\n');++ assert.ok(/blog_rate_limited_total\{limit="blog.read",window="minute"\} 3/.test(m), counted);++ assert.ok(/blog_rate_limited_total\{limit="blog.post.update",window="minute"\} 1/.test(m), counted);++ assert.ok(lines.some((l) => l.includes(`blog.read: user:${fay.subject} refused`)), lines.join('\n'));++ assert.ok(!lines.some((l) => /eyJ/.test(l)), 'a token in the log');++ });++++ await check('who is counted', () => {++ const q = (viewer, { xff = null, ip = '127.0.0.1' } = {}) => ({ viewer, ip, get: (n) => (n === 'x-forwarded-for' ? xff : undefined) });++ assert.strictEqual(actor(q({ kind: 'user', subject: 'usr_a' })), 'user:usr_a');++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: 'usr_a' })), 'user:usr_a');++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'ip:203.0.113.9');++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null })), 'svc:ai', 'acting as itself');++ assert.strictEqual(actor(q({ kind: 'service', service: 'app:app_1', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'app:app_1', 'an app never relays by address');++ assert.strictEqual(actor(q({ kind: 'anonymous', subject: null }, { ip: '198.51.100.4' })), 'ip:198.51.100.4');++ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'svc:ai', subject: null })), true);++ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'app:app_1', subject: null })), false);++ });++++ await t.close();++ done();++})();@@@ -44,7 +44,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = createApp({ config, now: clock.now, log: quiet, entitlementCheck: opts.entitlementCheck });++ built = createApp({ config, now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, 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,11 @@ NODE_ENV=development
@@ -11,6 +11,11 @@ NODE_ENV=development
BASE_URL=http://localhost:4810
BASE_URL=http://localhost:4810
# 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 person, a service or app
+# acting as itself, else an address) may make per minute and per hour; past them 429 rate_limited with
+# Retry-After. Writes have their own numbers (README). The per-address limits stay.
+#BLOG_LIMITS_MINUTE=120
+#BLOG_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
BLOG_DB_PATH=./data/blog.db
BLOG_DB_PATH=./data/blog.db
@@ -347,7 +347,7 @@ its commit or tag, date and GitHub URL, and is attached as a citation.
@@ -347,7 +347,7 @@ its commit or tag, date and GitHub URL, and is attached as a citation.
- **AI:** output is never attributed to a person. It needs a person's (`usr_`) review before
- **AI:** output is never attributed to a person. It needs a person's (`usr_`) review before
publication or indexing, and only a signed-in person can record a review.
publication or indexing, and only a signed-in person can record a review.
- **Abuse:** rate limits on `/auth`, `/write` and `/api/v1`, both in Express and in the nginx
- **Abuse:** rate limits on `/auth`, `/write` and `/api/v1`, both in Express and in the nginx
- reference.
+ reference, and per-actor limits (below).
- **Known gaps:**
- **Known gaps:**
- Uploads go through OpenVibe.Media. The editor attaches existing `med_…` ids and doesn't
- Uploads go through OpenVibe.Media. The editor attaches existing `med_…` ids and doesn't
upload.
upload.
@@ -355,6 +355,37 @@ its commit or tag, date and GitHub URL, and is attached as a citation.
@@ -355,6 +355,37 @@ its commit or tag, date and GitHub URL, and is attached as a citation.
interval).
interval).
- The Community thread visibility sync is best effort and needs `community.comment.moderate`.
- The Community thread visibility sync is best effort and needs `community.comment.moderate`.
+### Per-actor limits
+
+`/api/v1`, the editor and comment posts also limit who calls them, once `req.viewer` is resolved and
+before any work (before the body is read): `server/http/actor-limits.js`, openvibe-sdk/limits,
+roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, named by a service
+in `X-OV-Subject`, or an app's `on_behalf_of`); a first-party service relaying a signed-out visitor by
+the address it forwards; a service or app acting as itself by its principal; a signed-out caller by
+address. A first-party service reading for itself is not counted on reads, and neither is
+`GET /api/v1/changelog`, which OpenVibe.Network reads for every site's "shipped" widget from loopback
+without a token (it would count as one caller for the whole network). Past a limit: `429`
+problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
+`blog_rate_limited_total{limit,window}`. An editor form and the API route that do the same thing share
+one budget.
+
+| Routes (API and editor) | Per caller, a minute / an hour |
+|---|---|
+| API reads | `BLOG_LIMITS_MINUTE` / `BLOG_LIMITS_HOUR` (120 / 3000) |
+| Blog create (`POST /blogs`, `/write/start`) | 10 / 60 |
+| Blog settings and theme; members | 30 / 300 each |
+| Post create (`POST /blogs/:handle/posts`, `/write/@:handle/new`) | 30 / 300 |
+| Post edit and revert (`PATCH /posts/:id`, `/revert`, `/write/posts/:id`) | 30 / 600 |
+| AI draft (`…/posts/ai-draft`, `/write/@:handle/ai-draft`: an OpenVibe.AI run) | 5 / 30 |
+| Publish, schedule, unschedule, unpublish, reviews | 30 / 300 |
+| Delete | 30 / 300 |
+| Media attach and remove | 20 / 200 |
+| API diffs | 30 / 600 |
+| Comments (`POST /@:handle/:slug/comments`, sent to Community) | 20 / 300 |
+
+Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the
+pages and feeds people read. `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.53.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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": {
@@ -34,6 +34,7 @@ const { createPublicRoutes } = require('./http/public');
@@ -34,6 +34,7 @@ const { createPublicRoutes } = require('./http/public');
const { createEditorRoutes } = require('./http/editor');
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 { createActorLimits } = require('./http/actor-limits');
const { createBlogReadiness } = require('./observability');
const { createBlogReadiness } = 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 VERSION = require('../package.json').version;
@@ -43,7 +44,8 @@ const VERSION = require('../package.json').version;
/**
/**
* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),
* opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),
- * entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log
+ * entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,
+ * limitsNow (the per-actor limiter's clock, tests)
*/
*/
function createApp(opts = {}) {
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -78,6 +80,9 @@ function createApp(opts = {}) {
@@ -78,6 +80,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'blog', 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) for the API, the editor and comment posts, 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 set tighter numbers there.
+ limits: {
+ minute: Math.max(1, int(env.BLOG_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.BLOG_LIMITS_HOUR, 3000)),
+ },
dbPath: env.BLOG_DB_PATH || './data/blog.db',
dbPath: env.BLOG_DB_PATH || './data/blog.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * Per-actor rate limits on /api/v1, the editor and comment posts (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * The per-address limits in app.js (/api/v1 240 a minute, /write 120 a minute, sign-in) stay. These
+ * count requests by who makes them, once req.viewer is resolved (auth/viewer.js):
+ *
+ * a person user:usr_… (their own token or cookie, named by a service in
+ * X-OV-Subject, or an app's on_behalf_of)
+ * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For)
+ * a signed-out visitor
+ * a service or app acting as its principal (svc:ai, app:app_…)
+ * itself
+ * a signed-out caller ip:<address>
+ *
+ * A first-party service reading for itself (no person, no visitor) is not counted on reads: its
+ * pages speak for all its visitors, and the per-address limit already bounds it. GET
+ * /api/v1/changelog is not counted either: OpenVibe.Network reads it for every site's "shipped"
+ * widget, from loopback and without a token, so it would count as one caller for the whole network
+ * (Network caches each answer for 60 s; the per-address limit still applies).
+ *
+ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any
+ * work (before the body is read); the refusal is logged once and counted in
+ * blog_rate_limited_total{limit,window}. API reads get BLOG_LIMITS_MINUTE / BLOG_LIMITS_HOUR (120 and
+ * 3000); every write has its own number below, shared by the API route and the editor form that do
+ * the same thing. Counters live in this process: a restart forgets them.
+ *
+ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds
+ * people read.
+ */
+const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+
+const FIRST_PARTY = /^svc:/;
+const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
+
+/** A first-party service that forwards the address of the signed-out visitor it acts for. */
+function relaysVisitor(req) {
+ const v = req.viewer;
+ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));
+}
+
+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') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;
+ return defaultActor(req);
+}
+
+/** A first-party service reading for itself: no person, no visitor. */
+function serviceItself(req) {
+ const v = req.viewer;
+ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));
+}
+
+/**
+ * The writes and expensive reads, each with its numbers per caller (a minute, an hour). An editor
+ * form and the API route that do the same thing share one budget.
+ */
+const BUDGETS = {
+ // A member has one blog (get-or-create): a few tries at most.
+ 'blog.blog.create': { minute: 10, hour: 60 },
+ // Title, description, feeds, theme and members: an owner saves a form now and then.
+ 'blog.blog.configure': { minute: 30, hour: 300 },
+ 'blog.member.manage': { minute: 30, hour: 300 },
+ // A new draft, or a new revision of one (the whole text again): a writer saves every few seconds at
+ // most, 30 a minute, and 300 new posts or 600 revisions an hour. A revert writes a revision too.
+ 'blog.post.create': { minute: 30, hour: 300 },
+ 'blog.post.update': { minute: 30, hour: 600 },
+ // A draft written by OpenVibe.AI is a model run on the network's budget: 5 a minute, 30 an hour.
+ 'blog.post.ai_draft': { minute: 5, hour: 30 },
+ // Publishing, scheduling, unpublishing and reviews change what readers, feeds, sitemaps and Search
+ // see, and each emits events.
+ 'blog.post.publish': { minute: 30, hour: 300 },
+ 'blog.post.delete': { minute: 30, hour: 300 },
+ // Attaching media asks OpenVibe.Media about the object.
+ 'blog.post.media': { minute: 20, hour: 200 },
+ // A word diff of two long revisions costs CPU: a writer's pace, not a crawler's.
+ 'blog.post.diff': { minute: 30, hour: 600 },
+ // A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
+ 'blog.comment.create': { minute: 20, hour: 300 },
+};
+
+/**
+ * limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every
+ * GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).
+ */
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'blog_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(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name, { skip = null } = {}) => {
+ const limit = limiter(name);
+ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(req) && !(skip && skip(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;
+ };
+ return limiter;
+}
+
+module.exports = { createActorLimits, actor, serviceItself, BUDGETS };
@@ -53,10 +53,15 @@ function cors(origins) {
@@ -53,10 +53,15 @@ function cors(origins) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people } = ctx;
+ const { config, store, blogs, posts, publication, reading, access, entitlements, viewers, effects, people, 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());
+ // Per-actor limits (http/actor-limits.js), once req.viewer is resolved: every read takes the defaults
+ // (not the changelog, which Network reads for every site from loopback); each write names its budget
+ // after its capability guard and before its body is read.
+ router.use(limits.reads('blog.read', { skip: (req) => req.path === '/changelog' }));
+ const B = (name) => limits.budget(name);
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(); });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
@@ -124,14 +129,14 @@ function createApi(ctx) {
@@ -124,14 +129,14 @@ function createApi(ctx) {
return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };
return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };
}));
}));
- router.post('/blogs', guard('blog.blog.create'), jsonBody, run((req) => {
+ router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run((req) => {
const b = req.body || {};
const b = req.body || {};
const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
return { blog: blogDto(blog, { full: true }), created };
return { blog: blogDto(blog, { full: true }), created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.patch('/blogs/:handle', guard('blog.blog.configure'), jsonBody, run((req) => {
+ router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
const b = req.body || {};
const b = req.body || {};
@@ -143,7 +148,7 @@ function createApi(ctx) {
@@ -143,7 +148,7 @@ function createApi(ctx) {
return { blog: blogDto(updated, { full: true }) };
return { blog: blogDto(updated, { full: true }) };
}));
}));
- router.put('/blogs/:handle/theme', guard('blog.theme.set'), jsonBody, run((req) => {
+ router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
@@ -157,13 +162,13 @@ function createApi(ctx) {
@@ -157,13 +162,13 @@ function createApi(ctx) {
return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
}));
}));
- router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), jsonBody, run((req) => {
+ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
}));
}));
- router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), run((req) => {
+ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
return { removed: blogs.removeMember(blog, req.params.subject) };
return { removed: blogs.removeMember(blog, req.params.subject) };
@@ -203,7 +208,7 @@ function createApi(ctx) {
@@ -203,7 +208,7 @@ function createApi(ctx) {
}));
}));
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
- router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), jsonBody, run(async (req) => {
+ router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
@@ -211,7 +216,7 @@ function createApi(ctx) {
@@ -211,7 +216,7 @@ function createApi(ctx) {
return { post: postDto(post, { full: true }), revision: revision.number };
return { post: postDto(post, { full: true }), revision: revision.number };
}, 201));
}, 201));
- router.post('/blogs/:handle/posts', guard('blog.post.create'), jsonBody, run((req) => {
+ router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run((req) => {
const blog = mustBlog(req);
const blog = mustBlog(req);
const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
after(null, post.id, req);
after(null, post.id, req);
@@ -239,7 +244,7 @@ function createApi(ctx) {
@@ -239,7 +244,7 @@ function createApi(ctx) {
return { post: postDto(post) };
return { post: postDto(post) };
}));
}));
- const write = (method, path, capGuard, fn, status = 200) => router[method](path, capGuard, jsonBody, run(async (req) => {
+ const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {
const { post } = mustPost(req);
const { post } = mustPost(req);
const before = { ...post };
const before = { ...post };
const out = await fn(req, post, req.body || {});
const out = await fn(req, post, req.body || {});
@@ -247,46 +252,46 @@ function createApi(ctx) {
@@ -247,46 +252,46 @@ function createApi(ctx) {
return out;
return out;
}, status));
}, status));
- write('patch', '/posts/:id', guard('blog.post.update'), (req, post, b) => {
+ write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
const out = posts.update(req.viewer, post, input, tp(req));
const out = posts.update(req.viewer, post, input, tp(req));
return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
});
});
- write('post', '/posts/:id/publish', guard('blog.post.publish'), (req, post, b) => {
+ write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), (req, post, b) => {
const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
return { post: postDto(out.post, { full: true }), changed: out.changed };
return { post: postDto(out.post, { full: true }), changed: out.changed };
});
});
- write('post', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post, b) => {
+ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post, b) => {
const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };
return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };
}, 201);
}, 201);
- write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), (req, post) => {
+ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post) => {
const out = posts.cancelSchedule(req.viewer, post);
const out = posts.cancelSchedule(req.viewer, post);
return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };
return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };
});
});
- write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), (req, post) => {
+ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), (req, post) => {
const out = posts.unpublish(req.viewer, post, tp(req));
const out = posts.unpublish(req.viewer, post, tp(req));
return { post: postDto(out.post, { full: true }), changed: out.changed };
return { post: postDto(out.post, { full: true }), changed: out.changed };
});
});
- write('delete', '/posts/:id', guard('blog.post.delete'), (req, post) => {
+ write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), (req, post) => {
posts.remove(req.viewer, post, tp(req));
posts.remove(req.viewer, post, tp(req));
return { deleted: true, id: post.id };
return { deleted: true, id: post.id };
});
});
- write('post', '/posts/:id/revert', guard('blog.post.update'), (req, post, b) => {
+ write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
return { revision: out.revision.number, post: postDto(out.post, { full: true }) };
return { revision: out.revision.number, post: postDto(out.post, { full: true }) };
}, 201);
}, 201);
- router.post('/posts/:id/reviews', jsonBody, run(async (req) => {
+ router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
const { post } = mustPost(req);
const { post } = mustPost(req);
const b = req.body || {};
const b = req.body || {};
const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
after({ ...post }, post.id, req);
after({ ...post }, post.id, req);
return { review };
return { review };
}, 201));
}, 201));
- write('post', '/posts/:id/attachments', guard('blog.post.update'), (req, post, b) => ({
+ write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), (req, post, b) => ({
attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
}), 201);
}), 201);
- write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));
+ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));
function readable(req) {
function readable(req) {
const { post, blog } = mustPost(req);
const { post, blog } = mustPost(req);
@@ -303,7 +308,7 @@ function createApi(ctx) {
@@ -303,7 +308,7 @@ 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: posts.citations(post, rev.number) };
return { revision: rev, citations: posts.citations(post, rev.number) };
}));
}));
- router.get('/posts/:id/diff', guard('blog.post.read'), run((req) => {
+ router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run((req) => {
const post = readable(req);
const post = readable(req);
return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
}));
}));
@@ -34,7 +34,7 @@ function formInput(body) {
@@ -34,7 +34,7 @@ function formInput(body) {
}
}
function createEditorRoutes(ctx) {
function createEditorRoutes(ctx) {
- const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes } = ctx;
+ const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes, limits } = ctx;
const router = express.Router();
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '600kb' });
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -49,6 +49,18 @@ function createEditorRoutes(ctx) {
@@ -49,6 +49,18 @@ function createEditorRoutes(ctx) {
if (!req.viewer.subject) return publicRoutes.messagePage(req, res, 403, 'Account not ready', 'Your OpenVibe sign-in did not include an account id. Sign out and in again.');
if (!req.viewer.subject) return publicRoutes.messagePage(req, res, 403, 'Account not ready', 'Your OpenVibe sign-in did not include an account id. Sign out and in again.');
next();
next();
});
});
+ // Per-actor limits (http/actor-limits.js), for a signed-in member and before the form is read: each
+ // form shares its budget with the API route that does the same thing.
+ const B = (name) => limits.budget(name);
+ router.post('/start', B('blog.blog.create'));
+ router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
+ router.post('/@:handle/new', B('blog.post.create'));
+ router.post('/@:handle/settings', B('blog.blog.configure'));
+ router.post(['/@:handle/members', '/@:handle/members/remove'], B('blog.member.manage'));
+ router.post(['/posts/:id', '/posts/:id/revert'], B('blog.post.update'));
+ router.post(['/posts/:id/publish', '/posts/:id/schedule', '/posts/:id/unschedule', '/posts/:id/unpublish', '/posts/:id/review'], B('blog.post.publish'));
+ router.post('/posts/:id/delete', B('blog.post.delete'));
+ router.post(['/posts/:id/media', '/posts/:id/media/:aid/remove'], B('blog.post.media'));
router.post('*', form, (req, res, next) => {
router.post('*', 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();
@@ -29,7 +29,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
@@ -29,7 +29,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
const PER_PAGE = 15;
const PER_PAGE = 15;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers } = ctx;
+ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits } = ctx;
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -378,7 +378,9 @@ function createPublicRoutes(ctx) {
@@ -378,7 +378,9 @@ function createPublicRoutes(ctx) {
return renderPost(req, res, { blog, post, rev });
return renderPost(req, res, { blog, post, rev });
}));
}));
- router.post('/@:handle/:slug/comments', express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
+ // Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
+ // the person's name.
+ router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
const blog = activeBlog(req);
const blog = activeBlog(req);
const post = blog && posts.bySlug(blog, req.params.slug);
const post = blog && posts.bySlug(blog, req.params.slug);
if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller
+ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
+ * another caller still passes; the window reopens on the clock. A person is counted as themselves
+ * whether they call directly or a service names them; a service relaying signed-out visitors is
+ * counted by each forwarded address; a service reading for itself, and Network reading the changelog
+ * for every site, are not counted. Writes have their own budget, shared by the API and the editor
+ * form. Health, ready, release.json and metrics are never limited; refusals are logged and counted.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { actor, serviceItself } = require('../server/http/actor-limits');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
+ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+ const lines = [];
+ const log = { log() {}, error() {}, warn: (m) => lines.push(String(m)) };
+ const t = await boot({ env: { BLOG_LIMITS_MINUTE: '3', BLOG_LIMITS_HOUR: '100' }, limitsNow: () => clock, log });
+ const fay = t.network.addUser('fay');
+ const gus = t.network.addUser('gus');
+ assert.strictEqual((await t.get('/api/v1/blogs', { as: fay, json: {} })).status, 201);
+ assert.strictEqual((await t.get('/api/v1/blogs', { as: gus, json: {} })).status, 201);
+ const svc = t.network.serviceToken('ai', ['blog.post.read', 'blog.post.create']);
+
+ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: fay })).status, 200);
+ const r = await t.get('/api/v1/blogs/fay', { as: fay });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.strictEqual(r.headers.get('retry-after'), '45');
+ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));
+ const body = r.json();
+ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(body.detail.includes('blog.read'), body.detail);
+ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: gus })).status, 200, 'another person still passes');
+ });
+
+ await check('a service naming the person counts against that person', async () => {
+ const r = await t.get('/api/v1/blogs/fay', { as: svc, headers: { 'X-OV-Subject': fay.subject } });
+ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
+ });
+
+ await check('relayed visitors count by forwarded address; a service reading for itself and the changelog are not counted', async () => {
+ const visitor = (ip) => t.get('/api/v1/blogs/fay', { as: svc, headers: { 'X-Forwarded-For': ip } });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await visitor('203.0.113.7')).status, 200);
+ assert.strictEqual((await visitor('203.0.113.7')).status, 429);
+ assert.strictEqual((await visitor('203.0.113.8')).status, 200, 'another visitor still passes');
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: svc })).status, 200);
+ assert.strictEqual((await t.get('/api/v1/changelog')).status, 200, 'Network reads it for every site');
+ }
+ });
+
+ await check('the next minute opens the window again', async () => {
+ clock += 45 * 1000;
+ assert.strictEqual((await t.get('/api/v1/blogs/fay', { as: fay })).status, 200);
+ });
+
+ await check('a write has its own budget (30 revisions a minute), shared by the API and the editor form; nothing stored past it', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ let r = await t.get('/api/v1/blogs/fay/posts', { as: fay, json: { title: 'Counted', body: LONG } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json().post.id;
+ let rev = r.json().revision;
+ for (let i = 0; i < 29; i++) {
+ r = await t.get(`/api/v1/posts/${id}`, { as: fay, method: 'PATCH', json: { expected_revision: rev, body: `${LONG} edit ${i}` } });
+ assert.strictEqual(r.status, 200, `edit ${i + 1}: ${r.text}`);
+ rev = r.json().revision;
+ }
+ r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });
+ assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);
+ rev = r.json().revision;
+ const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id).n;
+ const before = stored();
+ r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });
+ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
+ assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);
+ assert.strictEqual(stored(), before, 'nothing stored');
+ r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });
+ assert.strictEqual(r.status, 201, 'another person still writes');
+ r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });
+ assert.strictEqual(r.status, 200, r.text);
+ });
+
+ await check('health, ready, release.json and metrics 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);
+ }
+ });
+
+ await check('refusals are counted in blog_rate_limited_total and logged without a token', async () => {
+ const m = (await t.get('/metrics')).text;
+ const counted = m.split('\n').filter((l) => l.includes('blog_rate_limited_total')).join('\n');
+ assert.ok(/blog_rate_limited_total\{limit="blog.read",window="minute"\} 3/.test(m), counted);
+ assert.ok(/blog_rate_limited_total\{limit="blog.post.update",window="minute"\} 1/.test(m), counted);
+ assert.ok(lines.some((l) => l.includes(`blog.read: user:${fay.subject} refused`)), lines.join('\n'));
+ assert.ok(!lines.some((l) => /eyJ/.test(l)), 'a token in the log');
+ });
+
+ await check('who is counted', () => {
+ const q = (viewer, { xff = null, ip = '127.0.0.1' } = {}) => ({ viewer, ip, get: (n) => (n === 'x-forwarded-for' ? xff : undefined) });
+ assert.strictEqual(actor(q({ kind: 'user', subject: 'usr_a' })), 'user:usr_a');
+ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: 'usr_a' })), 'user:usr_a');
+ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'ip:203.0.113.9');
+ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null })), 'svc:ai', 'acting as itself');
+ assert.strictEqual(actor(q({ kind: 'service', service: 'app:app_1', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'app:app_1', 'an app never relays by address');
+ assert.strictEqual(actor(q({ kind: 'anonymous', subject: null }, { ip: '198.51.100.4' })), 'ip:198.51.100.4');
+ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'svc:ai', subject: null })), true);
+ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'app:app_1', subject: null })), false);
+ });
+
+ await t.close();
+ done();
+})();
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,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, entitlementCheck: opts.entitlementCheck });
+ built = createApp({ config, now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, 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}`;