openvibers/openvibe.coupons Commits

Per-actor rate limits on Coupons' /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 call with their Network token, a service names them in X-OV-Subject, or one of their browser helpers sends the request with its cpx_ install token. Every browser a person connected counts together, never by the address it reports from. 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 coupons.extract_coupon, counts by its principal. A signed-out caller counts by address. A first-party service reading for itself is not counted on reads, since the lookup limiter already bounds it at 600 a minute. Past a limit a route answers 429 problem+json rate_limited with Retry-After before the route does any work (on the API after the guard, on forms before the body is read), logs one [Limits] line and counts coupons_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 64835d2a1c · 11 files, +293 −27

Lookups and other API reads take COUPONS_LIMITS_MINUTE / COUPONS_LIMITS_HOUR, 120 and 3000, counted after the existing lookup limiter and the scope check. Each write cap sits above the per-person limit for the same action, so those limits keep deciding and the difference is room for refused retries. A page form shares its budget with the API route:
- Reports: 30 a minute, 120 an hour (above 20 an hour and 60 a day).
- Submissions: a person 20 and 60 (above 10 an hour and 30 a day); a service submitting for people 120 and 600, since it gets ten times a person's limit.
- Staff and service moderation (coupon and merchant status, merchants, domains, approvals): 60 and 600.
- Connecting a browser helper, which mints a token: 5 and 20.
- Watching a merchant: 30 and 300.

Revoking a browser helper is never limited, so a person can always cut one off. The lookup limiter (per install, service and address), the per-address write and page limits, and the per-person report and submission limits stay. Health, ready, release.json, metrics, sign-in, and the pages and feeds people read are never limited per actor.

test/actor-limits.test.js runs on a fixed limiter clock. After three lookups from one browser, the person's second browser is refused from another address with Retry-After 45, while another person on the same address passes. Signed-out lookups count by forwarded address, a service reading for itself is not counted, and the next minute reopens. After 30 reports from two browsers in a minute the site form is refused (the budget is shared) and nothing is recorded, while another person still reports. Revoking is never refused. Health, ready, release.json and metrics 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 f5acf1d..87e02be 100644
---- a/.env.example
++++ b/.env.example
@@@ -52,6 +52,11 @@ OV_OAUTH_CLIENT_SECRET=
#COUPONS_SUBMISSIONS_PER_HOUR=10
#COUPONS_SUBMISSIONS_PER_DAY=30
#COUPONS_INSTALLS_PER_SUBJECT=10
++# Per-actor limits (server/http/actor-limits.js): API reads one caller (a person, with every browser
++# helper they connected together, 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 (README).
++#COUPONS_LIMITS_MINUTE=120
++#COUPONS_LIMITS_HOUR=3000
# OpenVibe.Sources — import the `coupons` category (runs when this and OV_OAUTH_CLIENT_SECRET are set)
#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
ddiff --git a/README.md b/README.md
iindex 9fb15fa..3be77d4 100644
---- a/README.md
++++ b/README.md
@@@ -220,6 +220,21 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
- Submissions: `COUPONS_SUBMISSIONS_PER_HOUR` (10) and `_PER_DAY` (30) per person, ×10 for
services.
- Plus a per-IP ceiling in Express and the nginx zones.
++ - Per-actor limits (`server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4) on
++ the API and the page forms, once the caller is known and before any work. A person counts as
++ `user:usr_…` whether they call with their Network token, a service names them, or one of their
++ browser helpers' install tokens sends the request: every browser a person connected counts
++ together, never by the address it reports from. A first-party service relaying a signed-out
++ visitor counts by the address it forwards, a service or app acting as itself by its principal,
++ and a signed-out caller by address. A first-party service reading for itself is not counted on
++ reads (the lookup limit above bounds it). API reads: `COUPONS_LIMITS_MINUTE` /
++ `COUPONS_LIMITS_HOUR` (120 a minute, 3000 an hour). Each write cap sits above the per-person
++ limit for the same action, and a form shares it with the API route: reports 30 a minute and 120
++ an hour; submissions 20 and 60 (a service submitting for people 120 and 600); staff and service
++ moderation (coupon and merchant status, merchants, domains, approvals) 60 and 600; connecting a
++ browser helper 5 and 20; watching a merchant 30 and 300. Revoking a browser helper is never
++ limited. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log
++ line and `coupons_rate_limited_total{limit,window}`. `test/actor-limits.test.js`.
- **Privacy:**
- No response, page or event names a submitter or reporter.
- Report times are published to the hour.
ddiff --git a/package-lock.json b/package-lock.json
iindex 7fbb568..4e59fad 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 2e20ec2..4fe2890 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 29915eb..15531d7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,13 +32,17 @@ const { createApi } = require('./http/api');
const { createPublicRoutes } = require('./http/public');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createCouponsReadiness } = 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, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log */
++/**
++ * opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
++ * limitsNow (the per-actor limiter's clock, tests)
++ */
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
@@@ -67,6 +71,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'coupons', 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 lookup, per-address and per-person limits stay.
++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
app.use(contracts.http.middleware());
app.use(helmet({
ddiff --git a/server/config.js b/server/config.js
iindex 5c576f9..abec05b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -28,6 +28,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.COUPONS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.COUPONS_LIMITS_HOUR, 3000)),
++ },
dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..65e8be8
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,111 @@
++'use strict';
++
++/**
++ * Per-actor rate limits on /api/v1 and the page forms (roadmap WS-R task 4; openvibe-sdk/limits).
++ *
++ * The limits already here stay and keep deciding: the lookup limiter (per install, per service, per
++ * address), the per-address write limit, the pages' per-address limit and the per-person report and
++ * submission limits. These count requests by who makes them, once req.viewer is resolved
++ * (auth/viewer.js):
++ *
++ * a person user:usr_… (their Network token or cookie, named by a service in
++ * X-OV-Subject, an app's on_behalf_of, or an extension install token:
++ * every browser helper a person connected counts as that person,
++ * never as the address it reports from)
++ * 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 coupons.extract_coupon, 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 lookup limiter already bounds it (600 a minute). 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
++ * coupons_rate_limited_total{limit,window}. API reads get COUPONS_LIMITS_MINUTE / COUPONS_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, revoking a browser helper
++ * (a person must always be able to cut one off), 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). Each cap sits above the per-person
++ * limit for the same action, so a person inside that limit is never refused here; the difference is
++ * room for refused retries. A page form and the API route that do the same thing share one budget.
++ */
++const BUDGETS = {
++ // Reports (20 an hour and 60 a day per person decide, every channel together): 30 a minute, 120 an hour.
++ 'coupons.report.create': { minute: 30, hour: 120 },
++ // Submissions by a person (10 an hour and 30 a day decide): 20 a minute, 60 an hour.
++ 'coupons.coupon.submit': { minute: 20, hour: 60 },
++ // Staff and services: coupon and merchant status, merchants and their domains, approvals.
++ 'coupons.moderate': { minute: 60, hour: 600 },
++ // A browser helper token is minted once per browser (10 active per person at most).
++ 'coupons.install.create': { minute: 5, hour: 20 },
++ // Watching or unwatching a merchant is one toggle.
++ 'coupons.watch': { minute: 30, hour: 300 },
++};
++// A service submitting for people gets ten times a person's submission limit (api.js): its cap is too.
++const SERVICE_SUBMIT = { minute: 120, hour: 600 };
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on GET/HEAD, a
++ * first-party service reading for itself not counted), limits.budget(name) (one of BUDGETS) and
++ * limits.submit (the submission budget: a person's, or a service's).
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'coupons_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;
++ };
++ const serviceSubmit = limiter('coupons.coupon.submit', SERVICE_SUBMIT);
++ limiter.submit = (req, res, next) => (req.viewer && req.viewer.kind === 'service' ? serviceSubmit : budgets.get('coupons.coupon.submit'))(req, res, next);
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, serviceItself, BUDGETS, SERVICE_SUBMIT };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 58d1910..5717a13 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -34,8 +34,13 @@ const hosts = require('../domain/hosts');
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
function createApi(ctx) {
-- const { config, viewers, merchants, coupons, reports, publication } = ctx;
++ const { config, viewers, merchants, coupons, reports, publication, actorLimits } = ctx;
const router = express.Router();
++ // Per-actor limits (http/actor-limits.js), after the caller is resolved and its capability or scope
++ // checked, before the route does any work: reads take the defaults (after the lookup limiter), writes
++ // name their budget.
++ const reads = actorLimits.reads('coupons.read');
++ const moderate = actorLimits.budget('coupons.moderate');
// ── Helpers ─────────────────────────────────────────────
const noStore = (res) => res.set('Cache-Control', 'private, no-store');
@@@ -112,7 +117,7 @@ function createApi(ctx) {
// ── Lookup ──────────────────────────────────────────────
router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);
-- router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), run((req, res) => {
++ router.get('/merchants/resolve', lookupCors, lookupLimiter, guard(CAPABILITIES.MERCHANT_RESOLVE, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');
const found = merchants.resolve(req.query.host);
publicCache(res);
@@@ -128,19 +133,19 @@ function createApi(ctx) {
};
}));
-- router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
++ router.get('/merchants/:id/coupons', lookupCors, lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
const m = activeMerchant(req.params.id);
publicCache(res);
return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };
}));
-- router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
++ router.get('/merchants/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
const m = activeMerchant(req.params.id);
publicCache(res);
return { merchant: merchantView(m) };
}));
-- router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), run((req, res) => {
++ router.get('/coupons/:id', lookupLimiter, guard(CAPABILITIES.COUPON_LOOKUP, { scope: SCOPES.LOOKUP }), reads, run((req, res) => {
const c = coupons.get(req.params.id);
const m = c && merchants.byId(c.merchant_id);
if (!c || !m || m.status !== 'active' || c.review_state !== 'published' || c.status === 'disabled') throw new ApiError(404, 'coupon.not_found', 'no such code');
@@@ -149,13 +154,13 @@ function createApi(ctx) {
}));
// ── Reports ─────────────────────────────────────────────
-- router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), run((req, res) => {
++ router.post('/coupons/:id/report', writeLimiter, jsonBody, guard(CAPABILITIES.REPORT_CREATE, { scope: SCOPES.REPORT }), actorLimits.budget('coupons.report.create'), run((req, res) => {
noStore(res);
return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
}, (out) => (out.deduplicated ? 200 : 201)));
// ── Submissions ─────────────────────────────────────────
-- router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), run((req, res) => {
++ router.post('/coupons/submit', writeLimiter, jsonBody, guard(CAPABILITIES.COUPON_SUBMIT), actorLimits.submit, run((req, res) => {
noStore(res);
const v = req.viewer;
let who;
@@@ -173,7 +178,7 @@ function createApi(ctx) {
}, (out) => (out.duplicate ? 200 : 201)));
// ── Staff and services ──────────────────────────────────
-- router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), run((req, res) => {
++ router.post('/coupons/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.STATUS_UPDATE), moderate, run((req, res) => {
noStore(res);
const actor = requireStaffOrService(req);
const c = coupons.get(req.params.id);
@@@ -183,7 +188,7 @@ function createApi(ctx) {
return { coupon: coupons.view(after) };
}));
-- router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
++ router.post('/merchants', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
noStore(res);
const actor = requireStaffOrService(req);
const body = req.body || {};
@@@ -193,7 +198,7 @@ function createApi(ctx) {
return { merchant: { ...merchantView(m), status: m.status } };
}, 201));
-- router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
++ router.post('/merchants/:id/domains', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
noStore(res);
const actor = requireStaffOrService(req);
const m = merchants.get(req.params.id);
@@@ -202,7 +207,7 @@ function createApi(ctx) {
return { merchant: { ...merchantView(m), status: m.status } };
}, 201));
-- router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), run((req, res) => {
++ router.post('/merchants/:id/status', writeLimiter, jsonBody, guard(CAPABILITIES.MERCHANT_MANAGE), moderate, run((req, res) => {
noStore(res);
const actor = requireStaffOrService(req);
const m = merchants.get(req.params.id);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex c943dec..d73e770 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -33,10 +33,13 @@ const { SCOPES } = require('../auth/capabilities');
const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
function createPublicRoutes(ctx) {
-- const { config, store, merchants, coupons, reports, watches, installs, publication, viewers, moderation } = ctx;
++ const { config, store, merchants, coupons, reports, watches, installs, publication, viewers, moderation, actorLimits } = ctx;
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '32kb' });
router.use(viewers.pages());
++ // 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. Revoking a browser helper is never limited.
++ const B = (name) => actorLimits.budget(name);
// ── Helpers ─────────────────────────────────────────────
function cacheHeaders(res, { cacheable, robots }) {
@@@ -168,7 +171,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: !flashMsg });
});
-- router.post('/m/:slug/watch', form, (req, res) => {
++ router.post('/m/:slug/watch', B('coupons.watch'), form, (req, res) => {
if (!requireForm(req, res)) return;
const m = merchants.get(req.params.slug);
if (!m || m.status !== 'active') return notFound(req, res);
@@@ -210,7 +213,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: !flashMsg });
});
-- router.post('/c/:id/report', form, (req, res) => {
++ router.post('/c/:id/report', B('coupons.report.create'), form, (req, res) => {
if (!requireForm(req, res)) return;
const back = safeBack(req.body.back, `/c/${req.params.id}`);
try {
@@@ -233,7 +236,7 @@ function createPublicRoutes(ctx) {
});
});
-- router.post('/submit', form, (req, res) => {
++ router.post('/submit', B('coupons.coupon.submit'), form, (req, res) => {
if (!requireForm(req, res)) return;
const b = req.body || {};
const m = b.merchant ? merchants.get(String(b.merchant)) : null;
@@@ -269,7 +272,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: req.viewer.kind === 'anonymous' });
}
router.get('/connect-extension', (req, res) => connectPage(req, res, 200));
-- router.post('/connect-extension', form, (req, res) => {
++ router.post('/connect-extension', B('coupons.install.create'), form, (req, res) => {
if (!requireForm(req, res)) return;
try {
const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });
@@@ -322,19 +325,19 @@ function createPublicRoutes(ctx) {
};
}
router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });
-- router.post('/staff/merchants', form, staffAction((req) => {
++ router.post('/staff/merchants', B('coupons.moderate'), form, staffAction((req) => {
const b = req.body;
const m = merchants.create({ name: b.name, domains: [{ host: b.host, include_subdomains: Boolean(b.include_subdomains), path_prefix: b.path_prefix || '' }] }, { status: 'active', actor: req.viewer.subject });
store.tx(() => coupons.syncMerchant(m));
return `Added ${m.name}.`;
}));
-- router.post('/staff/merchants/:id/status', form, staffAction((req) => {
++ router.post('/staff/merchants/:id/status', B('coupons.moderate'), form, staffAction((req) => {
const m = merchants.get(req.params.id);
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
return `${after.name} is now ${after.status}.`;
}));
-- router.post('/staff/coupons/:id/approve', form, staffAction((req) => {
++ router.post('/staff/coupons/:id/approve', B('coupons.moderate'), form, staffAction((req) => {
const c = coupons.get(req.params.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
const m = merchants.byId(c.merchant_id);
@@@ -342,7 +345,7 @@ function createPublicRoutes(ctx) {
coupons.approve(c, { actor: req.viewer.subject });
return `Published ${c.code}.`;
}));
-- router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], form, staffAction((req) => {
++ router.post(['/staff/coupons/status', '/staff/coupons/:id/status'], B('coupons.moderate'), form, staffAction((req) => {
const c = coupons.get(req.params.id || req.body.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..d96b100
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,114 @@
++'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. The browser helper's install tokens
++ * count as the person who connected them (every browser together), never as the address they report
++ * from; signed-out callers count by 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 and metrics are
++ * never limited, nor is revoking a browser helper; 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)) };
++ // The per-person report limits out of the way, so the per-actor one is what answers.
++ const t = await boot({ env: { COUPONS_LIMITS_MINUTE: '3', COUPONS_LIMITS_HOUR: '100', COUPONS_REPORTS_PER_HOUR: '1000', COUPONS_REPORTS_PER_DAY: '1000' }, limitsNow: () => clock, log });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const carol = t.network.addUser('carol');
++ await t.merchant({ name: 'Blue Kettle', host: 'bluekettle.shop' });
++ const code = (await t.submit(alice, { host: 'bluekettle.shop', code: 'KETTLE10', title: 'Ten off' })).json().coupon;
++ const laptop = await t.connect(bob, { label: 'laptop' });
++ const phone = await t.connect(bob, { label: 'phone' });
++ const carols = await t.connect(carol);
++ const lookup = (o = {}) => t.get('/api/v1/merchants/resolve?host=bluekettle.shop', o);
++
++ await check('a lookup: 3 a minute per person across their browsers, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await lookup({ as: laptop, headers: { 'X-Forwarded-For': `198.51.100.${i + 1}` } })).status, 200);
++ const r = await lookup({ as: phone, headers: { 'X-Forwarded-For': '198.51.100.9' } });
++ assert.strictEqual(r.status, 429, 'the second browser, from another address, is the same person');
++ 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('coupons.read'), body.detail);
++ assert.strictEqual((await lookup({ as: carols, headers: { 'X-Forwarded-For': '198.51.100.1' } })).status, 200, 'another person on the same address still passes');
++ });
++
++ await check('signed-out lookups count by address; a service reading for itself is not counted', async () => {
++ const from = (ip) => lookup({ headers: { 'X-Forwarded-For': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
++ assert.strictEqual((await from('203.0.113.7')).status, 429);
++ assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
++ const svc = t.network.serviceToken('ai', ['coupons.merchant.resolve']);
++ for (let i = 0; i < 6; i++) assert.strictEqual((await lookup({ as: svc })).status, 200);
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await lookup({ as: phone })).status, 200);
++ });
++
++ await check('reports have their own budget (30 a minute) for the person, whichever browser or form sends them', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n, MAX(updated_at) AS at FROM coupon_validation_reports').get();
++ for (let i = 0; i < 30; i++) {
++ const r = await t.get(`/api/v1/coupons/${code.id}/report`, { as: i % 2 ? laptop : phone, json: { outcome: i % 3 ? 'worked' : 'failed', reason: i % 3 ? null : 'expired' } });
++ assert.ok(r.status === 200 || r.status === 201, `report ${i + 1}: ${r.text}`);
++ }
++ const before = JSON.stringify(stored());
++ const r = await t.get(`/c/${code.id}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'failed', reason: 'expired' } });
++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the site form shares the budget');
++ assert.ok(r.json().detail.includes('coupons.report.create'), r.json().detail);
++ assert.strictEqual(JSON.stringify(stored()), before, 'nothing recorded');
++ assert.strictEqual((await t.get(`/api/v1/coupons/${code.id}/report`, { as: carols, json: { outcome: 'worked' } })).status, 201, 'another person still reports');
++ });
++
++ await check('revoking a browser helper is never limited', async () => {
++ const installs = t.ctx.installs.list(bob.subject);
++ assert.ok(installs.length >= 2);
++ for (let i = 0; i < 6; i++) {
++ const r = await t.get(`/connect-extension/${installs[0].id}/revoke`, { as: bob, form: { csrf: t.csrf(bob) } });
++ assert.strictEqual(r.status, 303);
++ }
++ });
++
++ await check('health, ready, release.json and metrics 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);
++ }
++ });
++
++ await check('refusals are counted in coupons_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('coupons_rate_limited_total')).join('\n');
++ assert.ok(/coupons_rate_limited_total\{limit="coupons.read",window="minute"\} 2/.test(m), counted);
++ assert.ok(/coupons_rate_limited_total\{limit="coupons.report.create",window="minute"\} 1/.test(m), counted);
++ assert.ok(lines.some((l) => l.includes(`coupons.read: user:${bob.subject} refused`)), lines.join('\n'));
++ for (const l of lines) for (const tok of [laptop, phone, carols]) assert.ok(!l.includes(tok), '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: 'install', install: 'cpi_1', subject: 'usr_a' }, { ip: '203.0.113.1' })), 'user:usr_a', 'an install token is its person');
++ 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 (AI extraction)');
++ 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 c6f6701..11ae750 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,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}`;