openvibers/openvibe.deals Commits

Per-actor rate limits on Deals' /api/v1 and its page 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.viewer is resolved. A person counts as user:usr_…, whether they use their own token or cookie or a service names them in X-OV-Subject. A first-party service relaying a signed-out visitor counts by the address it forwards. A service or app acting as itself, such as svc:ai for deals.enrich_deal, counts by its principal. A signed-out caller counts by address. A first-party service reading for itself is not counted on reads. Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work, including before the body is read, logs one [Limits] line and counts deals_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · fa0582d267 · 11 files, +303 −35

API reads take DEALS_LIMITS_MINUTE / DEALS_LIMITS_HOUR, 120 and 3000. Each write has its own budget, and a page form shares it with the API route that does the same thing. Every write cap sits above the per-person abuse control for the same action, so the abuse controls keep deciding what is recorded and the difference is room for refused retries:
- Submit: 30 a minute, 120 an hour (above 20 a day).
- Observations: 40 and 200 (above 30 an hour).
- Votes, set and remove sharing one budget: 60 and 600 (above 60 an hour per person, 120 per address and 6 changes per deal).
- Flags: 30 and 120 (above 30 a day).
- Edits and expiry: 30 and 300. Moderation (merge, unmerge, disable, enable, review, resolving flags): 60 and 600.
- Product resolve: 60 and 1200, one per imported or enriched item.
- Watches: 30 and 300.
- Comments, which go to Community in the person's name: 20 and 300.

The per-address limits (/api/v1 240 a minute, forms 60, sign-in) and the SQLite abuse controls stay. Health, ready, release.json, metrics, sign-in, the signed Events deliveries at /internal/events (Sources wake-ups) and the pages and feeds people read are never limited per actor.

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 04e3151..b125cf7 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,6 +11,11 @@ NODE_ENV=development
BASE_URL=http://localhost:4840
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2). The abuse controls read the client IP from it.
TRUST_PROXY=2
++# Per-actor limits (server/http/actor-limits.js): API reads one caller (a person, a service or app
++# acting as itself, else an address) may make per minute and per hour; past them 429 rate_limited with
++# Retry-After. Writes have their own numbers, above the abuse controls below (README).
++#DEALS_LIMITS_MINUTE=120
++#DEALS_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)
DEALS_DB_PATH=./data/deals.db
ddiff --git a/README.md b/README.md
iindex b5c8cfc..82c5d8b 100644
---- a/README.md
++++ b/README.md
@@@ -177,6 +177,33 @@ the same moment.
Ring flags are for moderators only (`/mod`, `GET /api/v1/flags`); they never remove votes on their
own. One open flag per ring (or per person and offer for reports).
++**Per-actor limits** (`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4) cap
++requests, refused ones included, by who makes them, once `req.viewer` is resolved and before any work
++(before the body is read). Each write's cap sits above the abuse control for the same action, which
++keeps deciding what is recorded. Counted: a person as `user:usr_…` (their own token or cookie, or named
++by a service in `X-OV-Subject`); a first-party service relaying a signed-out visitor by the address it
++forwards; a service or app acting as itself (such as `svc:ai` for `deals.enrich_deal`) 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
++`deals_rate_limited_total{limit,window}`. A page form and the API route that do the same thing share one
++budget.
++
++| Routes (API and forms) | Per caller, a minute / an hour |
++|---|---|
++| API reads | `DEALS_LIMITS_MINUTE` / `DEALS_LIMITS_HOUR` (120 / 3000) |
++| Submit | 30 / 120 |
++| Edit, expire | 30 / 300 |
++| Price observations | 40 / 200 |
++| Votes (set and remove) | 60 / 600 |
++| Flags | 30 / 120 |
++| Moderation (merge, unmerge, disable, enable, review, resolve flags) | 60 / 600 |
++| Product resolve | 60 / 1200 |
++| Watches (create and delete) | 30 / 300 |
++| Comments (sent to Community) | 20 / 300 |
++
++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, the signed
++Events deliveries at `/internal/events`, and the pages and feeds people read. `test/actor-limits.test.js`.
++
## Watches and saved searches
`keyword` (every word must appear), `product`, `price_below` (product or words, a price **stated in
@@@ -304,8 +331,8 @@ from sources registered in OpenVibe.Sources.
- **Honesty:** no fabricated prices, currencies, availability, expiry or ratings in pages, JSON,
JSON-LD, feeds or Search; stale and unknown are labelled; AI and imported text need a person's review
before indexing.
--- **Abuse:** Express rate limits on `/auth`, forms and `/api/v1`, mirrored in the nginx reference;
-- `/internal/` and `/metrics` are host-local only.
++- **Abuse:** Express rate limits on `/auth`, forms and `/api/v1`, mirrored in the nginx reference, and
++ per-actor limits on the API and forms (above); `/internal/` and `/metrics` are host-local only.
- **Known gaps:**
- Watch notifications are events only; nobody delivers them to people until Network's consumer exists.
- The Community thread visibility sync on disable is best effort (needs `community.comment.moderate`).
ddiff --git a/package-lock.json b/package-lock.json
iindex eb296f5..786d975 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"devDependencies": {
@@@ -1049,9 +1049,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
++ "version": "0.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 224d809..482ab12 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"devDependencies": {
ddiff --git a/server/app.js b/server/app.js
iindex b92406f..d519eb3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -41,13 +41,14 @@ const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createDealsReadiness } = require('./observability');
++const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--/** opts: config, dbPath, now (clock), fetchImpl, auth, log */
++/** opts: config, dbPath, now (clock), fetchImpl, auth, log, limitsNow (the per-actor limiter's clock, tests) */
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
@@@ -84,6 +85,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'deals', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
++ // Per-actor limits (http/actor-limits.js) for the API and the page forms, counted once each router
++ // resolved req.viewer; the per-address limits below and the per-person abuse controls stay.
++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
app.use(contracts.http.middleware());
app.use(helmet({
@@@ -135,6 +139,7 @@ function createApp(opts = {}) {
}));
// ── Events consumer, API ────────────────────────────────
++ // Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
app.use(createInternalRoutes({ config, store, importer }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
ddiff --git a/server/config.js b/server/config.js
iindex 421590a..eed0a81 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -30,6 +30,12 @@ function load(env = process.env) {
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the API reads one caller may
++ // make per minute and per hour. Writes set tighter numbers there.
++ actorLimits: {
++ minute: Math.max(1, int(env.DEALS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.DEALS_LIMITS_HOUR, 3000)),
++ },
dbPath: env.DEALS_DB_PATH || './data/deals.db',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..d2f5878
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * Per-actor rate limits on /api/v1 and the page forms (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The per-address limits in app.js (/api/v1 240 a minute, form posts 60, sign-in) and the per-person
++ * abuse controls (domain/limits.js: votes an hour, submissions and flags a day, observations an hour,
++ * kept in SQLite) stay and keep deciding what is recorded. These cap requests, refused ones included,
++ * by who makes them, once req.viewer is resolved (auth/viewer.js):
++ *
++ * a person user:usr_… (their own token or cookie, named by a service in
++ * X-OV-Subject, or an app's on_behalf_of)
++ * a first-party service relaying ip:<address> of the signed-out visitor it forwards (X-Forwarded-For)
++ * a signed-out visitor
++ * a service or app acting as its principal (svc:ai for deals.enrich_deal, app:app_…)
++ * itself
++ * a signed-out caller ip:<address>
++ *
++ * A first-party service reading for itself (no person, no visitor) is not counted on reads: its
++ * pages speak for all its visitors, and the per-address limit already bounds it. Past a limit the
++ * route answers 429 problem+json `rate_limited` with Retry-After before it does any work (before the
++ * body is read); the refusal is logged once and counted in deals_rate_limited_total{limit,window}. API
++ * reads get DEALS_LIMITS_MINUTE / DEALS_LIMITS_HOUR (120 and 3000); every write has its own number
++ * below, shared by the API route and the page form that do the same thing. Counters live in this
++ * process: a restart forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the signed Events
++ * deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only
++ * make it retry and fall behind), and the pages and feeds people read.
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++
++const FIRST_PARTY = /^svc:/;
++const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
++
++/** A first-party service that forwards the address of the signed-out visitor it acts for. */
++function relaysVisitor(req) {
++ const v = req.viewer;
++ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && req.get('x-forwarded-for') && req.ip && !LOOPBACK.test(req.ip));
++}
++
++function actor(req) {
++ const v = req.viewer;
++ if (!v || v.kind === 'anonymous') return defaultActor(req);
++ if (v.subject) return `user:${v.subject}`;
++ if (v.kind === 'service') return relaysVisitor(req) ? `ip:${req.ip}` : v.service;
++ return defaultActor(req);
++}
++
++/** A first-party service reading for itself: no person, no visitor. */
++function serviceItself(req) {
++ const v = req.viewer;
++ return !!(v && v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service)) && !relaysVisitor(req));
++}
++
++/**
++ * The writes, each with its numbers per caller (a minute, an hour). A page form and the API route that
++ * do the same thing share one budget.
++ */
++const BUDGETS = {
++ // Each cap sits above the per-person abuse control for the same action, so a person inside that
++ // control is never refused here; the difference is room for refused retries.
++ // Submitting a deal (20 a day per person decide): 30 a minute, 120 an hour.
++ 'deals.offer.submit': { minute: 30, hour: 120 },
++ // Editing or expiring a deal: a submitter or moderator saves a form now and then.
++ 'deals.offer.update': { minute: 30, hour: 300 },
++ // A price observation (30 an hour per person decide): 40 a minute, 200 an hour.
++ 'deals.offer.observe': { minute: 40, hour: 200 },
++ // Votes (60 an hour per person, 120 per address and 6 changes an hour per deal decide): setting and
++ // removing share one budget, 60 a minute and 600 an hour.
++ 'deals.vote': { minute: 60, hour: 600 },
++ // Flags (30 a day per person decide): 30 a minute, 120 an hour.
++ 'deals.flag.create': { minute: 30, hour: 120 },
++ // Moderators merging, unmerging, disabling, enabling, reviewing and resolving flags work through queues.
++ 'deals.offer.moderate': { minute: 60, hour: 600 },
++ // Resolving (and maybe creating) a product from its aliases: one per imported or enriched item.
++ 'deals.product.resolve': { minute: 60, hour: 1200 },
++ // Price watches (50 per person at most): created and removed by hand.
++ 'deals.watch': { minute: 30, hour: 300 },
++ // A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
++ 'deals.comment.create': { minute: 20, hour: 300 },
++};
++
++/**
++ * 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).
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'deals_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.actorLimits.minute, hour: config.actorLimits.hour },
++ actor,
++ now,
++ onLimited(e) {
++ // The actor is a subject id, a principal or an address, never a token.
++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ 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 5041bbd..3a87cc0 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -50,10 +50,14 @@ function cors(origins) {
}
function createApi(ctx) {
-- const { config, reads, catalog, publication, listings, offers, votes, watches, flags, hotness, viewers, access } = ctx;
++ const { config, reads, catalog, publication, listings, offers, votes, watches, flags, hotness, viewers, access, actorLimits } = ctx;
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
++ // Per-actor limits (http/actor-limits.js), once req.viewer is resolved: every read takes the defaults;
++ // each write names its budget after its capability guard and before its body is read.
++ router.use(actorLimits.reads('deals.read'));
++ const B = (name) => actorLimits.budget(name);
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });
@@@ -90,23 +94,23 @@ function createApi(ctx) {
};
}));
-- router.post('/offers', guard('deals.offer.submit'), jsonBody, run((req) => {
++ router.post('/offers', guard('deals.offer.submit'), B('deals.offer.submit'), jsonBody, run((req) => {
needSubject(req);
const out = offers.submit(req.viewer, req.body || {}, opts(req));
return offerOut(out.offer);
}, 201));
-- router.patch('/offers/:id', guard('deals.offer.update'), jsonBody, run((req) => offerOut(offers.update(req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
++ router.patch('/offers/:id', guard('deals.offer.update'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.update(req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
-- router.post('/offers/:id/observations', guard('deals.offer.update'), jsonBody, run((req) => {
++ router.post('/offers/:id/observations', guard('deals.offer.update'), B('deals.offer.observe'), jsonBody, run((req) => {
needSubject(req);
const out = offers.observe(req.viewer, req.params.id, req.body || {}, opts(req));
return { ...offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };
}, 201));
-- router.post('/offers/:id/expire', guard('deals.offer.expire'), jsonBody, run((req) => offerOut(offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));
++ router.post('/offers/:id/expire', guard('deals.offer.expire'), B('deals.offer.update'), jsonBody, run((req) => offerOut(offers.expire(req.viewer, req.params.id, req.body || {}, opts(req)))));
-- router.put('/offers/:id/vote', guard('deals.vote.set'), jsonBody, run((req) => {
++ router.put('/offers/:id/vote', guard('deals.vote.set'), B('deals.vote'), jsonBody, run((req) => {
needSubject(req);
const value = Number((req.body || {}).value);
if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
@@@ -114,31 +118,31 @@ function createApi(ctx) {
return { offer_id: r.root.id, value: r.value, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
}));
-- router.delete('/offers/:id/vote', guard('deals.vote.remove'), run((req) => {
++ router.delete('/offers/:id/vote', guard('deals.vote.remove'), B('deals.vote'), run((req) => {
needSubject(req);
const r = votes.remove(req.viewer, req.params.id, opts(req));
return { offer_id: r.root.id, value: 0, previous: r.previous, changed: r.changed, votes: { up: r.tally.up, down: r.tally.down, up_weight: r.tally.upWeight, down_weight: r.tally.downWeight } };
}));
-- router.post('/offers/:id/flags', guard('deals.flag.create'), jsonBody, run((req) => {
++ router.post('/offers/:id/flags', guard('deals.flag.create'), B('deals.flag.create'), jsonBody, run((req) => {
needSubject(req);
const r = flags.report(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };
}, (out) => (out.created ? 201 : 200)));
-- router.post('/offers/:id/merge', guard('deals.offer.merge'), jsonBody, run((req) => {
++ router.post('/offers/:id/merge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {
const b = req.body || {};
const r = offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));
return { ...offerOut(r.offer), merged: { id: r.merged.id, slug: r.merged.slug }, before: r.before, after: r.after };
}));
-- router.post('/offers/:id/unmerge', guard('deals.offer.merge'), jsonBody, run((req) => {
++ router.post('/offers/:id/unmerge', guard('deals.offer.merge'), B('deals.offer.moderate'), jsonBody, run((req) => {
const r = offers.unmerge(req.viewer, req.params.id, req.body || {}, opts(req));
return { ...offerOut(r.offer), from: { id: r.from.id, slug: r.from.slug }, before: r.before, after: r.after };
}));
for (const action of ['disable', 'enable', 'review']) {
-- router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), jsonBody, run((req) => offerOut(offers[action](req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
++ router.post(`/offers/:id/${action}`, guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => offerOut(offers[action](req.viewer, req.params.id, req.body || {}, opts(req)).offer)));
}
router.get('/flags', guard('deals.offer.moderate'), run((req) => {
@@@ -147,7 +151,7 @@ function createApi(ctx) {
return { flags: flags.list({ status }).map(flags.dto) };
}));
-- router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {
++ router.post('/flags/:id/resolve', guard('deals.offer.moderate'), B('deals.offer.moderate'), jsonBody, run((req) => {
const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: flags.dto(r.flag), changed: r.changed };
}));
@@@ -164,7 +168,7 @@ function createApi(ctx) {
};
}));
-- router.post('/products/resolve', guard('deals.product.resolve'), jsonBody, run((req) => {
++ router.post('/products/resolve', guard('deals.product.resolve'), B('deals.product.resolve'), jsonBody, run((req) => {
const b = req.body || {};
const actor = req.viewer.subject || req.viewer.service || null;
if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
@@@ -188,12 +192,12 @@ function createApi(ctx) {
router.get('/watches', guard('deals.watch.read'), run((req) => { needSubject(req); return { watches: watches.list(req.viewer.subject).map(watches.dto) }; }));
-- router.post('/watches', guard('deals.watch.create'), jsonBody, run((req) => {
++ router.post('/watches', guard('deals.watch.create'), B('deals.watch'), jsonBody, run((req) => {
needSubject(req);
return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };
}, 201));
-- router.delete('/watches/:id', guard('deals.watch.delete'), run((req) => { needSubject(req); return watches.remove(req.viewer.subject, req.params.id); }));
++ router.delete('/watches/:id', guard('deals.watch.delete'), B('deals.watch'), run((req) => { needSubject(req); return watches.remove(req.viewer.subject, req.params.id); }));
router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 37ee25e..b7d7ede 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -33,9 +33,12 @@ const { ApiError, iso } = require('../domain/util');
const PER_PAGE = 25;
function createPages(ctx) {
-- const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers } = ctx;
++ const { config, store, reads, catalog, publication, listings, offers, votes, watches, flags, community, access, viewers, actorLimits } = ctx;
const router = express.Router();
router.use(viewers.middleware({ services: false }));
++ // Per-actor limits on the forms (http/actor-limits.js), before the form is read: each shares its budget
++ // with the API route that does the same thing. Reading pages is left to the per-address limits.
++ const B = (name) => actorLimits.budget(name);
const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath };
const form = express.urlencoded({ extended: false, limit: '32kb' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@@ -209,7 +212,7 @@ function createPages(ctx) {
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const ip = (req) => req.ip || null;
-- router.post('/d/:slug/vote', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ router.post('/d/:slug/vote', B('deals.vote'), form, wrap(async (req, res) => act(req, res, back(req), () => {
const value = { up: 1, down: -1, remove: 0 }[String(req.body.value || '')];
if (value === undefined) throw new ApiError(422, 'request.invalid', 'Choose hot, cold or remove');
if (value === 0) votes.remove(req.viewer, req.params.slug, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
@@@ -217,22 +220,22 @@ function createPages(ctx) {
return `${back(req)}?done=vote`;
})));
-- router.post('/d/:slug/observe', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ router.post('/d/:slug/observe', B('deals.offer.observe'), form, wrap(async (req, res) => act(req, res, back(req), () => {
offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=observe#history`;
})));
-- router.post('/d/:slug/flag', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ router.post('/d/:slug/flag', B('deals.flag.create'), form, wrap(async (req, res) => act(req, res, back(req), () => {
flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });
return `${back(req)}?done=flag`;
})));
-- router.post('/d/:slug/expire', form, wrap(async (req, res) => act(req, res, back(req), () => {
++ router.post('/d/:slug/expire', B('deals.offer.update'), form, wrap(async (req, res) => act(req, res, back(req), () => {
offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=expire`;
})));
-- router.post('/d/:slug/comments', form, wrap(async (req, res) => act(req, res, back(req), async () => {
++ router.post('/d/:slug/comments', B('deals.comment.create'), form, wrap(async (req, res) => act(req, res, back(req), async () => {
const root = reads.root(reads.mustFind(req.params.slug));
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
const message = String(req.body.message || '').trim().slice(0, 4000);
@@@ -311,7 +314,7 @@ function createPages(ctx) {
send(req, res, 200, { title: 'Submit a deal', decision: pageDecision('/submit', { indexable: false }), body: views.submitForm({ csrf: csrf(req) }) });
}));
-- router.post('/submit', form, wrap(async (req, res) => {
++ router.post('/submit', B('deals.offer.submit'), form, wrap(async (req, res) => {
if (!formGuard(req, res, '/submit')) return;
const b = req.body || {};
const input = {
@@@ -343,7 +346,7 @@ function createPages(ctx) {
watchesPage(req, res);
}));
-- router.post('/watches', form, wrap(async (req, res) => {
++ router.post('/watches', B('deals.watch'), form, wrap(async (req, res) => {
if (!formGuard(req, res, '/watches')) return;
try {
const b = req.body || {};
@@@ -355,7 +358,7 @@ function createPages(ctx) {
}
}));
-- router.post('/watches/:id/delete', form, wrap(async (req, res) => act(req, res, '/watches', () => {
++ router.post('/watches/:id/delete', B('deals.watch'), form, wrap(async (req, res) => act(req, res, '/watches', () => {
watches.remove(req.viewer.subject, req.params.id);
return '/watches';
})));
@@@ -377,7 +380,7 @@ function createPages(ctx) {
});
}));
-- router.post('/mod/offers/:slug/:action', form, wrap(async (req, res) => {
++ router.post('/mod/offers/:slug/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
if (!modGuard(req, res)) return;
const tp = { traceparent: req.ov && req.ov.traceparent };
await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {
@@@ -395,7 +398,7 @@ function createPages(ctx) {
});
}));
-- router.post('/mod/flags/:id/:action', form, wrap(async (req, res) => {
++ router.post('/mod/flags/:id/:action', B('deals.offer.moderate'), form, wrap(async (req, res) => {
if (!modGuard(req, res)) return;
await act(req, res, '/mod', () => {
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..98b3ea8
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,103 @@
++'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. Writes have their
++ * own budget, shared by the API and the page form. Health, ready, release.json, metrics and the
++ * signed Events deliveries are never limited; refusals are logged and counted.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { actor, serviceItself } = require('../server/http/actor-limits');
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const lines = [];
++ const log = { log() {}, error() {}, warn: (m) => lines.push(String(m)) };
++ const t = await boot({ env: { DEALS_LIMITS_MINUTE: '3', DEALS_LIMITS_HOUR: '100', DEALS_WATCH_LIMIT: '100' }, limitsNow: () => clock, log });
++ const ann = t.network.addUser('ann');
++ const ben = t.network.addUser('ben');
++ const svc = t.network.serviceToken('ai', ['deals.watch.read', 'deals.watch.create']);
++
++ await check('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/offers', { as: ann })).status, 200);
++ const r = await t.get('/api/v1/offers', { as: ann });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));
++ const body = r.json();
++ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(body.detail.includes('deals.read'), body.detail);
++ assert.strictEqual((await t.get('/api/v1/offers', { as: ben })).status, 200, 'another person still passes');
++ });
++
++ await check('a service naming the person counts against that person', async () => {
++ const r = await t.get('/api/v1/watches', { as: svc, headers: { 'X-OV-Subject': ann.subject } });
++ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
++ });
++
++ await check('relayed visitors count by forwarded address; a service reading for itself is not counted', async () => {
++ const visitor = (ip) => t.get('/api/v1/offers', { as: svc, headers: { 'X-Forwarded-For': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await visitor('203.0.113.7')).status, 200);
++ assert.strictEqual((await visitor('203.0.113.7')).status, 429);
++ assert.strictEqual((await visitor('203.0.113.8')).status, 200, 'another visitor still passes');
++ for (let i = 0; i < 6; i++) assert.strictEqual((await t.get('/api/v1/offers', { as: svc })).status, 200);
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await t.get('/api/v1/offers', { as: ann })).status, 200);
++ });
++
++ await check('a write has its own budget (30 watch changes a minute), shared by the API and the form; nothing stored past it', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const stored = (u) => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_watches WHERE subject = ?').get(u.subject).n;
++ for (let i = 0; i < 30; i++) {
++ const r = await t.get('/api/v1/watches', { as: ann, json: { kind: 'keyword', query: `thing ${i}` } });
++ assert.strictEqual(r.status, 201, `watch ${i + 1}: ${r.text}`);
++ }
++ const before = stored(ann);
++ const r = await t.get('/watches', { as: ann, form: { kind: 'keyword', query: 'one more' } });
++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
++ assert.ok(r.json().detail.includes('deals.watch'), r.json().detail);
++ assert.strictEqual(stored(ann), before, 'nothing stored');
++ assert.strictEqual((await t.get('/api/v1/watches', { as: ben, json: { kind: 'keyword', query: 'mine' } })).status, 201, 'another person still writes');
++ });
++
++ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ assert.notStrictEqual((await t.get('/internal/events', { method: 'POST', json: {} })).status, 429);
++ }
++ });
++
++ await check('refusals are counted in deals_rate_limited_total and logged without a token', async () => {
++ const m = (await t.get('/metrics')).text;
++ const counted = m.split('\n').filter((l) => l.includes('deals_rate_limited_total')).join('\n');
++ assert.ok(/deals_rate_limited_total\{limit="deals.read",window="minute"\} 3/.test(m), counted);
++ assert.ok(/deals_rate_limited_total\{limit="deals.watch",window="minute"\} 1/.test(m), counted);
++ assert.ok(lines.some((l) => l.includes(`deals.read: user:${ann.subject} refused`)), lines.join('\n'));
++ assert.ok(!lines.some((l) => /eyJ/.test(l)), 'a token in the log');
++ });
++
++ await check('who is counted', () => {
++ const q = (viewer, { xff = null, ip = '127.0.0.1' } = {}) => ({ viewer, ip, get: (n) => (n === 'x-forwarded-for' ? xff : undefined) });
++ assert.strictEqual(actor(q({ kind: 'user', subject: 'usr_a' })), 'user:usr_a');
++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: 'usr_a' })), 'user:usr_a');
++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'ip:203.0.113.9');
++ assert.strictEqual(actor(q({ kind: 'service', service: 'svc:ai', subject: null })), 'svc:ai', 'acting as itself');
++ assert.strictEqual(actor(q({ kind: 'service', service: 'app:app_1', subject: null }, { xff: '203.0.113.9', ip: '203.0.113.9' })), 'app:app_1', 'an app never relays by address');
++ assert.strictEqual(actor(q({ kind: 'anonymous', subject: null }, { ip: '198.51.100.4' })), 'ip:198.51.100.4');
++ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'svc:ai', subject: null })), true);
++ assert.strictEqual(serviceItself(q({ kind: 'service', service: 'app:app_1', subject: null })), false);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex d780d05..26e68d2 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -48,7 +48,7 @@ async function boot(opts = {}) {
let built = null;
async function start() {
const config = configLib.load(env);
-- built = createApp({ config, now: clock.now, log: quiet });
++ built = createApp({ config, now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;