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}.

OP OpenVibers committed 28 Sept 2026 · 21d537c6e6 · 12 files, +333 −31

API reads take BLOG_LIMITS_MINUTE / BLOG_LIMITS_HOUR, 120 and 3000. Each write has its own budget, and an editor form shares it with the API route that does the same thing:
- Blog create: 10 a minute, 60 an hour. Settings and theme: 30 and 300. Members: 30 and 300.
- Post create: 30 and 300. Edits and reverts: 30 and 600, since each stores the whole text as a new revision.
- AI drafts: 5 and 30, since each one is an OpenVibe.AI model run.
- Publish, schedule, unschedule, unpublish and reviews: 30 and 300, since each changes what readers, feeds, sitemaps and Search see and emits events.
- Delete: 30 and 300. Media attach and remove, which ask Media: 20 and 200.
- API diffs: 30 and 600.
- Comments, which go to Community in the person's name: 20 and 300.

The editor's limits sit before its body parser and CSRF check, for signed-in members only. The per-address limits on /api/v1, /write and sign-in stay. Health, ready, release.json, metrics, sign-in, and the pages and feeds people read are never limited per actor.

test/actor-limits.test.js runs on a fixed limiter clock. The fourth read in a minute is refused with Retry-After 45 while another person passes. A service naming the same person counts against them. Relayed visitors count by forwarded address, while a service reading for itself and the changelog are not counted. The next minute reopens. After 30 API revisions in a minute the editor form is refused (the budget is shared) and nothing is stored, while another person still writes. Health, ready, release.json and metrics never answer 429. Refusals are counted and logged without a token. The test boot helper now passes limitsNow and a log to createApp.

Changes

ddiff --git a/.env.example b/.env.example
iindex 046704c..5ce9b96 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,6 +11,11 @@ NODE_ENV=development
BASE_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
ddiff --git a/README.md b/README.md
iindex a24f91d..44a47c6 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
-- reference.
++ reference, and per-actor limits (below).
- **Known gaps:**
- Uploads go through OpenVibe.Media. The editor attaches existing `med_…` ids and doesn't
upload.
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
iindex bd37757..9157484 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
ddiff --git a/package.json b/package.json
iindex 4a10b48..0b992db 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex d2b18d6..2cdd40a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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({
ddiff --git a/server/config.js b/server/config.js
iindex 3ff236e..215f209 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -28,6 +28,12 @@ function load(env = process.env) {
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller may
++ // make per minute and per hour. Writes 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',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..e3d837c
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3fdf451..5d729dc 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) };
}));
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 45000aa..8205e28 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -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();
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 9e7296b..eed6d4c 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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);
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..6c14bb4
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex df0e12d..486cbfa 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;