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

OP OpenVibers committed 28 Sept 2026 · 32a26ef43c · 12 files, +326 −46

API reads take WIKI_LIMITS_MINUTE / WIKI_LIMITS_HOUR, 120 and 3000. Each edit, publish and settings change has its own budget, and a form shares that budget with the API route that does the same thing:
- Space create: 10 a minute, 60 an hour. Space settings and roles: 30 and 300.
- Page create: 30 and 300, because each one stores the text and asks Sources about every cited item. Edits, including previews and reverts: 30 and 600. Moves, visibility and deletes: 30 and 300.
- Publishing, scheduling, unpublishing and reviews: 30 and 300, since each one changes what readers, feeds, sitemaps and Search see and emits an event. Citations: 30 and 300.
- AI proposals: 60 and 1200, one a second at most.
- Imports: 5 and 20, as the heaviest write (the per-address limit already allows 20 an hour).
- Media attach and verify, which ask Media: 20 and 200. Watch: 60 and 600.
- Discussion comments sent to Community in the person's name: 20 and 300.
- API search and diffs, which scan every published page or diff two long revisions: 30 and 600 each.

The per-address limits (API 300 a minute, forms, search, diffs, imports, sign-in) stay. Health, ready, release.json, metrics, sign-in and the pages people read are never limited per actor. Like the per-address limits, the per-actor counters are off when createApp gets rateLimits=false, which only tests pass. The test helper now takes limitsNow and a log.

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 6c0cae6..fc57002 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 563b733..d2332d7 100644
---- a/README.md
++++ b/README.md
@@@ -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
`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
ddiff --git a/package-lock.json b/package-lock.json
iindex 65f31d4..d54fcc6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex d956c40..b81a276 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex e4a99fc..d995bec 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, ra
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.
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.'));
ddiff --git a/server/config.js b/server/config.js
iindex 04b005d..4260d51 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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).
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..8ba920e
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 330ee56..9b552fb 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 }) {
// Spaces
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) => {
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 })),
})));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bb9f8bf..5b011bd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex eddced4..3c018ca 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.fe
const 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) {
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..872bb8a
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -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); });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 91e992b..89ed98a 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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() {} },
});