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

OP OpenVibers committed 28 Sept 2026 · 8ed7667ff1 · 11 files, +239 −30

Queries, direct gets, owner reconciliation reads and saved-search reads and runs take SEARCH_LIMITS_MINUTE / SEARCH_LIMITS_HOUR, 120 and 3000. Suggest allows 300 a minute and 6000 an hour, because it answers a search box as the person types. Direct indexing (PUT and DELETE /api/v1/documents) keeps 120 a minute, since a catalog sync is a burst (Tools sends every tool on a first run), but only 1200 an hour, since bulk content comes through Events. Saving and deleting a saved search allow 30 and 300. The signed Events deliveries at /internal/events, health, ready, release.json and metrics are never limited; the HTML page keeps nginx's address limits.

test/actor-limits.test.js on a fixed clock: the fourth query in a minute is refused with Retry-After 45 while another person passes, a service vouching for the same person counts against them, the service's signed-out queries are not counted, a signed-out address is limited, and the next minute reopens; the 121st PUT by one service is refused and nothing is stored while another service indexes; deliveries, health, ready, release.json and metrics never answer 429; refusals are counted.

Changes

ddiff --git a/.env.example b/.env.example
iindex 55b5383..38d5195 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 19fc716..72f0564 100644
---- a/README.md
++++ b/README.md
@@@ -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 Events
subscription delivered 20 wiki events; the 10 upserts were rejected (`owner_not_accepted`, since
ddiff --git a/package-lock.json b/package-lock.json
iindex c446d49..58fe9a0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 8cc0637..749b21d 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/actor-limits.js b/server/actor-limits.js
nnew file mode 100644
iindex 0000000..31041ed
---- /dev/null
++++ b/server/actor-limits.js
@@@ -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 };
ddiff --git a/server/api/documents.js b/server/api/documents.js
iindex 1e2b668..cef349c 100644
---- a/server/api/documents.js
++++ b/server/api/documents.js
@@@ -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);
ddiff --git a/server/api/query.js b/server/api/query.js
iindex 8c4c1c9..ec87140 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -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);
ddiff --git a/server/api/saved.js b/server/api/saved.js
iindex 74a440f..08acb7c 100644
---- a/server/api/saved.js
++++ b/server/api/saved.js
@@@ -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);
ddiff --git a/server/app.js b/server/app.js
iindex 0907045..b36c2d0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, purge
release.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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 1a07ee6..3a14404 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..ec52168
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -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();