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}.
Changes
@@@ -11,6 +11,11 @@ NODE_ENV=developmentBASE_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@@@ -177,6 +177,33 @@ the same moment.Ring flags are for moderators only (`/mod`, `GET /api/v1/flags`); they never remove votes on theirown. 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 reviewbefore 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`).@@@ -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"@@@ -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": {@@@ -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));@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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 }));@@@ -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];nnew file mode 100644@@@ -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();++})();@@@ -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}`;
@@ -11,6 +11,11 @@ NODE_ENV=development
@@ -11,6 +11,11 @@ NODE_ENV=development
BASE_URL=http://localhost:4840
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.
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2). The abuse controls read the client IP from it.
TRUST_PROXY=2
TRUST_PROXY=2
+# Per-actor limits (server/http/actor-limits.js): API reads one caller (a person, a service or app
+# acting as itself, else an address) may make per minute and per hour; past them 429 rate_limited with
+# Retry-After. 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)
# SQLite database (production unit: /var/lib/openvibe-deals/deals.db)
DEALS_DB_PATH=./data/deals.db
DEALS_DB_PATH=./data/deals.db
@@ -177,6 +177,33 @@ the same moment.
@@ -177,6 +177,33 @@ the same moment.
Ring flags are for moderators only (`/mod`, `GET /api/v1/flags`); they never remove votes on their
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).
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
## Watches and saved searches
`keyword` (every word must appear), `product`, `price_below` (product or words, a price **stated in
`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.
@@ -304,8 +331,8 @@ from sources registered in OpenVibe.Sources.
- **Honesty:** no fabricated prices, currencies, availability, expiry or ratings in pages, JSON,
- **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
JSON-LD, feeds or Search; stale and unknown are labelled; AI and imported text need a person's review
before indexing.
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:**
- **Known gaps:**
- Watch notifications are events only; nobody delivers them to people until Network's consumer exists.
- 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`).
- The Community thread visibility sync on disable is best effort (needs `community.comment.moderate`).
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1049,9 +1049,9 @@
@@ -1049,9 +1049,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
- "integrity": "sha512-sY/0bl5bhixjxLv2ACeL/AKIE2QlnndiDqkNuA5YGo2pC7AMvPqRP05y4OAtuJ7JvWtaBZ9gETB03u07uYp3wQ==",
+ "version": "0.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -41,13 +41,14 @@ const { createApi } = require('./http/api');
@@ -41,13 +41,14 @@ const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createInternalRoutes } = require('./http/internal');
const { createDealsReadiness } = require('./observability');
const { createDealsReadiness } = require('./observability');
+const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-/** 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 = {}) {
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
@@ -84,6 +85,9 @@ function createApp(opts = {}) {
@@ -84,6 +85,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'deals', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'deals', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
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(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -135,6 +139,7 @@ function createApp(opts = {}) {
@@ -135,6 +139,7 @@ function createApp(opts = {}) {
}));
}));
// ── Events consumer, API ────────────────────────────────
// ── 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(createInternalRoutes({ config, store, importer }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
@@ -30,6 +30,12 @@ function load(env = process.env) {
@@ -30,6 +30,12 @@ function load(env = process.env) {
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
// Public origin: canonical URLs, feeds, sitemaps and JSON-LD are built from it.
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
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',
dbPath: env.DEALS_DB_PATH || './data/deals.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
@@ -50,10 +50,14 @@ function cors(origins) {
@@ -50,10 +50,14 @@ function cors(origins) {
}
}
function createApi(ctx) {
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();
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
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(); });
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 });
const opts = (req) => ({ ip: req.viewer.kind === 'service' ? null : req.ip, traceparent: req.ov && req.ov.traceparent });
@@ -90,23 +94,23 @@ function createApi(ctx) {
@@ -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);
needSubject(req);
const out = offers.submit(req.viewer, req.body || {}, opts(req));
const out = offers.submit(req.viewer, req.body || {}, opts(req));
return offerOut(out.offer);
return offerOut(out.offer);
}, 201));
}, 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);
needSubject(req);
const out = offers.observe(req.viewer, req.params.id, req.body || {}, opts(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' }) };
return { ...offerOut(out.offer), observation: publication.observationDto({ ...out.observation, source_kind: 'community' }) };
}, 201));
}, 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);
needSubject(req);
const value = Number((req.body || {}).value);
const value = Number((req.body || {}).value);
if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
if (value !== 1 && value !== -1) throw new ApiError(422, 'request.invalid', 'value must be 1 or -1');
@@ -114,31 +118,31 @@ function createApi(ctx) {
@@ -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 } };
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);
needSubject(req);
const r = votes.remove(req.viewer, req.params.id, opts(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 } };
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);
needSubject(req);
const r = flags.report(req.viewer, req.params.id, req.body || {}, opts(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 };
return { flag: { id: r.flag.id, kind: r.flag.kind, status: r.flag.status }, created: r.created };
}, (out) => (out.created ? 201 : 200)));
}, (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 b = req.body || {};
const r = offers.merge(req.viewer, req.params.id, b.into, { reason: b.reason }, opts(req));
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 };
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));
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 };
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']) {
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) => {
router.get('/flags', guard('deals.offer.moderate'), run((req) => {
@@ -147,7 +151,7 @@ function createApi(ctx) {
@@ -147,7 +151,7 @@ function createApi(ctx) {
return { flags: flags.list({ status }).map(flags.dto) };
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));
const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: flags.dto(r.flag), changed: r.changed };
return { flag: flags.dto(r.flag), changed: r.changed };
}));
}));
@@ -164,7 +168,7 @@ function createApi(ctx) {
@@ -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 b = req.body || {};
const actor = req.viewer.subject || req.viewer.service || null;
const actor = req.viewer.subject || req.viewer.service || null;
if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
if (!actor) throw new ApiError(401, 'auth.required', 'Sign in first');
@@ -188,12 +192,12 @@ function createApi(ctx) {
@@ -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.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);
needSubject(req);
return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };
return { watch: watches.dto(watches.create(req.viewer.subject, req.body || {})) };
}, 201));
}, 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 }));
router.use((req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -33,9 +33,12 @@ const { ApiError, iso } = require('../domain/util');
@@ -33,9 +33,12 @@ const { ApiError, iso } = require('../domain/util');
const PER_PAGE = 25;
const PER_PAGE = 25;
function createPages(ctx) {
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();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
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 urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath };
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -209,7 +212,7 @@ function createPages(ctx) {
@@ -209,7 +212,7 @@ function createPages(ctx) {
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const back = (req) => `/d/${encodeURIComponent(req.params.slug)}`;
const ip = (req) => req.ip || null;
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 || '')];
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 === 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 });
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) {
@@ -217,22 +220,22 @@ function createPages(ctx) {
return `${back(req)}?done=vote`;
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 });
offers.observe(req.viewer, req.params.slug, req.body, { ip: ip(req), traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=observe#history`;
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) });
flags.report(req.viewer, req.params.slug, req.body, { ip: ip(req) });
return `${back(req)}?done=flag`;
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 });
offers.expire(req.viewer, req.params.slug, {}, { traceparent: req.ov && req.ov.traceparent });
return `${back(req)}?done=expire`;
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));
const root = reads.root(reads.mustFind(req.params.slug));
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
if (root.status === 'disabled') throw new ApiError(409, 'offer.disabled', 'This deal was removed');
const message = String(req.body.message || '').trim().slice(0, 4000);
const message = String(req.body.message || '').trim().slice(0, 4000);
@@ -311,7 +314,7 @@ function createPages(ctx) {
@@ -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) }) });
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;
if (!formGuard(req, res, '/submit')) return;
const b = req.body || {};
const b = req.body || {};
const input = {
const input = {
@@ -343,7 +346,7 @@ function createPages(ctx) {
@@ -343,7 +346,7 @@ function createPages(ctx) {
watchesPage(req, res);
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;
if (!formGuard(req, res, '/watches')) return;
try {
try {
const b = req.body || {};
const b = req.body || {};
@@ -355,7 +358,7 @@ function createPages(ctx) {
@@ -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);
watches.remove(req.viewer.subject, req.params.id);
return '/watches';
return '/watches';
})));
})));
@@ -377,7 +380,7 @@ function createPages(ctx) {
@@ -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;
if (!modGuard(req, res)) return;
const tp = { traceparent: req.ov && req.ov.traceparent };
const tp = { traceparent: req.ov && req.ov.traceparent };
await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {
await act(req, res, `/d/${encodeURIComponent(req.params.slug)}`, () => {
@@ -395,7 +398,7 @@ function createPages(ctx) {
@@ -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;
if (!modGuard(req, res)) return;
await act(req, res, '/mod', () => {
await act(req, res, '/mod', () => {
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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();
+})();
@@ -48,7 +48,7 @@ async function boot(opts = {}) {
@@ -48,7 +48,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
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();
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)); });
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}`;
t.base = `http://127.0.0.1:${server.address().port}`;