openvibers/openvibe.wiki Commits
Per-actor rate limits on Wiki's /api/v1 and its editing forms (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/http/actor-limits.js makes one limiter per app and counts requests once req.actor is resolved. 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, such as an AI workflow's proposals, counts by its principal. A signed-out caller counts by address. A first-party service reading for itself is not counted on reads, since its pages speak for all its visitors and the per-address /api/ limit already bounds it. Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work (for a form, before its body is read), logs one [Limits] line and counts wiki_rate_limited_total{limit,window}.
Changes
@@@ -11,6 +11,11 @@ NODE_ENV=development#BASE_URL=https://openvibe.wiki# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).#TRUST_PROXY=2++# Per-actor limits (server/http/actor-limits.js): API reads one caller (a 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. Edits, publishing and settings have their own numbers (README). Per-address limits stay.++#WIKI_LIMITS_MINUTE=120++#WIKI_LIMITS_HOUR=3000# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).WIKI_DB_PATH=./data/wiki.db@@@ -183,6 +183,39 @@ Rate limits per address (besides nginx's): sign-in 60 per 15 min, API 300/min, f10 min, `/search` 60/min, diffs 30/min, imports 20/hour. Outbound links in community spaces carry`rel="nofollow ugc noopener"`. The threat review is [docs/threat-review.md](docs/threat-review.md).++### Per-actor limits++++`/api/v1` and the editing forms also limit who calls them, once `req.actor` is resolved and before++any work (for a form, before its body is read): `server/http/actor-limits.js`, openvibe-sdk/limits,++roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, or 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 (an AI workflow's proposals)++by its principal; a signed-out caller by address. A first-party service reading for itself is not++counted on reads. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]`++log line and `wiki_rate_limited_total{limit,window}`. A form and the API route that do the same thing++share one budget.++++| Routes (API and form) | Per caller, a minute / an hour |++|---|---|++| API reads | `WIKI_LIMITS_MINUTE` / `WIKI_LIMITS_HOUR` (120 / 3000) |++| Space create (`POST /spaces`, `/new-space`) | 10 / 60 |++| Space settings and roles (`PATCH /spaces/:space`, `PUT …/roles/:subject`, `/s/:space/settings`) | 30 / 300 |++| Page create (`POST /spaces/:space/pages`, `/s/:space/new`) | 30 / 300 |++| Page edit and revert (`POST /pages/:id/revisions`, `/revert`, `/w/…/edit`, `/w/…/revert`) | 30 / 600 |++| Page move, visibility, delete (`PATCH`/`DELETE /pages/:id`, `/w/…/settings`) | 30 / 300 |++| Publish, schedule, unpublish, reviews (API and forms), proposal review | 30 / 300 |++| Citations attach | 30 / 300 |++| AI proposals (`POST /proposals`) | 60 / 1200 |++| Import (API and form) | 5 / 20 |++| Media attach and verify | 20 / 200 |++| Watch | 60 / 600 |++| Discussion comment (`/w/…/discuss`, sent to Community) | 20 / 300 |++| API search and diffs | 30 / 600 each |++++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the++pages people read (the per-address limits above bound them). `test/actor-limits.test.js`; the other++tests boot with `rateLimits: false`, which turns off both kinds.++## API `/api/v1`People use their Network user JWT (Bearer, or the `ov_token` cookie; cross-site cookie writes are@@@ -16,7 +16,7 @@"express-rate-limit": "^7.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {@@@ -1033,9 +1033,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.11.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",++ "version": "0.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==","license": "MIT","engines": {"node": ">=20"@@@ -22,7 +22,7 @@"express-rate-limit": "^7.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},"engines": {@@@ -17,11 +17,12 @@ const { createSessionRoutes } = require('./auth/session');const { createApi } = require('./http/api');const { createPages } = require('./http/pages');const { createMachine } = require('./http/machine');++const { createActorLimits } = require('./http/actor-limits');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;--function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch }) {++function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -86,7 +87,10 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra// and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));app.use('/api/', limiter(60000, 300));-- app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));++ // Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router++ // resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).++ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });++ app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).@@@ -106,7 +110,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, raapp.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.app.get('/search', limiter(60000, 60));-- const pages = createPages({ svc, viewers, platform, config, log });++ const pages = createPages({ svc, viewers, platform, config, log, limits });app.use(pages.router);app.use((req, res) => pages.errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address.'));@@@ -24,6 +24,12 @@ function load(env = process.env) {host: env.HOST || '127.0.0.1',baseUrl,trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,++ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller++ // may make per minute and per hour. Edits, publishing and settings set tighter numbers there.++ limits: {++ minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),++ },dbPath: env.WIKI_DB_PATH || './data/wiki.db',// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * Per-actor rate limits on /api/v1 and on the editing forms (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * The per-address limits in app.js (every /api/ call, imports, diffs, form posts, search) stay. These++ * count requests by who makes them, once req.actor is resolved (auth/viewer.js):++ *++ * a person user:usr_… (their own token or cookie, or named by a 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_…): an AI workflow's proposals++ * 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 /api/ limit already bounds it. Past a limit++ * the route answers 429 problem+json `rate_limited` with Retry-After before it does any work (before++ * the body is parsed for a form); the refusal is logged once and counted in++ * wiki_rate_limited_total{limit,window}. API reads get WIKI_LIMITS_MINUTE / WIKI_LIMITS_HOUR (120 and++ * 3000); every edit, publish and settings change has its own number below, shared by the API route++ * and the form that does 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 people read++ * (the per-address limits bound those).++ */++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 a = req.actor;++ return !!(a && a.kind === 'service' && !a.subject && FIRST_PARTY.test(String(a.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));++}++++function actor(req) {++ const a = req.actor;++ if (!a || a.kind === 'anonymous') return defaultActor(req);++ if (a.subject) return `user:${a.subject}`;++ if (a.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : a.service;++ return defaultActor(req);++}++++/** A first-party service reading for itself: no person, no visitor. */++function serviceItself(req) {++ const a = req.actor;++ return !!(a && a.kind === 'service' && !a.subject && FIRST_PARTY.test(String(a.service)) && !relaysVisitor(req));++}++++/**++ * The writes and expensive reads, each with its numbers per caller (a minute, an hour). A form and the++ * API route that do the same thing share one budget.++ */++const BUDGETS = {++ // A space is a new tree with its own roles: people start a few.++ 'wiki.space.create': { minute: 10, hour: 60 },++ // Space settings, roles, and moves, visibility, media and deletes of a page: a person saves a form++ // now and then.++ 'wiki.space.update': { minute: 30, hour: 300 },++ 'wiki.page.update': { minute: 30, hour: 300 },++ // A new page or a new revision stores the whole text again and asks Sources about each cited item:++ // an editor saves (or previews) every few seconds at most, so 30 a minute, and 300 new pages or 600++ // revisions an hour. A revert writes a revision too.++ 'wiki.page.create': { minute: 30, hour: 300 },++ 'wiki.page.edit': { minute: 30, hour: 600 },++ // Citations on an unpublished revision: each item is looked up in Sources.++ 'wiki.citation.attach': { minute: 30, hour: 300 },++ // Publishing, scheduling, unpublishing and reviews change what readers, feeds, sitemaps and++ // search see, and emit an event each.++ 'wiki.revision.publish': { minute: 30, hour: 300 },++ // An AI workflow proposes one revision per page it worked on: one a second at most.++ 'wiki.revision.propose': { minute: 60, hour: 1200 },++ // Imports are the heaviest writes (up to 200 pages in one transaction); the per-address limit++ // allows 20 an hour.++ 'wiki.page.import': { minute: 5, hour: 20 },++ // Attaching or checking media asks OpenVibe.Media about each object.++ 'wiki.media.attach': { minute: 20, hour: 200 },++ // Watching a page is one toggle.++ 'wiki.page.watch': { minute: 60, hour: 600 },++ // A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).++ 'wiki.discussion.comment': { minute: 20, hour: 300 },++ // Search scans the text of every published page (LIKE); a word diff of two long revisions costs++ // hundreds of ms of CPU. A person's pace, not a crawler's.++ 'wiki.search.query': { minute: 30, hour: 600 },++ 'wiki.page.diff': { minute: 30, hour: 600 },++};++++/**++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on every GET/HEAD,++ * a first-party service reading for itself not counted) and limits.budget(name) (one of BUDGETS).++ * enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the++ * per-address limits.++ */++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {++ const refused = registry++ ? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const limiter = createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor: enabled ? actor : () => null,++ now,++ onLimited(e) {++ // The actor is a subject id, a principal or an address, never a token.++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name) => {++ const limit = limiter(name);++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(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 };@@@ -78,7 +78,7 @@ function serializeTree(nodes, svc, space) {function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }--function createApi({ svc, viewers, platform, config, log = console }) {++function createApi({ svc, viewers, platform, config, log = console, limits }) {const router = express.Router();const ownOrigin = new URL(config.baseUrl).origin;router.use(http.middleware());@@@ -98,6 +98,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {next();});router.use(actorMiddleware(viewers));++ // Per-actor limits (http/actor-limits.js), once req.actor is resolved: every read takes the defaults;++ // each write below names its budget after its capability guard, before the handler does any work.++ router.use(limits.reads('wiki.read'));++ const B = (name) => limits.budget(name);const R = (fn, status) => run(fn, status, log);// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());@@@ -125,15 +129,15 @@ function createApi({ svc, viewers, platform, config, log = console }) {// Spacesrouter.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));-- router.post('/spaces', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));++ router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));router.get('/spaces/:space', R((req) => {const space = svc.getSpace(req.params.space, req.actor);return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };}));-- router.patch('/spaces/:space', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));++ router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));-- router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));-- router.post('/spaces/:space/pages', guard('wiki.page.create'), R(async (req) => {++ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));++ router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);@@@ -141,7 +145,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };}, 201));-- router.post('/spaces/:space/import', guard('wiki.page.create'), R(async (req) => {++ router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);await resolveBundleCitations(bundle, platform);const out = svc.importPages(space.id, bundle, req.actor);@@@ -164,7 +168,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {discussion_thread: v.discussion ? v.discussion.threadId : null,};}));-- router.patch('/pages/:id', guard('wiki.page.create'), R((req) => {++ router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {const b = req.body || {};let page = svc.pageById(req.params.id);if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');@@@ -172,7 +176,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };}));-- router.delete('/pages/:id', guard('wiki.page.create'), R((req) => {++ router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {const out = svc.deletePage(req.params.id, req.actor);return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };}));@@@ -181,7 +185,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };}));-- router.post('/pages/:id/revisions', guard('wiki.page.create'), R(async (req) => {++ router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);const out = svc.editPage(req.params.id, {@@@ -196,7 +200,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };}));-- router.get('/pages/:id/diff', guard('wiki.page.read'), R((req) => {++ router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R((req) => {const { page, space } = readable(req, req.params.id);if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };@@@ -206,48 +210,48 @@ function createApi({ svc, viewers, platform, config, log = console }) {svc.view(space, page, req.actor, { revision: int(req.params.n) });return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };}));-- router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), R(async (req) => {++ router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {const cites = await resolveCitations((req.body || {}).citations, platform);return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };}, 201));-- router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), R((req) => {++ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {const { page, space } = pageAndSpace(req.params.id);const b = req.body || {};const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };}, 201));-- router.post('/pages/:id/publish', guard('wiki.revision.publish'), R((req) => {++ router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };}));-- router.post('/pages/:id/schedule', guard('wiki.revision.publish'), R((req) => {++ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {const b = req.body || {};const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);return { job: out.job, created: out.created };}, (out) => (out.created ? 201 : 200)));-- router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), R((req) => {++ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {const out = svc.unpublish(req.params.id, req.actor);return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };}));-- router.post('/pages/:id/revert', guard('wiki.revision.revert'), R((req) => {++ router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R((req) => {const b = req.body || {};const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };}, 201));-- router.post('/pages/:id/media', guard('wiki.page.create'), R(async (req) => {++ router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {const b = req.body || {};const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });return { attachment: serializeAttachment(a) };}, 201));-- router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {++ router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {const { space } = pageAndSpace(req.params.id);if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };}));-- router.put('/pages/:id/watch', R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));++ router.put('/pages/:id/watch', B('wiki.page.watch'), R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));// AI proposals-- router.post('/proposals', guard('wiki.revision.propose'), R(async (req) => {++ router.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);const wf = b.workflow || {};@@@ -266,12 +270,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {}return { proposal: p };}));-- router.post('/proposals/:id/review', guard('wiki.revision.publish'), R((req) => {++ router.post('/proposals/:id/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {const b = req.body || {};return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);}));-- router.get('/search', guard('wiki.search.query'), R((req) => ({++ router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R((req) => ({results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),})));@@@ -37,10 +37,14 @@ const content = require('../wiki/content');const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');const importer = require('../wiki/import');--function createPages({ svc, viewers, platform, config, log = console }) {++function createPages({ svc, viewers, platform, config, log = console, limits }) {const router = express.Router();const form = express.urlencoded({ extended: false, limit: '600kb' });router.use(actorMiddleware(viewers, { services: false }));++ // Per-actor limits on the editing forms (http/actor-limits.js): each form shares its budget with the++ // API route that does the same thing, counted before the form body is read. Reading pages is left++ // to the per-address limits.++ const B = (name) => limits.budget(name);// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.@@@ -134,7 +138,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');send(req, res, 200, views.newSpacePage({ staff: req.actor.staff }), { title: 'Start a space', robots: 'noindex, nofollow' });});-- router.post('/new-space', form, wrap(async (req, res) => {++ router.post('/new-space', B('wiki.space.create'), form, wrap(async (req, res) => {if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');const b = req.body || {};try {@@@ -179,7 +183,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });});-- router.post('/s/:space/new', form, wrap(async (req, res) => {++ router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {const space = locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;const b = req.body || {};@@@ -205,7 +209,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });});-- router.post('/s/:space/settings', form, wrap(async (req, res) => {++ router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {const space = locateSpace(req, res);if (!space) return;const b = req.body || {};@@@ -235,7 +239,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {if (!space || !ownersOnly(req, res, space)) return;send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });});-- router.post('/s/:space/import', importForm, wrap(async (req, res) => {++ router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {const space = locateSpace(req, res);if (!space || !ownersOnly(req, res, space)) return;const b = req.body || {};@@@ -262,7 +266,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });});-- router.post('/proposals/:id', form, wrap(async (req, res) => {++ router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');const p = svc.getProposal(req.params.id);if (!p) return notFound(req, res);@@@ -408,7 +412,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });});-- router.post('/w/:space/:slug/edit', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {const found = locate(req, res, '/edit');if (!found) return;const { space, page } = found;@@@ -446,7 +450,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });});-- router.post('/w/:space/:slug/revert', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {const found = locate(req, res, '/revert');if (!found) return;const { space, page } = found;@@@ -479,7 +483,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });}-- router.post('/w/:space/:slug/settings', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {const found = locate(req, res, '/settings');if (!found) return;const { space, page } = found;@@@ -513,7 +517,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {}}));-- router.post('/w/:space/:slug/review', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {const found = locate(req, res, '/review');if (!found) return;const { space, page } = found;@@@ -523,7 +527,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {res.redirect(303, `${views.wpath(space, page)}/history`);}));-- router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {const found = locate(req, res, '/watch');if (!found) return;if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');@@@ -531,7 +535,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {res.redirect(303, views.wpath(found.space, found.page));}));-- router.post('/w/:space/:slug/discuss', form, wrap(async (req, res) => {++ router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {const found = locate(req, res, '/discuss');if (!found) return;const { space, page } = found;@@@ -16,7 +16,7 @@ const { createKeyStore } = require('./auth/keys');const { createViewerResolver } = require('./auth/viewer');const { createApp } = require('./app');--async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true } = {}) {++async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {config = config || load();const db = openDb(config.dbPath);const stores = createStores(db, { now });@@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.feconst keys = createKeyStore({ config, fetchImpl, log, publicKey });keys.ensure().catch(() => {});const viewers = createViewerResolver({ keys, config });-- const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl });++ const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl, limitsNow });const timers = [];if (workers) {nnew file mode 100644@@@ -0,0 +1,101 @@++'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 is not counted. Edits have their++ * own budget, shared by the API and the edit form. Health, ready, release.json and metrics are never++ * limited; refusals are logged (no token) and counted.++ */++const assert = require('assert');++const H = require('./helpers');++const { actor, serviceItself } = require('../server/http/actor-limits');++++(async () => {++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++ const lines = [];++ const log = { ...H.quiet, warn: (m) => lines.push(String(m)) };++ const h = await H.boot({ rateLimits: true, limitsNow: () => clock, log, env: { WIKI_LIMITS_MINUTE: '3', WIKI_LIMITS_HOUR: '100' } });++ const alice = H.subject();++ const bob = H.subject();++ const aliceTok = H.userToken({ subject: alice, username: 'alice' });++ const bobTok = H.userToken({ subject: bob, username: 'bob' });++ const svc = (extra = {}) => H.serviceToken({ client: 'ai', cap: ['wiki.page.read', 'wiki.page.create'], ...extra });++ try {++ // a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes++ for (let i = 0; i < 3; i++) assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok })).status, 200);++ let r = await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok });++ assert.strictEqual(r.status, 429, r.text);++ assert.strictEqual(r.headers.get('retry-after'), '45');++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));++ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.json.detail.includes('wiki.read'), r.json.detail);++ assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: bobTok })).status, 200, 'another person still passes');++++ // a service naming the person counts against that person++ r = await H.req(h, 'GET', '/api/v1/spaces', { token: svc(), headers: { 'X-OV-Subject': alice } });++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);++++ // a service relaying signed-out visitors: each forwarded address on its own; reading for itself: not counted++ const visitor = (ip) => H.req(h, 'GET', '/api/v1/spaces', { token: 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 H.req(h, 'GET', '/api/v1/spaces', { token: svc() })).status, 200);++++ // the next minute opens the window again++ clock += 45 * 1000;++ assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok })).status, 200);++++ // an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past it++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ const owner = { kind: 'user', subject: alice, staff: false };++ const space = h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);++ const { page } = h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);++ h.svc.setRole(space.id, bob, 'editor', owner);++ const head = () => h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;++ for (let i = 0; i < 30; i++) {++ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: head(), body: `${H.LONG} Edit ${i}.` } });++ assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);++ }++ const stored = head();++ r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });++ assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');++ assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);++ assert.strictEqual(head(), stored, 'nothing stored');++ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });++ assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);++++ // health, ready, release.json and metrics are never limited++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await H.req(h, 'GET', '/api/health')).status, 200);++ assert.notStrictEqual((await H.req(h, 'GET', '/api/ready')).status, 429);++ assert.strictEqual((await H.req(h, 'GET', '/release.json')).status, 200);++ assert.strictEqual((await H.req(h, 'GET', '/metrics')).status, 200);++ }++++ // refusals are counted in wiki_rate_limited_total and logged without a token++ const m = (await H.req(h, 'GET', '/metrics')).text;++ const counted = m.split('\n').filter((l) => l.includes('wiki_rate_limited_total')).join('\n');++ assert.ok(/wiki_rate_limited_total\{limit="wiki.read",window="minute"\} 3/.test(m), counted);++ assert.ok(/wiki_rate_limited_total\{limit="wiki.page.edit",window="minute"\} 1/.test(m), counted);++ assert.ok(lines.some((l) => l.includes(`wiki.read: user:${alice} refused`)), lines.join('\n'));++ for (const l of lines) for (const tok of [aliceTok, bobTok]) assert.ok(!l.includes(tok), 'a token in the log');++++ // who is counted++ const q = (a, { xff = null, ip = '127.0.0.1' } = {}) => ({ actor: a, 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 (proposals)');++ 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);++ console.log('actor limits: ok');++ } finally {++ await h.stop();++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -38,7 +38,7 @@ const quiet = { log() {}, warn() {}, error() {} };* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls* (Community, Sources, Media, Events, Network token endpoint).*/--async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false } = {}) {++async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));const config = load({NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',@@@ -47,7 +47,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,...env,});const h = await start({-- config, publicKey, log: quiet, listen: true, workers, rateLimits, now,++ config, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },});
@@ -11,6 +11,11 @@ NODE_ENV=development
@@ -11,6 +11,11 @@ NODE_ENV=development
#BASE_URL=https://openvibe.wiki
#BASE_URL=https://openvibe.wiki
# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
# Hops in front of Node that set X-Forwarded-For (Cloudflare → nginx → Node).
#TRUST_PROXY=2
#TRUST_PROXY=2
+# Per-actor limits (server/http/actor-limits.js): API reads one caller (a 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. Edits, publishing and settings have their own numbers (README). Per-address limits stay.
+#WIKI_LIMITS_MINUTE=120
+#WIKI_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
WIKI_DB_PATH=./data/wiki.db
WIKI_DB_PATH=./data/wiki.db
@@ -183,6 +183,39 @@ Rate limits per address (besides nginx's): sign-in 60 per 15 min, API 300/min, f
@@ -183,6 +183,39 @@ Rate limits per address (besides nginx's): sign-in 60 per 15 min, API 300/min, f
10 min, `/search` 60/min, diffs 30/min, imports 20/hour. Outbound links in community spaces carry
10 min, `/search` 60/min, diffs 30/min, imports 20/hour. Outbound links in community spaces carry
`rel="nofollow ugc noopener"`. The threat review is [docs/threat-review.md](docs/threat-review.md).
`rel="nofollow ugc noopener"`. The threat review is [docs/threat-review.md](docs/threat-review.md).
+### Per-actor limits
+
+`/api/v1` and the editing forms also limit who calls them, once `req.actor` is resolved and before
+any work (for a form, before its body is read): `server/http/actor-limits.js`, openvibe-sdk/limits,
+roadmap WS-R task 4. Counted: a person as `user:usr_…` (their own token or cookie, or 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 (an AI workflow's proposals)
+by its principal; a signed-out caller by address. A first-party service reading for itself is not
+counted on reads. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]`
+log line and `wiki_rate_limited_total{limit,window}`. A form and the API route that do the same thing
+share one budget.
+
+| Routes (API and form) | Per caller, a minute / an hour |
+|---|---|
+| API reads | `WIKI_LIMITS_MINUTE` / `WIKI_LIMITS_HOUR` (120 / 3000) |
+| Space create (`POST /spaces`, `/new-space`) | 10 / 60 |
+| Space settings and roles (`PATCH /spaces/:space`, `PUT …/roles/:subject`, `/s/:space/settings`) | 30 / 300 |
+| Page create (`POST /spaces/:space/pages`, `/s/:space/new`) | 30 / 300 |
+| Page edit and revert (`POST /pages/:id/revisions`, `/revert`, `/w/…/edit`, `/w/…/revert`) | 30 / 600 |
+| Page move, visibility, delete (`PATCH`/`DELETE /pages/:id`, `/w/…/settings`) | 30 / 300 |
+| Publish, schedule, unpublish, reviews (API and forms), proposal review | 30 / 300 |
+| Citations attach | 30 / 300 |
+| AI proposals (`POST /proposals`) | 60 / 1200 |
+| Import (API and form) | 5 / 20 |
+| Media attach and verify | 20 / 200 |
+| Watch | 60 / 600 |
+| Discussion comment (`/w/…/discuss`, sent to Community) | 20 / 300 |
+| API search and diffs | 30 / 600 each |
+
+Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the
+pages people read (the per-address limits above bound them). `test/actor-limits.test.js`; the other
+tests boot with `rateLimits: false`, which turns off both kinds.
+
## API `/api/v1`
## API `/api/v1`
People use their Network user JWT (Bearer, or the `ov_token` cookie; cross-site cookie writes are
People use their Network user JWT (Bearer, or the `ov_token` cookie; cross-site cookie writes are
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1033,9 +1033,9 @@
@@ -1033,9 +1033,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
+ "version": "0.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -17,11 +17,12 @@ const { createSessionRoutes } = require('./auth/session');
@@ -17,11 +17,12 @@ const { createSessionRoutes } = require('./auth/session');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPages } = require('./http/pages');
const { createPages } = require('./http/pages');
const { createMachine } = require('./http/machine');
const { createMachine } = require('./http/machine');
+const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch }) {
+function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -86,7 +87,10 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -86,7 +87,10 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
// and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.
// and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.
app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));
app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));
app.use('/api/', limiter(60000, 300));
app.use('/api/', limiter(60000, 300));
- app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
+ // Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
+ // resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
+ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });
+ app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
@@ -106,7 +110,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -106,7 +110,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
app.get('/search', limiter(60000, 60));
app.get('/search', limiter(60000, 60));
- const pages = createPages({ svc, viewers, platform, config, log });
+ const pages = createPages({ svc, viewers, platform, config, log, limits });
app.use(pages.router);
app.use(pages.router);
app.use((req, res) => pages.errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address.'));
app.use((req, res) => pages.errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address.'));
@@ -24,6 +24,12 @@ function load(env = process.env) {
@@ -24,6 +24,12 @@ function load(env = process.env) {
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
trustProxy: env.TRUST_PROXY != null && env.TRUST_PROXY !== '' ? Number(env.TRUST_PROXY) : 2,
+ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller
+ // may make per minute and per hour. Edits, publishing and settings set tighter numbers there.
+ limits: {
+ minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),
+ },
dbPath: env.WIKI_DB_PATH || './data/wiki.db',
dbPath: env.WIKI_DB_PATH || './data/wiki.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * Per-actor rate limits on /api/v1 and on the editing forms (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * The per-address limits in app.js (every /api/ call, imports, diffs, form posts, search) stay. These
+ * count requests by who makes them, once req.actor is resolved (auth/viewer.js):
+ *
+ * a person user:usr_… (their own token or cookie, or named by a 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_…): an AI workflow's proposals
+ * 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 /api/ limit already bounds it. Past a limit
+ * the route answers 429 problem+json `rate_limited` with Retry-After before it does any work (before
+ * the body is parsed for a form); the refusal is logged once and counted in
+ * wiki_rate_limited_total{limit,window}. API reads get WIKI_LIMITS_MINUTE / WIKI_LIMITS_HOUR (120 and
+ * 3000); every edit, publish and settings change has its own number below, shared by the API route
+ * and the form that does 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 people read
+ * (the per-address limits bound those).
+ */
+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 a = req.actor;
+ return !!(a && a.kind === 'service' && !a.subject && FIRST_PARTY.test(String(a.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));
+}
+
+function actor(req) {
+ const a = req.actor;
+ if (!a || a.kind === 'anonymous') return defaultActor(req);
+ if (a.subject) return `user:${a.subject}`;
+ if (a.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : a.service;
+ return defaultActor(req);
+}
+
+/** A first-party service reading for itself: no person, no visitor. */
+function serviceItself(req) {
+ const a = req.actor;
+ return !!(a && a.kind === 'service' && !a.subject && FIRST_PARTY.test(String(a.service)) && !relaysVisitor(req));
+}
+
+/**
+ * The writes and expensive reads, each with its numbers per caller (a minute, an hour). A form and the
+ * API route that do the same thing share one budget.
+ */
+const BUDGETS = {
+ // A space is a new tree with its own roles: people start a few.
+ 'wiki.space.create': { minute: 10, hour: 60 },
+ // Space settings, roles, and moves, visibility, media and deletes of a page: a person saves a form
+ // now and then.
+ 'wiki.space.update': { minute: 30, hour: 300 },
+ 'wiki.page.update': { minute: 30, hour: 300 },
+ // A new page or a new revision stores the whole text again and asks Sources about each cited item:
+ // an editor saves (or previews) every few seconds at most, so 30 a minute, and 300 new pages or 600
+ // revisions an hour. A revert writes a revision too.
+ 'wiki.page.create': { minute: 30, hour: 300 },
+ 'wiki.page.edit': { minute: 30, hour: 600 },
+ // Citations on an unpublished revision: each item is looked up in Sources.
+ 'wiki.citation.attach': { minute: 30, hour: 300 },
+ // Publishing, scheduling, unpublishing and reviews change what readers, feeds, sitemaps and
+ // search see, and emit an event each.
+ 'wiki.revision.publish': { minute: 30, hour: 300 },
+ // An AI workflow proposes one revision per page it worked on: one a second at most.
+ 'wiki.revision.propose': { minute: 60, hour: 1200 },
+ // Imports are the heaviest writes (up to 200 pages in one transaction); the per-address limit
+ // allows 20 an hour.
+ 'wiki.page.import': { minute: 5, hour: 20 },
+ // Attaching or checking media asks OpenVibe.Media about each object.
+ 'wiki.media.attach': { minute: 20, hour: 200 },
+ // Watching a page is one toggle.
+ 'wiki.page.watch': { minute: 60, hour: 600 },
+ // A discussion comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
+ 'wiki.discussion.comment': { minute: 20, hour: 300 },
+ // Search scans the text of every published page (LIKE); a word diff of two long revisions costs
+ // hundreds of ms of CPU. A person's pace, not a crawler's.
+ 'wiki.search.query': { minute: 30, hour: 600 },
+ 'wiki.page.diff': { minute: 30, hour: 600 },
+};
+
+/**
+ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on every GET/HEAD,
+ * a first-party service reading for itself not counted) and limits.budget(name) (one of BUDGETS).
+ * enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
+ * per-address limits.
+ */
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
+ const refused = registry
+ ? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const limiter = createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor: enabled ? actor : () => null,
+ now,
+ onLimited(e) {
+ // The actor is a subject id, a principal or an address, never a token.
+ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name) => {
+ const limit = limiter(name);
+ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !serviceItself(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 };
@@ -78,7 +78,7 @@ function serializeTree(nodes, svc, space) {
@@ -78,7 +78,7 @@ function serializeTree(nodes, svc, space) {
function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }
function int(v) { const n = Number(v); return Number.isInteger(n) ? n : undefined; }
-function createApi({ svc, viewers, platform, config, log = console }) {
+function createApi({ svc, viewers, platform, config, log = console, limits }) {
const router = express.Router();
const router = express.Router();
const ownOrigin = new URL(config.baseUrl).origin;
const ownOrigin = new URL(config.baseUrl).origin;
router.use(http.middleware());
router.use(http.middleware());
@@ -98,6 +98,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -98,6 +98,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {
next();
next();
});
});
router.use(actorMiddleware(viewers));
router.use(actorMiddleware(viewers));
+ // Per-actor limits (http/actor-limits.js), once req.actor is resolved: every read takes the defaults;
+ // each write below names its budget after its capability guard, before the handler does any work.
+ router.use(limits.reads('wiki.read'));
+ const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
const R = (fn, status) => run(fn, status, log);
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());
const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());
@@ -125,15 +129,15 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -125,15 +129,15 @@ function createApi({ svc, viewers, platform, config, log = console }) {
// Spaces
// Spaces
router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));
router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));
- router.post('/spaces', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));
+ router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));
router.get('/spaces/:space', R((req) => {
router.get('/spaces/:space', R((req) => {
const space = svc.getSpace(req.params.space, req.actor);
const space = svc.getSpace(req.params.space, req.actor);
return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };
return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };
}));
}));
- router.patch('/spaces/:space', guard('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
+ router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));
router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));
- router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
- router.post('/spaces/:space/pages', guard('wiki.page.create'), R(async (req) => {
+ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
+ router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
@@ -141,7 +145,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -141,7 +145,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
}, 201));
}, 201));
- router.post('/spaces/:space/import', guard('wiki.page.create'), R(async (req) => {
+ router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);
const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);
await resolveBundleCitations(bundle, platform);
await resolveBundleCitations(bundle, platform);
const out = svc.importPages(space.id, bundle, req.actor);
const out = svc.importPages(space.id, bundle, req.actor);
@@ -164,7 +168,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -164,7 +168,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
discussion_thread: v.discussion ? v.discussion.threadId : null,
discussion_thread: v.discussion ? v.discussion.threadId : null,
};
};
}));
}));
- router.patch('/pages/:id', guard('wiki.page.create'), R((req) => {
+ router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
const b = req.body || {};
const b = req.body || {};
let page = svc.pageById(req.params.id);
let page = svc.pageById(req.params.id);
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
@@ -172,7 +176,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -172,7 +176,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;
if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;
return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };
return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };
}));
}));
- router.delete('/pages/:id', guard('wiki.page.create'), R((req) => {
+ router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
const out = svc.deletePage(req.params.id, req.actor);
const out = svc.deletePage(req.params.id, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };
}));
}));
@@ -181,7 +185,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -181,7 +185,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
}));
}));
- router.post('/pages/:id/revisions', guard('wiki.page.create'), R(async (req) => {
+ router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
const out = svc.editPage(req.params.id, {
const out = svc.editPage(req.params.id, {
@@ -196,7 +200,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -196,7 +200,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });
const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });
return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
}));
}));
- router.get('/pages/:id/diff', guard('wiki.page.read'), R((req) => {
+ router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R((req) => {
const { page, space } = readable(req, req.params.id);
const { page, space } = readable(req, req.params.id);
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
@@ -206,48 +210,48 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -206,48 +210,48 @@ function createApi({ svc, viewers, platform, config, log = console }) {
svc.view(space, page, req.actor, { revision: int(req.params.n) });
svc.view(space, page, req.actor, { revision: int(req.params.n) });
return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };
return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };
}));
}));
- router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), R(async (req) => {
+ router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {
const cites = await resolveCitations((req.body || {}).citations, platform);
const cites = await resolveCitations((req.body || {}).citations, platform);
return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
}, 201));
}, 201));
- router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), R((req) => {
+ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
const { page, space } = pageAndSpace(req.params.id);
const { page, space } = pageAndSpace(req.params.id);
const b = req.body || {};
const b = req.body || {};
const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
}, 201));
}, 201));
- router.post('/pages/:id/publish', guard('wiki.revision.publish'), R((req) => {
+ router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
}));
}));
- router.post('/pages/:id/schedule', guard('wiki.revision.publish'), R((req) => {
+ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
const b = req.body || {};
const b = req.body || {};
const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
return { job: out.job, created: out.created };
return { job: out.job, created: out.created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), R((req) => {
+ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
const out = svc.unpublish(req.params.id, req.actor);
const out = svc.unpublish(req.params.id, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
}));
}));
- router.post('/pages/:id/revert', guard('wiki.revision.revert'), R((req) => {
+ router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R((req) => {
const b = req.body || {};
const b = req.body || {};
const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
}, 201));
}, 201));
- router.post('/pages/:id/media', guard('wiki.page.create'), R(async (req) => {
+ router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });
const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });
return { attachment: serializeAttachment(a) };
return { attachment: serializeAttachment(a) };
}, 201));
}, 201));
- router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {
+ router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
const { space } = pageAndSpace(req.params.id);
const { space } = pageAndSpace(req.params.id);
if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
}));
}));
- router.put('/pages/:id/watch', R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));
+ router.put('/pages/:id/watch', B('wiki.page.watch'), R((req) => svc.watch(req.params.id, req.actor, (req.body || {}).watching !== false)));
// AI proposals
// AI proposals
- router.post('/proposals', guard('wiki.revision.propose'), R(async (req) => {
+ router.post('/proposals', guard('wiki.revision.propose'), B('wiki.revision.propose'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
const wf = b.workflow || {};
const wf = b.workflow || {};
@@ -266,12 +270,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -266,12 +270,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {
}
}
return { proposal: p };
return { proposal: p };
}));
}));
- router.post('/proposals/:id/review', guard('wiki.revision.publish'), R((req) => {
+ router.post('/proposals/:id/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
const b = req.body || {};
const b = req.body || {};
return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
}));
}));
- router.get('/search', guard('wiki.search.query'), R((req) => ({
+ router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R((req) => ({
results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
})));
})));
@@ -37,10 +37,14 @@ const content = require('../wiki/content');
@@ -37,10 +37,14 @@ const content = require('../wiki/content');
const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
const importer = require('../wiki/import');
const importer = require('../wiki/import');
-function createPages({ svc, viewers, platform, config, log = console }) {
+function createPages({ svc, viewers, platform, config, log = console, limits }) {
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(actorMiddleware(viewers, { services: false }));
router.use(actorMiddleware(viewers, { services: false }));
+ // Per-actor limits on the editing forms (http/actor-limits.js): each form shares its budget with the
+ // API route that does the same thing, counted before the form body is read. Reading pages is left
+ // to the per-address limits.
+ const B = (name) => limits.budget(name);
// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
@@ -134,7 +138,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -134,7 +138,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
send(req, res, 200, views.newSpacePage({ staff: req.actor.staff }), { title: 'Start a space', robots: 'noindex, nofollow' });
send(req, res, 200, views.newSpacePage({ staff: req.actor.staff }), { title: 'Start a space', robots: 'noindex, nofollow' });
});
});
- router.post('/new-space', form, wrap(async (req, res) => {
+ router.post('/new-space', B('wiki.space.create'), form, wrap(async (req, res) => {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
const b = req.body || {};
const b = req.body || {};
try {
try {
@@ -179,7 +183,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -179,7 +183,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
});
});
- router.post('/s/:space/new', form, wrap(async (req, res) => {
+ router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
const space = locateSpace(req, res);
const space = locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
@@ -205,7 +209,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -205,7 +209,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
});
});
- router.post('/s/:space/settings', form, wrap(async (req, res) => {
+ router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {
const space = locateSpace(req, res);
const space = locateSpace(req, res);
if (!space) return;
if (!space) return;
const b = req.body || {};
const b = req.body || {};
@@ -235,7 +239,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -235,7 +239,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
if (!space || !ownersOnly(req, res, space)) return;
if (!space || !ownersOnly(req, res, space)) return;
send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
});
});
- router.post('/s/:space/import', importForm, wrap(async (req, res) => {
+ router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {
const space = locateSpace(req, res);
const space = locateSpace(req, res);
if (!space || !ownersOnly(req, res, space)) return;
if (!space || !ownersOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
@@ -262,7 +266,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -262,7 +266,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
});
});
- router.post('/proposals/:id', form, wrap(async (req, res) => {
+ router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
const p = svc.getProposal(req.params.id);
const p = svc.getProposal(req.params.id);
if (!p) return notFound(req, res);
if (!p) return notFound(req, res);
@@ -408,7 +412,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -408,7 +412,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
});
});
- router.post('/w/:space/:slug/edit', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {
const found = locate(req, res, '/edit');
const found = locate(req, res, '/edit');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
@@ -446,7 +450,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -446,7 +450,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
});
});
- router.post('/w/:space/:slug/revert', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {
const found = locate(req, res, '/revert');
const found = locate(req, res, '/revert');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
@@ -479,7 +483,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -479,7 +483,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
}
}
- router.post('/w/:space/:slug/settings', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
const found = locate(req, res, '/settings');
const found = locate(req, res, '/settings');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
@@ -513,7 +517,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -513,7 +517,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
}
}));
}));
- router.post('/w/:space/:slug/review', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {
const found = locate(req, res, '/review');
const found = locate(req, res, '/review');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
@@ -523,7 +527,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -523,7 +527,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
res.redirect(303, `${views.wpath(space, page)}/history`);
res.redirect(303, `${views.wpath(space, page)}/history`);
}));
}));
- router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {
const found = locate(req, res, '/watch');
const found = locate(req, res, '/watch');
if (!found) return;
if (!found) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
@@ -531,7 +535,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -531,7 +535,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
res.redirect(303, views.wpath(found.space, found.page));
res.redirect(303, views.wpath(found.space, found.page));
}));
}));
- router.post('/w/:space/:slug/discuss', form, wrap(async (req, res) => {
+ router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {
const found = locate(req, res, '/discuss');
const found = locate(req, res, '/discuss');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
@@ -16,7 +16,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -16,7 +16,7 @@ const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
const { createApp } = require('./app');
-async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true } = {}) {
+async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
config = config || load();
config = config || load();
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const stores = createStores(db, { now });
@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -27,7 +27,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
const viewers = createViewerResolver({ keys, config });
- const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl });
+ const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl, limitsNow });
const timers = [];
const timers = [];
if (workers) {
if (workers) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'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 is not counted. Edits have their
+ * own budget, shared by the API and the edit form. Health, ready, release.json and metrics are never
+ * limited; refusals are logged (no token) and counted.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+const { actor, serviceItself } = require('../server/http/actor-limits');
+
+(async () => {
+ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
+ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+ const lines = [];
+ const log = { ...H.quiet, warn: (m) => lines.push(String(m)) };
+ const h = await H.boot({ rateLimits: true, limitsNow: () => clock, log, env: { WIKI_LIMITS_MINUTE: '3', WIKI_LIMITS_HOUR: '100' } });
+ const alice = H.subject();
+ const bob = H.subject();
+ const aliceTok = H.userToken({ subject: alice, username: 'alice' });
+ const bobTok = H.userToken({ subject: bob, username: 'bob' });
+ const svc = (extra = {}) => H.serviceToken({ client: 'ai', cap: ['wiki.page.read', 'wiki.page.create'], ...extra });
+ try {
+ // a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes
+ for (let i = 0; i < 3; i++) assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok })).status, 200);
+ let r = await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.strictEqual(r.headers.get('retry-after'), '45');
+ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));
+ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.json.detail.includes('wiki.read'), r.json.detail);
+ assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: bobTok })).status, 200, 'another person still passes');
+
+ // a service naming the person counts against that person
+ r = await H.req(h, 'GET', '/api/v1/spaces', { token: svc(), headers: { 'X-OV-Subject': alice } });
+ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
+
+ // a service relaying signed-out visitors: each forwarded address on its own; reading for itself: not counted
+ const visitor = (ip) => H.req(h, 'GET', '/api/v1/spaces', { token: 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 H.req(h, 'GET', '/api/v1/spaces', { token: svc() })).status, 200);
+
+ // the next minute opens the window again
+ clock += 45 * 1000;
+ assert.strictEqual((await H.req(h, 'GET', '/api/v1/spaces', { token: aliceTok })).status, 200);
+
+ // an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past it
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ const owner = { kind: 'user', subject: alice, staff: false };
+ const space = h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);
+ const { page } = h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);
+ h.svc.setRole(space.id, bob, 'editor', owner);
+ const head = () => h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
+ for (let i = 0; i < 30; i++) {
+ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: head(), body: `${H.LONG} Edit ${i}.` } });
+ assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);
+ }
+ const stored = head();
+ r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });
+ assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
+ assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);
+ assert.strictEqual(head(), stored, 'nothing stored');
+ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });
+ assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);
+
+ // health, ready, release.json and metrics are never limited
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await H.req(h, 'GET', '/api/health')).status, 200);
+ assert.notStrictEqual((await H.req(h, 'GET', '/api/ready')).status, 429);
+ assert.strictEqual((await H.req(h, 'GET', '/release.json')).status, 200);
+ assert.strictEqual((await H.req(h, 'GET', '/metrics')).status, 200);
+ }
+
+ // refusals are counted in wiki_rate_limited_total and logged without a token
+ const m = (await H.req(h, 'GET', '/metrics')).text;
+ const counted = m.split('\n').filter((l) => l.includes('wiki_rate_limited_total')).join('\n');
+ assert.ok(/wiki_rate_limited_total\{limit="wiki.read",window="minute"\} 3/.test(m), counted);
+ assert.ok(/wiki_rate_limited_total\{limit="wiki.page.edit",window="minute"\} 1/.test(m), counted);
+ assert.ok(lines.some((l) => l.includes(`wiki.read: user:${alice} refused`)), lines.join('\n'));
+ for (const l of lines) for (const tok of [aliceTok, bobTok]) assert.ok(!l.includes(tok), 'a token in the log');
+
+ // who is counted
+ const q = (a, { xff = null, ip = '127.0.0.1' } = {}) => ({ actor: a, 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 (proposals)');
+ 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);
+ console.log('actor limits: ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -38,7 +38,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -38,7 +38,7 @@ const quiet = { log() {}, warn() {}, error() {} };
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* (Community, Sources, Media, Events, Network token endpoint).
* (Community, Sources, Media, Events, Network token endpoint).
*/
*/
-async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false } = {}) {
+async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const config = load({
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
@@ -47,7 +47,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
@@ -47,7 +47,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
...env,
...env,
});
});
const h = await start({
const h = await start({
- config, publicKey, log: quiet, listen: true, workers, rateLimits, now,
+ config, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});