openvibers/openvibe.search Commits
Per-actor rate limits at Search's API routes (roadmap WS-R task 4): openvibe-sdk v0.12.0 is now a dependency, and server/actor-limits.js makes one limiter per app. Owner routes count the service principal (svc:wiki); query and saved-search routes count the person, from their own token or the X-OV-Subject a delegating service vouches for, and a signed-out caller by address. A first-party service querying for its signed-out visitors is not counted, since it speaks for all of them and one budget would refuse its whole site. withViewer resolves the viewer first, so the limit runs after authentication and before the handler. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one [limits] line and counts search_rate_limited_total{limit,window}.
Changes
@@@ -52,6 +52,12 @@ SEARCH_DB_PATH=./data/search.db# Saved searches per signed-in person.#SEARCH_SAVED_MAX_PER_SUBJECT=50++# Per-actor limits (server/actor-limits.js): requests one caller (a service principal, a signed-in++# person, else an address) may make to a read route per minute and per hour; past them 429++# rate_limited with Retry-After. Indexing, suggest and saved-search changes have their own (README).++#SEARCH_LIMITS_MINUTE=120++#SEARCH_LIMITS_HOUR=3000++# Removal purge queue (README "Removals, caches and the CDN"). Every removal is recorded for# its owner (GET /api/v1/owners/:owner/removals). The Cloudflare purge of a formerly public URL# runs only when CLOUDFLARE_PURGE_TOKEN is set (inert otherwise). The token needs only@@@ -204,6 +204,25 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi[server/auth.js](server/auth.js) decides them with the contracts grant rule, and CI runs`openvibe-contracts-check --service search`.++### Per-actor limits++++Every API route also limits who calls it, after its credential and before any work++([server/actor-limits.js](server/actor-limits.js), openvibe-sdk/limits, roadmap WS-R task 4): the++service principal on owner routes; on query and saved-search routes the person (their own token, or++the `X-OV-Subject` a delegating service vouches for), else the address. A service querying for its++signed-out visitors is not counted (it speaks for all of them). Past a limit: `429` problem+json++`rate_limited` with `Retry-After`, one `[limits]` log line and `search_rate_limited_total{limit,window}`.++++| Route | Per caller |++|---|---|++| Queries, direct gets, owner reconciliation reads, saved-search reads and runs | `SEARCH_LIMITS_MINUTE` / `SEARCH_LIMITS_HOUR` (120 a minute, 3000 an hour) |++| `GET /api/v1/suggest` | 300 / 6000 (a search box as the person types) |++| `PUT/DELETE /api/v1/documents/…` | 120 / 1200 (a catalog sync is a burst; bulk content comes through Events) |++| Saved-search create, delete | 30 / 300 |++++Never limited: the signed Events deliveries (`POST /internal/events`), `/api/health`, `/api/ready`,++`/release.json`, `/metrics`; the HTML page keeps nginx's address limits. `test/actor-limits.test.js`.++## Acceptance (tests)- a private or draft document is never returned to an unauthorized query — hits, snippets, facet@@@ -222,6 +241,8 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi- saved searches: signed-in only, own only, run-time ACL, Origin check for cookies, limits(`test/saved-searches.test.js`)- the search page: public only, escaped, no-store, noindex results, paging (`test/page.test.js`)++- per-actor limits: 429 `rate_limited` with Retry-After per principal, person or address, another++ caller unaffected, deliveries and health never limited (`test/actor-limits.test.js`)Not yet demonstrated: a document with content indexed end to end in production. The Eventssubscription delivered 20 wiki events; the 10 upserts were rejected (`owner_not_accepted`, since@@@ -15,6 +15,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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": {@@@ -984,6 +985,15 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "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"++ }++ },"node_modules/openvibe-shared": {"version": "1.22.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",@@@ -21,6 +21,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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": {nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * Per-actor rate limits at the API routes (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * nginx limits by address; these limit by who calls, once the route's credential is checked:++ * owner routes (requireCap) the service principal (svc:wiki)++ * queries (withViewer) the person: a Network user's subject, or the X-OV-Subject a first-party++ * service vouches for (user:usr_…); a signed-out caller by address++ * A first-party service querying for its signed-out visitors (a delegate token without X-OV-Subject)++ * is not counted: it speaks for all of them and limits them itself, so one budget would refuse its++ * whole site. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before++ * it does any work; the refusal is logged once and counted in search_rate_limited_total{limit,window}.++ * Read routes get SEARCH_LIMITS_MINUTE / SEARCH_LIMITS_HOUR (120 and 3000). Counters live in this++ * process: a restart forgets them.++ *++ * Never limited: /api/health, /api/ready, /release.json, /metrics, and the signed Events deliveries++ * (POST /internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall++ * behind). The HTML page (GET /) stays with nginx's address limits.++ */++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++++function actor(req) {++ const v = req.viewer;++ if (!v) return defaultActor(req); // owner routes: req.principal.sub++ if (v.subject) return `user:${v.subject}`;++ if (v.kind === 'service') return null;++ return defaultActor(req); // signed out: ip:<address>++}++++/** limits(name, own) middleware for one app. */++function createLimits({ config, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'search_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ return createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor,++ now,++ onLimited(e) {++ // The actor is a principal, a subject id 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 });++ },++ });++}++++module.exports = { createLimits, actor };@@@ -42,9 +42,14 @@ function outcomeResponse(res, ctx, r) {return res.json(r);}--function documentsRouter({ store, auth, db, relay, purges }) {++function documentsRouter({ store, auth, db, relay, purges, limits }) {const router = express.Router();const guard = auth.requireCap(CAPS.write);++ // Per-actor limits (server/actor-limits.js), by service principal; reconciliation reads take the++ // defaults. A direct indexer syncs its catalog in a burst (Tools: every tool on a first run), so++ // indexing keeps the default minute, but the hour is tighter: bulk content comes through Events++ // (search.index_document.*), not one PUT at a time.++ const index = { minute: 120, hour: 1200 };function ownerCheck(req, res) {const ctx = req.ov;@@@ -59,7 +64,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {return true;}-- router.put('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {++ router.put('/api/v1/documents/:owner/:type/:id', guard, limits('search.document.index', index), (req, res) => {const ctx = req.ov;if (!ownerCheck(req, res)) return;const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;@@@ -77,7 +82,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {return outcomeResponse(res, ctx, r);});-- router.delete('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {++ router.delete('/api/v1/documents/:owner/:type/:id', guard, limits('search.document.remove', index), (req, res) => {const ctx = req.ov;if (!ownerCheck(req, res)) return;let revision = null;@@@ -90,7 +95,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {return outcomeResponse(res, ctx, r);});-- router.get('/api/v1/owners/:owner/documents', guard, (req, res) => {++ router.get('/api/v1/owners/:owner/documents', guard, limits('search.owner.documents'), (req, res) => {const ctx = req.ov;if (!ownerCheck(req, res)) return;const type = req.query.type ? String(req.query.type) : null;@@@ -100,7 +105,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {res.json({ owner: req.params.owner, ...store.ownerPage(req.params.owner, { type, after, limit }) });});-- router.get('/api/v1/owners/:owner/documents/:type/:id', guard, (req, res) => {++ router.get('/api/v1/owners/:owner/documents/:type/:id', guard, limits('search.owner.document'), (req, res) => {const ctx = req.ov;if (!ownerCheck(req, res)) return;const found = store.get(req.params.owner, req.params.type, req.params.id);@@@ -115,7 +120,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {});});-- router.get('/api/v1/owners/:owner/rejections', guard, (req, res) => {++ router.get('/api/v1/owners/:owner/rejections', guard, limits('search.owner.rejections'), (req, res) => {if (!ownerCheck(req, res)) return;const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;const rows = db.prepare('SELECT * FROM ingest_rejections WHERE owner = ? AND seq > ? ORDER BY seq LIMIT 200').all(req.params.owner, after);@@@ -128,7 +133,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {});});-- router.get('/api/v1/owners/:owner/removals', guard, (req, res) => {++ router.get('/api/v1/owners/:owner/removals', guard, limits('search.owner.removals'), (req, res) => {if (!ownerCheck(req, res)) return;const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 1000);@@@ -163,8 +163,11 @@ function createSearcher({ config, store, engine, now: clock = () => Date.now() }};}--/** Wraps a handler: resolves the viewer (AuthError → problem), no-store, QueryError → 400. */--function withViewer(auth, handler) {++/**++ * Wraps a handler: resolves the viewer (AuthError → problem), no-store, QueryError → 400. With `limit`++ * (a per-actor limit, server/actor-limits.js) the viewer is counted as req.viewer before the handler runs.++ */++function withViewer(auth, handler, limit = null) {return (req, res, next) => {const ctx = req.ov;let viewer;@@@ -178,20 +181,26 @@ function withViewer(auth, handler) {// personalised answer must never reach a shared cache.res.setHeader('Cache-Control', 'no-store');res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');-- try {-- return handler(req, res, viewer);-- } catch (err) {-- if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });-- return next(err);-- }++ const run = () => {++ try {++ return handler(req, res, viewer);++ } catch (err) {++ if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });++ return next(err);++ }++ };++ req.viewer = viewer;++ return limit ? limit(req, res, run) : run();};}--function queryRouter({ config, store, engine, auth, searcher = createSearcher({ config, store, engine }) }) {++function queryRouter({ config, store, engine, auth, limits, searcher = createSearcher({ config, store, engine }) }) {const router = express.Router();-- const guarded = (h) => withViewer(auth, h);++ const guarded = (limit, h) => withViewer(auth, h, limit);-- router.get('/api/v1/search', guarded((req, res, viewer) => {++ // Per-actor limits (server/actor-limits.js): queries and document reads take the defaults. Suggest++ // answers a search box as the person types, several requests a second, so it allows 300 a minute.++ router.get('/api/v1/search', guarded(limits('search.query'), (req, res, viewer) => {const q = one(req.query.q);res.json(searcher.run({text: q === undefined ? '' : String(q),@@@ -203,7 +212,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({}));}));-- router.get('/api/v1/suggest', guarded((req, res, viewer) => {++ router.get('/api/v1/suggest', guarded(limits('search.suggest', { minute: 300, hour: 6000 }), (req, res, viewer) => {const text = String(one(req.query.q) || '');if (text.length > 100) throw new QueryError('search.bad_query', 'q is longer than 100 characters');const filters = parseFilters(req.query, config);@@@ -220,7 +229,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({res.json({ suggestions });}));-- router.get('/api/v1/documents/:owner/:type/:id', guarded((req, res, viewer) => {++ router.get('/api/v1/documents/:owner/:type/:id', guarded(limits('search.document.read'), (req, res, viewer) => {const { owner, type, id } = req.params;if (!OWNER_RE.test(owner) || !TYPE_RE.test(type) || !ID_RE.test(id)) throw new QueryError('search.bad_identity', 'owner, type or id is malformed');const found = store.get(owner, type, id);@@@ -23,7 +23,7 @@ const { withViewer, QueryError, one } = require('./query');const NAME_MAX = 100;--function savedRouter({ config, saved, searcher, auth }) {++function savedRouter({ config, saved, searcher, auth, limits }) {const router = express.Router();const selfOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();@@@ -48,15 +48,18 @@ function savedRouter({ config, saved, searcher, auth }) {return viewer.subject;}-- const guarded = (h) => withViewer(auth, h);++ // Per-actor limits (server/actor-limits.js), counted by the signed-in person: reads (and running a++ // saved search) take the defaults; saving and deleting are a person's clicks, 30 a minute at most.++ const guarded = (limit, h) => withViewer(auth, h, limit);++ const change = { minute: 30, hour: 300 };-- router.get('/api/v1/saved-searches', guarded((req, res, viewer) => {++ router.get('/api/v1/saved-searches', guarded(limits('search.saved.list'), (req, res, viewer) => {const subject = person(req, res, viewer);if (!subject) return;res.json({ saved_searches: saved.list(subject), max: saved.maxPerSubject });}));-- router.post('/api/v1/saved-searches', guarded((req, res, viewer) => {++ router.post('/api/v1/saved-searches', guarded(limits('search.saved.create', change), (req, res, viewer) => {const ctx = req.ov;const subject = person(req, res, viewer, { write: true });if (!subject) return;@@@ -91,7 +94,7 @@ function savedRouter({ config, saved, searcher, auth }) {res.status(r.created ? 201 : 200).json({ saved_search: r.saved });}));-- router.get('/api/v1/saved-searches/:id', guarded((req, res, viewer) => {++ router.get('/api/v1/saved-searches/:id', guarded(limits('search.saved.read'), (req, res, viewer) => {const subject = person(req, res, viewer);if (!subject) return;const s = saved.get(subject, req.params.id);@@@ -99,14 +102,14 @@ function savedRouter({ config, saved, searcher, auth }) {res.json({ saved_search: s });}));-- router.delete('/api/v1/saved-searches/:id', guarded((req, res, viewer) => {++ router.delete('/api/v1/saved-searches/:id', guarded(limits('search.saved.delete', change), (req, res, viewer) => {const subject = person(req, res, viewer, { write: true });if (!subject) return;if (!saved.remove(subject, req.params.id)) return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such saved search', ctx: req.ov });res.status(204).end();}));-- router.get('/api/v1/saved-searches/:id/results', guarded((req, res, viewer) => {++ router.get('/api/v1/saved-searches/:id/results', guarded(limits('search.saved.run'), (req, res, viewer) => {const subject = person(req, res, viewer);if (!subject) return;const s = saved.get(subject, req.params.id);@@@ -11,6 +11,7 @@ const { queryRouter, createSearcher } = require('./api/query');const { savedRouter } = require('./api/saved');const { pageRouter } = require('./web/page');const { webhookRouter } = require('./api/webhook');++const { createLimits } = require('./actor-limits');const pkg = require('../package.json');function createApp({ config, db, store, engine, auth, keys, outbox, relay, purges, purger, saved, log = console, now }) {@@@ -50,9 +51,12 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, purgerelease.mount(app, { registry: metrics.registry });const searcher = createSearcher({ config, store, engine, now });-- app.use(documentsRouter({ store, auth, db, relay, purges }));-- app.use(queryRouter({ config, store, engine, auth, searcher }));-- app.use(savedRouter({ config, saved, searcher, auth }));++ // One per-actor limiter for the API routes below (server/actor-limits.js); the Events webhook,++ // health, ready, release.json and metrics above are never limited.++ const limits = createLimits({ config, now: now || (() => Date.now()), registry: metrics.registry, log });++ app.use(documentsRouter({ store, auth, db, relay, purges, limits }));++ app.use(queryRouter({ config, store, engine, auth, searcher, limits }));++ app.use(savedRouter({ config, saved, searcher, auth, limits }));// GET / (HTML search page for browsers, the text route index otherwise) and /robots.txt.app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl }));@@@ -100,6 +100,14 @@ function load(env = process.env) {maxPerSubject: Math.max(1, int(env.SEARCH_SAVED_MAX_PER_SUBJECT, 50)),},++ // Per-actor limits at the API routes (server/actor-limits.js, roadmap WS-R task 4): the requests++ // one caller may make to a read route per minute and per hour. Indexing, suggest and saved-search++ // changes set their own numbers where they are mounted.++ limits: {++ minute: Math.max(1, int(env.SEARCH_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.SEARCH_LIMITS_HOUR, 3000)),++ },++// Removal purge queue (server/purge.js). Every removal is recorded for owners; the// Cloudflare purge of a formerly public URL runs only when the token is set.purge: {nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++/**++ * Per-actor rate limits (server/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. Owner routes count the service principal,++ * queries the person (their own token or the X-OV-Subject a service vouches for), signed-out callers++ * their address; a service querying for its signed-out visitors is not counted. Indexing has its own++ * numbers. The Events webhook, health, ready, release.json and metrics are never limited.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, request, serviceToken, userToken, doc, indexEvent, deliver, suite } = require('./helpers');++const { actor } = require('../server/actor-limits');++++const t = suite('actor-limits');++// 15 s into a minute, so the minute window has 45 s left.++let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++const now = () => clock;++const WIKI = serviceToken('wiki', ['search.document.write']);++const BLOG = serviceToken('blog', ['search.document.write']);++const DELEGATE = serviceToken('live', ['search.query.delegate']);++const alice = ids.newId('user');++const bob = ids.newId('user');++const as = (subjectId) => ({ token: userToken({ subjectId, aud: ['openvibe.search'] }) });++++let svc;++const call = (method, p, opts = {}) => request(svc.base, method, p, opts);++++t('boot (SEARCH_LIMITS_MINUTE=3)', async () => {++ svc = await boot({ now, env: { SEARCH_LIMITS_MINUTE: '3', SEARCH_LIMITS_HOUR: '100', SEARCH_EVENT_OWNERS: 'wiki' } });++});++++t('a query: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(alice))).status, 200);++ const r = await call('GET', '/api/v1/search?q=zebra', as(alice));++ 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')));++ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.body.detail.includes('search.query'), r.body.detail);++ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(bob))).status, 200, 'another person still passes');++ // A service vouching for alice counts against alice; for its signed-out visitors it is not counted.++ const vouched = await call('GET', '/api/v1/search?q=zebra', { token: DELEGATE, headers: { 'X-OV-Subject': alice } });++ assert.deepStrictEqual([vouched.status, vouched.body.code], [429, 'rate_limited']);++ for (let i = 0; i < 6; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', { token: DELEGATE })).status, 200);++ // Signed out: counted by address (127.0.0.1 here).++ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra')).status, 200);++ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra')).status, 429);++ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');++ clock += 45 * 1000;++ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(alice))).status, 200, 'the next minute opens the window again');++});++++t('indexing: 120 a minute per service principal, refused before the store is touched', async () => {++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);++ for (let i = 0; i < 120; i++) {++ const d = doc({ id: `pg_${i}` });++ assert.strictEqual((await call('PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d })).status, 200);++ }++ const d = doc({ id: 'pg_over' });++ const r = await call('PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });++ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);++ assert.strictEqual(svc.store.get('wiki', 'page', 'pg_over'), null, 'nothing stored');++ const b = doc({ owner: 'blog', type: 'post', id: 'post_1', canonical_url: 'https://openvibe.blog/p/1' });++ assert.strictEqual((await call('PUT', '/api/v1/documents/blog/post/post_1', { token: BLOG, body: b })).status, 200, 'another service still indexes');++});++++t('the signed Events deliveries, health, ready, release.json and metrics are never limited', async () => {++ for (let i = 0; i < 6; i++) {++ const r = await deliver(svc.base, indexEvent(doc({ id: `ev_${i}`, canonical_url: `https://openvibe.wiki/p/ev${i}` })), { seq: i + 1 });++ assert.ok(r.status < 300, `delivery ${i + 1}: ${r.status} ${r.text}`);++ assert.strictEqual((await call('GET', '/api/health')).status, 200);++ assert.notStrictEqual((await call('GET', '/api/ready')).status, 429);++ assert.strictEqual((await call('GET', '/release.json')).status, 200);++ assert.strictEqual((await call('GET', '/metrics')).status, 200);++ }++});++++t('refusals are counted in search_rate_limited_total', async () => {++ const m = (await call('GET', '/metrics')).text;++ assert.ok(/search_rate_limited_total\{limit="search.query",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));++ assert.ok(/search_rate_limited_total\{limit="search.document.index",window="minute"\} 1/.test(m));++});++++t('who is counted', () => {++ assert.strictEqual(actor({ principal: { sub: 'svc:wiki' } }), 'svc:wiki');++ assert.strictEqual(actor({ viewer: { kind: 'user', subject: alice } }), `user:${alice}`);++ assert.strictEqual(actor({ viewer: { kind: 'service', service: 'svc:live', subject: alice } }), `user:${alice}`);++ assert.strictEqual(actor({ viewer: { kind: 'service', service: 'svc:live', subject: null } }), null);++ assert.strictEqual(actor({ viewer: { kind: 'anonymous', subject: null }, ip: '203.0.113.9' }), 'ip:203.0.113.9');++});++++t('stop', async () => { await svc.stop(); });++++t.run();
@@ -52,6 +52,12 @@ SEARCH_DB_PATH=./data/search.db
@@ -52,6 +52,12 @@ SEARCH_DB_PATH=./data/search.db
# Saved searches per signed-in person.
# Saved searches per signed-in person.
#SEARCH_SAVED_MAX_PER_SUBJECT=50
#SEARCH_SAVED_MAX_PER_SUBJECT=50
+# Per-actor limits (server/actor-limits.js): requests one caller (a service principal, a signed-in
+# person, else an address) may make to a read route per minute and per hour; past them 429
+# rate_limited with Retry-After. Indexing, suggest and saved-search changes have their own (README).
+#SEARCH_LIMITS_MINUTE=120
+#SEARCH_LIMITS_HOUR=3000
+
# Removal purge queue (README "Removals, caches and the CDN"). Every removal is recorded for
# Removal purge queue (README "Removals, caches and the CDN"). Every removal is recorded for
# its owner (GET /api/v1/owners/:owner/removals). The Cloudflare purge of a formerly public URL
# its owner (GET /api/v1/owners/:owner/removals). The Cloudflare purge of a formerly public URL
# runs only when CLOUDFLARE_PURGE_TOKEN is set (inert otherwise). The token needs only
# runs only when CLOUDFLARE_PURGE_TOKEN is set (inert otherwise). The token needs only
@@ -204,6 +204,25 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi
@@ -204,6 +204,25 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi
[server/auth.js](server/auth.js) decides them with the contracts grant rule, and CI runs
[server/auth.js](server/auth.js) decides them with the contracts grant rule, and CI runs
`openvibe-contracts-check --service search`.
`openvibe-contracts-check --service search`.
+### Per-actor limits
+
+Every API route also limits who calls it, after its credential and before any work
+([server/actor-limits.js](server/actor-limits.js), openvibe-sdk/limits, roadmap WS-R task 4): the
+service principal on owner routes; on query and saved-search routes the person (their own token, or
+the `X-OV-Subject` a delegating service vouches for), else the address. A service querying for its
+signed-out visitors is not counted (it speaks for all of them). Past a limit: `429` problem+json
+`rate_limited` with `Retry-After`, one `[limits]` log line and `search_rate_limited_total{limit,window}`.
+
+| Route | Per caller |
+|---|---|
+| Queries, direct gets, owner reconciliation reads, saved-search reads and runs | `SEARCH_LIMITS_MINUTE` / `SEARCH_LIMITS_HOUR` (120 a minute, 3000 an hour) |
+| `GET /api/v1/suggest` | 300 / 6000 (a search box as the person types) |
+| `PUT/DELETE /api/v1/documents/…` | 120 / 1200 (a catalog sync is a burst; bulk content comes through Events) |
+| Saved-search create, delete | 30 / 300 |
+
+Never limited: the signed Events deliveries (`POST /internal/events`), `/api/health`, `/api/ready`,
+`/release.json`, `/metrics`; the HTML page keeps nginx's address limits. `test/actor-limits.test.js`.
+
## Acceptance (tests)
## Acceptance (tests)
- a private or draft document is never returned to an unauthorized query — hits, snippets, facet
- a private or draft document is never returned to an unauthorized query — hits, snippets, facet
@@ -222,6 +241,8 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi
@@ -222,6 +241,8 @@ Released in `openvibe-contracts` v0.12.0 with the service manifest (this repo pi
- saved searches: signed-in only, own only, run-time ACL, Origin check for cookies, limits
- saved searches: signed-in only, own only, run-time ACL, Origin check for cookies, limits
(`test/saved-searches.test.js`)
(`test/saved-searches.test.js`)
- the search page: public only, escaped, no-store, noindex results, paging (`test/page.test.js`)
- the search page: public only, escaped, no-store, noindex results, paging (`test/page.test.js`)
+- per-actor limits: 429 `rate_limited` with Retry-After per principal, person or address, another
+ caller unaffected, deliveries and health never limited (`test/actor-limits.test.js`)
Not yet demonstrated: a document with content indexed end to end in production. The Events
Not yet demonstrated: a document with content indexed end to end in production. The Events
subscription delivered 20 wiki events; the 10 upserts were rejected (`owner_not_accepted`, since
subscription delivered 20 wiki events; the 10 upserts were rejected (`owner_not_accepted`, since
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-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": {
@@ -984,6 +985,15 @@
@@ -984,6 +985,15 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "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"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"version": "1.22.0",
"version": "1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * Per-actor rate limits at the API routes (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * nginx limits by address; these limit by who calls, once the route's credential is checked:
+ * owner routes (requireCap) the service principal (svc:wiki)
+ * queries (withViewer) the person: a Network user's subject, or the X-OV-Subject a first-party
+ * service vouches for (user:usr_…); a signed-out caller by address
+ * A first-party service querying for its signed-out visitors (a delegate token without X-OV-Subject)
+ * is not counted: it speaks for all of them and limits them itself, so one budget would refuse its
+ * whole site. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before
+ * it does any work; the refusal is logged once and counted in search_rate_limited_total{limit,window}.
+ * Read routes get SEARCH_LIMITS_MINUTE / SEARCH_LIMITS_HOUR (120 and 3000). Counters live in this
+ * process: a restart forgets them.
+ *
+ * Never limited: /api/health, /api/ready, /release.json, /metrics, and the signed Events deliveries
+ * (POST /internal/events: Events pushes at its own pace, and a 429 would only make it retry and fall
+ * behind). The HTML page (GET /) stays with nginx's address limits.
+ */
+const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+
+function actor(req) {
+ const v = req.viewer;
+ if (!v) return defaultActor(req); // owner routes: req.principal.sub
+ if (v.subject) return `user:${v.subject}`;
+ if (v.kind === 'service') return null;
+ return defaultActor(req); // signed out: ip:<address>
+}
+
+/** limits(name, own) middleware for one app. */
+function createLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'search_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ return createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor,
+ now,
+ onLimited(e) {
+ // The actor is a principal, a subject id 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 });
+ },
+ });
+}
+
+module.exports = { createLimits, actor };
@@ -42,9 +42,14 @@ function outcomeResponse(res, ctx, r) {
@@ -42,9 +42,14 @@ function outcomeResponse(res, ctx, r) {
return res.json(r);
return res.json(r);
}
}
-function documentsRouter({ store, auth, db, relay, purges }) {
+function documentsRouter({ store, auth, db, relay, purges, limits }) {
const router = express.Router();
const router = express.Router();
const guard = auth.requireCap(CAPS.write);
const guard = auth.requireCap(CAPS.write);
+ // Per-actor limits (server/actor-limits.js), by service principal; reconciliation reads take the
+ // defaults. A direct indexer syncs its catalog in a burst (Tools: every tool on a first run), so
+ // indexing keeps the default minute, but the hour is tighter: bulk content comes through Events
+ // (search.index_document.*), not one PUT at a time.
+ const index = { minute: 120, hour: 1200 };
function ownerCheck(req, res) {
function ownerCheck(req, res) {
const ctx = req.ov;
const ctx = req.ov;
@@ -59,7 +64,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -59,7 +64,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
return true;
return true;
}
}
- router.put('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {
+ router.put('/api/v1/documents/:owner/:type/:id', guard, limits('search.document.index', index), (req, res) => {
const ctx = req.ov;
const ctx = req.ov;
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
@@ -77,7 +82,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -77,7 +82,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
return outcomeResponse(res, ctx, r);
return outcomeResponse(res, ctx, r);
});
});
- router.delete('/api/v1/documents/:owner/:type/:id', guard, (req, res) => {
+ router.delete('/api/v1/documents/:owner/:type/:id', guard, limits('search.document.remove', index), (req, res) => {
const ctx = req.ov;
const ctx = req.ov;
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
let revision = null;
let revision = null;
@@ -90,7 +95,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -90,7 +95,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
return outcomeResponse(res, ctx, r);
return outcomeResponse(res, ctx, r);
});
});
- router.get('/api/v1/owners/:owner/documents', guard, (req, res) => {
+ router.get('/api/v1/owners/:owner/documents', guard, limits('search.owner.documents'), (req, res) => {
const ctx = req.ov;
const ctx = req.ov;
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
const type = req.query.type ? String(req.query.type) : null;
const type = req.query.type ? String(req.query.type) : null;
@@ -100,7 +105,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -100,7 +105,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
res.json({ owner: req.params.owner, ...store.ownerPage(req.params.owner, { type, after, limit }) });
res.json({ owner: req.params.owner, ...store.ownerPage(req.params.owner, { type, after, limit }) });
});
});
- router.get('/api/v1/owners/:owner/documents/:type/:id', guard, (req, res) => {
+ router.get('/api/v1/owners/:owner/documents/:type/:id', guard, limits('search.owner.document'), (req, res) => {
const ctx = req.ov;
const ctx = req.ov;
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
const found = store.get(req.params.owner, req.params.type, req.params.id);
const found = store.get(req.params.owner, req.params.type, req.params.id);
@@ -115,7 +120,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -115,7 +120,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
});
});
});
});
- router.get('/api/v1/owners/:owner/rejections', guard, (req, res) => {
+ router.get('/api/v1/owners/:owner/rejections', guard, limits('search.owner.rejections'), (req, res) => {
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
const rows = db.prepare('SELECT * FROM ingest_rejections WHERE owner = ? AND seq > ? ORDER BY seq LIMIT 200').all(req.params.owner, after);
const rows = db.prepare('SELECT * FROM ingest_rejections WHERE owner = ? AND seq > ? ORDER BY seq LIMIT 200').all(req.params.owner, after);
@@ -128,7 +133,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
@@ -128,7 +133,7 @@ function documentsRouter({ store, auth, db, relay, purges }) {
});
});
});
});
- router.get('/api/v1/owners/:owner/removals', guard, (req, res) => {
+ router.get('/api/v1/owners/:owner/removals', guard, limits('search.owner.removals'), (req, res) => {
if (!ownerCheck(req, res)) return;
if (!ownerCheck(req, res)) return;
const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? Number(req.query.after) : 0;
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 1000);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 1000);
@@ -163,8 +163,11 @@ function createSearcher({ config, store, engine, now: clock = () => Date.now() }
@@ -163,8 +163,11 @@ function createSearcher({ config, store, engine, now: clock = () => Date.now() }
};
};
}
}
-/** Wraps a handler: resolves the viewer (AuthError → problem), no-store, QueryError → 400. */
-function withViewer(auth, handler) {
+/**
+ * Wraps a handler: resolves the viewer (AuthError → problem), no-store, QueryError → 400. With `limit`
+ * (a per-actor limit, server/actor-limits.js) the viewer is counted as req.viewer before the handler runs.
+ */
+function withViewer(auth, handler, limit = null) {
return (req, res, next) => {
return (req, res, next) => {
const ctx = req.ov;
const ctx = req.ov;
let viewer;
let viewer;
@@ -178,20 +181,26 @@ function withViewer(auth, handler) {
@@ -178,20 +181,26 @@ function withViewer(auth, handler) {
// personalised answer must never reach a shared cache.
// personalised answer must never reach a shared cache.
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');
res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');
- try {
- return handler(req, res, viewer);
- } catch (err) {
- if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });
- return next(err);
- }
+ const run = () => {
+ try {
+ return handler(req, res, viewer);
+ } catch (err) {
+ if (err instanceof QueryError) return http.sendProblem(res, 400, err.code, { detail: err.message, ctx });
+ return next(err);
+ }
+ };
+ req.viewer = viewer;
+ return limit ? limit(req, res, run) : run();
};
};
}
}
-function queryRouter({ config, store, engine, auth, searcher = createSearcher({ config, store, engine }) }) {
+function queryRouter({ config, store, engine, auth, limits, searcher = createSearcher({ config, store, engine }) }) {
const router = express.Router();
const router = express.Router();
- const guarded = (h) => withViewer(auth, h);
+ const guarded = (limit, h) => withViewer(auth, h, limit);
- router.get('/api/v1/search', guarded((req, res, viewer) => {
+ // Per-actor limits (server/actor-limits.js): queries and document reads take the defaults. Suggest
+ // answers a search box as the person types, several requests a second, so it allows 300 a minute.
+ router.get('/api/v1/search', guarded(limits('search.query'), (req, res, viewer) => {
const q = one(req.query.q);
const q = one(req.query.q);
res.json(searcher.run({
res.json(searcher.run({
text: q === undefined ? '' : String(q),
text: q === undefined ? '' : String(q),
@@ -203,7 +212,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({
@@ -203,7 +212,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({
}));
}));
}));
}));
- router.get('/api/v1/suggest', guarded((req, res, viewer) => {
+ router.get('/api/v1/suggest', guarded(limits('search.suggest', { minute: 300, hour: 6000 }), (req, res, viewer) => {
const text = String(one(req.query.q) || '');
const text = String(one(req.query.q) || '');
if (text.length > 100) throw new QueryError('search.bad_query', 'q is longer than 100 characters');
if (text.length > 100) throw new QueryError('search.bad_query', 'q is longer than 100 characters');
const filters = parseFilters(req.query, config);
const filters = parseFilters(req.query, config);
@@ -220,7 +229,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({
@@ -220,7 +229,7 @@ function queryRouter({ config, store, engine, auth, searcher = createSearcher({
res.json({ suggestions });
res.json({ suggestions });
}));
}));
- router.get('/api/v1/documents/:owner/:type/:id', guarded((req, res, viewer) => {
+ router.get('/api/v1/documents/:owner/:type/:id', guarded(limits('search.document.read'), (req, res, viewer) => {
const { owner, type, id } = req.params;
const { owner, type, id } = req.params;
if (!OWNER_RE.test(owner) || !TYPE_RE.test(type) || !ID_RE.test(id)) throw new QueryError('search.bad_identity', 'owner, type or id is malformed');
if (!OWNER_RE.test(owner) || !TYPE_RE.test(type) || !ID_RE.test(id)) throw new QueryError('search.bad_identity', 'owner, type or id is malformed');
const found = store.get(owner, type, id);
const found = store.get(owner, type, id);
@@ -23,7 +23,7 @@ const { withViewer, QueryError, one } = require('./query');
@@ -23,7 +23,7 @@ const { withViewer, QueryError, one } = require('./query');
const NAME_MAX = 100;
const NAME_MAX = 100;
-function savedRouter({ config, saved, searcher, auth }) {
+function savedRouter({ config, saved, searcher, auth, limits }) {
const router = express.Router();
const router = express.Router();
const selfOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
const selfOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
@@ -48,15 +48,18 @@ function savedRouter({ config, saved, searcher, auth }) {
@@ -48,15 +48,18 @@ function savedRouter({ config, saved, searcher, auth }) {
return viewer.subject;
return viewer.subject;
}
}
- const guarded = (h) => withViewer(auth, h);
+ // Per-actor limits (server/actor-limits.js), counted by the signed-in person: reads (and running a
+ // saved search) take the defaults; saving and deleting are a person's clicks, 30 a minute at most.
+ const guarded = (limit, h) => withViewer(auth, h, limit);
+ const change = { minute: 30, hour: 300 };
- router.get('/api/v1/saved-searches', guarded((req, res, viewer) => {
+ router.get('/api/v1/saved-searches', guarded(limits('search.saved.list'), (req, res, viewer) => {
const subject = person(req, res, viewer);
const subject = person(req, res, viewer);
if (!subject) return;
if (!subject) return;
res.json({ saved_searches: saved.list(subject), max: saved.maxPerSubject });
res.json({ saved_searches: saved.list(subject), max: saved.maxPerSubject });
}));
}));
- router.post('/api/v1/saved-searches', guarded((req, res, viewer) => {
+ router.post('/api/v1/saved-searches', guarded(limits('search.saved.create', change), (req, res, viewer) => {
const ctx = req.ov;
const ctx = req.ov;
const subject = person(req, res, viewer, { write: true });
const subject = person(req, res, viewer, { write: true });
if (!subject) return;
if (!subject) return;
@@ -91,7 +94,7 @@ function savedRouter({ config, saved, searcher, auth }) {
@@ -91,7 +94,7 @@ function savedRouter({ config, saved, searcher, auth }) {
res.status(r.created ? 201 : 200).json({ saved_search: r.saved });
res.status(r.created ? 201 : 200).json({ saved_search: r.saved });
}));
}));
- router.get('/api/v1/saved-searches/:id', guarded((req, res, viewer) => {
+ router.get('/api/v1/saved-searches/:id', guarded(limits('search.saved.read'), (req, res, viewer) => {
const subject = person(req, res, viewer);
const subject = person(req, res, viewer);
if (!subject) return;
if (!subject) return;
const s = saved.get(subject, req.params.id);
const s = saved.get(subject, req.params.id);
@@ -99,14 +102,14 @@ function savedRouter({ config, saved, searcher, auth }) {
@@ -99,14 +102,14 @@ function savedRouter({ config, saved, searcher, auth }) {
res.json({ saved_search: s });
res.json({ saved_search: s });
}));
}));
- router.delete('/api/v1/saved-searches/:id', guarded((req, res, viewer) => {
+ router.delete('/api/v1/saved-searches/:id', guarded(limits('search.saved.delete', change), (req, res, viewer) => {
const subject = person(req, res, viewer, { write: true });
const subject = person(req, res, viewer, { write: true });
if (!subject) return;
if (!subject) return;
if (!saved.remove(subject, req.params.id)) return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such saved search', ctx: req.ov });
if (!saved.remove(subject, req.params.id)) return http.sendProblem(res, 404, 'search.not_found', { detail: 'no such saved search', ctx: req.ov });
res.status(204).end();
res.status(204).end();
}));
}));
- router.get('/api/v1/saved-searches/:id/results', guarded((req, res, viewer) => {
+ router.get('/api/v1/saved-searches/:id/results', guarded(limits('search.saved.run'), (req, res, viewer) => {
const subject = person(req, res, viewer);
const subject = person(req, res, viewer);
if (!subject) return;
if (!subject) return;
const s = saved.get(subject, req.params.id);
const s = saved.get(subject, req.params.id);
@@ -11,6 +11,7 @@ const { queryRouter, createSearcher } = require('./api/query');
@@ -11,6 +11,7 @@ const { queryRouter, createSearcher } = require('./api/query');
const { savedRouter } = require('./api/saved');
const { savedRouter } = require('./api/saved');
const { pageRouter } = require('./web/page');
const { pageRouter } = require('./web/page');
const { webhookRouter } = require('./api/webhook');
const { webhookRouter } = require('./api/webhook');
+const { createLimits } = require('./actor-limits');
const pkg = require('../package.json');
const pkg = require('../package.json');
function createApp({ config, db, store, engine, auth, keys, outbox, relay, purges, purger, saved, log = console, now }) {
function createApp({ config, db, store, engine, auth, keys, outbox, relay, purges, purger, saved, log = console, now }) {
@@ -50,9 +51,12 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, purge
@@ -50,9 +51,12 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, purge
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
const searcher = createSearcher({ config, store, engine, now });
const searcher = createSearcher({ config, store, engine, now });
- app.use(documentsRouter({ store, auth, db, relay, purges }));
- app.use(queryRouter({ config, store, engine, auth, searcher }));
- app.use(savedRouter({ config, saved, searcher, auth }));
+ // One per-actor limiter for the API routes below (server/actor-limits.js); the Events webhook,
+ // health, ready, release.json and metrics above are never limited.
+ const limits = createLimits({ config, now: now || (() => Date.now()), registry: metrics.registry, log });
+ app.use(documentsRouter({ store, auth, db, relay, purges, limits }));
+ app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
+ app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise) and /robots.txt.
// GET / (HTML search page for browsers, the text route index otherwise) and /robots.txt.
app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl }));
app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl }));
@@ -100,6 +100,14 @@ function load(env = process.env) {
@@ -100,6 +100,14 @@ function load(env = process.env) {
maxPerSubject: Math.max(1, int(env.SEARCH_SAVED_MAX_PER_SUBJECT, 50)),
maxPerSubject: Math.max(1, int(env.SEARCH_SAVED_MAX_PER_SUBJECT, 50)),
},
},
+ // Per-actor limits at the API routes (server/actor-limits.js, roadmap WS-R task 4): the requests
+ // one caller may make to a read route per minute and per hour. Indexing, suggest and saved-search
+ // changes set their own numbers where they are mounted.
+ limits: {
+ minute: Math.max(1, int(env.SEARCH_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.SEARCH_LIMITS_HOUR, 3000)),
+ },
+
// Removal purge queue (server/purge.js). Every removal is recorded for owners; the
// Removal purge queue (server/purge.js). Every removal is recorded for owners; the
// Cloudflare purge of a formerly public URL runs only when the token is set.
// Cloudflare purge of a formerly public URL runs only when the token is set.
purge: {
purge: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * Per-actor rate limits (server/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. Owner routes count the service principal,
+ * queries the person (their own token or the X-OV-Subject a service vouches for), signed-out callers
+ * their address; a service querying for its signed-out visitors is not counted. Indexing has its own
+ * numbers. The Events webhook, health, ready, release.json and metrics are never limited.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, request, serviceToken, userToken, doc, indexEvent, deliver, suite } = require('./helpers');
+const { actor } = require('../server/actor-limits');
+
+const t = suite('actor-limits');
+// 15 s into a minute, so the minute window has 45 s left.
+let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+const now = () => clock;
+const WIKI = serviceToken('wiki', ['search.document.write']);
+const BLOG = serviceToken('blog', ['search.document.write']);
+const DELEGATE = serviceToken('live', ['search.query.delegate']);
+const alice = ids.newId('user');
+const bob = ids.newId('user');
+const as = (subjectId) => ({ token: userToken({ subjectId, aud: ['openvibe.search'] }) });
+
+let svc;
+const call = (method, p, opts = {}) => request(svc.base, method, p, opts);
+
+t('boot (SEARCH_LIMITS_MINUTE=3)', async () => {
+ svc = await boot({ now, env: { SEARCH_LIMITS_MINUTE: '3', SEARCH_LIMITS_HOUR: '100', SEARCH_EVENT_OWNERS: 'wiki' } });
+});
+
+t('a query: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
+ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(alice))).status, 200);
+ const r = await call('GET', '/api/v1/search?q=zebra', as(alice));
+ 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')));
+ assert.deepStrictEqual([r.body.code, r.body.status, r.body.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.body.detail.includes('search.query'), r.body.detail);
+ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(bob))).status, 200, 'another person still passes');
+ // A service vouching for alice counts against alice; for its signed-out visitors it is not counted.
+ const vouched = await call('GET', '/api/v1/search?q=zebra', { token: DELEGATE, headers: { 'X-OV-Subject': alice } });
+ assert.deepStrictEqual([vouched.status, vouched.body.code], [429, 'rate_limited']);
+ for (let i = 0; i < 6; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', { token: DELEGATE })).status, 200);
+ // Signed out: counted by address (127.0.0.1 here).
+ for (let i = 0; i < 3; i++) assert.strictEqual((await call('GET', '/api/v1/search?q=zebra')).status, 200);
+ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra')).status, 429);
+ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');
+ clock += 45 * 1000;
+ assert.strictEqual((await call('GET', '/api/v1/search?q=zebra', as(alice))).status, 200, 'the next minute opens the window again');
+});
+
+t('indexing: 120 a minute per service principal, refused before the store is touched', async () => {
+ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
+ for (let i = 0; i < 120; i++) {
+ const d = doc({ id: `pg_${i}` });
+ assert.strictEqual((await call('PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d })).status, 200);
+ }
+ const d = doc({ id: 'pg_over' });
+ const r = await call('PUT', `/api/v1/documents/wiki/page/${d.id}`, { token: WIKI, body: d });
+ assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
+ assert.strictEqual(svc.store.get('wiki', 'page', 'pg_over'), null, 'nothing stored');
+ const b = doc({ owner: 'blog', type: 'post', id: 'post_1', canonical_url: 'https://openvibe.blog/p/1' });
+ assert.strictEqual((await call('PUT', '/api/v1/documents/blog/post/post_1', { token: BLOG, body: b })).status, 200, 'another service still indexes');
+});
+
+t('the signed Events deliveries, health, ready, release.json and metrics are never limited', async () => {
+ for (let i = 0; i < 6; i++) {
+ const r = await deliver(svc.base, indexEvent(doc({ id: `ev_${i}`, canonical_url: `https://openvibe.wiki/p/ev${i}` })), { seq: i + 1 });
+ assert.ok(r.status < 300, `delivery ${i + 1}: ${r.status} ${r.text}`);
+ assert.strictEqual((await call('GET', '/api/health')).status, 200);
+ assert.notStrictEqual((await call('GET', '/api/ready')).status, 429);
+ assert.strictEqual((await call('GET', '/release.json')).status, 200);
+ assert.strictEqual((await call('GET', '/metrics')).status, 200);
+ }
+});
+
+t('refusals are counted in search_rate_limited_total', async () => {
+ const m = (await call('GET', '/metrics')).text;
+ assert.ok(/search_rate_limited_total\{limit="search.query",window="minute"\} 3/.test(m), m.split('\n').filter((l) => l.includes('rate_limited')).join('\n'));
+ assert.ok(/search_rate_limited_total\{limit="search.document.index",window="minute"\} 1/.test(m));
+});
+
+t('who is counted', () => {
+ assert.strictEqual(actor({ principal: { sub: 'svc:wiki' } }), 'svc:wiki');
+ assert.strictEqual(actor({ viewer: { kind: 'user', subject: alice } }), `user:${alice}`);
+ assert.strictEqual(actor({ viewer: { kind: 'service', service: 'svc:live', subject: alice } }), `user:${alice}`);
+ assert.strictEqual(actor({ viewer: { kind: 'service', service: 'svc:live', subject: null } }), null);
+ assert.strictEqual(actor({ viewer: { kind: 'anonymous', subject: null }, ip: '203.0.113.9' }), 'ip:203.0.113.9');
+});
+
+t('stop', async () => { await svc.stop(); });
+
+t.run();